fix(parser): nested text-box run-vanish leak (#641); refute object-table rule-row leak (#633) - #647
Conversation
…or paragraph (#641) body-objects.ts's captured-object text walk (collectText/extractBlobText) had no depth limit and no visibility check, so a w:vanish run's text leaked into an ancestor interior paragraph's captured objectText regardless of how deep it sat — including inside a text box nested within another text box's interior paragraph, and in the simpler same-paragraph case of two sibling runs (one visible, one vanish). Neither existing hidden/visible mechanism caught this: body-text-box-visibility.ts's box-level correlation treats a text box's interior as opaque and never recurses into nested boundaries, and header-footer-region.ts's run classification stops at each w:r. collectText now skips any w:r flagged by a new run-vanish presence check before descending into it, at whatever depth it's encountered — closing both shapes with one mechanism, entirely inside the tree already being walked (ADR-092), rather than extending the box-level hiddenFlags/hiddenSubtrees correlation deeper (which would reintroduce the #636 near-miss's correlation-desync risk one level down). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… are deliberately verbatim hidden-text.integration.test.ts's "no bare-asterisk node anywhere" assertion was failing against hidden-text-test.docx: 4 leaked objectText nodes, traced to body-objects.ts's interior-paragraph capture never routing through isRuleRow/classifyOne's rule-row suppression (ADR-086). Routing object capture through that suppression was tried first and broke note-region-corpus.integration.test.ts's own pre-existing, deliberately scoped regression test for this SAME fixture, which pins its body table rendering those SAME 4 asterisk-rule cells verbatim — ADR-072 decision 8 (#300) already establishes that a captured table/text-box's interior text is a faithful, out-of-band, VERBATIM mirror of the source document, never re-run through paragraph-tier suppression. The two tests encoded contradictory expectations for the same nodes; #294's assertion simply predates #300 and was never reconciled with it. Resolution: narrow hidden-text.integration.test.ts's assertion to exclude objectText nodes, with a comment citing ADR-072 decision 8 and the sibling test that pins the verbatim behavior. body-objects.ts gets an explanatory comment only, no logic change. body-objects.test.ts gains positive regression tests pinning verbatim capture (a text-box and a table interior paragraph whose text is a rule row both surface unchanged), replacing the suppression tests from the initial, incorrect approach. Mutation-verified: temporarily disabling ADR-086's paragraph-tier role === 'rule' check still fails the narrowed #294 assertion, confirming it still catches genuine paragraph-tier regressions rather than vacuously excluding too much. Closes #633. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tation record Records the combined design decision for #641 (fixed: run-vanish awareness in the captured-object text walk, at whatever depth) and #633 (refuted: the reported leak is ADR-072 decision 8's deliberate verbatim object-capture behavior, not a defect — resolved by narrowing a test assertion instead). Cross-references ADR-087 decision 7 (why hiddenSubtrees/hiddenFlags correlation is deliberately not extended to nested boundaries), ADR-072 decision 8 (#300, the already-shipped verbatim-capture rule this ADR defers to), and ADR-086 (paragraph-tier rule-row suppression, untouched). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe PR adds toggle-aware ChangesDOCX visibility and object text
Sequence Diagram(s)sequenceDiagram
participant DOCX as DOCX object content
participant collectText
participant hasRunVanish
participant objectText
DOCX->>collectText: Traverse nested text-box or table XML
collectText->>hasRunVanish: Check each run's w:vanish value
hasRunVanish-->>collectText: Return visible or hidden state
collectText->>objectText: Add visible w:t content
Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
… path (#641) Review found that #641's regression coverage only exercised the shared run-vanish text walk (collectText) via buildTextBoxObject; buildTableObject calls the identical anchorInteriorParagraphs/collectText walk but had no independent test proving a table cell's hidden run stays excluded. The existing all-vanish table fixture never reaches collectText at all — it is classified fully-hidden by the pre-existing ADR-038 path before extraction. Add two table-analogue tests mirroring the existing text-box cases: a visible cell paragraph mixing one visible run and one w:rPr>w:vanish run, and a visible cell paragraph with a nested hidden text box. Verified both fail (leak the hidden text) with the vanish skip disabled and pass with it restored — confirming the coverage gap is closed, not vacuous. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… text
The object-tier verbatim rule — a captured table/text-box's cell text is an
out-of-band mirror, never re-run through paragraph-tier note suppression — is
ADR-072 decision 14, which names this exact case ("an asterisk-rule decoration
row a spec author used purely as in-cell visual separation"). Decision 8 is
floating-vs-inline placement and says nothing about text capture.
This citation predates the branch (origin/main a9e6a69); #641 propagated it
into new comments, corrected separately. Comment-only, no behaviour change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndary
computeBodyOrder's comment describes a KNOWN AMBIGUITY about nested
table/text-box artifacts never being independently visited. That is ADR-072
decision 20 ("a nested table/text-box inside a captured object's blob is
double-anchored but never independently addressable"), which carries the same
KNOWN AMBIGUITY label. Decision 8 is floating-vs-inline placement.
Pre-existing on origin/main (a9e6a69); found while auditing #641's citations.
Comment-only, no behaviour change — drop this commit if you prefer it separate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The object-tier verbatim rule these comments rely on is ADR-072 decision 14, not decision 8. Decision 14 names this exact case — "an asterisk-rule decoration row a spec author used purely as in-cell visual separation" — and states that suppressing it would mean selectively editing locked content. Decision 8 governs floating-vs-inline placement and is unrelated. The wrong number was inherited from note-region-corpus.integration.test.ts and propagated into the new comments and ADR-092. Comment/doc-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… walk #641's new `hasRunVanish` suppression used element PRESENCE to decide a run was hidden. `w:vanish` is an ST_OnOff toggle (ECMA-376 §17.3.2.45): an explicit `w:val` of 0/false/off means the toggle is switched OFF — a VISIBLE run, usually one overriding an inherited vanish from its style. Because `collectText`'s new branch DROPS text, reading presence as "hidden" silently deletes visible spec text from a captured table or text box. ADR-092 dismissed this as "vanishingly rare". It is not: two real CPI corpus fixtures carry 15 `<w:vanish w:val="0"/>` runs between them, several of them text-bearing — CPI_COMMUNICATIONS_RACK_MOUNTED_POWER_PROTECTION_CSIMFS.docx holds "Select voltage/phase; breaker number, " and "rating" in exactly that shape. No fixture puts one inside a captured object today, so nothing was lost yet; the presence-only rule was a latent silent-data-loss landmine of the same class as the #636 near-miss. Regression tests pin the SURVIVAL direction on both capture paths (text box and table) — the direction an over-suppression bug hides in, since it raises no error, only missing text — plus the ON direction (`w:val="1"` and a bare `<w:vanish/>` must still suppress) so the guard cannot regress to a no-op. Both mutants verified red: presence-only fails the 2 survival tests; suppress-nothing fails 5 including the ON-side test. ADR-092's scope-limit section is corrected accordingly. The one remaining limit (no `w:rStyle` character-style vanish resolution) is now stated with its measured basis — 0 of the 39 corpus DOCX files use that shape — and with the reason it is the acceptable side to err on: it under-suppresses, so its failure mode is a detectable leak, never invisible data loss. Corpus revalidation (`pnpm fixture:snapshot`/`fixture:diff`, 705 fixtures): 0/705 changed for main -> branch-before-this-fix, 0/705 for branch-before-this-fix -> final, and 0/705 for main -> final. Three-part structure and note-leak counts unchanged; same single pre-existing parse-error fixture (11_53_00nle.docx) throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sibling of the object-text-walk fix in the preceding commit, found while verifying it. `runIsVanish` and `paragraphMarkVanish` both decided hiddenness from the mere PRESENCE of `w:vanish`. Per ECMA-376 §17.3.2.45 it is an ST_OnOff toggle: an explicit `w:val` of 0/false/off switches it OFF, marking a VISIBLE run or paragraph mark — usually one overriding an inherited vanish from its style. Both call sites feed hidden-content suppression, so the presence-only read could classify visible spec text as hidden and drop it. Measured before changing anything: no fixture's output moves. The 15 `<w:vanish w:val="0"/>` runs in the two CPI corpus fixtures sit in paragraphs that already resolve hidden through their `CMT` paragraph style, so the run-level path never decides them. `pnpm fixture:snapshot` / `fixture:diff` over all 705 fixtures: 0/705 changed, three-part structure and note-leak counts unchanged, same single pre-existing parse-error fixture. This removes a latent silent-data-loss path; it does not fix a live regression, and it is deliberately kept to its own commit for that reason. Three regression tests pin both directions: `w:val="0"` on a run and on a paragraph mark must stay VISIBLE, and an explicit `w:val="1"` must still hide. Mutants verified red — presence-only fails the 2 survival tests; never-vanish fails 8 including the ON-side test, so the guard cannot regress to a no-op in either direction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…k too
Adversarial-review P1. Dropping vanish runs from `collectText` changed what
an anchored interior paragraph's `objectText` MEANS — it is now the
paragraph's VISIBLE text, not all of its text — but `object-blob-edit.ts`'s
`rewriteFirstText` still assumed "all text". Its own docstring already
promised it mirrors `collectText` ("an edit reaches the text wherever
capture read it from"); that promise silently became false.
The damage is ordering-dependent and severe. On an interior paragraph whose
HIDDEN run precedes its visible one, an edit landed in the hidden run — so
the user's new text was invisible in Word — while the following visible run
was blanked, destroying real spec text. Verified by mutation: removing the
new skip fails both new ordering tests.
Fixed by reusing the SAME predicate rather than writing a second copy free to
drift: `hasRunVanish` is now exported from body-objects.ts and imported here,
so capture and rewrite cannot disagree again. A skipped vanish run is left
untouched rather than blanked — its text is locked, out-of-band content this
edit path has no mandate to rewrite (ADR-072's no-silent-loss posture) — and
that also keeps post-edit visible text exactly equal to the new text, which
is what capture reads back.
Three regression tests: hidden-run-first (the dangerous ordering),
hidden-run-last (which a naive "skip the first run" fix would break), and a
`w:vanish w:val="0"` run, which is VISIBLE and must remain a normal edit
target.
ADR-092 additionally records the ONE reader deliberately left unchanged:
merge/extract.ts's `visibleText`. Verified empirically to extract
"HIDDEN SECRETvisible text" where the AST now stores "visible text", so an
untouched round-trip can report as modified. The obvious remedy is wrong —
`visibleText` is merge's general paragraph walk, and the paragraph tier
deliberately KEEPS hidden-run text in a mixed paragraph (pinned by
document.test.ts's KNOWN AMBIGUITY test), so a blanket skip would create the
mirror divergence for every ordinary paragraph. A correct fix must scope
visibility to object interiors, changing merge's notion of text for every
table-cell paragraph — a merge-engine semantics change owed its own ADR and
PR. Latent today: 0/705 fixtures reach the shape.
Corpus revalidation main -> final: 0/705 changed. Unit 3563 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This was written agentically; verify its assertions and edit accordingly: Adversarial review — Codex
|
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/parser/docx/document.ts (1)
133-145: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winPreserve explicit OFF when resolving inherited vanish.
A direct
w:vanish w:val="0"does not stop the fallback style checks.runIsVanishcontinues tow:rStyle, andresolveParagraphVanishcontinues tovanishStyleIds. A direct OFF value can therefore still suppress text from an enabled character or paragraph style.Return a tri-state value: absent, ON, or OFF. Consult inherited styles only when the local value is absent.
src/parser/docx/document.ts#L133-L145: make explicit OFF terminal before checkingvanishCharStyleIds.src/parser/docx/document.ts#L187-L202: make explicit paragraph-mark OFF terminal before checkingvanishStyleIds.src/parser/docx/document.test.ts#L211-L243: add named regressions for direct-OFF character-style and paragraph-style overrides.docs/adr/092-object-text-walk-run-and-paragraph-suppression.md#L119-L137: revise the decision text to match the tested precedence.As per coding guidelines, add a regression test for every bug fix named after the observed symptom.
🤖 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/parser/docx/document.ts` around lines 133 - 145, Update runIsVanish and resolveParagraphVanish in src/parser/docx/document.ts (lines 133-145 and 187-202) to resolve local vanish values as absent, ON, or OFF, stopping inheritance when an explicit OFF is present before checking character or paragraph style IDs. Add named regressions in src/parser/docx/document.test.ts (lines 211-243) for direct-OFF character-style and paragraph-style overrides, and revise docs/adr/092-object-text-walk-run-and-paragraph-suppression.md (lines 119-137) to document the tested precedence.Source: Coding guidelines
🤖 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.
Inline comments:
In `@src/parser/docx/body-objects.ts`:
- Around line 187-192: Resolve character-style vanish through
StyleMap.vanishCharStyleIds before object capture and rewrite: update
hasRunVanish and the collectText path in src/parser/docx/body-objects.ts (lines
187-192 and 216-225), and the rewriteFirstText flow in
src/parser/docx/object-blob-edit.ts (lines 196-202), to recognize runs whose
w:rStyle applies vanish. Add capture regressions in
src/parser/docx/body-objects.test.ts (lines 1038-1076) and rewrite regressions
in src/parser/docx/object-blob-edit.test.ts (lines 277-339). Document the
behavior and updated handling in
docs/adr/092-object-text-walk-run-and-paragraph-suppression.md (lines 139-153).
---
Outside diff comments:
In `@src/parser/docx/document.ts`:
- Around line 133-145: Update runIsVanish and resolveParagraphVanish in
src/parser/docx/document.ts (lines 133-145 and 187-202) to resolve local vanish
values as absent, ON, or OFF, stopping inheritance when an explicit OFF is
present before checking character or paragraph style IDs. Add named regressions
in src/parser/docx/document.test.ts (lines 211-243) for direct-OFF
character-style and paragraph-style overrides, and revise
docs/adr/092-object-text-walk-run-and-paragraph-suppression.md (lines 119-137)
to document the tested precedence.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 08432a20-0523-4cea-94ef-05bb796c729c
📒 Files selected for processing (10)
docs/adr/092-object-text-walk-run-and-paragraph-suppression.mdsrc/parser/docx/body-objects.test.tssrc/parser/docx/body-objects.tssrc/parser/docx/body-order.tssrc/parser/docx/document.test.tssrc/parser/docx/document.tssrc/parser/docx/hidden-text.integration.test.tssrc/parser/docx/note-region-corpus.integration.test.tssrc/parser/docx/object-blob-edit.test.tssrc/parser/docx/object-blob-edit.ts
Why
Both issues are the same class of defect in
body-objects.ts's captured-object text walk (collectText/extractBlobText), and both surface throughhidden-text.integration.test.ts— investigated together, as directed.objectText, because the walk had no depth limit and no visibility check. This is the same privacy-class bug as fix(parser): body text-box visibility is decided per host-paragraph, not per text box (mixed visible/hidden boxes leak or suppress objectText) #515/ADR-087, one level deeper — the box-levelhiddenFlags/hiddenSubtreescorrelation never sees a nested boundary, so nothing catches it.hidden-text.integration.test.tswas failing againsthidden-text-test.docxbecause 4objectTextnodes contain bare asterisk-rule text. Investigating this (not blindly portinginference.ts's rule-row suppression) revealed it is not a parser defect —note-region-corpus.integration.test.tsalready has a deliberate, pre-existing, ADR-072-decision-14-backed test proving this SAME fixture's body table should render those SAME 4 asterisk-rule cells verbatim (captured object content is a faithful, out-of-band mirror of the source, never re-run through paragraph-tier suppression). The#294test's assertion simply predates that decision and was never reconciled with it.What
body-objects.ts:collectTextnow skips anyw:rflagged by a new presence-onlyhasRunVanishcheck, at whatever depth it's encountered — closing both the nested-text-box leak and the same-paragraph mixed visible/vanish-run leak with one mechanism, entirely inside the tree already being walked (no extension of the box-level correlation machinery, avoiding the fix(parser): decide body text-box visibility per box, not per host paragraph #636 near-miss's correlation-desync risk one level deeper).hidden-text.integration.test.ts: the "no bare-asterisk node anywhere" assertion is narrowed to excludeobjectTextnodes, with a comment citing ADR-072 decision 14 and the sibling test that already pins the verbatim behavior. This is the actual fix for test(parser): hidden-text-test.docx leaks a bare-asterisk objectText node (corpus-gated — does not reach CI) #633 — a test-scoping correction, not a behavior change.body-objects.test.ts: new regression tests for fix(parser): a hidden text box nested inside a visible text box leaks its interior text into objectText #641 (visible-outer/hidden-nested, same-paragraph mixed visible/vanish runs, hidden-outer/visible-nested pinning existing behavior, and a byte-identical round-trip assertion comparing real serialized bytes) and for test(parser): hidden-text-test.docx leaks a bare-asterisk objectText node (corpus-gated — does not reach CI) #633 (positive pins that a rule-row-only interior paragraph in both a text box and a table surfaces verbatim, replacing an earlier, incorrect suppression-based test).docs/adr/092-object-text-walk-run-and-paragraph-suppression.md: records both decisions — the fix(parser): a hidden text box nested inside a visible text box leaks its interior text into objectText #641 fix and the test(parser): hidden-text-test.docx leaks a bare-asterisk objectText node (corpus-gated — does not reach CI) #633 refutation, including why the first (incorrect) approach was tried and reverted.Investigation notes (both premises proven empirically, per sprint policy)
#641 — reproduced on an unmodified checkout, then fixed:
#633 — reproduced on an unmodified checkout; the "fix" that broke an existing test:
Implementing direction (a) from the issue (route object capture through
isRuleRow) immediately turnednote-region-corpus.integration.test.ts's "DOCX object-table verbatim rendering — hidden-text-test.docx (#300, ADR-072)" test red (expected 0 to be 4) — that test already pins this exact fixture's table rendering those exact 4 rule rows verbatim, by design. Reverted the code-level suppression; resolved via direction (b) instead (narrow the#294test).Related case named in #641 ("likely same fix"), confirmed and closed by the same mechanism: a single interior paragraph mixing one visible run and one
w:vanishrun (no nesting at all) leaked the vanish run's text the same way — pinned by its own regression test.Mutation verification
hasRunVanish(fix(parser): a hidden text box nested inside a visible text box leaks its interior text into objectText #641): temporarily forced to always returnfalse→ exactly the 2 vanish-specific unit tests failed ('a visible outer text box exposes only its own text…','a single interior paragraph mixing…'); nothing else broke. Reverted.hidden-text.integration.test.ts's narrowed assertion (test(parser): hidden-text-test.docx leaks a bare-asterisk objectText node (corpus-gated — does not reach CI) #633): temporarily disabledinference.ts'sclassifyOnerole === 'rule'branch (ADR-086's own paragraph-tier suppression) → the narrowed assertion still failed (expected true to be false), proving it still catches genuine paragraph-tier regressions and isn't vacuously excluding too much by droppingobjectText. Reverted.Fixture corpus revalidation
pnpm fixture:snapshot/pnpm fixture:diffover the full 705-fixture corpus, comparing an unmodified checkout against this branch's final code: 0/705 fixtures changed. (No fixture in the corpus exercises the #641 nested-text-box shape, and #633 ended up as a test-only change — so this is the expected result, not a false negative; logged in the ADR.) Same single pre-existing parse-error fixture (11_53_00nle.docx) before and after — not a regression.LOC note
This PR grows slightly past the file it touches most (
body-objects.test.ts) by the sprint's own accounting rule (fixtures/tests count differently, but flagging per policy): the diff is concentrated in one parser module + its test file + one test-scoping fix + one ADR, all one coherent investigation per the sprint's "one PR, one question" directive.Draft-phase review round (adversarial + independent verification)
Three further defects were found and fixed on this branch after the initial
implementation. All three are latent — no corpus fixture reaches any of
them — and full-corpus revalidation stayed at 0/705 changed throughout.
w:vanishwas read as element presence, not as the ST_OnOff toggle itis (
82a05540). Per ECMA-376 §17.3.2.45 an explicitw:valof0/false/offswitches the toggle OFF — a visible run. Because thenew
collectTextbranch DROPS text, reading presence as "hidden" wouldsilently delete visible spec text. The ADR had dismissed this as
"vanishingly rare"; it is not — two real CPI fixtures carry 15
<w:vanish w:val="0"/>runs between them, several text-bearing(
CPI_COMMUNICATIONS_RACK_MOUNTED_POWER_PROTECTION_CSIMFS.docxholds"Select voltage/phase; breaker number, " and "rating" in exactly that
shape). Same fix applied to
document.ts'srunIsVanish/paragraphMarkVanish, which had the identical bug (8e083953,fix(cross):).The object EDIT walk was not updated to match the capture walk
(
039e1f77) — found by the Codex adversarial review. Dropping vanish runschanged what
objectTextmeans, butobject-blob-edit.ts'srewriteFirstTextstill assumed "all text", despite a docstring promisingit mirrors
collectText. On a paragraph whose hidden run precedes itsvisible one, an edit landed in the hidden run while the visible run
was blanked — the edit disappears from the document and real text is
destroyed. Fixed by exporting
hasRunVanishand reusing it, so the twowalks cannot drift apart again.
Every gate added or changed here is mutation-verified in both directions,
so none of them can silently degrade into a no-op:
#294assertionclassifyOne'srole === 'rule'branchexpected true to be false) — still catches the paragraph-tier classhasRunVanish(object walk)hasRunVanish(object walk)runIsVanish/paragraphMarkVanishrunIsVanish/paragraphMarkVanishKnown divergence, deliberately NOT fixed here
merge/extract.ts'svisibleTextstill reads hidden runs: for anSDT-anchored cell paragraph mixing a hidden and a visible run it extracts
"HIDDEN SECRETvisible text"where the AST now stores"visible text", so anuntouched round-tripped DOCX can report as modified. The obvious remedy —
make
visibleTextskip vanish runs — is wrong as stated: it is merge'sgeneral paragraph walk, and the paragraph tier deliberately KEEPS hidden-run
text in a mixed paragraph (
document.ts'sextractParagraphText, pinned bya KNOWN AMBIGUITY test). A blanket skip would create the mirror divergence
for every ordinary paragraph. A correct fix must scope visibility to object
interiors only — a merge-engine semantics change affecting every table-cell
paragraph, owed its own ADR and PR. Recorded in ADR-092 rather than left to
be rediscovered; latent today (0/705).
Testing
pnpm lint(eslint + tsc --noEmit + prettier --check)pnpm buildpnpm test— 252 files / 3563 tests passedpnpm test:integration— 169 files passed, 1 skipped / 2053 tests passed, 2 skipped (full suite, DB-backed)hidden-text.integration.test.tsandnote-region-corpus.integration.test.tsboth greenpnpm fixture:snapshot/pnpm fixture:diff— 0/705 changed🤖 Co-authored by Claude Sonnet 5. Closes #641. Closes #633.
Summary by CodeRabbit
New Features
Bug Fixes
Documentation