@ansonlai/docx-redline-js 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/AGENTS.md +36 -10
  2. package/README.md +83 -6
  3. package/adapters/xml-adapter.js +73 -10
  4. package/core/list-targeting.js +3 -0
  5. package/core/paragraph-targeting.js +33 -7
  6. package/core/redline-validation.js +22 -0
  7. package/core/types.js +122 -27
  8. package/core/xml-query.js +3 -1
  9. package/dist/docx-redline-js.esm.js +1148 -572
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +79 -78
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/docs/TESTING.md +687 -0
  14. package/docs/VALIDATION.md +81 -2
  15. package/docs/WORD-MANUAL-REVIEW.md +138 -0
  16. package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
  17. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
  18. package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
  19. package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
  20. package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
  21. package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
  22. package/docs/test-comparison-dashboard.html +95 -0
  23. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
  24. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
  25. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
  26. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
  27. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
  28. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
  29. package/engine/format-extraction.js +1 -1
  30. package/engine/formatting-removal.js +95 -104
  31. package/engine/oxml-engine.js +176 -83
  32. package/engine/reconstruction-mapper.js +276 -79
  33. package/engine/reconstruction-mode.js +20 -6
  34. package/engine/reconstruction-writer.js +117 -72
  35. package/engine/run-builders.js +17 -13
  36. package/engine/surgical-diff-application.js +7 -21
  37. package/engine/surgical-mode.js +3 -2
  38. package/engine/table-mode.js +27 -16
  39. package/index.d.ts +95 -3
  40. package/index.js +14 -13
  41. package/orchestration/list-structural-fallback.js +16 -39
  42. package/package.json +23 -5
  43. package/pipeline/diff-engine.js +174 -55
  44. package/pipeline/ingestion-export.js +39 -24
  45. package/pipeline/ingestion-paragraph.js +7 -5
  46. package/pipeline/list-generation.js +27 -18
  47. package/pipeline/patching.js +2 -3
  48. package/pipeline/pipeline.js +65 -36
  49. package/pipeline/serialization.js +13 -5
  50. package/scripts/build-test-dashboard.mjs +43 -0
  51. package/scripts/check-types.mjs +16 -24
  52. package/scripts/export-validation-fixtures.mjs +191 -45
  53. package/scripts/fetch-superdoc-corpus.mjs +61 -0
  54. package/scripts/generate-test-dashboard.mjs +199 -0
  55. package/scripts/inspect-visual-evidence.mjs +271 -0
  56. package/scripts/lib/minimal-zip.mjs +199 -18
  57. package/scripts/lib/word-coverage-catalogue.mjs +207 -0
  58. package/scripts/lib/word-coverage-metadata.mjs +93 -0
  59. package/scripts/lib/zip-reader.mjs +64 -0
  60. package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
  61. package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
  62. package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
  63. package/scripts/prepare-word-review.mjs +77 -0
  64. package/scripts/prepare-word-visual-review.mjs +90 -0
  65. package/scripts/render-agenda-multilevel.mjs +70 -0
  66. package/scripts/render-case22.mjs +73 -0
  67. package/scripts/render-case40.ps1 +35 -0
  68. package/scripts/render-multilevel-bullet-images.py +58 -0
  69. package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
  70. package/scripts/render-multilevel-cases.mjs +80 -0
  71. package/scripts/report-coverage-gaps.mjs +103 -0
  72. package/scripts/report-word-coverage.mjs +71 -0
  73. package/scripts/sample-multimodal-visual-check.mjs +221 -0
  74. package/scripts/test-multilevel-bullet-visual.mjs +187 -0
  75. package/scripts/word-com-corpus-suite.ps1 +43 -0
  76. package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
  77. package/scripts/word-com-differential.ps1 +158 -16
  78. package/scripts/word-com-suite.ps1 +19 -0
  79. package/scripts/word-com-visual-suite.ps1 +132 -0
  80. package/services/comment-engine.js +51 -46
  81. package/services/comment-locator.js +0 -1
  82. package/services/comment-package.js +11 -10
  83. package/services/numbering-service.js +1 -1
  84. package/services/revision-comment-management.js +31 -10
  85. package/services/standalone-docx-plumbing.js +45 -34
  86. package/services/standalone-operation-runner.js +315 -75
  87. package/services/table-reconciliation.js +23 -11
@@ -0,0 +1,1155 @@
1
+ # Redline Reliability Improvement Plan — Round 2
2
+
3
+ **Status:** Complete (Phases 1–8, including Phase 1.5, complete)
4
+
5
+ Follow-on to `completed/2026-05-31-architectural changes.md`, which is complete. That plan
6
+ hardened the *OOXML shape* of generated redlines (paragraph marks, moves, inert
7
+ markup, structural invariants). This plan targets a different class of problem:
8
+ **places where the library silently produces wrong output, or where the test
9
+ suite cannot see that it did.**
10
+
11
+ Every finding below was reproduced against the current `master` (`73ef9c5`)
12
+ before being written down. Reproduction snippets are included so each phase can
13
+ be re-confirmed before work starts.
14
+
15
+ **Scope note (unchanged):** a docx-in/docx-out wrapper API is still OUT of scope.
16
+ Do not add a JSZip dependency or an `applyRedlineToDocx`-style API.
17
+
18
+ ## Production API compatibility map
19
+
20
+ This plan distinguishes production API compatibility from test quality. Changes
21
+ to test helpers, fuzz generators, assertions, linting, coverage, and other
22
+ development-only tooling are **not** marked as production API breaks here.
23
+
24
+ In this document:
25
+
26
+ - **BREAKING — production API** means an existing caller may need code changes
27
+ because an exception/return contract, default option, or documented result
28
+ behavior changes.
29
+ - **CONDITIONAL — production behavior** means the public API shape remains
30
+ compatible, but callers exercising the affected edge case may observe a
31
+ different result or generated OOXML.
32
+ - **NOT BREAKING — production API** means the change is internal, additive, or
33
+ limited to test/development tooling.
34
+
35
+ | Phase | Production API impact |
36
+ |---|---|
37
+ | 1 | **NOT BREAKING — production API.** Test-only verification and fuzz-corpus changes. |
38
+ | 1.5 | **NOT BREAKING — production API.** Windows/Word verification and pinned real-document corpus tooling only. |
39
+ | 2 | **CONDITIONAL — production behavior.** `DIFF_TOKEN_LIMIT` introduces an error path for oversized inputs; whitespace/schema fixes and deterministic diffs may change generated OOXML for affected inputs. |
40
+ | 3 | **BREAKING — production API.** Several malformed-input paths change from throwing to returning result objects; multi-line target misses can change from no-op to error. |
41
+ | 4 | **BREAKING — production API if `sanitizeInput` defaults to `false`.** Existing callers relying on implicit sanitization receive different edits. The existing-revision fix is **CONDITIONAL — production behavior**. |
42
+ | 5 | **NOT BREAKING — production API.** Public signatures remain compatible; revision IDs may change, which matters only to callers treating generated IDs as stable cross-document identifiers. |
43
+ | 6 | **BREAKING — production API.** `atomic: true` as the default changes batch results from partial application to rollback on error. |
44
+ | 7 | **NOT BREAKING — production API.** Development checks and coverage tooling only; declaration tightening may require fixes in the repository's type fixtures. |
45
+ | 8 | **CONDITIONAL — production behavior.** Structural fixes alter malformed output, and `PARTIAL_TARGET` changes the result for previously mis-scoped document edits. |
46
+
47
+ If all proposed defaults and contracts land as written, publish the change as a
48
+ major version. A minor release would require retaining the current defaults and
49
+ error behavior, or introducing the new behavior as opt-in/deprecated paths.
50
+
51
+ ---
52
+
53
+ ## Conventions (read before starting any phase)
54
+
55
+ Same as the previous plan, repeated here so this file stands alone:
56
+
57
+ - Tests live in `tests/*.mjs`, auto-discovered by `scripts/run-tests.mjs` (`npm test`).
58
+ `tests/helpers/` and `tests/setup-xml-provider.mjs` are excluded from discovery.
59
+ Plain `assert/strict`, no test framework.
60
+ - Shared assertion helpers go in `tests/helpers/ooxml-assertions.mjs`.
61
+ - All `w:*` element creation goes through `createWordElement` (`core/word-xml.js`).
62
+ - Revision metadata comes from `createRevisionMetadata(author)` (`core/types.js`).
63
+ - New public API is re-exported through `index.js`.
64
+ - After each phase: `npm test`, `npm run test:isolation`, `npm run check:types` all pass.
65
+ - Do not change existing public signatures. New behavior arrives as optional
66
+ fields on options/result objects.
67
+
68
+ Additional convention for this plan:
69
+
70
+ - **Every phase that changes engine behavior must add its regression case to the
71
+ fuzz corpus (Phase 1.3), not only to a hand-written test.** Fixed cases prove a
72
+ bug is gone; the corpus proves it stays gone.
73
+
74
+ ---
75
+
76
+ ## Findings summary
77
+
78
+ | # | Finding | Severity | Phase | Status |
79
+ |---|---------|----------|-------|--------|
80
+ | F1 | Round-trip oracle is lossy — whitespace/tab/break corruption is invisible to the entire suite | Critical (test blindness) | 1 | **Fixed** |
81
+ | F2 | Diff engine silently drops text in documents with >65,536 unique tokens | Critical (data loss) | 2 | **Fixed** |
82
+ | F3 | Diff output depends on wall-clock time (`Diff_Timeout = 1s`) | High (non-reproducible output) | 2 | **Fixed** |
83
+ | F4 | Public API error contract is inconsistent: some functions throw raw `ParseError`, some return `''`, some return `status:'error'` | High | 3 | **Fixed** |
84
+ | F5 | `existingRevisions: 'accept-all-first'` can silently discard another reviewer's revisions while reporting `hasChanges: false` | High (data loss) | 4 | **Fixed** |
85
+ | F6 | `sanitizeAiResponse` unconditionally mutates legitimate document text | High (data corruption) | 4 | **Fixed** |
86
+ | F7 | Revision-ID counter is process-global and permanently poisonable | Medium | 5 | **Fixed** |
87
+ | F8 | `TARGET_NOT_FOUND` detection is disabled whenever `original` contains a newline | Medium | 3 | **Fixed** |
88
+ | F9 | `npm run check:types` does not type-check anything | Medium | 7 | **Fixed** |
89
+ | F10 | `applyOperationsToDocumentXml` is non-atomic; a mid-batch failure returns a half-applied document | Medium | 6 | **Fixed** |
90
+ | F11 | Fuzz corpus is single-paragraph only — no tables, lists, multi-paragraph, or pre-existing revisions | Medium | 1 | **Fixed** |
91
+ | F16 | Synthetic fixtures alone do not cover the structural diversity of real English legal and administrative DOCX files | High (test coverage) | 1.5 | **Fixed** |
92
+
93
+ ### Found by Phase 1 once the oracle could see (added 2026-08-02)
94
+
95
+ | # | Finding | Severity | Phase | Status |
96
+ |---|---------|----------|-------|--------|
97
+ | F12 | Diff tokenizer `/(\S+)(\s*)/g` cannot match whitespace before the first word, so **leading whitespace is dropped from both sides of every diff** and all diff offsets shift by its length | High (data loss) | 2 | **Fixed** |
98
+ | F13 | An edit next to a `w:br` emits a **`w:p` nested inside a `w:p`** (schema-invalid, Word reports corruption) and destroys the `w:br` | Critical (corrupt output) | 8 | **Fixed** |
99
+ | F14 | Reconstruction moves `w:sectPr` to the **front** of `w:body`, violating `CT_Body` and the package's own plumbing validator | High (corrupt output) | 8 | **Fixed** |
100
+ | F15 | `applyRedlineToOxml` on a document whose `original` covers only some paragraphs **silently deletes the untargeted paragraphs** and returns `status: 'ok'` | High (data loss) | 8 | **Fixed** |
101
+
102
+ ---
103
+
104
+ ## Phase 1 — Make the verification oracle honest (do this first)
105
+
106
+ **Status: Complete (2026-08-02).** Suite is green at 24/24 (was 21/21).
107
+
108
+ What landed:
109
+
110
+ - `extractExactVisibleText(xml)` and `normalizeParagraphBreaks(text)` in
111
+ `tests/helpers/ooxml-assertions.mjs` — a lossless extractor written against the
112
+ DOM independently of `pipeline/ingestion-export.js`, modelling Word's accepted
113
+ view (`w:del`/`w:moveFrom` hidden, `w:ins`/`w:moveTo` visible, deleted
114
+ paragraph mark merges into the next paragraph).
115
+ - `assertRoundTrip(..., { fidelity })` in `tests/helpers/roundtrip.mjs`,
116
+ defaulting to `'exact'`. `'normalized'` remains for markdown cases that
117
+ legitimately rewrite whitespace; no call site needs it yet.
118
+ - Two new structural invariants, both of which the previous assertion set was
119
+ blind to: `assertNoNestedParagraphs` and `assertSectPrLast`. Both are wired
120
+ into `assertRoundTripStructure`.
121
+ - `tests/roundtrip_oracle_tests.mjs` — **self-tests for the oracle itself.** A
122
+ verification helper that cannot fail is worthless, so these pin down that the
123
+ exact extractor really does distinguish a tab from a space, a double space from
124
+ a single, and a lost trailing space, and that the old normalized comparison
125
+ genuinely could not.
126
+ - Seven whitespace-hostile cases added to `tests/roundtrip_invariant_tests.mjs`
127
+ (double spaces, `w:tab`, trailing/leading space, `w:br`, multi-paragraph),
128
+ plus a `knownGap` skip mechanism that logs each skip every run.
129
+ - `tests/roundtrip_fuzz_tests.mjs` widened from one shape to five —
130
+ `paragraph`, `multiParagraph`, `tableCell`, `whitespace`, `existingRevisions`
131
+ (the last driven with `existingRevisions: 'accept-all-first'`) — with a
132
+ per-shape count printed on every run, plus a narrow known-gap classifier so
133
+ registered defects are *counted and reported* rather than either failing the
134
+ build or vanishing.
135
+
136
+ Verification run: `npm test` 24/24, `npm run test:isolation`, `npm run check:types`,
137
+ `node scripts/export-validation-fixtures.mjs`, and a 12,000-case sweep
138
+ (`FUZZ_ITERATIONS=12000`) all pass.
139
+
140
+ **The oracle immediately earned its keep.** Under the old normalized comparison
141
+ every one of these passed; under `'exact'` plus the new structural invariants
142
+ they are F12, F13, F14 and F15 above. Three are registered as `KNOWN-GAP`
143
+ skips/classifiers rather than fixed here, because they are engine defects owned
144
+ by Phase 2 and the new Phase 8:
145
+
146
+ | Defect | Where it shows up | Rate |
147
+ |---|---|---|
148
+ | F12 leading whitespace dropped | corpus case + fuzz classifier `leading-whitespace-dropped` | 12 / 12,000 fuzz cases *(fixed and suppression removed in Phase 2)* |
149
+ | F13 `w:br` → nested `w:p` | corpus case `w:br survives an edit in an adjacent run` | — |
150
+ | F14 `w:sectPr` moved to front | corpus case + fuzz classifier `sectPr-not-last` | 10,000 / 12,000 fuzz cases (i.e. **every** case that has a `sectPr` at all) |
151
+
152
+ Grep `KNOWN-GAP` to find every suppression; each names the phase that owns it.
153
+ The fuzz harness prints `KNOWN-GAP <id>: 0 cases -- possibly fixed` when a
154
+ registered gap stops reproducing, so a stale entry announces itself.
155
+
156
+ Two deliberate deviations from the plan as written:
157
+
158
+ 1. `assertSectPrLast` needed an opt-out. F14 fires on essentially all
159
+ document-scoped reconstruction output, including the pre-existing
160
+ `tests/paragraph_mark_revision_tests.mjs` fixtures. Rather than strip
161
+ `w:sectPr` from those fixtures (which would have made them less realistic and
162
+ hidden the defect), `assertRoundTripStructure` takes
163
+ `{ knownGaps: ['sectPr-not-last'] }` and each use carries a
164
+ `KNOWN-GAP: Phase 8` comment.
165
+ 2. The planned list-paragraph fuzz shape is **not** included. List generation
166
+ routes through markdown preprocessing and numbering allocation, which
167
+ legitimately rewrite whitespace and would have needed `'normalized'` fidelity —
168
+ a noisy shape that cannot see the very class of bug this phase exists to
169
+ catch. Tracked as Phase 1 follow-up below rather than shipped weak.
170
+
171
+ ### Phase 1 follow-up (not blocking)
172
+
173
+ - Add the list-paragraph fuzz shape, with `w:numPr` paragraphs and markdown-list
174
+ target text, once there is an exact-fidelity story for list output. Until then
175
+ `tests/list_tests.mjs` (845 lines) remains the only list coverage.
176
+ - `validateRedlineOoxml` (the *runtime* guardrail exported from `index.js`) still
177
+ does not check nested paragraphs or `sectPr` placement — the two invariants
178
+ added here live only in the test helpers. Port both into
179
+ `core/redline-validation.js` when Phase 8 fixes the underlying defects, so
180
+ downstream packagers get the same protection.
181
+
182
+ **Why:** This is the highest-leverage item in the plan, because it is the reason
183
+ the other findings survived a suite that already has a 20,000-case nightly fuzz
184
+ sweep.
185
+
186
+ `assertRoundTrip` (`tests/helpers/roundtrip.mjs`) extracts text with
187
+ `ingestWordOoxmlToPlainText`, then compares with
188
+ `normalizeVisibleText` (`.replace(/\s+/g, ' ').trim()`).
189
+ But `ingestWordOoxmlToPlainText` is a *display-oriented, deliberately lossy*
190
+ reader: `normalizeInlineWhitespace` in `pipeline/ingestion-export.js:126-132`
191
+ already collapses `[ \t]+` → `' '` and trims every line. So the invariant the
192
+ suite actually enforces is "accept-all yields the modified text, up to
193
+ whitespace collapsing applied twice."
194
+
195
+ Reproduced — the engine is **correct** here; the oracle is blind:
196
+
197
+ ```js
198
+ // input: <w:t xml:space="preserve">Section 1 applies.</w:t> (two spaces)
199
+ // redlined XML retains "Section 1 " exactly ✓
200
+ // accepted XML retains "Section 1 " exactly ✓
201
+ // ingestWordOoxmlToPlainText(accepted) === "Section 1 governs." ✗ collapsed
202
+ ```
203
+
204
+ Same for `w:tab`: `readRunText` maps it to `\t` correctly, then
205
+ `normalizeInlineWhitespace` turns it into a space. A future change that drops a
206
+ `w:tab`, loses an `xml:space="preserve"`, or doubles a space at a splice point
207
+ would pass `npm test` and all 20,000 nightly fuzz cases.
208
+
209
+ Do not "fix" `ingestWordOoxmlToPlainText` — lossy normalization is the right
210
+ behavior for a display/markdown reader, and downstream consumers depend on it.
211
+ Build a second, lossless extractor for verification.
212
+
213
+ ### 1.1 Lossless verification extractor — done
214
+
215
+ Shipped as described, with one addition: `collectExactText` refuses to recurse
216
+ into a nested `w:p`, so extraction stays well-defined even on the malformed
217
+ output F13 produces (otherwise the nested paragraph's text is counted twice and
218
+ the failure message misleads).
219
+
220
+ Add to `tests/helpers/ooxml-assertions.mjs`:
221
+
222
+ ```js
223
+ /**
224
+ * Extracts the exact visible text of an OOXML fragment with NO normalization.
225
+ * Unlike ingestWordOoxmlToPlainText (which collapses runs of whitespace for
226
+ * display), this preserves every space, tab, and break so that whitespace
227
+ * regressions are detectable.
228
+ *
229
+ * Mapping: w:t -> textContent, w:tab -> '\t', w:br|w:cr -> '\n',
230
+ * w:noBreakHyphen -> '‑'. Skips runs inside w:del / w:moveFrom.
231
+ * Paragraph boundaries emit '\n'.
232
+ */
233
+ export function extractExactVisibleText(xml)
234
+ ```
235
+
236
+ Implement it directly against the DOM (mirror the traversal in
237
+ `collectParagraphSegments` / `readRunText` in `pipeline/ingestion-export.js`,
238
+ minus the `normalizeInlineWhitespace` call). It must be independent of the
239
+ production reader so a bug in ingestion cannot mask itself.
240
+
241
+ ### 1.2 Two-tier round-trip assertion — done
242
+
243
+ Note on what `'exact'` actually means in the shipped version: it normalizes
244
+ **paragraph separators only** (`normalizeParagraphBreaks` folds `\n{2,}` to `\n`
245
+ and `\r\n` to `\n`), because markdown treats a blank line as a paragraph
246
+ separator while OOXML represents one paragraph break as one boundary. Spaces,
247
+ tabs, and every other whitespace difference are compared byte-exact. The
248
+ existing eleven-case corpus migrated to `'exact'` with **no** failures — the
249
+ engine was already correct on all of them; only the oracle had been weak. The
250
+ failures came from the newly added whitespace-hostile cases.
251
+
252
+ Rework `assertRoundTrip` in `tests/helpers/roundtrip.mjs` to take a
253
+ `fidelity` option:
254
+
255
+ - `fidelity: 'exact'` (**new default for all new cases**) — compares
256
+ `extractExactVisibleText` output against the expected string with no
257
+ normalization at all.
258
+ - `fidelity: 'normalized'` — current behavior, retained only for cases where
259
+ markdown preprocessing legitimately changes whitespace (list generation, table
260
+ reconciliation). Every call site that uses `'normalized'` must carry a comment
261
+ saying why exact comparison does not apply.
262
+
263
+ Migrate the existing corpus in `tests/roundtrip_invariant_tests.mjs` to
264
+ `'exact'` one case at a time. **Expect failures.** For each one, determine
265
+ whether it is engine whitespace corruption (fix the engine) or an artifact of
266
+ markdown preprocessing (document it and use `'normalized'` for that case only).
267
+ Do not bulk-migrate and then bulk-downgrade the failures.
268
+
269
+ ### 1.3 Widen the fuzz corpus (F11) — done except the list shape
270
+
271
+ Four of the five families shipped (`multiParagraph`, `tableCell`, `whitespace`,
272
+ `existingRevisions`, alongside the original `paragraph`). The list shape is
273
+ deferred — see the Phase 1 follow-up above for why. Runtime for the default
274
+ 100-case sweep is unchanged; a 12,000-case sweep completes well inside the
275
+ nightly budget.
276
+
277
+ `generateParagraph` in `tests/roundtrip_fuzz_tests.mjs` only ever emits a single
278
+ `<w:p>` with runs, an optional hyperlink, and optional bookmarks. Structures
279
+ that the engine routes very differently are never generated. Add generators for:
280
+
281
+ 1. **Multi-paragraph bodies** (2-5 `w:p`), including edits that delete a whole
282
+ paragraph, insert one, and merge two — the Phase 3 paragraph-mark paths from
283
+ the previous plan currently have only hand-written coverage.
284
+ 2. **Tables** — a `w:tbl` with 2-4 rows, edits aimed at a single cell paragraph
285
+ (exercises `detectTableCellContext` and the isolate-then-recurse path in
286
+ `engine/oxml-engine.js:119-127`).
287
+ 3. **List paragraphs** — `w:numPr` bearing paragraphs, plus markdown-list target
288
+ text (exercises `orchestration/list-structural-fallback.js`, 530 lines with
289
+ comparatively little targeted coverage).
290
+ 4. **Pre-existing revisions** — paragraphs already containing `w:ins`/`w:del`
291
+ from a different author, driven with `existingRevisions: 'accept-all-first'`.
292
+ 5. **Whitespace-hostile text** — deliberate double spaces, leading/trailing
293
+ spaces, `w:tab` and `w:br` elements mid-paragraph. These only become
294
+ meaningful once 1.1/1.2 land.
295
+
296
+ Keep the harness seeded and deterministic. Raise the default `FUZZ_ITERATIONS`
297
+ only if wall-clock stays under ~15s for `npm test`.
298
+
299
+ **Acceptance for Phase 1:** met. `extractExactVisibleText` exported;
300
+ `assertRoundTrip` defaults to `'exact'` and no call site needs `'normalized'`;
301
+ fuzz corpus emits four of five structure families (list deferred with a written
302
+ reason); `npm test` green at 24/24.
303
+
304
+ ---
305
+
306
+ ## Phase 1.5 — Independent Word and real-document corpus verification
307
+
308
+ > **Production API: NOT BREAKING.** Everything in this phase is development-only
309
+ > test tooling. The published library remains host-independent clean JavaScript;
310
+ > Microsoft Word COM automation is confined to Windows-only scripts.
311
+
312
+ **Status: Complete (2026-08-29).**
313
+
314
+ Synthetic OOXML remains valuable for exact regression tests, but it cannot
315
+ represent the full variety of documents produced by Word in the wild. Add a
316
+ layered verification lane before continuing engine changes:
317
+
318
+ ### 1.5.1 Make desktop Word a first-class independent oracle
319
+
320
+ - Promote the existing COM differential from a two-command release smoke check
321
+ to `npm run test:word`. The command exports a fresh task suite, opens every
322
+ `.docx` in installed desktop Microsoft Word, verifies that Word sees tracked
323
+ revisions, then independently checks Accept All and Reject All against
324
+ intent-derived expected text.
325
+ - Compare text exactly by default (normalizing Word paragraph terminators only),
326
+ rather than collapsing all whitespace. Individual cases may opt into
327
+ normalized comparison only with a written reason.
328
+ - Keep this lane manual/local because it requires Windows and Word. Do not add a
329
+ Word dependency or COM code to `index.js`, `core/`, `engine/`, or any runtime
330
+ package path.
331
+
332
+ The first catalogue covers English legal and administrative tasks: term and
333
+ deadline replacement, clause/procedure insertion, sentence and paragraph
334
+ deletion, paragraph insertion, bold/italic/underline formatting, and
335
+ significant spacing. Continue adding tables, lists, comments, fields,
336
+ headers/footers, footnotes, numbering, and mixed batches as the harness grows.
337
+
338
+ ### 1.5.2 Introduce a pinned SuperDoc docx-corpus lane
339
+
340
+ Use [docx-corpus](https://docxcorp.us/), built by
341
+ [SuperDoc](https://superdoc.dev/), as the real-document source. The dataset is
342
+ offered under ODC-By 1.0; retain attribution in the README, the pinned manifest,
343
+ and per-download metadata. Note that ODC-By governs the database and may not
344
+ grant every right in each individual document, so corpus documents remain
345
+ local, uncommitted test inputs.
346
+
347
+ Initial guardrails:
348
+
349
+ - English only (`language: en`).
350
+ - Document type only `legal` or `administrative`, initially biased toward
351
+ government/legal-administrative topics and classifier confidence ≥0.9.
352
+ - Pin every document by corpus ID, explicit download URL, and observed download
353
+ SHA-256 in `tests/corpus/superdoc-english-legal-administrative.json`. The
354
+ separate digest detects upstream byte changes even where served bytes do not
355
+ match the corpus's advertised content ID.
356
+ - No bulk/floating manifest downloads. The fetch command requires one or more
357
+ explicit pinned `--id` arguments, verifies the downloaded SHA-256, and writes
358
+ the document plus attribution metadata under ignored `tmp/` storage.
359
+ - Before a pinned document becomes a permanent test scenario, inspect it in
360
+ Word, record why it adds structural coverage, define deterministic operations
361
+ and assertions, and confirm that it contains no material unsuitable for a
362
+ local test corpus.
363
+
364
+ ### 1.5.3 Grow task coverage as a matrix
365
+
366
+ For every selected real document, combine a structural shape with a task type
367
+ and oracle:
368
+
369
+ | Shape | Initial tasks | Required checks |
370
+ |---|---|---|
371
+ | Body paragraphs | replace, insert, delete, format | XML invariants; Word open; exact accept/reject |
372
+ | Lists | item insert/delete, level change | numbering continuity; Word accept/reject |
373
+ | Tables/forms | cell edit, row change, checkbox/field adjacency | no package repair; untargeted cells stable |
374
+ | Administrative layout | headings, sections, headers/footers | section order; untouched parts stable |
375
+ | Legal apparatus | definitions, citations, footnotes/comments | references and anchors survive |
376
+
377
+ **Acceptance for Phase 1.5:** `npm run test:word` passes the expanded synthetic
378
+ catalogue in desktop Word; at least 20 reviewed pinned corpus documents (10
379
+ legal, 10 administrative) cover the matrix above; each has deterministic task
380
+ definitions and provenance; no corpus `.docx` is committed; README attribution
381
+ and ODC-By notice remain present.
382
+
383
+ **Acceptance recorded 2026-08-30:** the synthetic Word differential contains 25
384
+ cases and passes 25/25 in desktop Word. In addition to the original text,
385
+ formatting, whitespace, multi-paragraph, and prior-revision scenarios, it now
386
+ checks atomic rollback, hostile revision IDs, bookmark and internal-hyperlink
387
+ adjacency, mixed formatted runs, content controls, and table-cell editing. The
388
+ last five cases also assert that their required structural OOXML survives before
389
+ Word independently verifies Accept All and Reject All. The pinned corpus
390
+ contains 10 legal and 10 administrative reviewed scenarios across all five
391
+ matrix shapes; `npm run test:corpus:word` passes 20/20 in desktop Word. The
392
+ corpus lane verifies every untouched package part by SHA-256 and leaves all
393
+ source/output `.docx` files under ignored `tmp/` storage.
394
+
395
+ **Post-completion test backlog:** targeting a paragraph containing `w:tab`
396
+ currently cannot identify the same complete text range used by reconstruction,
397
+ and a reconstruction adjacent to a complex field does not preserve `w:fldChar`
398
+ and `w:instrText`. Keep those as explicit engine/test follow-ups. The next
399
+ synthetic package expansion should then cover comments, footnotes/endnotes, and
400
+ headers/footers; those require the fixture packager to emit the related package
401
+ parts and relationships before Word can serve as a meaningful oracle.
402
+
403
+ ---
404
+
405
+ ## Phase 2 — Diff engine correctness and determinism
406
+
407
+ > **Production API: CONDITIONAL — production behavior.** The public signatures
408
+ > remain compatible, but oversized token streams may now return the new
409
+ > `DIFF_TOKEN_LIMIT` error instead of producing corrupted output. The whitespace
410
+ > and determinism fixes can also change generated OOXML for affected inputs.
411
+
412
+ **Status: Complete (2026-08-29).**
413
+
414
+ ### 2.1 Token-space overflow silently destroys text (F2)
415
+
416
+ `wordsToChars` in `pipeline/diff-engine.js:35-48` assigns each unique token a
417
+ code unit via `String.fromCharCode(wordArray.length)`. `String.fromCharCode`
418
+ takes its argument **modulo 0x10000**, so token 65,536 collides with token 0.
419
+ `charsToWords` (`:67-82`) then *silently drops* any code whose value exceeds
420
+ `wordArray.length`:
421
+
422
+ ```js
423
+ if (charCode < wordArray.length) parts.push(wordArray[charCode]);
424
+ // else: token vanishes, no error, no warning
425
+ ```
426
+
427
+ Reproduced with 70,001 unique tokens:
428
+
429
+ ```
430
+ String.fromCharCode(70000).charCodeAt(0) === 4464 // wrapped, not 70000
431
+ wordArray[5] = 't4' wordArray[65541] = 't65540' // collide to the same code unit
432
+ diff reconstructs original exactly: false
433
+ original length 478889 -> reconstructed 473310 // 5,579 characters silently lost
434
+ ```
435
+
436
+ A large agreement, a full `word/document.xml` fed through the standalone runner,
437
+ or any document with heavy unique-token content (IDs, part numbers, citations,
438
+ multilingual text) can cross 65,536 unique tokens. The failure mode is silent
439
+ text loss inside generated redlines — the worst possible outcome for this
440
+ library.
441
+
442
+ Additionally, token codes in `0xD800-0xDFFF` are lone surrogates. They survive
443
+ `charCodeAt` round-tripping, but they make the intermediate strings ill-formed
444
+ UTF-16 and are fragile under any future change to diff-match-patch internals.
445
+
446
+ **Fix, in order:**
447
+
448
+ 1. **Guard first, hard.** Before diffing, if `wordArray.length` would exceed the
449
+ safe token ceiling, do not produce a wrong answer. Return a structured error
450
+ (`DIFF_TOKEN_LIMIT`, threaded through the Phase 3 error contract) so the
451
+ caller can split the work. Silent corruption must become a loud refusal
452
+ *before* any capacity work lands — ship this step on its own if needed.
453
+ 2. **Then raise the ceiling.** Switch the encoding to `String.fromCodePoint`
454
+ over a non-surrogate plane (e.g. base `0x10000`, giving ~1M tokens) and
455
+ decode with `codePointAt` + correct index advancement, or keep BMP encoding
456
+ but skip the surrogate range. Whichever is chosen, `charsToWords` must
457
+ **throw on an out-of-range code** rather than dropping it — an unmappable
458
+ code is a bug, never a value to discard.
459
+ 3. Add a unit test asserting exact reconstruction (`equal + delete` recovers the
460
+ original, `equal + insert` recovers the modified) for token counts spanning
461
+ the old boundary: 1,000 / 65,535 / 65,537 / 200,000.
462
+
463
+ ### 2.1b Leading whitespace is dropped from every diff (F12)
464
+
465
+ Found by Phase 1. Same function as 2.1, separate defect. `tokenize` in
466
+ `pipeline/diff-engine.js:24-33` scans with `/(\S+)(\s*)/g`, which can only start
467
+ matching at a non-space character. Whitespace *before the first word* is never
468
+ captured by any token:
469
+
470
+ ```js
471
+ computeWordDiffs(' indented text', ' indented copy')
472
+ // -> [[0,"indented "],[-1,"text"],[1,"copy"]]
473
+ // the two leading spaces are absent from both sides
474
+ ```
475
+
476
+ Two consequences, the second worse than the first:
477
+
478
+ 1. The leading whitespace is deleted from the output — and because it lands on
479
+ *unchanged* text outside any revision, rejecting the redline does not bring it
480
+ back.
481
+ 2. Every diff offset is short by the length of that whitespace, so
482
+ `computeWordLevelDiffOps` hands surgical run splitting positions that are
483
+ misaligned with the real text for any paragraph starting with whitespace.
484
+
485
+ Fix: capture a leading-whitespace prefix as its own token before the main scan.
486
+
487
+ ```js
488
+ function tokenize(text) {
489
+ const tokens = [];
490
+ const leading = text.match(/^\s+/);
491
+ if (leading) tokens.push(leading[0]);
492
+ // ...existing /(\S+)(\s*)/g scan
493
+ }
494
+ ```
495
+
496
+ Then un-skip the `leading whitespace is preserved` case in
497
+ `tests/roundtrip_invariant_tests.mjs` and delete the `leading-whitespace-dropped`
498
+ entry from `KNOWN_GAPS` in `tests/roundtrip_fuzz_tests.mjs`. Expect existing
499
+ expectations to shift for any fixture whose paragraph starts with whitespace —
500
+ that shift is the bug being fixed, not a regression.
501
+
502
+ ### 2.2 Diff output depends on wall-clock time (F3)
503
+
504
+ `pipeline/diff-engine.js:10` creates one module-level instance:
505
+
506
+ ```js
507
+ const DMP = new diff_match_patch(); // Diff_Timeout = 1 (seconds), confirmed at runtime
508
+ ```
509
+
510
+ When `diff_main` exceeds one second of wall clock it abandons the optimal
511
+ bisection and returns a valid-but-cruder diff. The same input therefore produces
512
+ **different redlines on a slower or more loaded machine** — different `w:ins`/
513
+ `w:del` boundaries, different revision counts. That breaks reproducibility,
514
+ makes user-reported bugs hard to reproduce, and can make the nightly 20k fuzz
515
+ sweep flake for reasons unrelated to any code change.
516
+
517
+ **Fix:**
518
+
519
+ - Set `DMP.Diff_Timeout = 0` (no timeout → deterministic output) and rely on the
520
+ Phase 2.1 size guard to bound worst-case work instead of a timer.
521
+ - If unbounded time is unacceptable for some consumer, expose it as an explicit
522
+ option (`diffTimeoutSeconds`) that defaults to `0`, and document that any
523
+ non-zero value makes output non-deterministic.
524
+ - The shared mutable instance is also a latent hazard if timeouts ever become
525
+ per-call configurable — construct per call, or snapshot/restore the setting.
526
+ - Test: diff a large paragraph pair twice with an artificially tiny timeout and
527
+ assert the default (`0`) path produces byte-identical output across runs.
528
+
529
+ **Acceptance for Phase 2:** overflow guard returns a structured error; encoding
530
+ handles ≥200k unique tokens with exact reconstruction; `charsToWords` throws
531
+ rather than dropping; diff output is deterministic; fuzz corpus gains a
532
+ high-unique-token case.
533
+
534
+ **Acceptance recorded 2026-08-30:** token encoding now uses Unicode scalar
535
+ values with a hard 262,144-unique-token ceiling. DMP receives a separate
536
+ surrogate-free BMP encoding for ordinary inputs; larger valid inputs use a
537
+ deterministic token-level fallback. Tests reconstruct both sides exactly at
538
+ 1,000 / 65,535 / 65,537 / 200,000 unique words, unmappable codes throw,
539
+ overflow returns `DIFF_TOKEN_LIMIT` with caller OOXML byte-identical, leading
540
+ whitespace and offsets are exact, and the default per-call DMP timeout is zero.
541
+ The seeded fuzz corpus includes a 70,000-unique-token case and has no Phase 2
542
+ suppression remaining.
543
+
544
+ ---
545
+
546
+ ## Phase 3 — One error contract across the public API
547
+
548
+ **Status: Complete (2026-08-29).**
549
+
550
+ > **Production API: BREAKING.** Existing callers that catch parse exceptions, or
551
+ > assume the current return shape from the affected functions, must adapt when
552
+ > malformed-input handling changes to structured results. The new sibling
553
+ > ingestion result helpers are additive, but do not remove this break for the
554
+ > existing throwing functions.
555
+
556
+ ### 3.1 Unify parse-failure behavior (F4)
557
+
558
+ The library has four different behaviors for the same malformed input. Confirmed
559
+ by feeding `<w:p ...><w:r><w:t>hello</w:t></w:p>` (unclosed `w:r`) to each
560
+ public entry point:
561
+
562
+ | API | Behavior on malformed XML |
563
+ |---|---|
564
+ | `applyRedlineToOxml` | returns `{ status: 'error', error: { code: 'PARSE_ERROR' } }` ✓ |
565
+ | `validateRedlineOoxml` | returns `{ valid: false, issues: [PARSE_ERROR] }` ✓ |
566
+ | `injectCommentsIntoOoxml` | returns original oxml + a warning |
567
+ | `ingestWordOoxmlToPlainText` | returns `''` — indistinguishable from an empty document |
568
+ | `acceptTrackedChangesInOoxml` | **throws raw `ParseError`** |
569
+ | `rejectTrackedChangesInOoxml` | **throws raw `ParseError`** |
570
+ | `deleteCommentsByAuthorInOoxml` | **throws raw `ParseError`** |
571
+
572
+ The three throwing functions are exactly the ones a downstream tool calls in a
573
+ cleanup pass, often in a loop over many documents — an uncaught `ParseError`
574
+ from a dependency's internals takes down the batch.
575
+
576
+ Of the 31 `parseFromString`/`parseXml` call sites across `core/`, `engine/`,
577
+ `pipeline/`, `services/`, and `orchestration/`, only about five are inside a
578
+ `try`. Under `@xmldom/xmldom` (the Node peer dependency) a fatal parse **throws**
579
+ `ParseError`; it does not return a document.
580
+
581
+ **Fix:**
582
+
583
+ 1. Add `parseOoxmlSafe(xmlString)` to `adapters/xml-adapter.js` returning
584
+ `{ doc, error }` — never throwing. Route every parse site through it.
585
+ 2. Every public function that currently throws returns the established shape
586
+ instead: `{ oxml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message } }`
587
+ for transforms, `{ valid: false, issues: [...] }` for validators.
588
+ 3. `ingestWordOoxmlToPlainText` / `ingestWordOoxmlToMarkdown` keep returning a
589
+ string (signature change would be breaking), but add sibling
590
+ `ingestWordOoxmlToPlainTextResult(oxml)` returning `{ text, status, error }`
591
+ so callers can distinguish "empty document" from "unparseable input".
592
+ Re-export both from `index.js`, and note the distinction in `AGENTS.md`.
593
+ 4. Add `tests/error_contract_tests.mjs` asserting **every** exported function
594
+ that accepts an OOXML string returns (never throws) for: malformed XML, empty
595
+ string, `null`, `undefined`, and a non-OOXML but well-formed document
596
+ (`<html><body/></html>`). Drive it off `Object.keys(await import('../index.js'))`
597
+ so newly added exports are covered automatically.
598
+
599
+ ### 3.2 Retire or fix the browser-only parse-error probe
600
+
601
+ `getXmlParseError` (`core/xml-query.js:97`) looks for a `<parsererror>` element.
602
+ That is a browser-DOMParser convention; `@xmldom/xmldom` throws instead and never
603
+ produces such an element. On the Node path the check is dead code, which makes
604
+ the `PARSE_ERROR` branches in `engine/oxml-engine.js:72-81` and `:91-103` look
605
+ better covered than they are. Keep the function for browser hosts, but document
606
+ that it is browser-only and make `parseOoxmlSafe` (3.1) the single source of
607
+ truth for both runtimes.
608
+
609
+ ### 3.3 Route xmldom diagnostics through the logger
610
+
611
+ `@xmldom/xmldom` writes `[xmldom error]` / `[xmldom warning]` / `[xmldom fatalError]`
612
+ straight to the console, bypassing `adapters/logger.js`. A package that offers
613
+ injectable logging should not print to a host's stdout behind its back. Note
614
+ also that non-fatal errors do **not** throw and **do** silently alter content —
615
+ `<a>&nosuch;</a>` parses to `<a>&amp;nosuch;</a>` with only a console line.
616
+
617
+ - Pass `@xmldom/xmldom`'s `onError`/error-handler option (see its `DOMParser`
618
+ options) from `parseOoxmlSafe`, forwarding messages to `adapters/logger.js`.
619
+ - Surface non-fatal parse diagnostics as `warnings[]` on the result so callers
620
+ can detect content-altering recoveries such as an undefined entity.
621
+
622
+ ### 3.4 Fix `TARGET_NOT_FOUND` for multi-line originals (F8)
623
+
624
+ `engine/oxml-engine.js:141` disables the whole target-existence check when the
625
+ original text contains a newline:
626
+
627
+ ```js
628
+ && !originalText.includes('\n')
629
+ ```
630
+
631
+ So a multi-paragraph edit whose `original` does not appear in the document falls
632
+ through to a mode handler and silently returns `hasChanges: false` — the caller
633
+ cannot tell "nothing to do" from "I aimed at text that isn't there," which is
634
+ precisely the distinction Phase 6.1 of the previous plan set out to create.
635
+
636
+ Extend the check to multi-line originals: normalize both sides per paragraph
637
+ (split on `\n`, apply `normalizeTargetText` to each) and require every non-empty
638
+ line to be present in the document's visible text. Add tests for a multi-line
639
+ original that does match, and one that does not.
640
+
641
+ **Acceptance for Phase 3:** no exported function throws on any malformed input in
642
+ `tests/error_contract_tests.mjs`; xmldom diagnostics reach the injected logger;
643
+ multi-line `TARGET_NOT_FOUND` covered; README/AGENTS error-code tables updated
644
+ with `DIFF_TOKEN_LIMIT` (Phase 2) and the new result helpers.
645
+
646
+ **Acceptance recorded:** `parseOoxmlSafe` is the only production call site for
647
+ `DOMParser.parseFromString`; it normalizes browser `<parsererror>` documents and
648
+ xmldom exceptions, routes diagnostics through the logger, and retains
649
+ recoverable diagnostics in `warnings`. The export-driven error matrix covers 19
650
+ OOXML-consuming main-entry exports across malformed XML, empty strings,
651
+ `null`, `undefined`, and well-formed non-OOXML. Cleanup transforms return
652
+ structured `PARSE_ERROR` results without changing caller input; both ingestion
653
+ result helpers distinguish parse failures from empty content; normalized
654
+ multi-line matches and misses are covered. The suite is green at 28/28.
655
+
656
+ ---
657
+
658
+ ## Phase 4 — Stop silent mutation of caller content
659
+
660
+ **Status: Complete (2026-08-29).**
661
+
662
+ > **Production API: BREAKING if `sanitizeInput` defaults to `false`.** That
663
+ > default changes the behavior of existing callers that rely on implicit input
664
+ > sanitization. The `accept-all-first` correction below is a conditional output
665
+ > change, not a signature change.
666
+
667
+ ### 4.1 `accept-all-first` can destroy another reviewer's work (F5)
668
+
669
+ In `engine/oxml-engine.js`, `noChanges` closes over the `oxml` parameter:
670
+
671
+ ```js
672
+ const noChanges = () => finalize({ oxml, hasChanges: false }); // :57
673
+ ...
674
+ oxml = accepted.oxml; // :89 — parameter reassigned
675
+ ```
676
+
677
+ So when `existingRevisions: 'accept-all-first'` normalizes the input and the edit
678
+ then turns out to be a no-op, the caller receives OOXML with **another author's
679
+ tracked changes already accepted and stripped**, labelled `hasChanges: false,
680
+ status: 'no-op'`. Reproduced:
681
+
682
+ ```
683
+ input had w:ins (author "Prior"): true
684
+ result: hasChanges=false, status='no-op'
685
+ output has w:ins: false // the prior reviewer's revision is gone
686
+ ```
687
+
688
+ Any caller that follows the natural contract — "`hasChanges: false`, so writing
689
+ the payload back is harmless" — silently discards a human reviewer's revisions.
690
+
691
+ **Fix:**
692
+
693
+ - Introduce `finalizeUnchanged()` that returns the **original, pre-normalization**
694
+ `oxml` string. Capture it as `const inputOoxml = oxml;` at function entry and
695
+ never reassign that binding.
696
+ - When normalization did occur and the edit was a no-op, that is a real change to
697
+ the payload: return the normalized oxml with `hasChanges: true` and a
698
+ `warnings: ['existing revisions were accepted before redlining']` entry, OR
699
+ return the untouched input with `hasChanges: false`. **Pick the second** — it
700
+ is the non-destructive default — and add an explicit
701
+ `existingRevisions: 'accept-all-first-keep-normalized'` value for callers who
702
+ actually want the normalized document back.
703
+ - Test: prior-author `w:ins` + a no-op edit under `'accept-all-first'` →
704
+ output still contains the prior `w:ins`. Add a fuzz-corpus case (Phase 1.3 item 4).
705
+
706
+ ### 4.2 `sanitizeAiResponse` corrupts legitimate document text (F6)
707
+
708
+ `engine/oxml-engine.js:353-360` runs unconditionally on `modifiedText` for every
709
+ `applyRedlineToOxml` call, with no opt-out. Reproduced on realistic contract text:
710
+
711
+ | Input | Output |
712
+ |---|---|
713
+ | `The rate is $X$ per unit as defined in Schedule A.` | `The rate is X per unit...` — `$` delimiters eaten |
714
+ | `Costs range from $ten thousand$ upward.` | `Costs range from ten thousand upward.` |
715
+ | `Escape sequences such as \n and \r\n must be preserved literally.` | literal `\n` converted to real newlines — **changes paragraph structure** |
716
+ | `Here is the text: this clause is part of the actual contract body.` | `this clause is part of the actual contract body.` — sentence truncated |
717
+
718
+ These are LaTeX/chat-response heuristics applied to what is, by the library's own
719
+ contract, *document content*. The `\n` case is the most damaging: it silently
720
+ splits one paragraph into several.
721
+
722
+ **Fix:**
723
+
724
+ - Add `options.sanitizeInput`, defaulting to **`false`** (do not touch caller
725
+ content). This is a behavior change; call it out in the changelog and bump the
726
+ minor version. Hosts that genuinely feed raw LLM output opt in with `true`.
727
+ - If a fully backward-compatible landing is required, default to `true` for one
728
+ minor release while emitting `warnings: ['input was sanitized; pass sanitizeInput:false to disable']`
729
+ whenever sanitization actually altered the text, then flip the default.
730
+ Either way the caller must be able to find out that their text was rewritten.
731
+ - Narrow the transforms themselves regardless of the default: drop the `$...$`
732
+ and `\n`-unescaping rules entirely (they are unsafe on document text), and
733
+ anchor prefix stripping to a full leading line rather than a sentence prefix.
734
+ - Test each row of the table above as a regression case.
735
+
736
+ **Acceptance for Phase 4:** no-op under `'accept-all-first'` preserves input;
737
+ sanitization is opt-in (or loudly warned); all six corruption samples covered by
738
+ tests; README documents `sanitizeInput`.
739
+
740
+ **Acceptance recorded:** the engine keeps immutable caller OOXML separate from
741
+ working normalized OOXML. Default `accept-all-first` no-ops are byte-identical
742
+ and retain prior-author revisions; the explicit
743
+ `accept-all-first-keep-normalized` policy returns normalization with
744
+ `hasChanges: true` and a warning. `sanitizeInput` defaults to `false`, opt-in
745
+ sanitization warns when it changes text, and sanitization now removes only a
746
+ standalone leading preface line. Dollar-delimited text, literal `\\n` and
747
+ `\\r\\n`, and inline preface-like contract sentences are covered. The fuzz
748
+ corpus includes prior-revision no-ops and the full suite was green at 29/29 at
749
+ that phase checkpoint (30/30 after Phase 5).
750
+
751
+ ---
752
+
753
+ ## Phase 5 — Remove process-global mutable state
754
+
755
+ **Status: Complete (2026-08-30).** Public redline and standalone operation
756
+ invocations now use a document-scoped `RevisionIdAllocator`, shared through DOM,
757
+ list, table, formatting, and string-serialization paths. Seeding considers only
758
+ revision-bearing Word elements and comments, ignores bookmark/relationship ID
759
+ spaces, and restarts from a collision-free low value near the signed 32-bit
760
+ boundary. Tests cover hostile-to-clean document isolation, comment seeding,
761
+ standalone calls, and interleaved explicit authors.
762
+
763
+ **Why (F7):** `revisionIdCounter` in `core/types.js:161` is a module-level
764
+ counter that `seedRevisionIdsFromDocument` only ever raises, never resets or
765
+ scopes. Reproduced:
766
+
767
+ ```
768
+ start id: 1000
769
+ after seeding from a doc containing w:id="2147483000" -> next id 2147483001
770
+ next id for a COMPLETELY UNRELATED clean document -> 2147483002
771
+ ids remaining before int32 overflow: 644
772
+ ```
773
+
774
+ `w:id` is `ST_DecimalNumber`, so a large value is schema-legal; a single hostile
775
+ or merely unusual document permanently poisons every later document processed by
776
+ that Node process. Past int32 the ids Word receives are out of its practical
777
+ range. In a long-running server (the primary deployment shape for this package)
778
+ this is a slow-burning corruption source with no signal.
779
+
780
+ `seedRevisionIdsFromDocument` also scans **every** attribute with local name
781
+ `id` — including `w:bookmarkStart/@w:id`, `w:comment/@w:id`, and any `r:id` that
782
+ happens to parse as an integer — so it inherits the maximum of an unrelated id
783
+ space.
784
+
785
+ `adapters/config.js` (`_defaultAuthor`, `_platform`) and `adapters/xml-adapter.js`
786
+ (`_DOMParser`, `_XMLSerializer`) are process-globals too. That is acceptable for
787
+ providers, but a multi-tenant server cannot safely vary the default author per
788
+ request.
789
+
790
+ ### 5.1 Scope revision ids to a document
791
+
792
+ - Add an internal `RevisionIdAllocator` (`core/types.js`) — a small object with
793
+ `next()` seeded from one document — and create one per `applyRedlineToOxml` /
794
+ per `applyOperationToDocumentXml` invocation.
795
+ - Thread it through the engine and builders. Keep `createRevisionMetadata(author)`
796
+ working against a module-level default allocator so no public signature breaks;
797
+ add `createRevisionMetadata(author, allocator)` as the internal path.
798
+ - Narrow seeding to the id spaces that actually matter: revision-bearing elements
799
+ (`w:ins`, `w:del`, `w:moveFrom`, `w:moveTo`, `w:rPrChange`, `w:pPrChange`,
800
+ `w:cellIns`, `w:cellDel`) plus `w:comment`. Do not seed from `r:id` or
801
+ bookmark ids.
802
+ - Clamp: if a seeded value would push allocation within a safety margin of
803
+ `2^31 - 1`, restart low and rely on per-document uniqueness (already asserted
804
+ by `assertUniqueRevisionIds`) instead of global monotonicity.
805
+ - Test: process document A with `w:id="2147483000"`, then document B (clean);
806
+ assert B's generated ids are small and `assertUniqueRevisionIds` passes on both.
807
+
808
+ ### 5.2 Per-call author override
809
+
810
+ `options.author` already exists and takes precedence; verify no code path reads
811
+ `getDefaultAuthor()` after a caller supplied `options.author`. Add a test that
812
+ two interleaved `await`ed `applyRedlineToOxml` calls with different `author`
813
+ values produce correctly-attributed revisions — the engine is `async`, so
814
+ interleaving is real.
815
+
816
+ **Acceptance for Phase 5:** id allocation is per-document; a hostile id in one
817
+ document does not affect the next; interleaved concurrent calls attribute
818
+ authors correctly.
819
+
820
+ ---
821
+
822
+ ## Phase 6 — Batch operation atomicity (F10)
823
+
824
+ **Status: Complete (2026-08-30).** Batches now default to `atomic: true` and
825
+ roll back the document, package artifacts, and mutable runtime context after any
826
+ operation error. `atomic: false` preserves partial-result behavior;
827
+ `continueOnError` is explicit and defaults to `true`. Stale batch-start anchors
828
+ must rematch exact visible text or return `TARGET_NOT_FOUND`. Tests cover a
829
+ five-operation batch failing at operation 3 and overlapping replacements.
830
+
831
+ > **Production API: BREAKING.** Making `atomic` default to `true` changes the
832
+ > established batch result on failure from a partially applied document to the
833
+ > untouched original. Existing callers that intentionally consume partial
834
+ > results must pass `atomic: false` or migrate their handling.
835
+
836
+ `applyOperationsToDocumentXml` (`services/standalone-operation-runner.js:1289-1348`)
837
+ applies operations sequentially onto a running `currentDocumentXml`. On failure
838
+ it records an error entry and — unless `options.continueOnError === false` —
839
+ keeps going. The returned `documentXml` is therefore a **partially applied
840
+ batch**, returned with `hasChanges: true`, and the caller has to reconstruct what
841
+ landed by walking `results`.
842
+
843
+ For the package's primary full-document entry point, "half the edits applied" is
844
+ usually worse than "none applied": the document no longer matches either the
845
+ original or the intended outcome, and the operation list is not replayable
846
+ because earlier edits have moved the anchors.
847
+
848
+ - Add `options.atomic` (default **`true`**): on any operation error, return the
849
+ **original** `documentXml`, `hasChanges: false`, and the full `results` array
850
+ describing what would have applied. Callers wanting today's behavior pass
851
+ `atomic: false`.
852
+ - Make the current `continueOnError` semantics explicit in the JSDoc — right now
853
+ the default (`undefined`) means "continue," which reads backwards.
854
+ - Also audit `context.targetRefSnapshot` (`:1296-1299`): it is built once from
855
+ the pre-mutation document and reused for every operation. That is deliberate
856
+ (comment anchors are resolved before replacements), but it means a replacement
857
+ operation late in the batch resolves anchors against a document state that no
858
+ longer exists. Add a test with two overlapping replacements targeting adjacent
859
+ text and assert the second either applies correctly or reports
860
+ `TARGET_NOT_FOUND` — never silently edits the wrong span.
861
+ - Tests in `tests/standalone_operation_runner_tests.mjs`: 5-operation batch with
862
+ op 3 failing → `atomic: true` returns the untouched original; `atomic: false`
863
+ reproduces today's partial result.
864
+
865
+ **Acceptance for Phase 6:** atomic batches by default; overlapping-anchor case
866
+ covered; `AGENTS.md` batch section documents the new default.
867
+
868
+ ---
869
+
870
+ ## Phase 7 — Make the tooling checks real
871
+
872
+ **Status: Complete (2026-08-29).** `check:types` now runs TypeScript over a
873
+ consumer fixture and checks all 99 runtime exports against `index.d.ts` with no
874
+ allowlisted gaps. ESLint enforces the documented XML construction/parsing
875
+ boundaries and correctness rules in CI. Per-file c8 reporting is available as
876
+ `npm run test:coverage`, with the initial baseline recorded in
877
+ `docs/VALIDATION.md`.
878
+
879
+ > **Production API: NOT BREAKING.** This phase changes repository validation,
880
+ > type fixtures, linting, and coverage visibility. It does not change runtime
881
+ > behavior or the public API contract.
882
+
883
+ ### 7.1 `check:types` does not type-check (F9)
884
+
885
+ `scripts/check-types.mjs` greps `index.d.ts` for seven fixed substrings and
886
+ counts curly braces. It never invokes `tsc`. Consequence, measured:
887
+
888
+ ```
889
+ runtime exports: 97
890
+ typed in index.d.ts: 51
891
+ exported but NOT typed: 63 (ContainerKind, DiffOp, NS_W, escapeXml,
892
+ buildReconciliationPlan, getParagraphText, ...)
893
+ npm run check:types: PASS
894
+ ```
895
+
896
+ TypeScript consumers get `any`/implicit errors on two thirds of the surface, and
897
+ declaration drift is structurally undetectable.
898
+
899
+ - Add `typescript` as a devDependency and run a real `tsc --noEmit` over
900
+ `index.d.ts` plus a small `tests/types/usage.ts` fixture that exercises the
901
+ documented shapes (options bag, result with `status`/`error`, accept/reject
902
+ results, config functions).
903
+ - Add a **completeness check**: import `index.js`, diff `Object.keys` against the
904
+ declarations, and fail on any untyped export. Land it with an explicit
905
+ allowlist of the 63 current gaps so CI goes green immediately, then burn the
906
+ allowlist down — new exports are typed from day one.
907
+ - Wire both into the existing `check:types` script so `.github/workflows/ci.yml`
908
+ picks them up with no workflow change.
909
+
910
+ ### 7.2 Add a linter
911
+
912
+ There is no ESLint/Prettier/tsconfig in the repo. For a 14,355-line library
913
+ whose failure mode is silent wrong output, the highest-value rules are the ones
914
+ that catch the bug classes this plan documents:
915
+
916
+ - `no-unused-vars`, `no-undef`, `require-atomic-updates` (would flag the F5
917
+ reassigned-parameter-captured-in-closure pattern).
918
+ - A `no-restricted-syntax` rule banning `document.createElement('w:...')` and
919
+ `createElementNS(NS_W, ...)` outside `core/word-xml.js`, and bare
920
+ `parseFromString` outside `adapters/xml-adapter.js` — this converts two
921
+ hand-audited conventions from the previous plan (its "Final verification"
922
+ section audited these manually) into enforced ones.
923
+ - `no-empty` with `allowEmptyCatch: false` — the silent `catch {}` in
924
+ `services/numbering-helpers.js:75` swallows malformed numbering XML.
925
+
926
+ Add `npm run lint` and a CI step. Do not add Prettier or reformat the codebase in
927
+ the same change.
928
+
929
+ ### 7.3 Coverage visibility
930
+
931
+ Source is 14,355 lines against 4,976 lines of test, and the largest module
932
+ (`services/standalone-operation-runner.js`, 1,348 lines) and
933
+ `orchestration/list-structural-fallback.js` (530 lines) are among the least
934
+ directly covered. Add `node --experimental-test-coverage` (or `c8`) as
935
+ `npm run test:coverage`, report per-file, and record a baseline in
936
+ `docs/VALIDATION.md`. Do not gate CI on a threshold yet — get the number visible
937
+ first, and use it to aim Phase 1.3's corpus work.
938
+
939
+ **Acceptance for Phase 7:** `npm run check:types` runs `tsc` and fails on drift;
940
+ `npm run lint` passes and is in CI; coverage baseline recorded.
941
+
942
+ ---
943
+
944
+ ## Phase 8 — Reconstruction structural correctness (found by Phase 1)
945
+
946
+ **Status: Complete (2026-08-29).** Reconstruction now restores content at its
947
+ original container position, preserves `w:br`/`w:cr` as structural sentinels,
948
+ and scopes whole-paragraph reconstruction to the contiguous range named by the
949
+ caller. Partial paragraph targets return `PARTIAL_TARGET`. Runtime validation
950
+ now rejects nested paragraphs and misplaced or duplicate body-level `w:sectPr`.
951
+ The 12,000-case sweep passes with no Phase 8 suppression; its remaining 45
952
+ classified cases are the Phase 2 leading-whitespace gap.
953
+
954
+ > **Production API: CONDITIONAL — production behavior.** These fixes preserve
955
+ > the API shape but change results for malformed or mis-scoped inputs: invalid
956
+ > nested paragraphs/section placement are corrected, and partial targeting may
957
+ > return `PARTIAL_TARGET` instead of silently reporting success.
958
+
959
+ **Priority: high.** F13 and F14 produce output that is not valid
960
+ WordprocessingML. Word reports such files as corrupt, and F14 is rejected by
961
+ this package's own plumbing validator, so it is not a theoretical concern.
962
+ Despite the number, schedule this alongside Phase 2 rather than last.
963
+
964
+ All three defects live in the same code path: reconstruction mode rebuilds
965
+ paragraphs into a fragment, removes the originals, and re-inserts the fragment
966
+ (`engine/reconstruction-writer.js:134-156`).
967
+
968
+ ### 8.1 `w:sectPr` is moved to the front of `w:body` (F14)
969
+
970
+ ```js
971
+ paragraphs.forEach(paragraph => {
972
+ if (paragraph.parentNode) paragraph.parentNode.removeChild(paragraph);
973
+ });
974
+ // ...
975
+ target.appendChild(fragment); // lands AFTER whatever is left in the body
976
+ ```
977
+
978
+ Removing the target paragraphs and then *appending* means the rebuilt content is
979
+ placed after every remaining sibling — including `w:sectPr`, which is normally
980
+ the body's last child. Result: `<w:body><w:sectPr/><w:p>…</w:p></w:body>`.
981
+ `CT_Body` requires section properties last, and
982
+ `services/standalone-docx-plumbing.js:361-362` throws
983
+ `Validation failed: w:sectPr not last` on exactly this shape.
984
+
985
+ Measured rate: **10,000 of 12,000 fuzz cases** — every generated case that
986
+ contains a `w:sectPr`.
987
+
988
+ Fix: capture the position of the first removed paragraph (its `nextSibling`, or
989
+ its index among the container's children) *before* removal, and `insertBefore`
990
+ the rebuilt fragment at that position instead of appending. That also fixes
991
+ ordering against any other trailing sibling, not just `w:sectPr`. Reuse
992
+ `insertBodyElementBeforeSectPr` from `services/standalone-docx-plumbing.js` if it
993
+ fits, rather than adding a second placement rule.
994
+
995
+ Then delete the `sectPr-not-last` entry from `KNOWN_GAPS` in
996
+ `tests/roundtrip_fuzz_tests.mjs`, un-skip the corpus case, and remove the two
997
+ `{ knownGaps: ['sectPr-not-last'] }` opt-outs in
998
+ `tests/paragraph_mark_revision_tests.mjs` (grep `KNOWN-GAP` to confirm none
999
+ remain).
1000
+
1001
+ ### 8.2 `w:br` round-trips into a nested `w:p` (F13)
1002
+
1003
+ Input `first line<w:br/>second line`, editing only the second line, produces:
1004
+
1005
+ ```xml
1006
+ <w:p><w:r><w:t>first line</w:t></w:r>
1007
+ <w:p>…second row…</w:p> <!-- a paragraph INSIDE a paragraph -->
1008
+ </w:p>
1009
+ ```
1010
+
1011
+ The `w:br` is gone (0 in output) and `CT_P` has no paragraph child, so the file
1012
+ is corrupt. Root cause is an ambiguity, not a typo: ingestion maps `w:br` to
1013
+ `'\n'` (`pipeline/ingestion-export.js:88`, `pipeline/ingestion-paragraph.js:326`),
1014
+ and reconstruction reads `'\n'` back as a *paragraph* boundary. The two meanings
1015
+ of `'\n'` are not distinguished anywhere.
1016
+
1017
+ Fix requires picking a representation and applying it consistently:
1018
+
1019
+ - Preferred: keep `w:br` out of the text stream entirely — treat a break-bearing
1020
+ run as inert scaffolding the way footnote references are handled in
1021
+ `engine/surgical-spans.js`, so edits anchor around it and it is neither split
1022
+ nor recreated.
1023
+ - Alternative: give `w:br` a distinct sentinel character in the text model and
1024
+ teach the reconstruction writer to emit `w:br` for it and a paragraph break
1025
+ only for a real `'\n'`.
1026
+
1027
+ Either way, add `assertNoNestedParagraphs` coverage (already available from
1028
+ Phase 1) and un-skip the `w:br survives an edit in an adjacent run` corpus case.
1029
+
1030
+ ### 8.3 Untargeted paragraphs are silently deleted (F15)
1031
+
1032
+ ```js
1033
+ // body has three paragraphs; original names only the first
1034
+ await applyRedlineToOxml(threeParagraphDoc, 'alpha beta gamma', 'alpha beta delta')
1035
+ // -> status: 'ok', and paragraphs two and three are GONE
1036
+ ```
1037
+
1038
+ `AGENTS.md` gotcha 3 already warns that paragraph APIs are not always safe on a
1039
+ full `word/document.xml`, so a caller doing this is misusing the API — but
1040
+ `status: 'ok'` while deleting two paragraphs is the wrong failure mode for a
1041
+ misuse, and nothing in the result distinguishes it from a clean edit.
1042
+
1043
+ Fix: when reconstruction is about to rebuild a container, compare the paragraphs
1044
+ it matched against the paragraphs present. If it did not match all of them,
1045
+ either scope the rewrite to the matched paragraphs (preferred) or return
1046
+ `status: 'error'` with a new `PARTIAL_TARGET` code (Phase 3's contract). Do not
1047
+ leave a path that silently drops content and reports success.
1048
+
1049
+ Add a fuzz shape for it: multi-paragraph body, `original` naming exactly one
1050
+ paragraph, asserting the other paragraphs survive byte-identical.
1051
+
1052
+ **Acceptance for Phase 8:** all three `KNOWN-GAP` suppressions removed;
1053
+ `grep -rn "KNOWN-GAP" tests/` returns nothing for Phase 8; fuzz sweep of ≥12,000
1054
+ cases green with no known-gap counts; the Phase 1 follow-up port of
1055
+ `assertNoNestedParagraphs` / `assertSectPrLast` into `core/redline-validation.js`
1056
+ done as part of this phase.
1057
+
1058
+ ---
1059
+
1060
+ ## Execution order and dependencies
1061
+
1062
+ ```
1063
+ Phase 1 (honest oracle) ← DONE; every later phase is verified through it
1064
+ Phase 8 (reconstruction shape) ← DONE
1065
+ Phase 1.5 (Word + real corpus) ← DONE; expand alongside every later phase
1066
+ Phase 2 (diff correctness) ← DONE
1067
+ Phase 3 (error contract) ← DONE
1068
+ Phase 4 (silent mutation) ← DONE
1069
+ Phase 5 (global state) ← DONE
1070
+ Phase 6 (batch atomicity) ← DONE
1071
+ Phase 7 (tooling) ← DONE
1072
+ ```
1073
+
1074
+ Highest value per unit of work, if the whole plan cannot be taken on:
1075
+ **Phase 8.1** (one-line-ish placement fix, clears a defect affecting essentially
1076
+ all document-scoped reconstruction output), **Phase 2.1b** (leading whitespace,
1077
+ also small), **Phase 2.1** (silent text loss at scale), then **Phase 4.1**
1078
+ (silent revision loss).
1079
+
1080
+ Each phase is a separate commit with the suite green. Suggested messages:
1081
+
1082
+ - `test: compare round-trip text losslessly and widen the fuzz corpus` *(done)*
1083
+ - `fix: keep w:sectPr last and stop emitting nested paragraphs` *(done)*
1084
+ - `test: add Word differential task suite and pinned SuperDoc corpus references` *(done)*
1085
+ - `fix: guard and widen diff token space; make diff output deterministic` *(done)*
1086
+ - `feat: return structured errors instead of throwing on malformed OOXML` *(done)*
1087
+ - `fix: stop discarding existing revisions and mutating caller text` *(done)*
1088
+ - `refactor: scope revision id allocation to a single document`
1089
+ - `feat: make batched document operations atomic by default`
1090
+ - `build: type-check index.d.ts for real, add lint and coverage`
1091
+
1092
+ ---
1093
+
1094
+ ## Global guardrails (carried forward, plus new)
1095
+
1096
+ From the previous plan — still binding:
1097
+
1098
+ - Never emit `w:t` inside `w:del`; never nest `w:ins`/`w:del`; never emit empty
1099
+ `w:t`/`w:delText` or empty revision wrappers.
1100
+ - Source author strings through `createRevisionMetadata`; never trim or transform
1101
+ them at call sites.
1102
+ - Use `RPR_SCHEMA_ORDER` (`engine/rpr-helpers.js`) for `w:rPr` ordering; `w:pPr`
1103
+ is the first child of `w:p` and `w:rPr` is its last child.
1104
+
1105
+ New, from this plan:
1106
+
1107
+ - **A function must never silently drop content it cannot map.** `charsToWords`
1108
+ discarding out-of-range tokens is the archetype: if a value cannot be handled,
1109
+ raise or report it.
1110
+ - **`hasChanges: false` must guarantee the returned payload is byte-identical to
1111
+ the input.** If the library normalized anything, that is a change and must be
1112
+ reported as one.
1113
+ - **The library must not rewrite caller-supplied document text** outside of the
1114
+ explicitly-requested edit. Heuristic cleanup is opt-in.
1115
+ - **Never use a lossy reader as a test oracle.** Verification helpers compare
1116
+ exact bytes/characters; normalization in an assertion needs a written
1117
+ justification at the call site.
1118
+ - **Wall-clock time must not influence output.** Any timeout that changes results
1119
+ rather than just aborting is a determinism bug.
1120
+
1121
+ ---
1122
+
1123
+ ## Verification commands
1124
+
1125
+ ```bash
1126
+ npm test # 30/30 as of Phase 5
1127
+ npm run test:isolation
1128
+ npm run check:types
1129
+ npm run test:word # Windows + installed desktop Word
1130
+ npm run test:corpus:word # Windows + Word + 20 reviewed real documents
1131
+ npm run lint # added in Phase 7.2
1132
+ npm run test:coverage # added in Phase 7.3
1133
+ node scripts/export-validation-fixtures.mjs
1134
+ npm run corpus:fetch:superdoc -- --id <pinned-sha256>
1135
+ FUZZ_SEED=1 FUZZ_ITERATIONS=5000 node tests/roundtrip_fuzz_tests.mjs
1136
+ rg -n "KNOWN-GAP" tests/ # should print nothing after Phase 8
1137
+ ```
1138
+
1139
+ The fuzz run prints a per-shape breakdown and a line per known gap, if any. As
1140
+ of Phase 2 there are no suppressed gaps, so the deterministic release gate is:
1141
+
1142
+ ```
1143
+ PASS: 5000 fuzz round-trip cases (base seed 1) [paragraph=1667 multiParagraph=834 tableCell=833 whitespace=833 existingRevisions=833 highUniqueToken=1]
1144
+ ```
1145
+
1146
+ A gap reporting `0 cases -- possibly fixed` means the entry is stale: confirm
1147
+ and delete it.
1148
+
1149
+ Release-time, unchanged from `docs/VALIDATION.md`:
1150
+
1151
+ ```bash
1152
+ npm run smoke:word:diff # Windows + desktop Word differential
1153
+ bash scripts/validate-fixtures-xsd.sh
1154
+ soffice --headless --convert-to pdf tmp/validation-docx/*.docx
1155
+ ```