From a261347863f4fd59a69b019d79752e28fb33ea45 Mon Sep 17 00:00:00 2001 From: thewrz Date: Tue, 4 Aug 2026 13:59:02 -0700 Subject: [PATCH 1/9] fix(merge): visibleText skips vanish runs at object interiors (#648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/api/diff.integration.test.ts | 147 +++++++++++++++++++++++++++++++ src/merge/extract.test.ts | 40 +++++++++ src/merge/extract.ts | 101 ++++++++++++++++----- src/parser/docx/index.ts | 5 ++ src/parser/index.ts | 1 + 5 files changed, 274 insertions(+), 20 deletions(-) diff --git a/src/api/diff.integration.test.ts b/src/api/diff.integration.test.ts index 09d24f71..684b838c 100644 --- a/src/api/diff.integration.test.ts +++ b/src/api/diff.integration.test.ts @@ -317,6 +317,48 @@ function anchoredTableMeta(anchorUuid: string, text: string): ObjectMeta { }; } +/** + * #648: a table cell mixing one hidden (w:vanish) run and one visible run. + * `visibleText` matches ONLY the visible run — the object tier's AST + * (objectText, via parser/docx/body-objects.ts's collectText) already drops + * hidden runs (#641/ADR-092), so this is what a correctly round-tripped + * capture must store. Before the #648 fix, merge/extract.ts's visibleText + * had no vanish handling at all and would concatenate the hidden run's text + * in front of the visible one, diverging from this AST snapshot and reading + * an untouched round-tripped DOCX as modified. + */ +function anchoredMixedVisibilityTableMeta( + anchorUuid: string, + hiddenText: string, + visibleRunText: string +): ObjectMeta { + const anchoredCell: ObjectBlobNode = { + 'w:sdt': [ + { 'w:sdtPr': [{ 'w:tag': [], ':@': { '@_w:val': `specr-uuid-${anchorUuid}` } }] }, + { + 'w:sdtContent': [ + { + 'w:p': [ + { + 'w:r': [{ 'w:rPr': [{ 'w:vanish': [] }] }, { 'w:t': [{ '#text': hiddenText }] }], + }, + { 'w:r': [{ 'w:t': [{ '#text': visibleRunText }] }] }, + ], + }, + ], + }, + ], + } as ObjectBlobNode; + return { + kind: 'table', + floating: false, + generation: 'drawingml', + rows: 1, + columns: 1, + blob: [{ 'w:tbl': [{ 'w:tr': [{ 'w:tc': [anchoredCell] }] }] }], + }; +} + describe('body-level object round-trip — real wiring (#520 review finding)', () => { const OBJ_PART_ID = randomUUID(); const OBJ_OBJECT_ID = randomUUID(); @@ -458,3 +500,108 @@ describe('body-level object round-trip — real wiring (#520 review finding)', ( } ); }); + +// #648: an object-interior table cell mixing a hidden and a visible run. +// Before the fix, merge/extract.ts's visibleText had no w:vanish handling at +// all, so it disagreed with the AST (objectText, which already drops hidden +// runs — #641/ADR-092): an untouched round-tripped DOCX with this shape would +// report as modified. Proved at the real wiring — DB → generateDocx → +// extractContentControls → getObjectStructuralSnapshots → computeDiff — not +// just at the unit level, since that is where the two code paths' divergence +// actually surfaces as a false diff entry. +describe('body-level object round-trip — hidden/visible run mix (#648)', () => { + const VAN_PART_ID = randomUUID(); + const VAN_OBJECT_ID = randomUUID(); + const VAN_TEXT_ID = randomUUID(); + const VAN_HIDDEN_TEXT = 'Hidden internal guidance. '; + const VAN_VISIBLE_TEXT = 'Visible spec cell text.'; + let vanSpecId: string; + + beforeAll(async () => { + vanSpecId = await createSpec({ + section: '09 91 26', + title: 'Object Vanish Diff Wiring Spec', + source: `d648diff_${randomUUID().slice(0, 8)}`, + }); + await insertTree( + { + id: vanSpecId, + section: '09 91 26', + title: 'Object Vanish Diff Wiring Spec', + parts: [ + { + id: VAN_PART_ID, + type: 'part', + text: 'GENERAL', + meta: {}, + children: [ + { + id: VAN_OBJECT_ID, + type: 'object', + text: '', + meta: { + object: anchoredMixedVisibilityTableMeta( + VAN_TEXT_ID, + VAN_HIDDEN_TEXT, + VAN_VISIBLE_TEXT + ), + }, + children: [ + { + id: VAN_TEXT_ID, + type: 'objectText', + // Matches the object tier's AST (visible run only) — + // never the hidden run's text (#641/ADR-092). + text: VAN_VISIBLE_TEXT, + meta: {}, + children: [], + }, + ], + }, + ], + }, + ], + }, + vanSpecId, + pool + ); + }); + + afterAll(async () => { + await pool.query('DELETE FROM specs WHERE id = $1', [vanSpecId]); + }); + + it( + 'an unmodified generated DOCX round-trips with an empty diff — the hidden run never ' + + 'leaks into the object-interior text merge extracts', + async () => { + const generateRes = await fetch(`${baseUrl}/specs/${vanSpecId}/generate`, { + method: 'POST', + }); + expect(generateRes.status).toBe(200); + const buffer = Buffer.from(await generateRes.arrayBuffer()); + + const form = new FormData(); + form.append( + 'file', + new Blob([new Uint8Array(buffer)], { type: DOCX_MIME }), + 'object-vanish.docx' + ); + const diffRes = await fetch(`${baseUrl}/specs/${vanSpecId}/diff`, { + method: 'POST', + body: form, + }); + const body = (await diffRes.json()) as ApiResponse; + + expect(diffRes.status).toBe(200); + expect(body.data).toEqual({ + added: [], + modified: [], + deleted: [], + conflicts: [], + objectConflicts: [], + warnings: [], + }); + } + ); +}); diff --git a/src/merge/extract.test.ts b/src/merge/extract.test.ts index 8eafbf24..47491afa 100644 --- a/src/merge/extract.test.ts +++ b/src/merge/extract.test.ts @@ -71,6 +71,13 @@ const tableRow = (cellsXml: readonly string[]): string => `${cellsXml.map(tableCell).join('')}`; const table = (rowsXml: readonly string[]): string => `${rowsXml.join('')}`; +/** A run with w:vanish bare/enabled (ST_OnOff ON — hidden). */ +const hiddenRun = (text: string): string => + `${text}`; +/** A run with w:vanish explicitly val="0" (ST_OnOff OFF — visible, #648). */ +const visibleOffRun = (text: string): string => + `${text}`; + describe('extractContentControls', () => { it('roundtrip: recovers every SpecNode id → text from generateDocx output', async () => { const buffer = await generateDocx(TREE); @@ -353,3 +360,36 @@ describe('extractContentControls — object-block extraction (#520)', () => { expect(result.objectBlocks[0]?.interiorUuids).toEqual([U1, U2]); }); }); + +// #648: visibleText had no w:vanish handling at all, so it disagreed with the +// object-tier AST walk (which already drops hidden runs — #641/ADR-092): an +// untouched round-tripped DOCX with this shape could report as modified. The +// fix is deliberately scoped to OBJECT INTERIORS ONLY (table/drawing/pict) — +// the ordinary paragraph tier's KNOWN AMBIGUITY (document.test.ts near line +// 259: a mixed hidden/visible paragraph is treated as visible) must not +// change, and is pinned explicitly below as the regression this issue most +// risks. +describe('extractContentControls — object-interior vanish handling (#648)', () => { + it('object interior: a table-cell paragraph mixing hidden and visible runs extracts only the visible text', async () => { + const body = table([tableRow([sdt(U1, para(hiddenRun('secret ') + run('visible')))])]); + const result = await extractContentControls(await craftDocx(body)); + expect(result.controlled.get(U1)).toBe('visible'); + }); + + // KNOWN AMBIGUITY (unchanged by #648, mirrors document.test.ts near line 259): + // an ORDINARY paragraph mixing hidden and visible runs is NOT object-interior, + // so it still yields both concatenated — the visible text is real content, and + // only a fully-hidden paragraph would drop out. A blanket hidden-run skip here + // was proposed during PR 647's review and rejected on exactly this ground. + it('ordinary paragraph tier is UNCHANGED: a mixed hidden/visible paragraph still yields both, concatenated', async () => { + const body = sdt(U1, para(hiddenRun('secret ') + run('visible'))); + const result = await extractContentControls(await craftDocx(body)); + expect(result.controlled.get(U1)).toBe('secret visible'); + }); + + it('object interior: a run whose w:vanish carries val="0" is VISIBLE (ST_OnOff toggle, not presence-only)', async () => { + const body = table([tableRow([sdt(U1, para(visibleOffRun('kept ') + run('too')))])]); + const result = await extractContentControls(await craftDocx(body)); + expect(result.controlled.get(U1)).toBe('kept too'); + }); +}); diff --git a/src/merge/extract.ts b/src/merge/extract.ts index 81244c24..bd3ced8a 100644 --- a/src/merge/extract.ts +++ b/src/merge/extract.ts @@ -4,6 +4,15 @@ import { UUID_TAG_PREFIX } from '../ast/index.js'; import { MergeError } from './error.js'; import { fingerprintBlob } from './object-fingerprint.js'; import type { ExtractResult, ExtractedObjectBlock, TrackChangeRecord } from './types.js'; +import type { ObjectBlobNode } from '../ast/index.js'; +// #648/ADR-092: a narrow, deliberate exception to module-boundaries.md's +// "merge/ knows nothing about parsing" prose line — hasRunVanish is the +// SINGLE ST_OnOff-aware vanish predicate shared by object capture +// (body-objects.ts) and object-blob edit rewrite (object-blob-edit.ts); a +// second copy here would reintroduce the exact capture/rewrite drift +// ADR-092 closed. Reached through the parser's own barrel (parser/index.ts), +// per the repo's sibling-barrel-only import rule. +import { hasRunVanish } from '../parser/index.js'; // preserveOrder keeps w:sdt blocks and bare w:p siblings in document order — // required for orphan indexes (non-preserveOrder grouping destroys ordering). @@ -20,10 +29,13 @@ const xmlParser = new XMLParser({ }); // fast-xml-parser preserveOrder node: one element key → children array, -// '#text' → string, ':@' → attribute record. -interface OrderedNode { - readonly [key: string]: unknown; -} +// '#text' → string, ':@' → attribute record. This is the SAME runtime shape +// as ast/object-schemas.ts's ObjectBlobNode (both describe fast-xml-parser's +// preserveOrder output), so extract.ts aliases it directly rather than +// keeping a second, duplicate node-shape definition — that alignment is +// also what lets hasRunVanish (which is typed against ObjectBlobNode) accept +// an extract.ts node with zero cross-boundary cast (#648). +type OrderedNode = ObjectBlobNode; interface ParaContext { readonly uuid: string | undefined; @@ -32,6 +44,14 @@ interface ParaContext { * text-box run (visitRunNode's w:drawing/w:pict branch) capture interior * specr-uuid anchors without threading the whole ExtractAcc through. */ readonly controlled: Map; + /** true once the walk has descended into a body-level object block + * (w:tbl/w:drawing/w:pict — OBJECT_BLOCK_TAGS). Object-interior paragraphs + * must match the AST's objectText, which already drops hidden runs + * (issue #641/ADR-092); the ordinary paragraph tier's KNOWN AMBIGUITY + * (a mixed hidden/visible paragraph is treated as visible — see + * document.test.ts near line 259) is deliberately untouched outside an + * object interior. See visibleText's use of this flag (#648). */ + readonly objectInterior: boolean; } interface ExtractAcc { @@ -65,8 +85,12 @@ function childrenOf(node: OrderedNode, tag: string): readonly OrderedNode[] { function attrStr(node: OrderedNode, name: string): string | undefined { const attrs = node[':@']; - if (typeof attrs !== 'object' || attrs === null) return undefined; - const value = (attrs as Record)[name]; + // ObjectBlobNode's `:@` is `Readonly> | + // undefined` (an optional property, never nullable) — `typeof attrs !== + // 'object'` alone already excludes `undefined` (whose typeof is + // 'undefined'), so no separate null check or value cast is needed. + if (typeof attrs !== 'object') return undefined; + const value = attrs[name]; return typeof value === 'string' ? value : undefined; } @@ -149,7 +173,9 @@ function visitRunNode(node: OrderedNode, tag: string, ctx: ParaContext): string * are discarded — a drawing/shape body has no CSI tier to anchor an addition * against, mirroring the table-cell anchorless rule. Track-change records * ARE preserved: `ctx.records` is the same array reference as the top-level - * walk's. + * walk's. objectInterior is forced true (#648): this function is only ever + * reached from visitRunNode's w:drawing/w:pict dispatch, i.e. a text-box + * interior, which is object territory by construction. */ function collectDrawingAnchors(nodes: readonly OrderedNode[], ctx: ParaContext): void { const throwawayAcc: ExtractAcc = { @@ -157,15 +183,29 @@ function collectDrawingAnchors(nodes: readonly OrderedNode[], ctx: ParaContext): orphans: [], records: ctx.records, }; - walkBlocks(nodes, undefined, throwawayAcc, { index: 0, lastControlledUuid: undefined }, false); + walkBlocks( + nodes, + undefined, + throwawayAcc, + { index: 0, lastControlledUuid: undefined }, + false, + true + ); } -/** Visible (post virtual-accept) text of paragraph content nodes, in order. */ +/** Visible (post virtual-accept) text of paragraph content nodes, in order. + * Inside an object interior (ctx.objectInterior), a run whose w:vanish is + * enabled (hasRunVanish, ST_OnOff-aware) is skipped — matching the object + * tier's objectText, which already drops the same runs (#641/ADR-092). This + * check is intentionally scoped to objectInterior only: the ordinary + * paragraph tier's KNOWN AMBIGUITY (a mixed hidden/visible paragraph reads + * as visible) is out of scope for #648 and must not change. */ function visibleText(nodes: readonly OrderedNode[], ctx: ParaContext): string { let out = ''; for (const node of nodes) { const tag = tagOf(node); if (tag === undefined || tag === '#text' || PROPERTY_TAGS.has(tag)) continue; + if (ctx.objectInterior && hasRunVanish(node)) continue; out += visitRunNode(node, tag, ctx); } return out; @@ -204,12 +244,14 @@ function visitParagraph( uuid: string | undefined, acc: ExtractAcc, state: WalkState, - inTable: boolean + inTable: boolean, + objectInterior: boolean ): WalkState { const text = visibleText(childrenOf(node, 'w:p'), { uuid, records: acc.records, controlled: acc.controlled, + objectInterior, }); if (!text.trim()) return state; // whitespace-only spacer paragraphs ignored if (uuid !== undefined) { @@ -226,34 +268,52 @@ function visitParagraph( return { index: state.index + 1, lastControlledUuid: state.lastControlledUuid }; } -/** Walk block-level nodes in document order, tracking the enclosing sdt uuid - * and whether the walk has descended into a w:tbl (table cells never anchor). */ +const OBJECT_BLOCK_TAGS = new Set(['w:tbl', 'w:drawing', 'w:pict']); + +/** Walk block-level nodes in document order, tracking the enclosing sdt uuid, + * whether the walk has descended into a w:tbl (table cells never anchor), + * and whether it has descended into a body-level object block (#648 — + * OBJECT_BLOCK_TAGS: w:tbl/w:drawing/w:pict; a visibleText vanish-skip). */ function walkBlocks( nodes: readonly OrderedNode[], uuid: string | undefined, acc: ExtractAcc, state: WalkState, - inTable: boolean + inTable: boolean, + objectInterior: boolean ): WalkState { let s = state; for (const node of nodes) { const tag = tagOf(node); if (tag === undefined || tag === '#text' || PROPERTY_TAGS.has(tag)) continue; if (tag === 'w:sdt') { - s = walkBlocks(childrenOf(node, tag), readSdtUuid(node) ?? uuid, acc, s, inTable); + s = walkBlocks( + childrenOf(node, tag), + readSdtUuid(node) ?? uuid, + acc, + s, + inTable, + objectInterior + ); } else if (tag === 'w:p') { - s = visitParagraph(node, uuid, acc, s, inTable); + s = visitParagraph(node, uuid, acc, s, inTable, objectInterior); } else { // w:document, w:body, w:sdtContent, w:tbl, … — a w:tbl marks its whole - // subtree as table-descended so its cell paragraphs stay anchorless. - s = walkBlocks(childrenOf(node, tag), uuid, acc, s, inTable || tag === 'w:tbl'); + // subtree as table-descended so its cell paragraphs stay anchorless; + // any OBJECT_BLOCK_TAGS tag also marks its subtree as object-interior. + s = walkBlocks( + childrenOf(node, tag), + uuid, + acc, + s, + inTable || tag === 'w:tbl', + objectInterior || OBJECT_BLOCK_TAGS.has(tag) + ); } } return s; } -const OBJECT_BLOCK_TAGS = new Set(['w:tbl', 'w:drawing', 'w:pict']); - /** Every specr-uuid `w:sdt` anchor's uuid found anywhere in `nodes`, in document order. */ function findInteriorUuids(nodes: readonly OrderedNode[]): string[] { const uuids: string[] = []; @@ -334,7 +394,8 @@ export async function extractContentControls(docxBuffer: Buffer): Promise Date: Tue, 4 Aug 2026 14:30:41 -0700 Subject: [PATCH 2/9] test(merge): pin hasRunVanish as the single w:vanish predicate under src/merge/ (#648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encodes the manual "grep -rn vanish src/merge/" audit from #648's verification (commit a2613478) 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 --- src/merge/extract.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/merge/extract.test.ts b/src/merge/extract.test.ts index 47491afa..b716bf41 100644 --- a/src/merge/extract.test.ts +++ b/src/merge/extract.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect } from 'vitest'; import JSZip from 'jszip'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { generateDocx } from '../generator/index.js'; import { extractContentControls } from './extract.js'; import { MergeError } from './error.js'; import type { SpecTree } from '../ast/types.js'; +const MERGE_DIR = join(process.cwd(), 'src/merge'); + const PART_ID = '00000000-0000-0000-0000-000000000002'; const ART_ID = '00000000-0000-0000-0000-000000000003'; const PR1_ID = '00000000-0000-0000-0000-000000000004'; @@ -392,4 +396,31 @@ describe('extractContentControls — object-interior vanish handling (#648)', () const result = await extractContentControls(await craftDocx(body)); expect(result.controlled.get(U1)).toBe('kept too'); }); + + // Structural guard (not a behavior test): #648's fix reaches vanish detection + // through the single, already-existing hasRunVanish predicate (body-objects.ts, + // #641/ADR-092) rather than growing a second, drifting w:vanish reader inside + // merge/. Pins the manual "grep -rn vanish src/merge/" audit from #648's + // verification as an automated regression guard, so a future change can't + // silently reintroduce a second predicate that disagrees with the first. + it('src/merge/*.ts touches raw w:vanish only through extract.ts, and only via the imported hasRunVanish', () => { + const nonTestFiles = readdirSync(MERGE_DIR).filter( + (name) => name.endsWith('.ts') && !name.endsWith('.test.ts') + ); + + const filesReferencingRawVanishTag = nonTestFiles.filter((name) => + readFileSync(join(MERGE_DIR, name), 'utf8').includes('w:vanish') + ); + expect(filesReferencingRawVanishTag).toEqual(['extract.ts']); + + const extractSrc = readFileSync(join(MERGE_DIR, 'extract.ts'), 'utf8'); + expect(extractSrc).toMatch( + /import\s*\{\s*hasRunVanish\s*\}\s*from\s*'\.\.\/parser\/index\.js';/ + ); + // No locally-declared vanish predicate alongside the import — hasRunVanish + // must remain the ONLY definition, never a second, independently-drifting one. + const localVanishDeclarations = + extractSrc.match(/\b(?:function|const)\s+\w*[Vv]anish\w*\s*[:(=]/g) ?? []; + expect(localVanishDeclarations).toEqual([]); + }); }); From ae943c7314efce686cb7d135c76ca9d436561f08 Mon Sep 17 00:00:00 2001 From: thewrz Date: Tue, 4 Aug 2026 14:58:14 -0700 Subject: [PATCH 3/9] test(merge): cover collectDrawingAnchors vanish-skip for drawing/pict objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/merge/extract.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/merge/extract.test.ts b/src/merge/extract.test.ts index b716bf41..7f8c284b 100644 --- a/src/merge/extract.test.ts +++ b/src/merge/extract.test.ts @@ -397,6 +397,32 @@ describe('extractContentControls — object-interior vanish handling (#648)', () expect(result.controlled.get(U1)).toBe('kept too'); }); + // collectDrawingAnchors (the w:drawing/w:pict text-box interior walk, gap-1 + // #520) forces objectInterior=true through a call site separate from the + // generic OBJECT_BLOCK_TAGS union path that reaches w:tbl. Without a test + // driving a hidden/visible mixed run through THIS specific path, a + // regression that broke vanish-skipping only for drawing/pict (e.g. + // collectDrawingAnchors's hard-coded `true` reverted to `false`, or never + // threaded through at all) would pass every other #648 test while silently + // reintroducing the false-modified bug for 2 of the 3 OBJECT_BLOCK_TAGS. + it('object interior (DrawingML text box): a mixed hidden/visible paragraph inside the box extracts only the visible text', async () => { + const body = sdt( + U1, + para(run('before ') + drawingTextBoxRun(sdt(U2, para(hiddenRun('secret ') + run('visible'))))) + ); + const result = await extractContentControls(await craftDocx(body)); + expect(result.controlled.get(U2)).toBe('visible'); + }); + + it('object interior (VML text box): a mixed hidden/visible paragraph inside the box extracts only the visible text', async () => { + const body = sdt( + U1, + para(run('before ') + vmlTextBoxRun(sdt(U2, para(hiddenRun('secret ') + run('visible'))))) + ); + const result = await extractContentControls(await craftDocx(body)); + expect(result.controlled.get(U2)).toBe('visible'); + }); + // Structural guard (not a behavior test): #648's fix reaches vanish detection // through the single, already-existing hasRunVanish predicate (body-objects.ts, // #641/ADR-092) rather than growing a second, drifting w:vanish reader inside From cf4300e7f3698ff44f5ea515ac1173929b06f257 Mon Sep 17 00:00:00 2001 From: thewrz Date: Tue, 4 Aug 2026 15:06:41 -0700 Subject: [PATCH 4/9] test(merge): decouple #648 structural guard from comment prose 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 --- src/merge/extract.test.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/merge/extract.test.ts b/src/merge/extract.test.ts index 7f8c284b..8451f209 100644 --- a/src/merge/extract.test.ts +++ b/src/merge/extract.test.ts @@ -425,28 +425,36 @@ describe('extractContentControls — object-interior vanish handling (#648)', () // Structural guard (not a behavior test): #648's fix reaches vanish detection // through the single, already-existing hasRunVanish predicate (body-objects.ts, - // #641/ADR-092) rather than growing a second, drifting w:vanish reader inside + // #641/ADR-092) rather than growing a second, drifting vanish reader inside // merge/. Pins the manual "grep -rn vanish src/merge/" audit from #648's // verification as an automated regression guard, so a future change can't // silently reintroduce a second predicate that disagrees with the first. - it('src/merge/*.ts touches raw w:vanish only through extract.ts, and only via the imported hasRunVanish', () => { + // + // Deliberately does NOT scan file contents for the literal string "w:vanish" + // — that coupled the guard to a doc comment's exact prose (#650) and made a + // purely cosmetic reword of extract.ts's comments fail CI with no functional + // change. Instead it asserts the two things that actually matter: no file + // declares its own vanish-named predicate, and extract.ts both imports and + // calls the shared one. + it('src/merge/*.ts declares no local vanish predicate, and extract.ts calls the imported hasRunVanish', () => { const nonTestFiles = readdirSync(MERGE_DIR).filter( (name) => name.endsWith('.ts') && !name.endsWith('.test.ts') ); - const filesReferencingRawVanishTag = nonTestFiles.filter((name) => - readFileSync(join(MERGE_DIR, name), 'utf8').includes('w:vanish') - ); - expect(filesReferencingRawVanishTag).toEqual(['extract.ts']); + // No locally-declared vanish predicate anywhere in merge/ — hasRunVanish + // must remain the ONLY definition, never a second, independently-drifting one. + for (const name of nonTestFiles) { + const src = readFileSync(join(MERGE_DIR, name), 'utf8'); + const localVanishDeclarations = + src.match(/\b(?:function|const)\s+\w*[Vv]anish\w*\s*[:(=]/g) ?? []; + expect(localVanishDeclarations).toEqual([]); + } const extractSrc = readFileSync(join(MERGE_DIR, 'extract.ts'), 'utf8'); expect(extractSrc).toMatch( /import\s*\{\s*hasRunVanish\s*\}\s*from\s*'\.\.\/parser\/index\.js';/ ); - // No locally-declared vanish predicate alongside the import — hasRunVanish - // must remain the ONLY definition, never a second, independently-drifting one. - const localVanishDeclarations = - extractSrc.match(/\b(?:function|const)\s+\w*[Vv]anish\w*\s*[:(=]/g) ?? []; - expect(localVanishDeclarations).toEqual([]); + // The import must actually be exercised, not just present. + expect(extractSrc).toMatch(/\bhasRunVanish\(/); }); }); From fbb3ea3a2b7dd95f94c2a369a94935052e46d915 Mon Sep 17 00:00:00 2001 From: thewrz Date: Tue, 4 Aug 2026 15:39:43 -0700 Subject: [PATCH 5/9] docs(merge): record the measured parser-barrel cost at the hasRunVanish import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/merge/extract.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/merge/extract.ts b/src/merge/extract.ts index bd3ced8a..ff69cebe 100644 --- a/src/merge/extract.ts +++ b/src/merge/extract.ts @@ -12,6 +12,18 @@ import type { ObjectBlobNode } from '../ast/index.js'; // second copy here would reintroduce the exact capture/rewrite drift // ADR-092 closed. Reached through the parser's own barrel (parser/index.ts), // per the repo's sibling-barrel-only import rule. +// +// The barrel does transitively load parser/pdf/index.ts's static pdfjs-dist / +// unpdf / tesseract.js imports (~+370ms, ~+290MB RSS on a cold graph), so this +// line was reviewed as a possible cold-start regression and deliberately kept: +// every production path that reaches merge/ ALREADY loads parser/index.js in +// the same file — src/api/diff.ts imports assertDocxSafe one line above its +// ../merge/index.js import, as does src/mcp/handlers.ts — and no merge-only +// worker, script, or CLI exists. Net production cost is therefore zero; the +// only module graph that grows is merge/extract.test.ts (31 tests, ~1.4s). +// Deep-importing ../parser/docx/body-objects.js instead would trade that zero +// for a strictly deeper violation of the same module-boundaries.md rule, and +// relocating hasRunVanish is what ADR-092 exists to prevent. import { hasRunVanish } from '../parser/index.js'; // preserveOrder keeps w:sdt blocks and bare w:p siblings in document order — From 298e338ad8c4399f6454a556fe187b2f1e6d9e54 Mon Sep 17 00:00:00 2001 From: thewrz Date: Tue, 4 Aug 2026 15:59:55 -0700 Subject: [PATCH 6/9] fix(merge): fingerprint the host w:p for textBox/pict object blocks (#652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- src/api/diff.integration.test.ts | 158 +++++++++++++++++++++++++++++++ src/merge/extract.ts | 55 ++++++++++- 2 files changed, 209 insertions(+), 4 deletions(-) diff --git a/src/api/diff.integration.test.ts b/src/api/diff.integration.test.ts index 684b838c..e4536c9a 100644 --- a/src/api/diff.integration.test.ts +++ b/src/api/diff.integration.test.ts @@ -605,3 +605,161 @@ describe('body-level object round-trip — hidden/visible run mix (#648)', () => } ); }); + +/** + * #652: a textBox-kind body object, whose blob root is the HOST body + * paragraph (`w:p`) carrying the `w:r > w:drawing` run — the capture shape + * `parser/docx/body-objects.ts` documents in its module comment and + * `buildTextBoxObject` actually produces (`blob: [anchored.node]`, where + * `anchorInteriorParagraphs` preserves the root's own tag and only wraps + * INTERIOR paragraphs with SDT anchors). + * + * A table's blob root is the `w:tbl` itself, which is also exactly what + * `merge/extract.ts`'s `walkObjectBlocks` matches — so the table tier is + * symmetric and every existing table-based test passes. For a textBox the + * two sides used to hash different trees: base fingerprinted the host `w:p`, + * theirs fingerprinted the bare `w:drawing`, so `fingerprintsDiverge` was + * ALWAYS true and any untouched round trip false-reported an objectConflict. + */ +function anchoredTextBoxMeta(anchorUuid: string, text: string): ObjectMeta { + const anchoredInterior: ObjectBlobNode = { + 'w:sdt': [ + { 'w:sdtPr': [{ 'w:tag': [], ':@': { '@_w:val': `specr-uuid-${anchorUuid}` } }] }, + { 'w:sdtContent': [{ 'w:p': [{ 'w:r': [{ 'w:t': [{ '#text': text }] }] }] }] }, + ], + } as ObjectBlobNode; + return { + kind: 'textBox', + floating: false, + generation: 'drawingml', + blob: [ + { + 'w:p': [ + { + 'w:r': [ + { + 'w:drawing': [ + { + 'wp:inline': [ + { + 'a:graphic': [ + { + 'a:graphicData': [ + { + 'wps:wsp': [ + { 'wps:txbx': [{ 'w:txbxContent': [anchoredInterior] }] }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; +} + +// #652: the textBox counterpart of the table-kind wiring test above. Proved +// at the real wiring — DB → generateDocx → extractContentControls → +// getObjectStructuralSnapshots → computeDiff — because the asymmetry is +// between two production call sites (diff.ts fingerprints the stored blob +// root; extract.ts fingerprints the matched OBJECT_BLOCK_TAGS node), so a +// unit test on either side alone cannot see it. +describe('body-level object round-trip — textBox fingerprint symmetry (#652)', () => { + const TB_PART_ID = randomUUID(); + const TB_OBJECT_ID = randomUUID(); + const TB_TEXT_ID = randomUUID(); + const TB_TEXT = 'Text box interior paragraph.'; + let tbSpecId: string; + + beforeAll(async () => { + tbSpecId = await createSpec({ + section: '09 91 26', + title: 'Object TextBox Diff Wiring Spec', + source: `d652diff_${randomUUID().slice(0, 8)}`, + }); + await insertTree( + { + id: tbSpecId, + section: '09 91 26', + title: 'Object TextBox Diff Wiring Spec', + parts: [ + { + id: TB_PART_ID, + type: 'part', + text: 'GENERAL', + meta: {}, + children: [ + { + id: TB_OBJECT_ID, + type: 'object', + text: '', + meta: { object: anchoredTextBoxMeta(TB_TEXT_ID, TB_TEXT) }, + children: [ + { + id: TB_TEXT_ID, + type: 'objectText', + text: TB_TEXT, + meta: {}, + children: [], + }, + ], + }, + ], + }, + ], + }, + tbSpecId, + pool + ); + }); + + afterAll(async () => { + // id-scoped teardown: only the spec row this describe block created. + await pool.query('DELETE FROM specs WHERE id = $1', [tbSpecId]); + }); + + it('an unmodified generated DOCX round-trips with ZERO objectConflicts', async () => { + const generateRes = await fetch(`${baseUrl}/specs/${tbSpecId}/generate`, { method: 'POST' }); + expect(generateRes.status).toBe(200); + const buffer = Buffer.from(await generateRes.arrayBuffer()); + + const form = new FormData(); + form.append( + 'file', + new Blob([new Uint8Array(buffer)], { type: DOCX_MIME }), + 'object-textbox.docx' + ); + const diffRes = await fetch(`${baseUrl}/specs/${tbSpecId}/diff`, { + method: 'POST', + body: form, + }); + const body = (await diffRes.json()) as ApiResponse; + + expect(diffRes.status).toBe(200); + // The assertion that fails without the #652 fix: base hashed the host + // w:p, theirs hashed the bare w:drawing, so objectConflicts held one + // entry. Asserting the WHOLE diff (not objectConflicts alone) also keeps + // this gate from going vacuous: `detectObjectConflicts` only reports a + // fingerprint divergence for a block `findMatchingBlock` actually matched + // by interior uuid, so a future regression that stopped + // `findInteriorUuids` from seeing the text box's interior anchor would + // silently satisfy an objectConflicts-only check — but it would surface + // here as a `deleted`/`modified` entry for the objectText uuid instead. + expect(body.data).toEqual({ + added: [], + modified: [], + deleted: [], + conflicts: [], + objectConflicts: [], + warnings: [], + }); + }); +}); diff --git a/src/merge/extract.ts b/src/merge/extract.ts index ff69cebe..3abe692a 100644 --- a/src/merge/extract.ts +++ b/src/merge/extract.ts @@ -352,7 +352,8 @@ function findInteriorUuids(nodes: readonly OrderedNode[]): string[] { function walkObjectBlocks( nodes: readonly OrderedNode[], inBlock: boolean, - blocks: ExtractedObjectBlock[] + blocks: ExtractedObjectBlock[], + hostParagraph: OrderedNode | undefined ): void { for (const node of nodes) { const tag = tagOf(node); @@ -361,16 +362,62 @@ function walkObjectBlocks( if (isObjectTag && !inBlock) { blocks.push({ interiorUuids: findInteriorUuids(childrenOf(node, tag)), - fingerprint: fingerprintBlob([node]), + fingerprint: fingerprintBlob([fingerprintRoot(node, tag, hostParagraph)]), }); } - walkObjectBlocks(childrenOf(node, tag), inBlock || isObjectTag, blocks); + walkObjectBlocks( + childrenOf(node, tag), + inBlock || isObjectTag, + blocks, + // A w:p becomes the host for any drawing run nested beneath it; deeper + // non-paragraph nodes inherit it unchanged. + tag === 'w:p' ? node : hostParagraph + ); } } +/** Tags whose captured blob root is the HOST body `w:p`, not the tag itself. */ +const PARAGRAPH_HOSTED_OBJECT_TAGS = new Set(['w:drawing', 'w:pict']); + +/** + * The node to fingerprint for a detected object block (#652) — it MUST be the + * same root that capture stored as `ObjectMeta.blob[0]`, because diff.ts's + * `detectObjectConflicts` fingerprints that stored blob directly and compares + * the two hashes. + * + * The two capture kinds do not agree on what their root is + * (parser/docx/body-objects.ts's module comment, "Two capture paths, one + * shape"): a table's blob root is the `w:tbl` itself, so the matched tag IS + * the root; a textBox/pict's blob root is the HOST body paragraph (`w:p`) + * carrying the `w:r > w:drawing`/`w:pict` run, so the matched tag is one + * wrapper layer BELOW the root. + * + * Fingerprinting the matched tag unconditionally is what made the table tier + * symmetric (and every table test pass) while making the textBox tier compare + * a bare `w:drawing(...)` shape against a stored `w:p(w:r(w:drawing(...)))` + * shape — hashes that can never match, so every untouched round trip of a + * text box reported a false `objectConflict`. + * + * Mirroring capture here (rather than changing capture to store the bare + * node) keeps `buildObjectBlocks`'s re-emit contract intact: the generator + * emits `blob[0]` as a body child, and a bare `w:drawing` is not a valid + * block-level body child — it must sit inside a run inside a paragraph. + */ +function fingerprintRoot( + node: OrderedNode, + tag: string, + hostParagraph: OrderedNode | undefined +): OrderedNode { + if (!PARAGRAPH_HOSTED_OBJECT_TAGS.has(tag)) return node; + // A drawing with no enclosing w:p is malformed OOXML; degrade to the bare + // node rather than throwing — extract.ts never rejects a document it can + // still partially read. + return hostParagraph ?? node; +} + function extractObjectBlocks(nodes: readonly OrderedNode[]): ExtractedObjectBlock[] { const blocks: ExtractedObjectBlock[] = []; - walkObjectBlocks(nodes, false, blocks); + walkObjectBlocks(nodes, false, blocks, undefined); return blocks; } From d605ce6fbc4040321c25767f4c404b8b8d8073be Mon Sep 17 00:00:00 2001 From: thewrz Date: Tue, 4 Aug 2026 16:08:54 -0700 Subject: [PATCH 7/9] test(merge): correct the #652 non-vacuity comment's overstated claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/api/diff.integration.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/api/diff.integration.test.ts b/src/api/diff.integration.test.ts index e4536c9a..4d2f4c63 100644 --- a/src/api/diff.integration.test.ts +++ b/src/api/diff.integration.test.ts @@ -746,13 +746,18 @@ describe('body-level object round-trip — textBox fingerprint symmetry (#652)', expect(diffRes.status).toBe(200); // The assertion that fails without the #652 fix: base hashed the host // w:p, theirs hashed the bare w:drawing, so objectConflicts held one - // entry. Asserting the WHOLE diff (not objectConflicts alone) also keeps - // this gate from going vacuous: `detectObjectConflicts` only reports a - // fingerprint divergence for a block `findMatchingBlock` actually matched - // by interior uuid, so a future regression that stopped - // `findInteriorUuids` from seeing the text box's interior anchor would - // silently satisfy an objectConflicts-only check — but it would surface - // here as a `deleted`/`modified` entry for the objectText uuid instead. + // entry. + // + // Non-vacuity is established by mutation, not by this shape: reverting + // fingerprintRoot's host-paragraph pick makes ONLY this test fail, and it + // fails carrying BOTH a base and a theirs fingerprint — which + // detectObjectConflicts emits only for a block findMatchingBlock actually + // matched by interior uuid, so the findInteriorUuids path is provably + // live here. (Asserting the whole diff rather than objectConflicts alone + // is broader coverage of the object round trip, but it is NOT by itself a + // guard on findInteriorUuids: theirsControlled is built by walkBlocks + // independently, so interior text would still round-trip cleanly if + // interior-uuid collection regressed. extract.test.ts pins that path.) expect(body.data).toEqual({ added: [], modified: [], From 96ae58465422cd76291de77814716f7efa5f8777 Mon Sep 17 00:00:00 2001 From: thewrz Date: Wed, 5 Aug 2026 00:07:16 -0700 Subject: [PATCH 8/9] refactor(merge): import SpecTree through the ast barrel in extract.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/merge/extract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/merge/extract.test.ts b/src/merge/extract.test.ts index 8451f209..0eef59f4 100644 --- a/src/merge/extract.test.ts +++ b/src/merge/extract.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { generateDocx } from '../generator/index.js'; import { extractContentControls } from './extract.js'; import { MergeError } from './error.js'; -import type { SpecTree } from '../ast/types.js'; +import type { SpecTree } from '../ast/index.js'; const MERGE_DIR = join(process.cwd(), 'src/merge'); From 561b49ed9fd22f2883cb927093f93618be1c695e Mon Sep 17 00:00:00 2001 From: thewrz Date: Wed, 5 Aug 2026 08:20:55 -0700 Subject: [PATCH 9/9] fix(test): add deleteConflicts to the exact-shape diff assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- src/api/diff.integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/diff.integration.test.ts b/src/api/diff.integration.test.ts index c1877d4c..c35673a6 100644 --- a/src/api/diff.integration.test.ts +++ b/src/api/diff.integration.test.ts @@ -767,6 +767,7 @@ describe('body-level object round-trip — hidden/visible run mix (#648)', () => added: [], modified: [], deleted: [], + deleteConflicts: [], conflicts: [], objectConflicts: [], warnings: [], @@ -931,6 +932,7 @@ describe('body-level object round-trip — textBox fingerprint symmetry (#652)', added: [], modified: [], deleted: [], + deleteConflicts: [], conflicts: [], objectConflicts: [], warnings: [],