@ansonlai/docx-redline-js 0.2.1 → 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.
- package/AGENTS.md +36 -10
- package/README.md +83 -6
- package/adapters/xml-adapter.js +73 -10
- package/core/list-targeting.js +3 -0
- package/core/paragraph-targeting.js +33 -7
- package/core/redline-validation.js +22 -0
- package/core/types.js +122 -27
- package/core/xml-query.js +3 -1
- package/dist/docx-redline-js.esm.js +1124 -545
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -78
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +687 -0
- package/docs/VALIDATION.md +81 -2
- package/docs/WORD-MANUAL-REVIEW.md +138 -0
- package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
- package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
- package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
- package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
- package/docs/test-comparison-dashboard.html +95 -0
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
- package/engine/format-extraction.js +1 -1
- package/engine/formatting-removal.js +84 -99
- package/engine/oxml-engine.js +176 -83
- package/engine/reconstruction-mapper.js +276 -79
- package/engine/reconstruction-mode.js +20 -6
- package/engine/reconstruction-writer.js +117 -72
- package/engine/run-builders.js +3 -3
- package/engine/surgical-mode.js +3 -2
- package/engine/table-mode.js +27 -16
- package/index.d.ts +95 -3
- package/index.js +14 -13
- package/orchestration/list-structural-fallback.js +16 -39
- package/package.json +22 -4
- package/pipeline/diff-engine.js +174 -55
- package/pipeline/ingestion-export.js +39 -24
- package/pipeline/ingestion-paragraph.js +7 -5
- package/pipeline/list-generation.js +27 -18
- package/pipeline/patching.js +2 -3
- package/pipeline/pipeline.js +65 -36
- package/pipeline/serialization.js +13 -5
- package/scripts/build-test-dashboard.mjs +43 -0
- package/scripts/check-types.mjs +16 -24
- package/scripts/export-validation-fixtures.mjs +191 -45
- package/scripts/fetch-superdoc-corpus.mjs +61 -0
- package/scripts/generate-test-dashboard.mjs +199 -0
- package/scripts/inspect-visual-evidence.mjs +271 -0
- package/scripts/lib/minimal-zip.mjs +199 -18
- package/scripts/lib/word-coverage-catalogue.mjs +207 -0
- package/scripts/lib/word-coverage-metadata.mjs +93 -0
- package/scripts/lib/zip-reader.mjs +64 -0
- package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
- package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
- package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
- package/scripts/prepare-word-review.mjs +77 -0
- package/scripts/prepare-word-visual-review.mjs +90 -0
- package/scripts/render-agenda-multilevel.mjs +70 -0
- package/scripts/render-case22.mjs +73 -0
- package/scripts/render-case40.ps1 +35 -0
- package/scripts/render-multilevel-bullet-images.py +58 -0
- package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
- package/scripts/render-multilevel-cases.mjs +80 -0
- package/scripts/report-coverage-gaps.mjs +103 -0
- package/scripts/report-word-coverage.mjs +71 -0
- package/scripts/sample-multimodal-visual-check.mjs +221 -0
- package/scripts/test-multilevel-bullet-visual.mjs +187 -0
- package/scripts/word-com-corpus-suite.ps1 +43 -0
- package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
- package/scripts/word-com-differential.ps1 +158 -16
- package/scripts/word-com-suite.ps1 +19 -0
- package/scripts/word-com-visual-suite.ps1 +132 -0
- package/services/comment-engine.js +51 -46
- package/services/comment-locator.js +0 -1
- package/services/comment-package.js +11 -10
- package/services/numbering-service.js +1 -1
- package/services/revision-comment-management.js +31 -10
- package/services/standalone-docx-plumbing.js +45 -34
- package/services/standalone-operation-runner.js +315 -75
- 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>&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
|
+
```
|