Skip to content

Latest commit

 

History

History
267 lines (244 loc) · 89.4 KB

File metadata and controls

267 lines (244 loc) · 89.4 KB

Project Context: Adeu

System Overview

Adeu acts as a "Virtual DOM" for DOCX files, enabling LLMs to edit documents via a text proxy while preserving complex XML structure.

  • Ingestion: ingest.py creates a Markdown/CriticMarkup representation of the document.
  • Mapping: mapper.py builds a linear index of text spans linking back to python-docx objects.
  • Reconciliation: engine.py calculates and applies atomic XML patches (w:ins/w:del).
  • Agent Interface: server.py exposes these capabilities as an MCP (Model Context Protocol) server, while cli.py handles automated environment configuration.

Architectural Decisions & Invariants

1. Ingestion & Formatting

  • Newline Isolation: Markdown formatting markers (**, _, etc.) must never enclose newline characters (\n).
    • Reasoning: Wrapping newlines breaks many Markdown parsers and complicates line-based text segmentation.
    • Implementation: utils.docx.apply_formatting_to_segments splits text by newlines before wrapping segments in markers.
    • Pattern: **Line 1**\n**Line 2**, NOT **Line 1\nLine 2**.
  • Boundary Whitespace Outside Markers (QA 2026-07-19 F-03): markers must hug non-whitespace — a bold run "The Supplier " projects as **The Supplier** (never the malformed **The Supplier **, which poisons every CriticMarkup consumer). split_boundary_whitespace is shared by apply_formatting_to_segments and both mappers so the Virtual Text contract holds by construction; whitespace-only styled runs are never wrapped. Consequence: ONE run may back SEVERAL real spans (lead/core/trail), so TextSpan.run_offset records each span's offset within its run and every span→run local-offset computation (_resolve_runs_at_range, get_insertion_anchor) must add it — and resolution must deduplicate working runs by identity or a run gets split/wrapped once per span. Adjacent same-style runs still elide across hoisted whitespace (**A** + " B" bold → **A B**) — the ingest elision regex and the mapper's part-level pop/skip produce identical text.
  • Resolved Ranges May Start on Virtual Spans (QA 2026-07-19 v8 F-04): word-diff hunks legally absorb a style marker adjacent to real changes (dmp's semantic cleanup merges the **-deletion into the neighboring content hunk), so a resolution range can BEGIN on a virtual marker span. Virtual characters have no physical width: _resolve_runs_at_range (both engines) clamps start_idx to the first REAL span's start before computing the run-local split offset — the unclamped subtraction goes negative and splits the preceding run inside its kept text, which is exactly how a full-sentence replacement across bold/italic runs shipped **The Suppli** must perform …. The end side is inherently clamped (min(last_real_span.end, end_idx)).
  • Multi-Level Lists: OOXML <w:ilvl> maps natively to standard Markdown indentation (4 spaces per level) on read. On write, the engine parses leading spaces divided by 4 to explicitly inject <w:ilvl> into <w:numPr>.

2. XML Normalization & Surgical Mode

  • Surgical Mode: The RedlineEngine operates in "Surgical Mode" — it never performs global document normalization (normalize_docx) on initialization or save. It strictly preserves untouched paragraphs, preventing the silent destruction of unrelated metadata (like <w:proofErr>) and preserving exact XML whitespace lines to guarantee minimal, readable diffs.
  • Run Coalescing: We merge adjacent runs with identical styling to reduce token count and simplify mapping ("Con" + "tract" -> "Contract").
  • Safety Constraint: Runs containing "Special Content" (w:br, w:tab, w:commentReference, w:drawing) are immutable boundaries.
    • Rule: Never merge a run containing special tags into a text run, or the special tag will be destroyed.
    • Deletion Survival: When a text run containing Special Content is marked for deletion (w:del), the engine uses deepcopy to clone the run. This ensures images and structural elements physically survive inside the deletion block instead of being silently erased.

3. The "Virtual Text" Contract

  • ingest.py and mapper.py must be strictly synchronized.
  • If ingest.py produces virtual characters (e.g., {== or **), mapper.py must explicitly account for them as virtual spans so the RedlineEngine knows they do not exist in the DOM.

4. Agentic Distribution Strategy & Monorepo

  • Dual Engine Architecture: Adeu maintains parallel backends in Python (FastMCP, rich CLI) and TypeScript/Node.js (@adeu/core, @adeu/mcp-server).
  • "Make Both Perfect" Principle: When discovering behavior divergence between the Python and Node engines, we do not blindly enforce bug-for-bug parity. Instead, we identify the most natural, intuitive Microsoft Word behavior and backport it to both engines to elevate the overall baseline.
  • Native Desktop Extension (MCPB): We ship a fully self-contained Node.js backend bundled as a zero-dependency index.js for Claude Desktop extensions. This eliminates Python/uvx environment constraints for end-users. The 1.2MB bundle is ignored in .gitignore, built entirely via CI/CD, and distributed via NPM and GitHub Releases to avoid repository bloat.
  • Auto-Configuration: The Python CLI adeu init command still manages local dev injections into claude_desktop_config.json.
    • Safety: It must always create a timestamped backup (.bak) before modifying the user's config.
    • OS Agnostic: It handles path resolution for Windows (%APPDATA%) and macOS (~/Library) automatically.
  • Smithery Marketplace Publishing: To bypass the "Schema Deadlock" (Anthropic's mcpb pack rejects tool schemas, but Smithery's registry requires them), we use a dynamic patch strategy. scripts/patch_smithery_mcpb.py boots the compiled Node server, extracts live schemas via JSON-RPC (tools/list), and injects them into the packaged .mcpb manifest before publishing.
  • Unified Monorepo & LangChain Integration: We maintain a dedicated langchain-adeu package inside langchain/ that wraps our offline-capable core Python engine as native LangChain tools.
    • Version Alignment: To maintain a cohesive release footprint, all sub-projects (Python, Node, and LangChain) are synchronized to the exact same semantic version number via scripts/bump.py, which automatically triggers lockfile updates (uv.lock and package-lock.json) across workspaces.
    • n8n Codex Exception (do NOT version-sync): The n8n codex file node/packages/n8n-nodes-adeu/nodes/Adeu/Adeu.node.json is deliberately excluded from bump.py because its fields do not track the npm package version. nodeVersion mirrors the version property of the node class in Adeu.node.ts (currently version: 1"1.0"), and codexVersion is the codex schema version (fixed at "1.0"). Syncing either to the package version (e.g. 1.17) breaks n8n Cloud verification — this happened once and was flagged by n8n Community Engineering. Only bump nodeVersion when the node class's version actually changes.
    • Hatchling Relative Workspace Sandbox: Hatchling prohibits scanning parent relative directories outside the workspace root during isolated builds. We bypass this constraint by programmatically localizing files (e.g. copying the repository root LICENSE file locally into packaging subdirectories) and referencing local paths in pyproject.toml.
    • OIDC Trusted Publishing: In our release pipeline, package uploads to PyPI are handled securely via GitHub Actions OIDC Trusted Publishers (configured for both adeu and langchain-adeu environments), completely eliminating the storage of static credentials in GitHub Secrets.

5. Block-Level Parsing & Tables

  • Sequential Iteration: We iterate over document elements (w:p and w:tbl) in strict XML order using iter_block_items. We do not iterate part.paragraphs and part.tables separately, as this destroys document flow (e.g., tables appearing after all text).
  • Recursion: Ingestion and Mapping are recursive. Document -> Table -> Cell -> Block Items -> ...
  • Synchronization Invariants:
    • Empty Rows: ingest.py must never skip empty table rows. mapper.py iterates all rows in the DOM; skipping one in text extraction causes index misalignment.
    • Deleted Rows (Clean View): To simulate the "Accepted" document state (clean_view=True), the extraction pipelines explicitly skip table rows that contain a <w:del> tag inside their <w:trPr> properties.
    • Separators: Row separators (\n) are injected between rows. Virtual pipes (|) separate cells.
    • Cell Isolation: The virtual | boundary represents a hard <w:tc> cell wall. Modifying text across | boundaries is dynamically segmented into per-cell edits by the engine. Structural table changes (adding or removing rows, or adding/removing | columns) via text replacement are explicitly intercepted and strictly rejected to prevent gridspan corruption and misaligned <w:tc> elements.
    • Heuristic Cell Matching: When modifying table rows via text substitution, we explicitly .strip() individual cell contents to bypass whitespace drift and accurately anchor comments to the semantically modified cell.
    • Structural Table Safety: Table row manipulation is strictly enforced via intent-based API models (InsertTableRow, DeleteTableRow) to safely manipulate the DOM without risking gridspan corruption. (Note: Currently supported for disk-based DOCX editing only; gracefully intercepted and rejected in Live Word COM).
    • Structured Row Diff (QA 2026-07-18 C2): DOCX-to-DOCX diff never expresses table row insertions/deletions as text edits. generate_structured_edits pairs tables by document order, aligns their row sets, and emits insert_row/delete_row operations (below-anchor inserts emitted in reverse so sequential application preserves order; anchors chosen from surviving rows). The engine additionally rejects any ModifyText that would introduce newline-separated pipe lines inside a table — a fake "row" rendered inside one cell.
    • Row Ops Resolve in ONE Coordinate Space (QA 2026-07-18 v6 H1): insert_row/delete_row anchors resolve raw-view first, then fall back to the clean view (that fallback is what lets a row op anchor on the FINAL text of a row an earlier batch edit just modified — the exact shape diff --json emits). The mapper that produced the offset is recorded on the edit (_active_mapper_ref) and the row lookup MUST run against that same mapper: a clean-view offset applied to the raw mapper points at earlier text once tracked changes exist, inserting the row at the wrong position (or failing) — this broke the diff → apply replay of combined row-modify + row-insert batches in both engines.
    • Cell Changes Diff as ROW-LEVEL Edits (2026-07-18 hypothesis P3): both diff layers (structured DOCX-to-DOCX and the text path's table blobs) express cell-internal changes as one unpinned ModifyText per differing row (full old row text → full new row text, _is_table_edit, excluded from context widening). Word-level dmp hunks over a table span start/end inside " | " separators and land in the wrong cell; widening drags neighboring cells or rows into the target; pinned application bypasses the engine's cell splitter and writes literal pipe text. The engine's per-cell splitter computes offsets by exact arithmetic over the matched slice (never by searching full_text, which cannot distinguish repeated cell text), and a 1-"cell" split (the target merely brushes a separator) falls through to standard resolution instead of a bogus structural rejection.

5a. Diff Output Must Be Applicable By Apply (QA 2026-07-19)

  • Context Widening Never Crosses Another Hunk (F-01): make_edits_self_contained clamps expansion at neighboring edits' raw hunk ranges (in addition to part bounds). Batches apply sequentially, so a later edit whose anchor context contains an earlier edit's ORIGINAL text can only match inside that edit's tracked deletion — apply rightly rejects it and diff --json stops being closed under its own apply. When uniqueness is unreachable without crossing a neighbor, the two edits are COALESCED (hunk + stable gap + hunk); callers must consume the RETURNED list (coalescing removes absorbed edit objects).
  • Structured Diff Paragraph-Aligns Every Segment (QA 2026-07-19 ADEU-QA-002 A): generate_structured_edits (both engines) diffs each part/segment via generate_edits_via_paragraph_alignment, never a raw word-level dmp pass — dmp legally shifts hunk boundaries across paragraphs sharing a prefix ("…arrears.\n\nThe "), producing deletions with body text on both sides of a paragraph break that the engine rightly rejects (every paragraph deletion/reordering in the QA corpus failed replay this way). Alignment emits mid-document multi-paragraph deletions as ONE separator-carrying deletion PER paragraph ("A\n\n", "B\n\n", never "A\n\nB\n\n" — apply's merge protocol handles one deleted pilcrow per edit), and _split_cross_paragraph_hunks decomposes any residual unbalanced replace-chunk hunk (leading pieces become separator-carrying deletions; the last piece carries the replacement — sequentially identical output).
  • Widening Simulates the Sequential Batch (ADEU-QA-002 C): make_edits_self_contained checks uniqueness against a SIMULATION of the sequentially applied batch, not against the original text — a paragraph-swap diff writes B's text over A in edit 1, so edit 2's target ("B's text") legitimately occurs twice mid-batch and must widen with context that discriminates in the CURRENT text. The F-01 neighbor clamp is what keeps the simulation sound (a candidate never overlaps another hunk, so it reads identically until its own edit applies). Pure INSERTIONS widen LEFT-ONLY (right expansion only at a hard left edge, and then line-first): mirrored right context rides AFTER the inserted text in new_text, and trim_common_context's prefix-priority eats it when the following text begins like the insertion (the projection's "1. " list marker), landing the anchor INSIDE the marker.
  • Virtual Projection Text Never Matches (ADEU-QA-002 C, both engines): matches whose range overlaps NO run-backed span cover only projection chrome — meta bubbles (change headers, comment timestamps), style markers, list prefixes — and are dropped at every find/validate/resolve site (range_is_virtual_only/drop_virtual_only_matches): a target of "4" was rejected as "appears 8 times" because bubble timestamps matched, and a bubble-only author-name target "matched" text that does not exist. EXCEPTION: {#…} anchor tokens ({#Bookmark}, {#cell:paraId}) are deliberate virtual targeting surfaces (empty-cell writes) and stay matchable.
  • Full-Paragraph Deletions Keep the FOLLOWING Paragraph's Properties (ADEU-QA-002 B, both engines): the Phase-2 merge adopts p2's pPr (deep-copied; p1's w:sectPr carried over) when p1 retains no visible content — the only surviving text is p2's, so keeping p1's container restyled it (deleting a heading promoted the next body paragraph to a heading; deleting a paragraph before a list item stripped its numbering; a list item deleted before plain text made it "1. Thís…"). Partial cross-boundary deletions (p1 keeps visible text) keep p1's pPr as before. _paragraph_has_visible_content is the shared judge, evaluated BEFORE p2's children move in.
  • Image Differences Are Warnings, Never Edits (F-04/F-14): beyond the existing whole-marker multiset check, _drop_marker_interior_hunks removes hunks whose pinned range cuts INTO a ![alt](docx-image:N) marker without covering it (the alt-text-change shape) — the engine categorically rejects those. collect_media_difference_warnings fingerprints word/media/* members of both packages so an empty diff over visually different documents always carries a "text-only comparison" warning (CLI diff + Node MCP diff tool).
  • CriticMarkup Delimiters Are Atomic in Diff OUTPUT (QA 2026-07-23 F15): the raw (compare_clean=false) word-patch path must never emit a hunk line containing a bare delimiter fragment (+ {== alone, orphaned ==}); Python passes generate_edits_from_text(atomic_criticmarkup=True) (whole {--…--}/{++…++}/{>>…<<}/{==…==} blocks as single diff tokens) and the output assembly clamps trim cuts off token interiors and keeps a bubble's multi-line content on one payload line; Node merges delimiter-splitting hunks in create_word_patch_diff. Strictly display-path only — the APPLY tokenizer is untouched in both engines.
  • Identical Documents Say So (QA 2026-07-23 F14): a diff with zero hunks prints an explicit "No textual differences found." (CLI stdout; the Node MCP diff appends it under the headers) — bare headers or "Found 0 changes:" left readers to infer identity.

5b. OPC Part Boundaries (QA 2026-07-18 C1)

The projection flattens headers, body, footers and notes into one string separated by \n\n, which historically let edits cross Word part boundaries — a "final body paragraph" was written into word/footer1.xml, producing a file LibreOffice refused to open while Adeu's own flattened comparison reported the documents equal.

  • Span Provenance: Every mapper TextSpan records part_index; the mapper exposes part_ranges ((start, end, kind) with kind ∈ header/body/footer/footnotes/endnotes) and part_boundary_at(index).
  • Hard Walls: validate_edits rejects a text edit whose trimmed effective range overlaps real text from two different parts; the apply path independently refuses such spans (pinned edits bypass validation, so both layers exist).
  • Boundary Insertions Prefer the Body: an insertion positioned in the separator gap after the body (the text-diff shape for "append a final body paragraph") re-anchors to the body's last run with forced new-paragraph semantics — never to the next part's first paragraph. Insertions whose text ends with \n\n keep next-part anchoring (the deliberate "insert above FOOTER text" shape).
  • Part-by-Part Diff: DOCX-to-DOCX diff extracts with return_structure=True and compares part-to-part when the part-kind sequences match (falling back to flattened text with a loud warning otherwise). Context expansion in make_edits_self_contained clamps at part boundaries — widening a body edit into footer text is exactly what produced cross-part targets.
  • Comments Live in the Main Story Only (QA H4): Word cannot anchor comments in headers, footers, footnotes or endnotes, and LibreOffice refuses to LOAD files that try. _attach_comment/_attach_comment_spanning verify the anchor's XML root is w:document before minting a comment; outside it, the comment is dropped with a user-visible warning (the tracked change itself still applies), and comment-only edits fail validation.

6. The Unified DocumentChange API

  • Flat API Structure: The LLM interacts with a flat list of DocumentChange objects (Discriminated Union of ModifyText, AcceptChange, RejectChange, ReplyComment, InsertTableRow, DeleteTableRow). We intentionally reject nested parameter objects in favor of flat top-level arguments (search_query, search_regex, match_mode, regex) to ensure seamless CLI argument parsing and high LLM execution accuracy.
  • Search & Replace First: Pure insertions and deletions are intentionally hidden from the LLM. All text modifications must be executed as search-and-replace (ModifyText) to guarantee sufficient anchoring context for the fuzzy matcher.
  • Representation-Based Linear Traversal: Match order for tools leveraging multiple occurrences (e.g., match_mode="first" or "all") is defined strictly by their linear sequence in the flattened, projected CriticMarkup representation (full_text), abstracting away the fragmented physical XML storage sequence.
  • Symmetric Channel Contract: Responses preserve identical Markdown layouts across both the content (LLM-facing) and structured_content (UI-facing) channels, packing the structured UI payload neatly inside standard JSON keys (markdown, file_path, title).
  • Structured Report Alignment: process_document_batch edit execution reports strictly mirror the layout of read_docx search matches (capturing pages, heading_path, and occurrences_modified) to provide the LLM with a highly correlatable audit trail.
  • Universal Tooling: Disk-based and Live Word tools share the same endpoints (read_docx, process_document_batch). On Windows, omitting file paths dynamically routes the command to the active Live Word COM object, preventing LLM tool selection paralysis.
  • Heading Depth Validation: Markdown heading depths (#) are strictly clamped to a maximum of 6. Exceeding this raises a BatchValidationError to prevent the silent generation of broken/unstyled XML blocks.
  • Strict Action Validation: The batch engine strictly enforces referential integrity. Attempting to execute review actions (like ReplyComment) on non-existent or fake IDs immediately raises a BatchValidationError rather than failing silently.
  • Review-Action Shape Validation (QA 2026-07-19 v8 F-07): before any action applies, validate_review_action_batch (python; the Node checks live at the top of validate_review_actions, and the Live Word pipeline shares the python helper) rejects blank/whitespace-only reply text, a duplicated accept/accept (or reject/reject) on one target_id (the old path counted the second as "applied" via resolved-history), a CONFLICTING accept+reject on one target_id, and an identical duplicate reply (same comment, same text). Distinct IDs one action resolves as a group (a modification's del+ins pair) and DIFFERENT replies to one comment remain legitimate.
  • Replacement Pairs Resolve as ONE Unit, and the Books Say So (QA 2026-07-19 ADEU-QA-004): a modification's contiguous same-author del+ins pair carries two ids but is one revision. BOTH engines group-resolve it (_get_paired_nodes — the Node engine historically resolved only the named id, leaving the paired insertion pending: an engine-parity divergence). The projection annotates grouped bubble lines (pairs with Chg:N) via the shared compute_change_pair_map (utils/docx in both languages; ingest + mapper render identically — Virtual Text contract; groups break on comment/format-only runs and author changes, mirroring the sibling walk). Within a batch: a SAME-direction follow-up on an already-group-resolved id is counted in the new actions_already_resolved stat (detail note; never "applied" — every reported applied action causes an observable state transition), and a CONTRADICTORY accept+reject across one pair is rejected up front by the document-aware validate_action_pairing (both engines, before any mutation). apply_review_actions returns a 3-tuple (applied, skipped, already_resolved).
  • ONE changes Parameter, Required, Typed (2026-07-22): process_document_batch takes exactly one changes argument on both engines — a REQUIRED array of typed items. A changes_json twin was tried and removed: two spellings of one argument make the model choose, and the engines had already drifted into opposite precedence rules for which one wins when both arrive. Tolerance is asymmetric BY SHAPE, identically on both engines: PER-ITEM stringification is repaired (coerce_stringified_changes / coerceChangeItemInPlace), a WHOLLY stringified payload is REJECTED with a type error the caller can retry from. The rejection is not fussiness — Node's zod schema cannot accept a bare string without dropping changes out of required (a preprocess wrapper's input type is unknown; a union publishes anyOf and hides the item schema), and one engine silently repairing what the other rejects is how a working agent call breaks on a backend switch. Do not "fix" this by widening one side alone.
  • The Flat Schema Is MCP-Only (2026-07-22): FlatDocumentChange exists because some MCP hosts cannot consume oneOf/anyOf, and FlatSchemaDocumentChange applies it AT THE MCP BOUNDARY ONLY. It is strictly the weaker contract — every field becomes optional, so "modify requires target_text + new_text" stops being expressed — so it must never be attached to the shared DocumentChange alias, which would silently flatten the CLI's StrictBatchChanges too. Validation is unaffected either way: the real discriminated union still runs. test_gemini_schema_compat.py pins BOTH shapes positively so the two can never swap places (an earlier if "$defs" in schema: … else: … guard passed under either and pinned neither).
  • type Requiredness Is Surface-Specific (QA 2026-07-19 v8 F-03): the MCP boundary keeps its documented LLM-tolerance layer (BatchChanges / Node coerceChangeItemInPlace: unambiguous missing type is inferred, schema says so). CLI changes files are authored artifacts and validate via StrictBatchChanges — stringified items and match_mode synonyms still normalize, but a missing type is a hard "Change #N is missing the required 'type' field" error, never a silent modify.
  • A Commented Modify Is Never Word-Split (QA 2026-07-22 bug #1): word-level diffing (_word_diff_sub_edits, both engines) fans a multi-word ModifyText into one sub-edit per changed word so redlines stay minimal — but a comment can only anchor to ONE of the resulting Chg pairs. Rejecting a DIFFERENT fragment then reverted its text AND silently deleted the wrapping comment (and any reply thread) while Actions: 1 applied gave no hint. So when parent_comment is set, both engines short-circuit to _single_commented_sub_edit: shared prefix/suffix are still trimmed (word-boundary aware, via trim_common_context) so the redline stays minimal at the edges, but the changed middle is emitted as ONE contiguous tracked change the comment wraps whole. A commented change is therefore atomic — exactly one del+ins pair — so partial rejection is impossible; target == new keeps the full span as a COMMENT_ONLY anchor (trimming identical strings to zero length leaves no runs to attach to). Separately, when accept/reject removes a comment because its anchor sat inside the resolved change, apply_review_actions diffs the comment-id set before/after and appends an informational - Note: to skipped_details (counted as applied, not skipped) so the removal is never silent.
  • Invalid Action IDs Get Self-Service Errors (QA 2026-07-22 bug #3): accept/reject/reply on an id that resolves nothing used to emit a dead-end Failed to apply action: reply on 99 (python) / Target ID … not found (node). _action_not_found_error (both engines) now names the expected id kind, lists the ids that actually exist (Chg:/Com:, capped), flags the common change↔comment id mix-up (a comment id given to accept/reject, or a change id given to reply), echoes the id the caller typed, and points at the id-discovery command — matching the self-service bar of the ambiguous-match and major-deletions errors.
  • Id-Discovery Advice Is Surface-Aware (QA 2026-07-23 F11): MCP callers cannot run the CLI, so the find-ids hint must name read_docx, never adeu markup -i/adeu extract. Python: RedlineEngine(id_discovery_hint=…) — default None keeps the CLI wording; every MCP-surface engine construction passes MCP_ID_DISCOVERY_HINT (mcp_components/shared.py), and the hint survives snapshot restores. Node core has no CLI consumer, so its engine string names read_docx outright.
  • A Logical Replacement Resolves as ONE Id-Set, Document-Wide (QA 2026-07-23 F1): a multi-paragraph replacement shares one insert id across content w:ins elements AND tracked paragraph marks; accept/reject collects every element carrying a group id (named id + pairs, the pair walk crossing a paragraph boundary only when that boundary's own mark is a pending same-author revision from the group). Rejecting the insertion removes inserted paragraph CONTAINERS entirely; rejecting the deletion restores the pre-edit document byte-identically. Node additionally emits w:pPrChange (original pPr snapshot) whenever it restyles an EXISTING paragraph — accept strips it, reject restores it, and pPrChange ids are first-class resolution targets. Python instead resolves the whole edit as one atomic modification when new_text spans paragraphs (no affix trimming — trimming paired the shared trailing "." and stranded a "."-only container). The cross-paragraph "(pairs with Chg:N)" projection annotation is NOT rendered (per-paragraph bubble states cannot see neighbours) — engine grouping alone carries the semantics.
  • Apply-Stage Failures Reject Transactionally Too (QA 2026-07-23 F2): validation errors were transactional but apply-stage failures ("Failed to locate row target", "Failed to apply edit targeting") silently skipped while the batch SAVED. Both engines now route any apply failure through the snapshot-restore + BatchValidationError path — a saved file with skipped edits is impossible. Empty/whitespace target_text (including row-op anchors) fails validation with a teaching message, never a skip.
  • Revision Ids Ascend in Document Order (QA 2026-07-23 F20): apply_edits reserves w:ids for all resolved sub-edits in ascending document order BEFORE the descending bottom-up sweep (del=N, ins=N+1 per occurrence) — lazy minting during the sweep numbered match_mode="all" fan-outs in reverse (Chg:5/6, 3/4, 1/2). Ids reserved for skipped sub-edits stay unused; gaps are fine, reverse-reading ids are not.
  • Previews Slice the Post-Apply Projection (QA 2026-07-23 F6/F7/F21b): per-edit report previews are windows of the ACTUAL raw/clean projections after the edit applied, located by the revision ids the edit wrote — all fan-out occurrences visible, unrelated pending changes render as pending, nesting and fabricated {----} tokens are impossible by construction (meta bubbles stripped from windows; window count/size bounded). Every per-edit report carries a comment field (both engines; MCP renderers print a **Comment:** line) so the report shows what a commented edit did.
  • Markdown Bullets Are Real Bullets (QA 2026-07-23 F5): inserted * /- paragraphs get List Paragraph style PLUS a direct w:numPr against a bullet numbering definition (_ensure_bullet_num_id, both engines: reuse an existing bullet abstractNum → extend numbering.xml → create the part + content-type override + rel from scratch), so bullets render in Word and round-trip as * item. 1. ordered-list continuation semantics unchanged (§11). The hyperlink-insert rejection names reality ("Inserting new hyperlinks is not supported; insert the display text instead…") — it used to point at a nonexistent "dedicated structural operation".
  • Search and Budget Guard Parameters on read_docx: read_docx accepts search parameters (search_query, max_matches, match_offset, full_paragraph) to return bounded, paginated search snippets with surrounding paragraph context. For documents exceeding the token budget threshold, read_docx returns a budget guard message outlining document sections; callers pass force=true to bypass the guard and retrieve full-document content.
  • Partial Batch Mode (partial=true): process_document_batch defaults to partial=true (non-transactional execution) — valid edits apply and save, while failing edits return detailed per-edit error reports rather than causing a full transaction rollback. Callers pass partial=false for full transaction rollback.
  • Whole-Text Diff Revision (apply_text_revision): apply_text_revision accepts a full revised_text payload alongside file_path (and optional author), automatically computing paragraph and line diffs against the original document and applying them as native Track Changes redlines.

7. MCP Apps & UI Rendering

  • Custom HTML Apps: We use FastMCP's AppConfig(resource_uri="ui://...") to serve custom HTML/CSS interfaces for complex tools (e.g., validate_documents). We maintain full control over the markup.
  • Vanilla JS: We avoid external untested JS libraries to bypass CSP restrictions and ensure offline reliability. The iframe client uses a minimal window.postMessage JSON-RPC implementation to complete the Host handshake (ui/initialize -> ui/notifications/initialized) and receive payloads (ui/notifications/tool-result).
  • Dynamic Resizing: HTML resources must include a ResizeObserver that emits ui/notifications/size-changed messages to the Host, allowing the iframe to expand seamlessly as content is injected.
  • Dual Payloads: Tools utilizing UIs return ToolResult(content=..., structured_content={"html": ...}). This ensures the LLM receives pure Markdown to reason about, while the human user sees the styled HTML.

7a. MCP Client Schema Compatibility (QA 2026-07-23)

Real MCP clients (measured: Claude Code) transform tool schemas in transit; the published schemas are designed WITHIN those constraints, pinned by repro.qa_2026_07_23.client-compat.test.ts:

  • Tool descriptions truncate at ~2048 chars (measured cut at 2046 + ellipsis, mid-word) — every description must fit INCLUDING the appended build tag; process_document_batch's rewrite publishes at 1933 chars while documenting the row-op fields (insert_row/delete_row with target_text anchor + cells + position, QA F10) and qualifying the {#cell:} stability claim in the SAME paragraph (stable across reads/edits; finalize/sanitize regenerates them, QA F9 — strip_para_ids runs in every sanitize mode by design).
  • Property-level anyOf/oneOf strips to {} (type, docs AND item schema lost) — one JSON type per property. read_docx.page publishes type: string via an item-level coercion preprocess; changes.items publishes the typed object schema only, with per-item stringified-JSON repair moved into an item-level z.preprocess (unparseable string items are smuggled through and unwrapped so the engine still sees the primitive; a WHOLLY stringified changes payload stays rejected — §6's asymmetric tolerance is unchanged).
  • required[] is rewritten client-side (primitive entries dropped; only array/object-typed survive) and optional-property descriptions are dropped entirely (falsy defaults too) — so models legitimately omit author_name (it now carries a schema default, "Adeu AI (TS)", instead of dumping a raw Zod issue array) and operative guidance for optional params lives in the tool description, not param descriptions.
  • MCP file/save errors are self-service (QA F16–F19): missing files echo the path AS GIVEN plus "Provide an absolute path…"; invalid bytes say "not a valid .docx (Word) document: …" (never bare "invalid zip data"); any save over an existing file discloses it — "overwritten in place" for the source, "replaced existing file" otherwise (wording matters: "overwrote" fails the pinned regex).

8. Document Sanitization & Part Ejection

  • Deep Part Ejection: When completely removing XML parts (e.g., Custom XML, Comments), deleting the elements is insufficient because python-docx will repackage empty XML files. We must explicitly sever relationships from pkg.rels and part.rels, and physically remove the part from pkg._parts.
  • Mathematical Scrub Verification: For metadata sanitization, we rely on lxml + XPath directly on the unzipped DOCX as the absolute source of truth. This strictly bypasses python-docx caching layers to mathematically guarantee artifacts are removed.
  • Modern Comments Architecture: Word's modern comments span four XML parts (comments.xml, commentsExtended.xml, commentsIds.xml, commentsExtensible.xml). The resolved status (w15:done="1") is stored inside commentsExtended.xml and must be parsed and scrubbed from there.
  • EVERY ST_LongHexNumber Is a SIGNED int32 (BUG 2026-08-11 B3 + 2026-08-12 B5, Word-verified): w14:paraId, w14:textId, w16cid:durableId, w16cex:durableId, w:rsid* and w14:docId are all schema-typed ST_LongHexNumber (xsd:hexBinary length 4, so anything validates), but Word parses all of them as signed 32-bit integers and ECMA-376 requires 0x00000000 < value < 0x80000000 in prose only. Out-of-range values are not rejected — Word silently discards and regenerates them on load, breaking every reference that pointed at them. Both engines mint every one of them from ONE generator (adeu.utils.long_hex_number.generate_long_hex_number / docx/long-hex-number.ts, 1..0x7FFFFFFF); the named _generate_para_id / _generate_rsid / _generate_durable_id / _generateHexId / _generateDurableId methods are thin aliases that must never diverge. Node's DERIVED {#cell:paraId} fallback (docx/cell-anchor.ts) folds its FNV-1a hash through the same range with toLongHexNumber.
    • RETRACTION. This bullet previously said "paraId and rsid keep the full 32-bit range, so narrowing the shared hex helper is wrong." That was wrong and it is why the bug shipped a second time. It reasoned from the schema type ("32-bit token") instead of from Word's parser, was written while looking directly at the correct answer for the adjacent attribute, and was pinned by two tests (test_para_id_and_rsid_keep_the_full_32_bit_range, "keeps the full 32-bit range for paraId / rsid") that went red when the bug was finally fixed. There is no attribute for which the high half is safe. Do not re-derive "which one is the special one" — there isn't one.
    • Symptoms, per attribute (each Word-verified through COM): durableId → the comment anchor collapses to a zero-length point, right author/text/date, highlight simply absent (Comment.Scope: F42758AF3584→3584 / ''; bit cleared → 3564→3584 / the real text). paraId on a thread ROOT → every reply in the thread renders as a separate top-level comment. paraId on a REPLY → that reply leaves the thread. paraId == 0x00000000 → Word refuses the package: "The file appears to be corrupted" (the one value that is not silently repaired — the generator starts at 1, not 0). Boundary is exact: 7FFFFFFF threads, 80000000 does not. rsid out of range has no observed symptom; it is masked anyway, because the cost is one bit of a namespace with no collision pressure and the benefit is that the class is closed.
    • Blast radius is the whole part, not the one id (Word-verified): a package with no bad ids keeps 32/32 of its w14:paraIds across an open/save; push exactly ONE over 0x7FFFFFFF and it keeps 0/32 — Word renumbers everything. So one bad id invalidates every {#cell:paraId} anchor in the document, and Node's cell-anchor derivation (deterministic, 95 of the first 128 paragraph indices were high-bit) meant that was the normal state for any document with an empty unlabeled table cell.
    • Cross-reference B1: a reply can be correctly parented in the XML and still not thread. B1's CommentThreadingError fires at WRITE time and correctly does not fire here — w15:paraIdParent is present and points at the right value; the value just stops existing when Word opens the file. The B3 symptom is additionally invisible without both commentsExtended AND commentsIds present (dropping either leaves Word's legacy path, which never consults the durable id) — which is exactly why it survived to production.
    • The guard is attribute-agnostic, deliberately. find_out_of_range_long_hex_numbers (python/tests/utils.py, mirrored in repro.para-id-signed-int32.test.ts) scans every .xml part of a saved package for every ST_LongHexNumber attribute. It would have caught all three instances on the day they shipped; prefer it to per-attribute tests. The XML is not an oracle for this class — every failing package was schema-valid, internally consistent and exactly what the writer intended. python/tests/word_com.py + the word_app fixture ask Word instead (Comment.Ancestor, Comment.Replies, Comment.Scope); use them for anything touching comments, anchoring or threading.
  • A Reply Threads or Fails Loudly (BUG 2026-08-11 B1): w15:paraIdParent was written only when _find_thread_root_para_id happened to resolve; when it did not (the parent comment carries no w14:paraId — pre-2013 Word and every generator that skips the modern-comments extensions), the reply was written anyway as a new TOP-LEVEL thread while apply_review_actions reported (1, 0, 0). An agent cannot detect that, so it retries and adds a third comment. Both engines now (a) REPAIR a legacy parent by minting a w14:paraId and registering it in commentsExtended and commentsIds together (a paraId in one but not the other drops the comment out of the modern path), and (b) resolve threading BEFORE writing any XML, raising CommentThreadingError when it is impossible — _reply_to_comment returns False, the action counts as skipped, and process_batch rejects transactionally. Never re-resolve the parent at the commentEx-append site: falling back to None there is exactly how the reply became a root. A silent no-op is the most expensive kind of bug for an agent to consume.
  • Comment Deletion Is Overridable and Attributed — the DEFAULT Is Surface-Specific (BUG 2026-08-11 B2): accept_all_revisions(remove_comments=False) is the LIBRARY default in both engines (Node's took no parameter and ejected every comment unconditionally — an engine-parity gap), while accept_all_changes (both MCP servers) and adeu accept-all deliberately default to True. That inversion is intentional and must not be "fixed": those surfaces exist to produce a DISTRIBUTABLE clean document, and shipping a counterparty a file still carrying internal review notes is the more expensive failure — docs/QA_ISSUES_DISCOVERED.md #10 logged exactly that as a 🔴 Major confidentiality risk. B2's real complaint was that the inversion was silent, anonymous and unavoidable, and all three are fixed: remove_comments is an explicit parameter (--remove-comments / --no-remove-comments on the CLI, z.boolean().default(true) / Annotated[bool] = True at the MCP boundary), the DEFAULT is stated in the published tool description and in --help (§7a: clients drop optional-property descriptions, so the description is the only channel that reaches the model), and every deleted comment is named WITH its author (Com:1 (by Sarah Chen)engine.removed_comment_notes plus the apply_review_actions note; an anonymous "removed comment Com:1" reads like engine bookkeeping). removed_comments counts the bodies the call ACTUALLY deleted (before/after id-set difference) instead of being hard-coded to 0 when the flag is off: a comment whose anchored text an accepted deletion consumes is removed either way (Word does the same) and under-reporting that as "nothing happened" is what let the reported run's data loss be rationalised as success. Empty comment parts stay registered rather than purged, per Empty Comment Part Lifecycle above.
  • Empty Comment Part Lifecycle: Empty comment XML parts are explicitly left intact rather than purged when all comments are removed, as dynamically mutating the pkg.rels matrix across different python-docx versions is volatile and can cause unrecoverable package corruption.
  • Multi-Author Sanitization Awareness: When executing a full document sanitization with auto-acceptance (accept_all=True), if multiple distinct authors are detected in pending track changes, a high-visibility warning is injected into the report to alert the user of potential 'silent smuggles'.
  • Proxy Class OPC Binding: When modifying python-docx XML parts that have native proxy classes (like XmlPart for Headers/Footers), we must re-bind part._element = part._adeu_element to ensure successful serialization on save.
  • Baseline Mode Keeps the Baseline Package (QA 2026-07-18 H3): sanitize --baseline diffs appendix-free structured projections (generate_structured_edits), applies them to the BASELINE via the sequential/transactional batch engine, and continues sanitizing the engine's own serialized package. Never graft the recomputed body into the working document's package — that leaves dangling relationship ids (raw KeyError('rIdN') crashes, unloadable files). The divergence warning uses a line-level sequence similarity, never positional character comparison (one inserted paragraph used to read as "93% different").
  • Low-Similarity Baselines BLOCK (QA 2026-07-19 v8 F-01): below _BASELINE_MIN_SIMILARITY (0.5) the divergence check is a blocker, not a warning — recomputing against an unrelated baseline REPLACES the working document's content with the baseline's while exiting 0 and printing "Result: CLEAN". The block happens before any diff is computed and writes nothing; --allow-low-similarity-baseline (CLI + MCP allow_low_similarity_baseline) downgrades it to the warning for deliberate near-rewrites. Because the line-level ratio counts only identical lines, a small doc whose only paragraph was edited reads as 0% similar: when the line ratio fails and both texts total ≤100k chars, a char-level SequenceMatcher ratio gets the final word (bounded — the matcher is quadratic; large related docs share verbatim lines anyway).
  • Batch Staging Files Never Survive a Failed Batch (QA 2026-07-19 v8 F-02): the CLI batch loop must not exit the process mid-loop (invalid-DOCX inputs are reported inline and counted as blocked, never routed through _handle_docx_error_and_exit), and a finally sweep unlinks every uncommitted .<name>.staging.tmp on every exit path — the staging file is itself an output artifact carrying full document content.
  • Report Text Is Data, Not a Format String (2026-07-22): percent signs in sanitize's similarity messages (and every other report/exception string) are LITERAL %, never pre-escaped %%. Nothing in Adeu %-formats these, so escaping them protects no printf host — it just corrupts the text for every consumer that does not un-escape. A fix once escaped them at construction and then stripped %% back out at the four CLI print sites, which left the CLI correct while the SDK, the MCP sanitize tool and the Node engine all disagreed about their own message. Format at the point of formatting.
  • Accept-All Counts Are Revision MARKS (2026-07-22): accept_all_revisions returns accepted_insertions / accepted_deletions / accepted_formatting / removed_comments in both engines. The unit is revision ELEMENTS, matching count_tracked_changes so sanitize and accept-all can never report different totals for one document — and because Word fragments one logical revision across several w:ins (§10), that unit is stated in accept-all --help rather than left for a caller to infer. removed_comments counts comment bodies the call ACTUALLY deleted: Python only when remove_comments=True, Node only its own-authored comments wrapping a resolved revision (foreign bodies are kept by design). Reading the document's comment total instead claimed removals that never happened.
  • The Report Lists Every Counted Element (QA 2026-07-23 F12): a headline of N revision elements must be backed by N listed items — textless elements render as Accepted insertion: "(paragraph mark)"-style lines instead of being filtered (both engines). Comments removed DURING accept-all are disclosed: Node's finalize snapshots the comment summary before/after accept_all_tracked_changes (the engine also ejects comment parts its own counter does not count) and renders them under COMMENTS (stripped); Python's accept_all_changes tool reports the engine's per-kind counts including Comments removed: N, and an all-zero result says "No tracked changes or comments to accept — the document is already clean" (F18) instead of claiming action on a no-op.
  • Sanitize Error Contract (QA 2026-07-19 v8 F-09): operational failures exit 1 through the shared CLI helpers (missing input via _require_input_file, runtime errors via _cli_error), reserving exit 2 for argument/usage errors. _atomic_write creates missing output parents and wraps any filesystem failure as a SanitizeError naming the OUTPUT path — never the raw Errno 2 … '.sanitize.docx.<random>.tmp' internals.
  • Watermark Visibility (QA 2026-07-18 M3): Sanitize never declares a document clean while a VML v:textpath watermark-like object remains unreported — headers, footers and body are scanned and surfaced as warnings (detection only; removal stays manual).
  • Batch Output Collisions (QA 2026-07-18 H2): CLI batch sanitize computes every destination path up front and refuses basename collisions before processing anything; a summary must never count an overwritten output as an independent success.
  • Retained Comments Get Normalized Timestamps (QA 2026-07-19 F-09): keep-markup/baseline modes normalize w:date in word/comments.xml and w16cex:dateUtc in word/commentsExtensible.xml to the same fixed date as tracked changes — retained comments carry the identical when-did-they-work signal. Contradictory CLI options are usage errors, never silent preferences: --keep-markup --accept-all and -o --outdir both exit 2 (F-07/F-08). A baseline that fails to open is reported as a BASELINE problem naming the baseline file, not as an invalid input document (F-19).
  • Custom Document Properties Are Metadata (QA 2026-07-18 v6 C1): every sanitize mode ejects docProps/custom.xml (part + package-root relationship, properties enumerated by name in the report) and scrubs dc:identifier, dc:language and cp:version from core.xml — custom properties and identifiers are standard carriers for matter numbers, client names and DMS/workflow secrets. After serialization, _verify_sanitized_package (Node: verifySanitizedPackage) re-opens the SAVED bytes and fails closed (SanitizeError/throw, nothing written) if any claimed-removed part or core field survives — "Result: CLEAN" is only ever printed over verified bytes.
  • Document Variables Are Metadata Too (QA 2026-07-19 ADEU-QA-001): every sanitize mode removes w:docVars/w:docVar from word/settings.xml (strip_document_variables, both engines) — docVars are invisible in Word's UI yet standard carriers for matter references, DMS identifiers and integration tokens, and the sanitizer used to print "Result: CLEAN" over a package still holding them. The report discloses variables BY NAME ONLY (their values are exactly the secrets a report must not echo), and the post-save package verification fails closed if any w:docVar survives in the saved bytes. The Python transform mutates whichever object actually serializes (XmlPart _element vs generic-part _blob).
  • Node Part Removal Must Purge pkg.unzipped (QA 2026-07-18 v6 C1): the Node DocumentObject.save() re-zips EVERY member of pkg.unzipped; dropping a part from pkg.parts alone ships the original bytes anyway (this silently leaked all customXml content). Part ejection goes through ejectPackageMembers, which removes parts, unzipped members, [Content_Types].xml overrides and .rels relationships together.
  • The Report Header Labels Its Flags (QA 2026-07-22 bug #2): SanitizeReport.render (both engines) prints the invocation flags under an Options: label. A bare --baseline/--keep-markup line directly beneath the title read as a stray debug/args token appended to the filename — and --report output is shown to a counterparty as proof of a clean document, so it must read as intentional metadata.
  • Text Payloads Never Overwrite Documents (QA 2026-07-18 v6 C2): adeu extract victim.docx -o victim.docx used to replace the source DOCX with extracted text and exit 0. Commands whose -o/--report-file payload is TEXT (extract, markup, sanitize reports) refuse an output path that aliases a document they read or write — filesystem identity via os.path.samefile + resolved paths, so sub/../x.docx, symlinks and hard links are caught — and refuse any .docx-suffixed target outright. DOCX-in/DOCX-out commands (apply, accept-all, sanitize -o) intentionally keep supporting in-place operation, as does markup's Markdown-in/Markdown-out same-path preview.
  • CLI Failure Contract (QA 2026-07-18 M2/M8): A batch with any skipped action/edit writes NO output and never prints "Batch complete"; empty target_text on a text-anchored edit is a validation error, not a silent skip. Under --json, every fatal CLI path emits one {"error": <stable code>, "message": …} object on stdout (codes: file_not_found, invalid_input, invalid_docx, invalid_changes_file, write_failed, unsupported, batch_validation_failed).
  • Text-Apply Post-Write Verification (QA 2026-07-19 F-05): the text-file apply path re-extracts the applied document's clean view and compares it with the supplied text; a mismatch (structural remnants a text replacement cannot remove — empty headings, table skeletons) reports verified: false + verification_error in --json, prints the first divergence, and exits 1 — never a false success. The major-deletion guard's 2,000-char floor is disclosed in --help.
  • Verification Failure Writes NOTHING at the Requested Path (QA 2026-07-19 ADEU-QA-003): verification runs BEFORE anything reaches disk; on failure the requested -o path does not exist (automation that checks file existence must never consume a wrong document — exit status is authoritative, but so is the filesystem). The failed result is written to a <stem>.unverified.docx sibling instead, surfaced as unverified_output_path in --json (output_path is null) and named in the stderr message.
  • Broken Pipes Exit Quietly (QA 2026-07-19 ADEU-QA-006): adeu … | head is normal shell behavior — main() catches BrokenPipeError, dup2s devnull over the streams (so the interpreter's shutdown flush cannot raise a second one) and exits 128+SIGPIPE (141), never a traceback.
  • Page Banners Say "synthetic" (QA 2026-07-19 ADEU-QA-005): every multi-page banner (CLI pagination + Node MCP response builder) states the page is a synthetic, length-based chunk — readers repeatedly mistook Adeu pages for printed Word pages. The banner regexes tolerate the qualifier, so paginated round-trips still strip chrome. The full preserved/normalized/omitted contract lives in docs/FIDELITY.md (ADEU-QA-007).
  • The Deletion Guard Also Arms on Small Documents (QA 2026-07-19 v8 F-12): ≥2,000 chars keeps the 50% threshold; below it the guard arms at 75% deletion (_MAJOR_DELETION_RATIO_SMALL_DOC) — halving a small draft stays one command, truncating a short contract to its title requires --allow-major-deletions. The flag never overrides the separate paginated page-N-of-M guard (now said in its --help).
  • Deleted Paragraph Blocks Carry One Separator (QA 2026-07-19 v8, F-12 fallout): generate_edits_via_paragraph_alignment delete hunks include one adjacent \n\n — trailing for mid-document blocks, leading for the document's trailing block (offset shifts −2) — mirroring the v6 H2 insertion rule. Without it a deleted paragraph leaves its empty container behind and the post-apply verification correctly fails what the user meant as a clean paragraph deletion.
  • CLI Stream Conventions (QA 2026-07-19 v8 F-05/F-08/F-13): -o - means stdout for text payloads — extract behaves exactly as with no -o, markup streams the CriticMarkup (under --json the payload rides inside the JSON object as content so stdout stays one machine-readable document), sanitize --report-file - prints the report; DOCX-writing commands keep rejecting - via _require_docx_output. --json success paths print NOTHING decorative to stderr (markup's "✅ Saved"/"Stats:" lines included). _write_output_or_exit creates missing output parents. adeu help [command] works alongside -h/--help, and --debug is accepted both before and after the subcommand (subparser copies use SUPPRESS defaults so they never clobber the global flag).
  • Visible Author Defaults (QA 2026-07-19 v8 F-11): the CLI's tracked-changes author resolves --authorADEU_AUTHOR env → OS username → "Adeu AI"; machine accounts (root, admin, administrator, system, daemon, nobody) never become the visible author — counterparty-facing documents signed "root" are a customer-visible defect. Engine defaults stay "Adeu AI"/"Adeu AI (TS)".
  • Server Binaries Answer --help/--version (QA 2026-07-19 v8 F-06): both adeu-server (argparse inside main(), parse_known_args so host-appended flags stay tolerated; never at import time — pytest's own argv would trigger it) and adeu-mcp-server (handleServerCliArgs in shared.ts, exercised against the built bundle) print and exit before the stdio transport starts.
  • Search Snippets Strip Style Markers Before Highlighting (QA 2026-07-19 v8 F-10): _emphasized_snippet/emphasizedSnippet remove the projection's **/word-edge _ markers across the WHOLE prefix+match+suffix region (a match boundary can cut a marker away from its word-edge context) and then wrap the match in **…** — a regex hit crossing styled runs used to render **The **Supplier** _shall provide**_.
  • match_mode Is a Strict Enum (QA 2026-07-19 F-12): recognized synonyms still normalize, but unrecognized values (including null and the help-string echo) are REJECTED with a clear enum error — never silently dropped to the default. adeu init pins the uvx reference to the configuring version (--from adeu==X.Y.Z, F-16); outline mode truly ignores --page after warning (F-18); search heading paths scan through the END of the matched line so hits inside headings report the full heading (F-17).

9. Live MS Word Interop (Windows COM)

  • Platform Safety: All live Word tools (live_word.py) depend on pywin32 and are conditionally registered via sys.platform == 'win32'.
  • COM Apartment Lifecycle: Microsoft Office COM objects are strictly Single-Threaded Apartment (STA). Because FastMCP and pytest hold proxy frames unpredictably, we intentionally omit pythoncom.CoUninitialize() and app.Quit() during test teardown. We let the OS/Python GC handle teardown naturally to prevent fatal RPC/Access Violations (0x800706be).
  • Index Drift Mitigation:
    • Extraction Parity: Active COM extraction uses an event-based string builder (sorting events by length and type) to inject CriticMarkup tags safely. This handles infinitely nested/overlapping annotations (e.g., comments wrapping redlines) without string offset drift.
    • Pre-Resolution: Modifying text natively adds Revisions, shifting doc.Revisions indices. We pre-resolve and cache all target COM objects before applying a batch of DocumentChange operations so Accept/Reject actions target the correct revisions.
    • Minimal-Diff Replacements: Live Word COM replacements must mathematically trim common context (trim_common_context) from the target string's prefix and suffix before executing the COM replacement. Replacing the entire target string wholesale creates bloat and destroys adjacent comment anchors.
    • Snapshot Engine Delegation: To safely process complex regex and match_mode workflows, the Live Word adapter leverages an in-memory RedlineEngine snapshot built via Flat OPC extraction to pre-resolve exact physical offsets, bypassing the need for complex Bounded Levenshtein or regex recalculations natively in COM.
  • Comment Bounds: We strictly use Comment.Scope (the highlighted text), not Comment.Reference (the 0-length anchor), to accurately extract target strings for Comment annotations.
  • Identity Spoofing & Deadlocks: Tools temporarily hijack Word.Application.UserName and toggle doc.TrackRevisions to apply tracked changes cleanly as the Agent. Constraint: Modern M365 enforces logged-in MS Account identities on Comments. Attempting to spoof comment authors via app.Options.UseLocalUserInfo causes fatal STA thread deadlocks. Live comments will natively show the local user's real name. Live COM batch executions will natively surface a warning when the author_name is overridden by the host OS M365 identity to maintain predictable audit trails.

10. COM vs XML Impedance Mismatches

Achieving 100% CriticMarkup extraction parity between Live COM and Disk XML requires bridging deep structural differences:

  • State Machine Parity: Both engines MUST feed into the exact same event-driven state machine (DocxEvent accumulation -> _get_wrappers -> _build_merged_meta_block) to ensure identical tag ordering and bubble grouping.
  • Formatting (Explicit vs Inherited): Disk XML evaluates explicit <w:b/> tags. Word COM's rng.Find.Font.Bold evaluates WYSIWYG bold (including inherited styles like Headings). Live COM must explicitly cross-check rng.Style.Font.Bold to avoid double-styling markdown markers (**) on inherited runs.
  • Table Rendering & COM Offset Drift: Word COM injects hidden structural characters (\r\x07) at cell boundaries, breaking Python string indices. Solution: Decouple structural markdown extraction (| for cells) from native COM execution, using exact index mapping arrays paired with rng.Find to securely bypass COM index drift.
  • Ephemeral Session IDs: Word natively assigns w:id="0" to all unsaved revisions/comments in live memory, randomly assigning persistent IDs during a Save. IDs are session-bound. Agents must treat Save/Reload boundaries as a state wipe and re-index the document IDs afterward.
  • Destructive Native Edits (Comment Rescue): Assigning Range.Text in Live Word natively destroys any comments anchored to that text. Batch processors must explicitly cache, rescue, and re-anchor comments during string replacements.
  • SmartSelection & Anchor Bleed: Microsoft Word's SmartSelection natively snaps comment anchors leftward across spaces into preceding un-tracked sentences. To mathematically isolate anchors during structured multi-paragraph replacements, we temporarily disable app.Options.SmartSelection, inject an un-tracked "Sacrificial 'X'" character, attach the comment, and un-track-delete the 'X' to perfectly collapse the anchor.
  • Pure Comment Redline Prevention: If an agent requests a pure comment (target_text == new_text), executing a .Text replacement natively forces Word to generate spurious <w:del> and <w:ins> pairs. We must explicitly short-circuit and attach the comment directly without modifying the text to prevent document timeline pollution.
  • Structured Insertions (Reverse Sandwich Algorithm): Word natively corrupts document structure if a tracked deletion precedes a new tracked paragraph break (\r), physically pushing the deletion into the next paragraph. To safely insert multi-paragraph replacements, we use a "Reverse Sandwich": insert Line 1 before the target, insert remaining lines after, and execute .Delete() on the target last.
  • COM Comment Truncation Limit: Microsoft Word's COM API (doc.Comments.Add) actively refuses to span a range containing both tracked deletions and tracked paragraph breaks. When executing multi-paragraph replacements, comments must be strictly anchored to the first inserted line (Line 1) to guarantee survival and visibility.
  • Empty Runs & Timestamps: Both engines must explicitly skip empty runs to synchronize lookahead bubble grouping. Both must emit full ISO-8601 timestamps without truncation to preserve chronological signals.
  • Tracked Formatting Fragmentation: Word natively splits a single tracked revision (<w:ins>) into multiple contiguous elements if partial formatting (like bold) is applied mid-revision. The ingestion state machine explicitly ignores pure state transition boundaries (like ins_end) when building CriticMarkup to seamlessly coalesce these fragments back into a single unified tag.

11. Redline Engine Execution Model (Performance & Safety)

  • Sequential Batch Contract (Chaining + Transactional Rejection): Batches apply SEQUENTIALLY in both engines: each edit is validated and applied against the document state produced by the edits before it, so a later edit may legitimately target text an earlier edit introduced (chaining). If any edit fails validation, the whole batch is rejected transactionally — the engine rolls back to a pre-batch snapshot and raises BatchValidationError. Validation errors raised after earlier edits already applied carry a sequential-contract hint (the failing target may be stale — re-target the updated text). Text projections (raw/clean/original mappers) are rebuilt between edits so each edit resolves against fully current state.
  • Pre-Resolution & Backwards-Sweep (within one edit): Inside a single edit's application, all of its resolved sub-edits are mapped against the current document state to cache their physical offsets before any DOM mutations occur, then sorted in reverse order and applied bottom-up in a single O(N) sweep — eliminating index drift without rebuilding the Virtual DOM map mid-edit. The sweep NEVER rebuilds the map between sub-edits (QA 2026-07-19 F-06): descending-offset application means every mutation (run splits, w:del wraps, w:ins insertions, bottom-up paragraph merges) lands at or above the current offset, so lower spans stay valid in the stale map; the old per-split-group rebuild made regex + match_mode="all" O(occurrences × document) — 78 s for 500 matches vs ~2 s. Caller-pinned index edits (e.g. generate_edits_from_text output, whose coordinates refer to the initial state) are applied together this way, first in the batch, before text-anchored edits re-resolve sequentially.
  • Namespace Injection & Serialization Safety: Custom namespaces (e.g., xmlns:w16du) are injected directly into the raw XML byte stream at the document root upon load to prevent lxml from generating ns0 alias artifacts that corrupt downstream processors. Crucially, we bypass python-docx's serialize_for_reading() which forces destructive pretty-printing. We use raw lxml.etree with pretty_print=False and remove_blank_text=False to strictly preserve Microsoft Word's original whitespace structure and prevent massive, noisy diffs.
  • Formatting Inheritance & Optimal Coalescing: When text is inserted or replaced inside a styled span (e.g., bold), the new text natively inherits the context's styling (suppress_inherited=False) to prevent visual data loss. The engine's run coalescer then merges the matching runs to produce optimal, single-run output for whole-span replacements.
  • Explicit Markers Are Authoritative (QA 2026-07-19 F-02): when a RESOLVED edit's post-trim target or new text carries bold/italic markers, inserted runs suppress inherited b/i and realize exactly the markers — **X**_X_ yields italic-only, **X**X yields plain (the old behavior shipped bold+italic while reporting success). Identical markers on both sides are absorbed into context by trim_common_context (formatting unchanged → inheritance keeps working), which is why the check keys on the resolved edit, never the parent's raw strings. Inline inserts additionally always strip inherited italic but keep bold (BUG-23-2, both engines — the Node engine's old blanket b+i strip was the mirror-image defect). Word-diff sub-edits DROP marker-only hunks: a plain target fuzzy-matched onto styled text ("Net 90 Days" vs **Net 90 Days**) must not emit **-deletion hunks that target virtual spans and can never apply.
  • edits_applied Counts Change Objects (QA 2026-07-19 F-21): apply_edits returns LOGICAL input-edit counts — one match_mode="all" edit over N occurrences is ONE applied edit; occurrence totals live in per-report occurrences_modified and the batch-level occurrences_modified sum. Any failed/skipped sub-edit (_any_sub_failure) makes its whole edit count as skipped, preserving the all-or-nothing write contract even for partial fan-outs.
  • Invalid Regex Fails as a Regex Error (QA 2026-07-19 F-13): validate_edits compiles regex: true targets up front; an unparsable pattern is a per-edit validation error naming the pattern problem, never the misleading "target text not found" the matcher's silent re.error guard produced.
  • The Writer Forgives Exactly What the Matcher Forgives (BUG 2026-08-11 B4): DocumentMapper._replace_smart_quotes deliberately folds curly quotes/apostrophes so an LLM's parties' Master matches a document's parties’ Master — but the apply path then word-diffed the document's REAL slice against the caller's literal new_text, so every forgiven character became a genuine w:del/w:ins pair in text the caller never targeted (four of eight change chunks in the reported legal-redline run were semantically null punctuation rewrites; in a court model the surrounding typography is the court's, not ours). restore_matched_typography (both engines, called in _resolve_edit_occurrences right after the regex substitution and before every downstream path) re-projects new_text onto the document's characters: normalization is 1:1 and length-preserving, both sides are char-aligned with dmp, and EQUAL runs adopt the document's characters while genuinely changed runs keep the caller's. Target and new differing only by normalized punctuation therefore yield ZERO tracked changes. The guard is the asymmetry itself — the document slice carries smart typography that the caller's own target_text does not — so a caller quoting the document's real characters (“Confidential”"Confidential") still gets the deliberate change. SMART_QUOTE_MAP (utils/text in both languages) must stay identical to the matcher's table; extending one side alone silently reintroduces the defect for the newly-forgiven characters (pinned by a structural test in both engines plus Hypothesis P6).
  • Modification Comment Anchoring: When a single edit causes a deletion and an insertion (w:del followed by w:ins), comments spanning the modification are explicitly anchored from the start of the del element to the end of the ins element to successfully encapsulate the full atomic revision.
  • Nested Redline Refusal (Foreign <w:ins> vs Comment Ranges): Edits overlapping an active <w:ins> authored by a different user are rejected (BatchValidationError) when they straddle the insertion boundary or fan out via match_mode="all"; a deliberate strict/first edit fully contained inside the foreign insertion is allowed and nested (the enclosing <w:ins> is split). Foreign comment ranges do not block deliberate strict/first edits — amending body text under a colleague's comment is a normal review workflow and the comment anchor survives the tracked change — but match_mode="all" fan-outs are refused (via TextSpan.comment_ids tracking) with a comment-specific error, so bulk replacements cannot silently sweep through another author's annotations.
  • Double-Sided Paragraph Merges Strict Refusal: Regex matches spanning a structural paragraph boundary (\n\n) with body text on both sides are explicitly rejected to safely prevent structural DOM corruption.
  • Paragraph Break Tracking: When multi-paragraph text is inserted (\n\n), the engine explicitly injects an <w:ins> marker inside the <w:pPr><w:rPr> of the newly created paragraph, ensuring MS Word natively tracks the paragraph break itself.
  • Multi-Paragraph Comment Anchoring (Disk): When a text replacement spans multiple paragraphs, the engine explicitly anchors the start of the comment to the first paragraph and the end of the comment to the last inserted paragraph to ensure survival and proper rendering.
  • Diff Hunk Coalescing: To prevent redline fragmentation, adjacent textual diff hunks separated by short runs of stable tokens (≤ 4 words) within the same paragraph are mathematically coalesced into a single unified edit hunk.
  • Paragraph Splits Relocate the Suffix (2026-07-18 hypothesis P1/P2): when inserted text carries \n\n mid-paragraph, everything after the insertion point in the host paragraph moves into the LAST new paragraph — track_insert's suffix relocation keys on the POSITIONAL anchor (the attached run/element the insertion physically follows: python positional_anchor_run, node positional_anchor_el), never on the not-yet-attached ins_elem and never on the STYLE anchor (_determine_style_source may return the following run purely for formatting; node's deletion step detaches the original runs entirely). The trailing empty line of a …\n\n insertion is dropped only when there is no suffix to relocate. Insertions attached BEFORE the anchor (paragraph-START insertions, python insert_before, node start_idx === 0) count the anchor itself — and with a bare paragraph anchor, the paragraph's whole content — as the suffix: without this, prepending a paragraph glued the host text onto the FIRST inserted line ("00." + insert "0.\n\n0 " read "0.00.\n\n0 "; hunt-profile counterexample, 2026-07-19, both engines).
  • Only "1. " Converts to a List (2026-07-18 hypothesis P2): _parse_markdown_style converts exactly the projection's own constant 1. marker into a List Number style; any other leading number (2024. Year…, 3. Clause…) is literal document text. List continuations keep full \d+. handling via the list-anchored insertion path.
  • Property-Test Harness: python/tests/test_property_invariants.py fuzzes the core invariants with Hypothesis — pinned text round-trip is exact; JSON round-trip is exact-or-loud (transactional rejection, never silently wrong output); structured table diff replays without pins or fails loud (and must not fail at all when the diff emitted no warnings); sanitize leaves no generated metadata value in any saved member; trim_common_context trims genuinely common, non-overlapping affixes; typographic restoration is semantics-preserving modulo quote folding, length-preserving and idempotent (P6); every minted durableId is a positive signed int32 (P7). Profiles live in tests/conftest.py; hunt with --hypothesis-profile=hunt (300 examples). Minimized counterexamples get deterministic pins in the repro test files.
  • Paragraph Insertions Carry Their Separator (QA 2026-07-18 v6 H2): the paragraph-alignment text diff (Python generate_edits_via_paragraph_alignment, the CLI extract → edit → apply/diff path) must emit whole-paragraph insertions WITH a \n\n separator — trailing when inserting before an existing paragraph, leading when appending after the last one. A bare insertion at a paragraph offset is (correctly) applied inline by the engine, gluing the new sentence to its neighbor; the concatenation survives accept-all. Deletions need no such handling. Relatedly, _resolve_single_match (both engines) short-circuits an edit whose context-trimmed target remainder is empty into a single INSERTION proxy instead of word-diffing the full strings: dmp's alignment can cross-match punctuation between the shared context and the inserted text (pairing the period of "two." with "marker."), stranding a suffix character in the wrong paragraph.
  • Contiguous Orphan Comment Sweep: When accepting/rejecting changes, comment anchor cleanup (w:commentRangeStart/End) must sweep across the entire contiguous block of adjacent redline tags (w:ins/w:del) to prevent orphaned anchors from leaking into the document body.
  • Phase 2 OOXML Paragraph Merges (Bottom-Up): When text deletions span paragraph boundaries (\n\n), the engine explicitly coalesces adjacent paragraphs. To prevent orphaned containers, these structural merges are executed in reverse order (bottom-up) over virtual_spans, actively jumping over invisible structural nodes (like w:bookmarkStart) via DOM sibling traversal to securely maintain DOM sibling pointers.
  • Safe Paragraph Acceptance: When executing accept_all_revisions, the engine explicitly checks for surviving visible content (w:t, w:tab, w:br) that is not enclosed in a <w:del> block. If content survives, it safely strips the tracked paragraph-break deletion marker but preserves the paragraph container, preventing catastrophic data loss of partially deleted paragraphs.
  • Table Cell Paragraph Floor (BUG_adeu_accept_all_table_row_loss): A <w:tc> must always retain at least one <w:p> — ECMA-376 requires a block-level element per cell and Word reports a cell with none as a corrupt document. Accepting or rejecting a paragraph mark therefore never removes a cell's last paragraph (_is_last_paragraph_in_cell, both engines): the marker is stripped instead, leaving the cell empty but valid. Outside a table there is no floor; the body may legitimately end up with no paragraphs.
  • Row Deletion Is Inferred After Insertion (BUG_adeu_accept_all_table_row_loss): Python's _mark_fully_deleted_rows_in_range stamps w:trPr/w:del when a targeted row retains no active runs. In the MODIFICATION branch it MUST run after track_insert has attached the replacement text. Evaluating it earlier saw every run in the row momentarily wrapped in <w:del>, so replacing the whole text of a cell marked the row deleted and accept_all_revisions then silently dropped the entire <w:tr> — inserted text included — with no exception and a normal-looking accepted_deletions count. The Node engine has no such inference; only an explicit delete_row writes w:trPr/w:del there.

12. FastMCP Concurrency & Tooling

  • Event Loop Blocking: Any heavy, synchronous CPU or disk-bound tasks (e.g., sanitize_docx, or heavy batch processing) called from an async FastMCP tool endpoint MUST be wrapped in asyncio.to_thread(). This prevents the asyncio event loop from freezing and dropping MCP client heartbeats.
  • Kwargs in to_thread: When dispatching functions via asyncio.to_thread that expect keyword-only arguments, arguments must be explicitly passed as keyword arguments to prevent TypeError: takes X positional arguments errors.
  • Testing Tools: FastMCP's @tool decorator heavily modifies function metadata. When asserting against an MCP tool's prompt or docstring in tests, prefer inspect.getsource(func) or getattr(func, "description", "") rather than func.__doc__.
  • Testing Redlines: python-docx's native Paragraph.text property silently fails to read text inside <w:ins> elements. Test assertions must strictly use the internal extract_text_from_stream(clean_view=True) function to accurately evaluate accepted document states.

13. Domain Gaps & Projection Syntax (Semantic Markdown)

To solve domain visibility gaps without adding new MCP tools, read_docx projects a strictly defined semantic dialect of Markdown:

  • Italics Strictness: Adeu strictly uses _italic_. The *italic* syntax is explicitly parsed as literal text.
  • Lists (QA 2026-07-18 M4): List detection resolves w:numPr from the paragraph OR its style chain (Word's built-in List Bullet/List Number styles keep numbering in styles.xml), and numbering.xml's numFmt distinguishes ordered lists (projected as a constant 1. — Markdown renumbers) from bullets (* ). Nesting stays 4 spaces per ilvl. get_paragraph_prefix is shared by ingest and mapper, so the Virtual Text contract holds by construction.
  • Images (QA 2026-07-18 M5): Inline w:drawing/w:object elements project as a read-only marker ![alt](docx-image:{docPr-id}) (alt from descr/title). The marker is a virtual span flagged is_image_marker; fabricating, altering or deleting it via text replacement is rejected at validation, and markers inside tracked deletions vanish from the clean view like any deleted content.
  • Reserved Notes (QA 2026-07-18 M6): Footnotes/endnotes with w:type separator/continuationSeparator OR a non-positive w:id (some generators omit the type attribute on Word's reserved -1/0 notes) never project — no more phantom [^fn--1]: entries.
  • Defined-Term Extraction (QA 2026-07-18 M7): Term extraction matches repeatedly WITHIN each paragraph — paragraph-leading, sentence-leading (after .;:!?), and parenthesized inline quotes — deduplicated by match position, so "Alpha" means… "Beta" means… in one paragraph yields both terms. Still typography-keyed, never English-phrase-keyed.
  • Footnotes/Endnotes: Projected inline as [^fn-{w:id}] (using stable OOXML IDs, not display numbers) and appended at the bottom. Fully bi-directional. Editing them natively updates footnotes.xml. Constraint: Generic XML parts lack get_style(), so _get_paragraph_style_safe gracefully handles missing formatting attributes.
  • Bi-directional Links: [text](url). Editing the text applies tracked changes. Editing the URL executes a silent URL_RETARGET operation in _rels (no redlines emitted).
  • Cross-References: Projected as [~text~](#_Ref). The [~...~] wrapper indicates computed/read-only text. Attempting to modify the display text or hash via ModifyText is strictly rejected (BatchValidationError) to prevent dependency corruption.
  • Internal Anchors: Structural bookmarks project inline as {#_BookmarkName}. Modifying or fabricating this syntax throws a BatchValidationError (Strict Refusal) as text-replacement cannot safely resolve these structural nodes.
  • Determined Page Ranges on Flat Outline Nodes: Section start and end pages are structurally calculated post-pagination over the flat OutlineNode array rather than descending recursively into the DOM tree.
  • Structural Appendix & Boundary Validation: Structural XML (Bookmarks, TOC boundaries) is appended to the bottom behind a <!-- READONLY_BOUNDARY_START --> marker. The boundary validator strictly uses resolved physical indices (find_all_match_indices) rather than blind string-matching, safely allowing body edits that coincidentally share text with the appendix.
  • Defined Terms & Semantic Diagnostics: We project a "Symbol Table" of terms and "Semantic Diagnostics" (Unresolved, Unused, Duplicate, Typo warnings) in the Appendix.
    • Language-Agnostic Extraction: Terms are extracted structurally via typography (leading/inline quotes), not brittle English regexes. Terms must be used ≥1 time to be included.
    • High-Signal Diagnostics: Typo candidates are grouped by target term. False positives are pruned via stop-word filtering, singular/plural exclusion, and a strict rule for short acronyms (≤5 chars: max edit distance of 1 and identical first letter). Usage counts read used 1 time / used N times (QA 2026-07-23 F22a).
  • Anchor Tokens Survive Rendering Chrome (QA 2026-07-23 F4/F22b): every outline/search marker-stripping pass placeholder-protects {#…} anchor tokens and literal _{3,} placeholder runs BEFORE emphasis stripping (both engines: outline _strip_inline_formatting, search _emphasized_snippet) — an agent that copies an anchor from a rendered view must get a real anchor, and [_________] form fields render literally. Search Path breadcrumbs additionally resolve CriticMarkup to the clean view (deletions dropped, insertions unwrapped, bubbles removed) before marker stripping.
  • Outline Entries Are Map Entries, Not Content (QA 2026-07-23 F13): heading text collapses internal line breaks to a space BEFORE marker stripping (every rendered line carries balanced **), truncates at 200 chars + "…", and entries whose cleaned text has no word characters (empty, ":"-only — the auto-numbered-heading shape) are dropped regardless of native style.
  • Tracked Rows Render a Separated Bubble (QA 2026-07-23 F21a): row-level w:ins/w:del project as {++ cells ++}{>>[Chg:N insert] Author<<} — never an id glued through a pipe ( |Chg:N++} read as last-cell text). Ingest and mapper twins stay byte-identical (Virtual Text contract), both engines.

14. TypeScript / Node.js Engine Constraints

  • DOM Simulation: python-docx is emulated via jszip and @xmldom/xmldom. Strict shim functions (findChild, findAllDescendants) simulate lxml's direct-child vs recursive search to prevent catastrophic DOM traversal mismatches.
  • Regex Engine Limits: JS regex does not support Python's atomic grouping (?>...). Catastrophic backtracking in fuzzy matchers is prevented using mathematically equivalent character classes (e.g., [*_]*).
  • Regex Compatibility (ES2022): We strictly target ES2022 to guarantee support for regex lookbehinds ((?<!...)), which are heavily utilized to cleanly port Python's semantic domain extraction logic.
  • Whitespace Evaluation: Python's isspace() checks the whole string, whereas JS requires explicit regex anchors (/^\s+$/) to prevent aggressive context-trimming bugs.
  • diff-match-patch Volatility & Format Parity: Inside the core Redline Engine, diff_charsToLines_ is bypassed using manual array-mapping loops to prevent minifier inconsistencies. For the external Diff Tool (diff.ts), both engines strictly emit a custom @@ Word Patch @@ sub-word level diff format. Standard line-based Unified Diffs are intentionally avoided to guarantee identical LLM interpretation and token-level granularity across both environments.
  • Package Mutation Parity: Emulating python-docx's save flow requires manually serializing mutated @xmldom/xmldom elements via XMLSerializer and dynamically updating [Content_Types].xml and .rels files inside JSZip to prevent OPC package corruption.
  • Explicit Relationship Mapping & Strict Namespaces: In pure JS (@xmldom + jszip), we cannot rely on implicit python-docx relationship abstractions. External targets (like Hyperlinks) require explicitly fetching/creating the target _rels/document.xml.rels file and manually injecting the <Relationship> node. Crucially, these nodes MUST be created with explicit namespace awareness (createElementNS), or XMLSerializer will silently drop them during package serialization.
  • Pure-TS Bounded Levenshtein: Node.js MCP environments cannot use C-bindings (like rapidfuzz). We implement a highly optimized, pure-TS Bounded Levenshtein algorithm (max_dist = 2) directly in the structural appendix generator to evaluate typographical errors without blocking the V8 event loop on large documents.
  • Test Utilities: Because JS lacks python-docx's in-memory factory methods, test-utils.ts shims document building by dynamically wiping and mutating an empty initial.docx fixture with raw OOXML node injection.
  • Zero-Dependency Finalization: The Node.js .mcpb bundle strictly omits PDF export (docx2pdf/LibreOffice) and AES encryption (msoffcrypto-tool) capabilities from the finalize_document tool to strictly maintain its zero-dependency architecture.
  • Native OOXML Locking: Document protection is achieved natively by injecting <w:documentProtection w:edit="readOnly" w:enforcement="1"/> directly into word/settings.xml using @xmldom/xmldom.
  • XML Serialization Quirks: Emptying elements in @xmldom/xmldom (el.textContent = "") serializes them as self-closing tags (e.g., <Template/> instead of <Template></Template>), requiring mathematically equivalent but structurally loose string assertions in unit tests.
  • Lazy w16du Declaration at Save (Node): DocumentObject.save() declares xmlns:w16du on any part whose serialized XML uses the prefix without a root declaration — the Node relocation of Python's _inject_w16du_if_needed. Required once part-boundary-correct anchoring (QA 2026-07-18 C1) started writing tracked changes into header/footer/footnote parts; unmodified parts never carry the prefix and stay byte-identical.

Developer Workflows

Testing

  • Regression Pattern: Create tests/test_repro_[issue].py to isolate bugs before fixing.
  • Golden Files: tests/fixtures/golden.docx is the source of truth for Modern Comments (Word 2021+) XML structure.
  • Python Tests Run Parallel by Default (2026-07-23): pyproject addopts is -n auto --dist loadgroup (pytest-xdist); -n 0 restores serial for debugging. Live-Word COM tests are pinned to ONE worker via an xdist_group marker added in tests/conftest.py — the hook MUST be @pytest.hookimpl(tryfirst=True) because xdist's own worker hook consumes the marker (rewriting nodeids to …@group) before ordinary conftest hooks run; without tryfirst the grouping silently does nothing and concurrent workers crash each other's Word sessions (0x80010108 class). conftest also configures structlog to the CLI's dynamic_stderr proxy once per worker — unconfigured structlog prints DEBUG to STDOUT, breaking --json CLI tests in fresh workers — and per-test output files must use tmp_path, never fixed paths in shared/fixtures/.

Deployment

  • Versioning: Semantic versioning in pyproject.toml. src/adeu/__init__.py dynamically loads this via importlib.metadata.
  • Dependencies: Uses uv (PEP 621 standard) with hatchling as the build backend. python-docx is patched at runtime in comments.py to support Modern Comments namespaces (w16cid, w15).
  • Release pipeline (.github/workflows/release.yml): Two-phase. Run scripts/bump.py X.Y.Z (syncs manifests + lockfiles), commit, then push a vX.Y.Z tag — the tag triggers draft-release (build + draft GitHub Release). A human then clicks Publish, which fires the npm / pypi / langchain-pypi / smithery jobs.
    • Consistency gate: scripts/check_release_consistency.mjs is the single source of truth for codex integrity (n8n nodeVersion/codexVersion/node/categories) + monorepo version lockstep. It runs in CI/pre-push (via a vitest test in n8n-nodes-adeu), inside bump.py, and as the first step of draft-release — a bad tag fails before any asset ships.
    • Manual gates: the only human gate is the draft → Publish click. The pypi/langchain-pypi GitHub Environments are name-only OIDC pins (Trusted Publishing), NOT approval gates — keep the environment: blocks; add a "Required reviewers" rule in repo Settings → Environments to re-gate. Smithery publishes on the publish event (not at tag/draft time) so all registries ship together.

Agent Integration Testing

  • To test changes to the MCP server without publishing to PyPI, use uv run adeu init --local.
  • This configures Claude Desktop to execute the server from the current local source (sys.executable + cwd), bypassing uvx.