feat(docx-compare): fail closed when a comparison input already contains tracked changes - #877
feat(docx-compare): fail closed when a comparison input already contains tracked changes#877stevenobiajulu wants to merge 2 commits into
Conversation
…ins tracked changes Comparing a document that already carries revision markup passed the pre-existing markup through and layered the comparison author's markup on top: the output kept two revision authors in word/document.xml and (edit density permitting) directly nested w:ins-in-w:ins, a file Microsoft Word refuses to open, while the compare exited 0 with normal stats. A 520-document corpus differential independently confirmed the mechanism: rebuild mode unwraps pre-existing tracked changes into bare w:delText outside any w:del wrapper, and inplace passes two authors' trees through merged. One guard, one boundary: assertComparisonInputsUntracked runs at the top of compareDocumentsAtomizer - the lowest public comparison boundary - so compareDocuments, both CLIs, the MCP tool, and the benchmark runner are all covered by a single scan with a single error shape. The scan covers word/document.xml plus every revision story part (enumerateRevisionStoryPartPaths, so numbered headers/footers are included) and detects the four content markers and six *PrChange records; row-level w:trPr markers share those local names and trip the guard. The typed, recoverable TrackedInputRevisionError names the offending operand, part, and markers, and is exported from the package root following the UnsupportedTextBoxRevisionError precedent. Surfaces map the error rather than re-scanning: the MCP compare_documents tool returns the distinct INPUT_HAS_TRACKED_CHANGES code (never the catch-all COMPARE_ERROR) with an accept_changes recovery hint, and both CLI entry handlers already print error.message and exit 1, so the CLI surface needed no code change. Parts the scan cannot parse are skipped by this guard: malformed-part failures belong to the ancillary safety boundary, whose precise AncillaryStorySafetyError/NOTE_PART_XML_INVALID diagnostics a preparatory scan must not pre-empt - the same rule textBoxRevisionSafety applies. Engine behaviors that only arise for pre-tracked inputs (provenance restoration, insertion-collision promotion, preserved-move identity, pre-existing-wrapper bookmark splitting, canonical-emission round-trips, the ADV mode-preservation characterization) remain implemented and tested via compareDocumentsAtomizerUnguarded, an explicitly named unguarded seam; a future accept-on-ingest opt-in would route there. It is exported from the package root because docx-core's integration tests can only reach docx-compare through it (a relative source import violates that package's tsc rootDir), with JSDoc marking it as NOT a supported comparison entry point. The eleven engine test files that deliberately compare pre-tracked fixtures now use that seam with per-site comments; assertions unchanged. OpenSpec change: add-tracked-input-comparison-guard (docx-comparison and mcp-server ADDED requirements; ADDED rather than MODIFIED because no deployed requirement makes any promise about comparison-input validation). Refs: #742
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Three execution-backed findings from the dynamic peer review of #742: 1. Detector gaps (blocking). A live probe proved w:cellIns, w:cellDel, w:cellMerge, and w:numberingChange passed the ten-name scan through public compareDocuments and survived in the output with their prior author intact. All four are now detection triggers with per-kind fixtures in [SDX-TRKIN-04]. The range-boundary marker family (w:*RangeStart/End, w:customXml*Range*) is documented as classified non-triggers: the probe showed an isolated range pair is dropped by the comparison, not passed through, and content-bearing moves are caught via their w:moveFrom/w:moveTo wrappers. 2. Package-root bypass (blocking). Exporting compareDocumentsAtomizerUnguarded from the package root was a live, demonstrated bypass of the guard. The export is removed and [SDX-TRKIN-06] now pins its absence. docx-core integration tests reach the seam through the package's dist subpath instead, which docx-core's vitest config aliases back to the same source module graph its root alias uses - TypeScript resolves the subpath against the built .d.ts (exactly like the root import), and the runtime alias prevents a second dist module instance. 3. Hint accuracy. accept_changes covers the body and revisionable side stories but not headers or footers, so recommending it for a header/footer detection would loop. The hint is now part-aware, and [SDX-TRKIN-MCP-04] pins it. Also added the recommended session-mode refusal coverage as [SDX-TRKIN-MCP-05]. Refs: #742
Codex peer review — verdict and adjudicationA dynamic Codex review (workspace-write sandbox, full execution trace) was run on the initial commit Verdict: REQUEST-CHANGES — three findings. Adjudication: 1. Detector gaps — ACCEPTED (blocking, execution-proven)Codex probed the public Fix (second commit): all four are now detection triggers, each with a public-boundary fixture in 2. Package-root bypass — ACCEPTED (blocking, execution-proven)Codex imported Fix: the root export is removed and 3. Recovery hint wrong for header/footer detections — ACCEPTEDCodex checked the deployed Fix: the hint is part-aware — header/footer detections state that Points reviewed and retained as-is
After the fixes: full pre-submit gate re-run green on exit codes ( |
Fails closed when a comparison input already contains tracked changes, instead of silently emitting a Word-unreadable file.
Refs #742 (deliberately not "Fixes": the issue also sketches an
--allow-tracked-inputaccept-on-ingest follow-up, which is out of scope here — see the OpenSpec proposal's Out-of-scope section. Maintainer may prefer to close it with this PR; wording left conservative on purpose.)Premise gate (reproduced at HEAD before any edit)
Clean
A;B=Aedited with the edit kept as tracked changes (w:ins/w:del, author "Original Author"); thencompareDocuments(A, B):{"insertions":2,"deletions":2,"modifications":2,...}— nothing indicates the input was unsuitable.word/document.xmlrevision authors:["Comparison","Original Author"]— the issue's smoking gun that pre-existing revisions were passed through rather than accepted before diffing.w:ins-inside-w:inspresent.scripts/check_emitted_document_schema.mjs) passes the corrupt file — the corruption is behavioral, not schema-visible, which is why no existing gate caught it.w:delTextoutside anyw:delwrapper — exactly the Word-unreadable shape compare_documents silently produces a Word-unreadable file when an input already contains tracked changes #742 reports — while inplace passes the two-author markup through. The guard therefore refuses in both modes.atomizer_redline.docx,typescript_redline.docx) are already memorialized as raising "Word found unreadable content".Design: one guard, one boundary
Every public path funnels through
compareDocumentsAtomizer—compareDocuments, both CLIs (which takecompareas an injectable dependency), the MCP tool, and the benchmark runner — andcompareDocumentsAtomizeris itself publicly exported (a genuine bypass if guarded higher). So the scan runs once, there:packages/docx-compare/src/baselines/atomizer/trackedInputRevisionSafety.ts:assertComparisonInputsUntracked+ typed recoverableTrackedInputRevisionError(operand, partPath, markers), following theUnsupportedTextBoxRevisionErrorprecedent; exported from the package root.word/document.xml+enumerateRevisionStoryPartPaths(footnotes, endnotes, comments, glossary, every numbered header/footer). Detectsw:ins/w:del/w:moveFrom/w:moveTo, the six*PrChangerecords, the cell-topology recordsw:cellIns/w:cellDel/w:cellMerge, andw:numberingChange— the last four added after Codex's review probe proved they passed the original ten-name scan and survived comparison with their prior author. Row-levelw:trPr > w:ins|w:delmarkers trip it via the same local names. Range-boundary markers (w:*RangeStart/End,w:customXml*Range*) are documented non-triggers (the probe showed an isolated pair is dropped, not passed through).compare_documentsreturns distinctINPUT_HAS_TRACKED_CHANGES(never the catch-allCOMPARE_ERROR) with anaccept_changesrecovery hint. Both CLI entry handlers already printerror.messageand exit 1, and the message names the offending operand — zero CLI code changes, which also keeps the diff rebase-friendly vs feat(docx-comparison): default to tagged-tree comparison #841.AncillaryStorySafetyError/NOTE_PART_XML_INVALID) — the same ruletextBoxRevisionSafetydocuments for its own preparatory scan. Missing parts are skipped.The one judgment call reviewers should look at
Twenty-one existing tests across eleven files (six in docx-compare, five in docx-core's integration suite) deliberately drive pre-tracked fixtures through the public entry to pin engine internals (original-insertion provenance restoration, insertion-collision promotion, preserved-move identity seeding, pre-existing-wrapper bookmark splitting, canonical-emission round-trips, the ADV mode-preservation characterization) — the "preserve pre-existing tracked changes" behaviors. Deleting that coverage would also trip the coverage ratchet, and the code remains live for the future accept-on-ingest path. Instead, the orchestration below the guard is exposed as
compareDocumentsAtomizerUnguardedand those tests use it with a per-site comment citing #742; assertions unchanged.Containment (revised after Codex review): the seam is NOT exported from the package root — Codex demonstrated a root export was a live public bypass, so it was removed and
[SDX-TRKIN-06]now pins its absence. docx-compare tests import it from the pipeline module; docx-core integration tests import it via the package's dist subpath, which docx-core's vitest config aliases back to the same source module graph its root alias uses (TypeScript resolves the subpath against the built.d.ts, exactly like the root import already does; a relative source import violates docx-core's tscrootDir).OpenSpec
add-tracked-input-comparison-guard— validated--strict. ADDED requirements ondocx-comparison(8 scenarios, SDX-TRKIN-01..08) andmcp-server(3 scenarios, SDX-TRKIN-MCP-01..03). ADDED rather than MODIFIED: no deployed requirement in either capability promises anything about comparison-input validation, so there was nothing to modify without inventing prior text for the archiver to overwrite.Tests
trackedInputRevisionSafety.test.ts(docx-compare): all ten revision kinds + row-level marker, both operands, both reconstruction modes, all six story flavors, missing parts skipped, malformed part defers toAncillaryStorySafetyError, clean-pair control, both public entry points, and the realrunCompareCli(no injected fake — an injected fake would bypass the guard and prove nothing) refusing with no output written.add_tracked_input_comparison_guard.test.ts(docx-mcp):INPUT_HAS_TRACKED_CHANGESmapping + no file written, realrunCompareCommandrejection naming the operand, clean control.Codex peer review (dynamic, execution-backed)
Verdict on the initial commit: REQUEST-CHANGES, three findings, all confirmed by live probes and all addressed in the follow-up commit (adjudication detailed in a PR comment):
w:cellIns/w:cellDel/w:cellMerge/w:numberingChangeexecution-proven to pass through with their prior author → added as triggers, with per-kind public-boundary fixtures.[SDX-TRKIN-06]pins its absence; docx-core tests reach the seam via a vitest-aliased dist subpath instead.accept_changescannot clean headers/footers, so the recovery hint is now part-aware ([SDX-TRKIN-MCP-04]); the recommended session-mode refusal test was added as[SDX-TRKIN-MCP-05].Gates
Full pre-submit sequence run on exit codes (
build,lint:workspaces,test:run,check:spec-coverage,check:conformance-citations,check:conformance-doc), plusopenspec validate --strict,check:tool-docsafter regenerating the tool reference, andgit diff --check. Rebased ontoorigin/mainat 26c6640 (#869).