fix(merge): visibleText skips vanish runs at object interiors (#648) - #654
Conversation
extractContentControls's visibleText re-derives paragraph text from OOXML with no w:vanish handling, while the object-tier AST walk (body-objects.ts, issue #641/ADR-092/PR 647) already drops hidden runs inside a captured table/text-box. The disagreement meant an object-interior paragraph mixing a hidden and a visible run round-tripped as "modified" even when untouched. Fix is scoped to OBJECT INTERIORS ONLY: an objectInterior flag is threaded through walkBlocks -> visitParagraph -> visibleText (mirroring the existing inTable flag), set on descent into any OBJECT_BLOCK_TAGS node (w:tbl, w:drawing, w:pict). Inside an object interior, visibleText skips a run flagged by the exported hasRunVanish predicate (parser/docx/body-objects.ts), reached through two new additive barrel re-export lines rather than a second, drifting copy of the ST_OnOff-aware check. The ordinary paragraph tier's KNOWN AMBIGUITY (a mixed hidden/visible paragraph reads as fully visible, document.test.ts near line 259) is deliberately untouched — pinned by a new regression test. extract.ts's local OrderedNode type is now a type alias of ast's ObjectBlobNode (the same fast-xml-parser preserveOrder shape) rather than a duplicate definition, which is what lets hasRunVanish accept an extract.ts node with zero cross-boundary cast. Design decisions (no ADR per this sprint's policy): - objectInterior rides on ParaContext, not a new visibleText parameter, so every wrapper (w:ins, w:hyperlink, w:sdt) keeps propagating it for free. - The vanish-skip lives inside visibleText's loop (next to its existing PROPERTY_TAGS skip), not inside visitRunNode's dispatch, keeping visitRunNode's contract pure compute. - Reaching hasRunVanish through parser/docx/index.ts + parser/index.ts is a narrow, deliberate, documented exception to module-boundaries.md's "merge/ knows nothing about parsing" prose line (unenforced by ESLint) rather than a second predicate, per the issue's explicit instruction. - body-objects.ts and merge/diff.ts were never touched (cross-branch territory: fix/issue-650 and fix/issue-465 respectively). Verification: - grep -rn vanish src/merge/ shows only extract.ts's new import/call-site/doc comments and extract.test.ts's fixtures — hasRunVanish remains the single definition. - Every new assertion mutation-verified: reverting the visibleText skip, reverting the OBJECT_BLOCK_TAGS widening in walkBlocks, and swapping hasRunVanish for a presence-only check each fail exactly the test(s) they pin, and only those. - New integration test (diff.integration.test.ts) proved it reproduces the real bug pre-fix: reverting the visibleText skip against the real DB -> generateDocx -> extractContentControls -> computeDiff wiring produces a false modified entry (theirs: hidden+visible concatenated vs base/ours: visible only); the fix makes that same round trip diff empty. - pnpm test (3632/3632), pnpm test:integration (1812 passed, 141 skipped, 0 failed), pnpm lint (eslint + tsc --noEmit + prettier) all green. - pnpm fixture:snapshot + fixture:diff over the full 666-file corpus: 0/666 changed (parser code itself is untouched by this fix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…src/merge/ (#648) Encodes the manual "grep -rn vanish src/merge/" audit from #648's verification (commit a261347) as a permanent, automated regression guard rather than a one-time manual check. The new structural test in extract.test.ts asserts (1) no src/merge/*.ts file other than extract.ts contains the literal w:vanish OOXML tag, and (2) extract.ts reaches vanish detection only through the imported hasRunVanish, never a local reimplementation. Mutation-verified: temporarily adding a second w:vanish-referencing predicate to diff.ts (then reverting) confirmed the guard fails for the right reason before this commit's real (already-passing) code confirmed green. Verification: pnpm lint clean; pnpm test 3633/3633 (255 files); pnpm test:integration 1812 passed / 141 skipped / 0 failed (170 files, after clearing one unrelated pre-existing stale fixture row in the shared integration DB — src/api/generate.integration.test.ts's ADR-079/#406 gate describe block, orphaned from an earlier interrupted run, unrelated to #648). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… objects Review finding on #648: the object-interior w:vanish fix was only exercised through the w:tbl path (OBJECT_BLOCK_TAGS' generic union branch). No test drove a hidden/visible mixed run through collectDrawingAnchors — the w:drawing/w:pict text-box interior walk, which threads objectInterior=true via a separate call site — so a regression scoped to that branch (e.g. its hard-coded `true` reverted) would have passed every existing #648 test while silently reintroducing the false-modified bug for 2 of the 3 OBJECT_BLOCK_TAGS. Added DrawingML and VML text-box variants reusing the existing drawingTextBoxRun/vmlTextBoxRun fixtures, and mutation-verified against a reverted collectDrawingAnchors to confirm they actually catch the regression. A real DB round-trip integration test for the textBox-kind case (mirroring the existing table-kind #648 wiring test) surfaced an unrelated, pre-existing bug: object-fingerprint.ts's structural hash is asymmetric for textBox/pict objects between capture (host w:p-wrapped, per body-objects.ts's own documented convention) and extraction (bare w:drawing/w:pict node), so any real text-box object round-trip false-conflicts. That integration-test addition is reverted here since the bug is unrelated to vanish handling — tracked separately as #652. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The "src/merge/*.ts touches raw w:vanish only through extract.ts" guard asserted on the literal string "w:vanish" appearing in file contents, which was only true because a doc comment happened to spell the tag out. A purely cosmetic reword of that comment (zero code change) flipped the assertion and would have failed CI for no reason. Replace the content-string scan with checks on the two things the guard actually claims to protect: no file in src/merge declares its own vanish-named predicate, and extract.ts imports and calls the shared hasRunVanish. Verified by temporarily rewording the comment and confirming the new guard is unaffected, then reverting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughChangesObject merge extraction
Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
…sh import
The adversarial review flagged `import { hasRunVanish } from '../parser/index.js'`
as a cold-start/memory regression: the parser barrel transitively loads
parser/pdf/index.ts's static pdfjs-dist / unpdf / tesseract.js imports.
Measured on a cold module graph, that is real (~+370ms, ~+290MB RSS) — but it
costs production nothing, because every path that reaches merge/ already loads
parser/index.js in the same file (src/api/diff.ts and src/mcp/handlers.ts each
import assertDocxSafe beside their ../merge/index.js import), and no merge-only
worker, script, or CLI exists. Only merge/extract.test.ts's graph grows.
Capture the finding, the numbers, and why both alternatives are worse — a deep
import of ../parser/docx/body-objects.js violates the same boundary rule more,
and relocating hasRunVanish reintroduces the capture/rewrite drift ADR-092
closed — so the tradeoff is not re-litigated from scratch by the next reader.
Comment-only; no behavior change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This was written agentically; verify its assertions and edit accordingly: Adversarial review — Codex
|
| module | cold import | RSS |
|---|---|---|
merge/extract.ts @ origin/main |
262 ms | 43.5 MB |
merge/extract.ts @ this PR |
632 ms | 332.4 MB |
But the stated harm does not exist in this codebase. There is no merge-only worker, script, or CLI — grep over scripts/ and tools/ finds nothing importing merge/. And every production consumer already loads the parser barrel in the same file:
src/api/diff.ts:4importsassertDocxSafefrom../parser/index.js, one line above its../merge/index.jsimport on line 5.src/mcp/handlers.ts:21does the same, two lines abovecomputeSpecDiffon line 23.
The remaining three consumers (api/merge.ts, mcp/merge-handlers.ts, mcp/resources.ts) are mounted in that same process. Net production cold-start and memory delta: zero. The only module graph that actually grows is merge/extract.test.ts — 31 tests, 1.35 s.
Both proposed alternatives are strictly worse:
- Relocate
hasRunVanishto alib/AST leaf (Codex's suggestion) requires editingsrc/parser/docx/body-objects.ts. A concurrent branch (fix/issue-650) is extending that exact function with an optional character-style-vanish parameter; moving it now would collide with that work and risks splitting the single predicate — which is precisely the capture/rewrite drift ADR-092 closed.hasRunVanishmust remain the one ST_OnOff-aware vanish predicate. - Deep-import
../parser/docx/body-objects.jswould violate the samemodule-boundaries.mdsibling-barrel-only rule more deeply than the barrel import Codex objects to, trading a measured-zero cost for a worse architectural violation.
The import therefore stands. The finding, the numbers, and this reasoning are now recorded at the import site in fbb3ea3 so the tradeoff is not re-litigated from scratch.
Also verified (not flagged, checked independently)
- Vanish-skip parity with the object tier.
visibleTextre-enters itself forw:r/w:hyperlink/w:smartTag/w:insviavisitRunNode's fallthrough, sohasRunVanishis applied at every nesting depth — matchingbody-objects.ts'scollectText. A hidden run wrapped in aw:hyperlinkis skipped by both. - The
attrStrnull-check removal is sound.ObjectBlobNode's':@'is declaredreadonly ':@'?: Readonly<Record<string, string | number>>— optional, never nullable — andisObjectBlobNodeexplicitly rejectsnull.typeof attrs !== 'object'alone is a complete guard. - The ordinary-paragraph KNOWN AMBIGUITY is untouched. The vanish skip is gated on
ctx.objectInterior; a mixed hidden/visible paragraph outside an object interior still reads as visible, as pinned indocument.test.ts.
CI green on fbb3ea3: Build, Lint, Verify harness, Test (unit + integration), LOC delta.
🤖 Co-authored by Claude Opus 5 (1M context).
…652) `fingerprintBlob` was applied asymmetrically for textBox/pict-kind body objects, so EVERY unmodified round-trip of a captured text box falsely reported an objectConflict. The two sides disagreed on what an object's root is. diff.ts's detectObjectConflicts fingerprints the DB-stored ObjectMeta.blob directly, and per body-objects.ts's own capture convention ("Two capture paths, one shape") a textBox/pict blob root is the HOST body paragraph carrying the drawing run — buildTextBoxObject stores `[anchored.node]`, and anchorInteriorParagraphs preserves the root's tag, wrapping only INTERIOR paragraphs. extract.ts's walkObjectBlocks instead fingerprinted the matched OBJECT_BLOCK_TAGS node itself, i.e. the bare w:drawing/w:pict. A table's blob root IS the w:tbl, which is also what walkObjectBlocks matches, so the table tier was already symmetric — which is exactly why every existing table-based test passed and this went unseen. For a textBox the sides hashed `w:p(w:r(w:drawing(...)))` against `w:drawing(...)`, so fingerprintsDiverge was unconditionally true. walkObjectBlocks now tracks the nearest enclosing w:p and fingerprints it for w:drawing/w:pict matches, mirroring capture. Chosen over changing capture to store the bare node: the generator emits blob[0] as a block-level body child, and a bare w:drawing is not a valid one — it must sit inside a run inside a paragraph. That option would also have had to edit body-objects.ts, which a sibling branch owns. interiorUuids stay scoped to the matched drawing's own subtree, so findMatchingBlock keeps per-drawing granularity when one paragraph hosts two text boxes. Pinned by a real DB -> generateDocx -> extractContentControls -> computeDiff integration test, since the asymmetry is between two production call sites and no unit test on either side alone can see it. Mutation-verified: with the fix reverted the suite reports 1 failed | 10 passed — only the new assertion, with every table-kind test still green. Closes #652 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adversarial review caught that the comment claimed asserting the whole diff guards against a findInteriorUuids regression. It does not: theirsControlled is built by walkBlocks independently of interior-uuid collection, so interior text would still round-trip cleanly if that path regressed. State what actually establishes non-vacuity — the mutation run, which fails carrying BOTH a base and a theirs fingerprint, a pairing detectObjectConflicts only emits for a block findMatchingBlock matched by interior uuid. Comment-only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This was written agentically; verify its assertions and edit accordingly: Adversarial review pass 2 — Codex
|
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/merge/extract.test.ts (1)
8-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winImport
SpecTreethrough the AST barrel.Use
../ast/index.jsafter confirming that it re-exportsSpecTree. This test currently depends on an AST internal module.As per coding guidelines, tests must use module API boundaries. Based on learnings, public AST exports outside
src/astmust usesrc/ast/index.ts.Proposed change
-import type { SpecTree } from '../ast/types.js'; +import type { SpecTree } from '../ast/index.js';🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/merge/extract.test.ts` at line 8, Update the SpecTree import in the extract test to use the public AST barrel at ../ast/index.js, confirming that this barrel re-exports SpecTree; do not import the AST internal types module directly.Sources: Coding guidelines, Learnings
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/merge/extract.test.ts`:
- Line 8: Update the SpecTree import in the extract test to use the public AST
barrel at ../ast/index.js, confirming that this barrel re-exports SpecTree; do
not import the AST internal types module directly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 7d56552d-6207-4a09-914d-39aae622fdae
📒 Files selected for processing (5)
src/api/diff.integration.test.tssrc/merge/extract.test.tssrc/merge/extract.tssrc/parser/docx/index.tssrc/parser/index.ts
…st.ts
module-boundaries.md line 7 — "Modules import only from a sibling's
index.ts barrel, never from its internal files" — has no type-only
carve-out (lib/ is the sole exception, and ast/ is not lib/). The test
reached ../ast/types.js directly; ast/index.ts already re-exports
SpecTree from its `export type { ... }` block, so this is a drop-in.
Scoped to this file deliberately: 24 other files outside src/ast reach
../ast/types.js the same way, all pre-existing on main and untouched by
this branch. A repo-wide sweep would collide with six concurrent
branches and is unrelated to #648's deliverable — surfaced to the owner
instead.
Type-only import change; tsc --noEmit clean, 31/31 extract tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Nitpick handled in
Scope, stated explicitly rather than left silent. The cited import is pre-existing on I fixed only the file this PR already modifies. The remaining 24 are deliberately untouched: a repo-wide import rewrite would collide with six concurrent branches and has nothing to do with #648's deliverable. That call — and whether the rule should be made executable as a lint rule instead of prose — is the owner's, and has been surfaced to them directly rather than filed as an issue. Verified: |
# Conflicts: # src/api/diff.integration.test.ts
#651 (issue #465) landed a `deleteConflicts` bucket on `DiffResult` after this branch forked. The two suites added here assert the whole response object with `toEqual`, so they failed on the new key the moment main was merged in — a textual-clean, semantically-broken merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Why
src/merge/extract.ts'svisibleTextre-derives paragraph text from OOXML and had now:vanishhandling. The object-tier AST walk skips hidden runs (#641, ADR-092), so the two disagreed: an SDT-anchored cell paragraph mixing a hidden and a visible run produced"HIDDEN SECRETvisible text"fromvisibleTextbut"visible text"from the AST'sobjectText. A DOCX that round-trips untouched could therefore report as modified.What
objectInterior: booleanthroughwalkBlocks→visitParagraph→visibleText, set true on descent into anyOBJECT_BLOCK_TAGS(w:tbl,w:drawing,w:pict) — the same pattern already used forinTable.visibleTextnow skips anyw:rfor whichhasRunVanish(imported from../parser/index.js) returns true, matchingobjectText's existing behavior.src/parser/docx/document.test.ts:259).hasRunVanishremains the single ST_OnOff-aware vanish predicate; no second copy was added undersrc/merge/.Design decisions
Synthesized from both candidates plus direct reads of extract.ts, body-objects.ts, spec-diff.ts, diff.integration.test.ts, and module-boundaries.md (neither candidate had verified these against source). Key calls:
objectInteriorrides onParaContextrather than a new positional parameter onvisibleText—visibleText's only two params arenodes/ctx, andctxalready transparently threads through every recursivevisitRunNode-to-visibleTextcall (w:ins,w:hyperlink,w:rfallthrough) with zero extra call-site edits, which a raw third boolean parameter would require touching every one of. This departs slightly from a literal reading of "threaded throughwalkBlocks, thenvisitParagraph, thenvisibleText, in exactly that way" but honors its intent since the value does flow through all three in order.visibleText's loop (notvisitRunNode's tag dispatch), sincevisibleTextalready owns the "does this child contribute text" decision (that's exactly what itsPROPERTY_TAGSskip already does), keepingvisitRunNode's contract as pure compute, never skip-with-no-output.hasRunVanishis reached via two new barrel re-export lines (parser/docx/index.ts,parser/index.ts) rather than a deep import — satisfies the enforceable "import only through a sibling'sindex.tsbarrel" rule. This does crossmodule-boundaries.md's prose diagram line that merge knows nothing about parsing, but that line has no ESLint enforcement (confirmed by readingeslint.config.js— no import-boundary rule exists) and the issue's SETTLED section explicitly mandates this exact reuse, so it is treated as an approved narrow exception recorded in code comments and this PR body, not an ADR (banned this sprint) and not an escalation (the issue itself already made the call).OrderedNodebecomes a type alias ofObjectBlobNodeinstead of casting at thehasRunVanishcall site, removing a duplicate node-shape definition rather than adding a forbidden cross-boundary assertion. This was flagged as the top spike risk; strict-tsconfig fallout across the four existing accessor functions was confirmed clean (lint +tsc --noEmitpass with zero changes needed totagOf/childrenOf/attrStr/elementText).api/diff.integration.test.tsbody-level object round-trip coverage —computeSpecDiff'stheirscomes fromextractContentControls(docxBuffer)whilebase/ourscome from AST-derived DB paragraph snapshots, exactly where the two paths' disagreement would surface as a false "modified" diff entry today — in addition to a unit-level pin inextract.test.ts.Manual audit evidence (acceptance bullet: "hasRunVanish remains the single definition")
No second
vanish-detection predicate exists undersrc/merge/— all references are either doc comments or calls into the single importedhasRunVanish.Additional scope folded in: #652 — textBox/pict fingerprint asymmetry
The #648 work surfaced a second, unrelated correctness bug on the same file and originally reverted its own coverage for it into issue #652. Per this sprint's zero-new-issues policy the fix belongs here, so #652 is now closed by this PR.
The bug:
fingerprintBlobwas applied asymmetrically fortextBox/pict-kind body objects, so every unmodified round-trip of a captured text box falsely reported anobjectConflict.diff.ts'sdetectObjectConflictsfingerprints the DB-storedObjectMeta.blobdirectly; perbody-objects.ts's own capture convention a textBox/pict blob root is the host bodyw:pcarrying the drawing run. Butextract.ts'swalkObjectBlocksfingerprinted the matchedOBJECT_BLOCK_TAGSnode itself — the barew:drawing/w:pict. The sides hashedw:p(w:r(w:drawing(...)))againstw:drawing(...), sofingerprintsDivergewas unconditionally true. A table's blob root is thew:tbl, which is also whatwalkObjectBlocksmatches, so the table tier was already symmetric — exactly why every existing table test passed and this went unseen.Design decisions (#652)
walkObjectBlocksnow tracks the nearest enclosingw:pand fingerprints it forw:drawing/w:pictmatches, mirroring capture. Option 2 (changing capture to store the bare node) was rejected on two independent grounds. First, correctness: the generator emitsblob[0]as a block-level body child (generator/index.ts→buildObjectBlocks), and a barew:drawingis not a valid block-level child — it must sit inside a run inside a paragraph, so option 2 would produce invalid OOXML on re-emit. Second, ownership: it would require editingsrc/parser/docx/body-objects.ts, which sibling branchfix/issue-650owns this sprint.interiorUuidsstay scoped to the matched drawing's own subtree, not the host paragraph's.findMatchingBlockmatches on any interiorUuid overlap, so per-drawing granularity is preserved when one host paragraph carries two text boxes; widening it to the hostw:pwould emit duplicate-uuid blocks.w:pdegrades to the bare node rather than throwing —extract.tsnever rejects a document it can still partially read.Option 1 verified against the two paths #652 flagged as unverified
findInteriorUuids(interior-uuid capture): holds. The fix does not touch it —interiorUuidsstill come fromchildrenOf(node, tag)on the matched drawing. The mutation run below proves the path is live: with the fix reverted the failure reports both abaseand atheirsfingerprint, whichdetectObjectConflictsonly produces for a blockfindMatchingBlockactually matched by interior uuid.buildObjectBlocksre-emitsblob[0](the hostw:p) unchanged, and the new test round-trips through the realgenerateDocxendpoint. Full suite green: 3635 unit + 1813 integration, 0 failures, including every#520/#517object and generator test.Mutation verification (#652 regression test)
The new test is a real DB →
generateDocx→extractContentControls→computeDiffintegration test for a textBox-kind object, mirroring the table-kind wiring test. With the fix reverted, only its assertion fails and every table-kind test stays green:The gate is non-vacuous by construction: it asserts the whole diff is empty, not
objectConflictsalone, so a future regression that stoppedfindInteriorUuidsfrom seeing the interior anchor would surface as adeleted/modifiedentry rather than silently passing. Teardown is id-scoped to the single spec row the block created.Testing
pnpm test— 255 files, 3635 tests, all green)pnpm test:integration— 158 files, 1813 tests, 0 failures)w:vanish w:val="0"run inside an object interior survives; single-predicate audit above🤖 Co-authored by Claude Sonnet 5 (#648) and Claude Opus 5 (#652 + review). Closes #648. Closes #652.
Summary by CodeRabbit
Bug Fixes
Tests