@ansonlai/docx-redline-js 0.1.4 → 0.2.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 (47) hide show
  1. package/AGENTS.md +53 -4
  2. package/ARCHITECTURE.md +75 -11
  3. package/README.md +62 -3
  4. package/core/redline-validation.js +156 -0
  5. package/core/types.js +35 -8
  6. package/core/word-xml.js +90 -0
  7. package/dist/docx-redline-js.esm.js +3195 -2592
  8. package/dist/docx-redline-js.esm.js.map +4 -4
  9. package/dist/docx-redline-js.esm.min.js +71 -67
  10. package/dist/docx-redline-js.esm.min.js.map +4 -4
  11. package/docs/VALIDATION.md +104 -0
  12. package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
  13. package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
  14. package/docs/plans/2026-05-31-architectural changes.md +591 -0
  15. package/engine/format-application.js +13 -14
  16. package/engine/format-span-application.js +7 -6
  17. package/engine/formatting-removal.js +15 -12
  18. package/engine/oxml-engine.js +146 -55
  19. package/engine/reconstruction-mapper.js +35 -8
  20. package/engine/reconstruction-mode.js +14 -13
  21. package/engine/reconstruction-writer.js +97 -78
  22. package/engine/rpr-helpers.js +34 -32
  23. package/engine/run-builders.js +150 -39
  24. package/engine/surgical-diff-application.js +216 -0
  25. package/engine/surgical-mode.js +84 -519
  26. package/engine/surgical-run-splitting.js +96 -0
  27. package/engine/surgical-spans.js +169 -0
  28. package/engine/table-cell-context.js +15 -13
  29. package/engine/table-mode.js +39 -35
  30. package/index.d.ts +172 -0
  31. package/index.js +50 -47
  32. package/package.json +10 -2
  33. package/pipeline/ingestion-export.js +1 -0
  34. package/pipeline/ingestion-paragraph.js +37 -12
  35. package/pipeline/ingestion-table.js +11 -8
  36. package/scripts/build.mjs +40 -0
  37. package/scripts/check-types.mjs +29 -0
  38. package/scripts/export-validation-fixtures.mjs +125 -0
  39. package/scripts/lib/minimal-zip.mjs +155 -0
  40. package/scripts/run-tests.mjs +43 -0
  41. package/scripts/validate-fixtures-xsd.sh +37 -0
  42. package/scripts/word-com-differential.ps1 +133 -0
  43. package/scripts/word-com-smoke.ps1 +48 -0
  44. package/services/comment-locator.js +10 -9
  45. package/services/revision-comment-management.js +115 -1
  46. package/services/standalone-operation-runner.js +119 -69
  47. package/services/table-reconciliation.js +7 -8
@@ -0,0 +1,591 @@
1
+ # Redline Reliability Improvement Plan
2
+
3
+ This plan hardens the library against the ways Microsoft Word redlines are brittle.
4
+ It is written to be executed phase by phase, in order. Each phase is independently
5
+ shippable and ends with a green `npm test`.
6
+
7
+ **Scope note:** A high-level docx-in/docx-out wrapper API is explicitly OUT of scope.
8
+ Downstream tools own the packaging layer. Do not add a JSZip dependency or any
9
+ `applyRedlineToDocx`-style API to this package.
10
+
11
+ **Final audit status:** Complete. The plan has been reviewed top to bottom after
12
+ Phases 1-6. Stale status notes were removed, remaining namespace/metadata
13
+ convention gaps were closed, and the validation commands at the end of this plan
14
+ pass. The only intentionally unimplemented item is Phase 5.4 move-emission, which
15
+ is explicitly marked as a stretch/separate-PR item; move consumption is complete.
16
+
17
+ ---
18
+
19
+ ## Conventions (read before starting any phase)
20
+
21
+ - Tests live in `tests/*.mjs` and are auto-discovered by `scripts/run-tests.mjs`
22
+ (run via `npm test`). Files in `tests/helpers/` and `tests/setup-xml-provider.mjs`
23
+ are excluded from discovery. Follow the style of existing test files
24
+ (e.g. `tests/revision_comment_management_tests.mjs`): plain `assert/strict`,
25
+ no test framework.
26
+ - Shared assertion helpers belong in `tests/helpers/ooxml-assertions.mjs`. Add new
27
+ helpers there rather than duplicating in test files.
28
+ - All XML element creation must go through `createWordElement` in `core/word-xml.js`
29
+ so namespaces are correct. Never use `document.createElement` for `w:*` elements.
30
+ - Revision metadata (`w:id`, `w:author`, `w:date`) must come from
31
+ `createRevisionMetadata(author)` in `core/types.js`. Never hand-roll these attributes.
32
+ - All public API additions must be exported through `index.js` (the only public surface,
33
+ per ARCHITECTURE.md).
34
+ - After each phase: run `npm test` and `npm run test:isolation`. Both must pass.
35
+ - Do not change existing public function signatures. New behavior is added via new
36
+ optional fields on options/result objects.
37
+
38
+ ---
39
+
40
+ ## Phase 1 — Round-trip invariant test harness (do this first)
41
+
42
+ **Status:** Complete.
43
+
44
+ - Added reusable structural assertions to `tests/helpers/ooxml-assertions.mjs`.
45
+ - Added `tests/helpers/roundtrip.mjs` with `assertRoundTrip(...)`.
46
+ - Added initial corpus in `tests/roundtrip_invariant_tests.mjs`.
47
+ - Fixed a Phase 1-discovered namespace bug in `engine/reconstruction-writer.js` by routing Word element creation through `createWordElement`.
48
+ - Added optional Word COM smoke script at `scripts/word-com-smoke.ps1`, exposed as `npm run smoke:word`.
49
+ - Verification: `npm test` passed (21/21); `npm run test:isolation` passed.
50
+
51
+ **Why:** The single most valuable check for redline correctness is:
52
+ *accepting all generated revisions must yield the modified text; rejecting all
53
+ generated revisions must yield the original text.* The library already owns both
54
+ halves of this loop (`applyRedlineToOxml` to generate, and
55
+ `acceptTrackedChangesInOoxml` / `rejectTrackedChangesInOoxml` in
56
+ `services/revision-comment-management.js` to resolve). Every later phase is
57
+ verified through this harness, so it lands first.
58
+
59
+ ### 1.1 Build the harness helper
60
+
61
+ Create `tests/helpers/roundtrip.mjs` exporting:
62
+
63
+ ```js
64
+ /**
65
+ * Applies a redline, then asserts the accept/reject round-trip invariant.
66
+ *
67
+ * @param {string} oxml - input OOXML (fragment, document, or package scope)
68
+ * @param {string} original - original plain text
69
+ * @param {string} modified - modified text (may contain markdown)
70
+ * @param {object} [options] - options forwarded to applyRedlineToOxml
71
+ * @returns {Promise<{ redlined, accepted, rejected }>}
72
+ */
73
+ export async function assertRoundTrip(oxml, original, modified, options = {})
74
+ ```
75
+
76
+ Implementation steps inside `assertRoundTrip`:
77
+
78
+ 1. Call `applyRedlineToOxml(oxml, original, modified, { generateRedlines: true, author: 'RoundTrip', ...options })`.
79
+ 2. Assert the result parses as XML (use `parseXmlFragment` from
80
+ `tests/helpers/ooxml-assertions.mjs`).
81
+ 3. Run `acceptTrackedChangesInOoxml(result.oxml, { author: 'RoundTrip' })`, extract
82
+ plain text with `ingestWordOoxmlToPlainText`, and assert it equals the
83
+ *plain-text rendering* of `modified` (strip markdown markers the same way the
84
+ engine does — reuse `preprocessMarkdown` from `pipeline/markdown-processor.js`
85
+ to get `cleanText`). Compare with normalized whitespace
86
+ (`s.replace(/\s+/g, ' ').trim()`).
87
+ 4. Run `rejectTrackedChangesInOoxml(result.oxml, { author: 'RoundTrip' })`, extract
88
+ plain text, assert it equals `original` (same whitespace normalization).
89
+ 5. Structural assertions on the redlined output (add these as separate exported
90
+ helpers so other tests can reuse them):
91
+ - `assertNoNestedRevisions(xml)` — no `w:ins` inside `w:del` or vice versa.
92
+ - `assertDelUsesDelText(xml)` — every `w:r` inside a `w:del` contains only
93
+ `w:delText` (never `w:t`).
94
+ - `assertRevisionMetadata(xml)` — every `w:ins`/`w:del` has non-empty `w:id`,
95
+ `w:author`, and a `w:date` matching `/^\d{4}-\d{2}-\d{2}T/`.
96
+ - `assertUniqueRevisionIds(xml)` — no duplicate `w:id` among `w:ins`/`w:del`/
97
+ `w:rPrChange`/`w:pPrChange` elements in the output.
98
+ - `assertSpacePreserved(xml)` — every `w:t`/`w:delText` whose text has leading
99
+ or trailing whitespace carries `xml:space="preserve"`.
100
+
101
+ ### 1.2 Build the corpus test
102
+
103
+ Create `tests/roundtrip_invariant_tests.mjs` that runs `assertRoundTrip` over a
104
+ corpus of (oxml, original, modified) cases. Reuse fixture inputs already present
105
+ in `tests/fixtures/` and `tests/sample_doc/` where possible. Minimum corpus
106
+ (each is one case; build the OOXML inline as template strings like the existing
107
+ tests do):
108
+
109
+ | # | Case |
110
+ |---|------|
111
+ | 1 | Single-run paragraph, one word replaced mid-sentence |
112
+ | 2 | Multi-run paragraph (3+ runs with different `w:rPr`), edit spanning a run boundary |
113
+ | 3 | Leading/trailing whitespace significant: replace `"foo "` with `"bar baz "` |
114
+ | 4 | Pure insertion at start of paragraph; pure insertion at end |
115
+ | 5 | Pure deletion of an entire sentence |
116
+ | 6 | Edit inside a paragraph that contains a `w:hyperlink` (edit text *outside* the link) |
117
+ | 7 | Edit inside a table cell paragraph |
118
+ | 8 | Markdown formatting added: `**bold**` around an existing word |
119
+ | 9 | Paragraph containing `w:proofErr` markers and a simple field (`w:fldChar`/`w:instrText`) |
120
+ | 10 | Unicode: text with emoji and CJK characters replaced |
121
+ | 11 | Two consecutive edits: feed the redlined output of case 1 back through accept-all, then redline again (exercises re-entry on clean docs) |
122
+
123
+ If a case fails, do NOT weaken the assertion to make it pass — fix the engine or,
124
+ if the fix belongs to a later phase (e.g. hyperlink failures belong to Phase 4),
125
+ mark the case with a `// KNOWN-GAP: Phase N` comment and skip it with a logged
126
+ warning, so later phases un-skip it.
127
+
128
+ ### 1.3 Optional Word smoke script (manual, not part of `npm test`)
129
+
130
+ Create `scripts/word-com-smoke.ps1` (Windows-only, requires desktop Word):
131
+ takes a `.docx` path, opens it via COM
132
+ (`New-Object -ComObject Word.Application`, `Documents.Open` with
133
+ `OpenAndRepair:$false`), reports whether Word opened it cleanly, counts
134
+ `document.Revisions.Count`, then closes without saving. Add an npm script
135
+ `"smoke:word": "powershell -File scripts/word-com-smoke.ps1"` and document it in
136
+ README under a new "Validating output" section. Do not wire it into CI.
137
+
138
+ **Acceptance for Phase 1:** new test file passes for all non-skipped cases;
139
+ helpers exported; `npm test` green.
140
+
141
+ ---
142
+
143
+ ## Phase 2 — Policy for pre-existing tracked changes in the source
144
+
145
+ **Status:** Complete.
146
+
147
+ - Added `containsTrackedChanges(xmlDoc)` in `core/word-xml.js` and exported it from `index.js`.
148
+ - Added the `existingRevisions` policy gate in `engine/oxml-engine.js`.
149
+ - Documented that `pipeline/ingestion-paragraph.js` records `w:delText` as a zero-width deletion model entry but excludes it from accepted text.
150
+ - Added `tests/existing_revisions_policy_tests.mjs`.
151
+ - Added README documentation for `existingRevisions`.
152
+ - Forwarded `existingRevisions` through `services/standalone-operation-runner.js` and updated prior-revision standalone tests to opt into `accept-all-first`.
153
+ - Final verification: `npm test` passed (21/21); `npm run test:isolation` passed.
154
+
155
+ **Why:** Running the engine over a paragraph that already contains `w:ins`/`w:del`
156
+ (from a human reviewer or a prior engine run) is the most common real-world
157
+ corruption source. Diff text extraction must treat `w:delText` as invisible and
158
+ `w:ins` content as visible, and the patcher must never nest revisions.
159
+
160
+ ### 2.1 Detection
161
+
162
+ Add to `core/word-xml.js`:
163
+
164
+ ```js
165
+ /**
166
+ * Returns true if the document/fragment contains any revision markup:
167
+ * w:ins, w:del, w:moveFrom, w:moveTo, w:rPrChange, w:pPrChange,
168
+ * w:cellIns, w:cellDel, or a w:del/w:ins inside w:pPr/w:rPr (paragraph mark).
169
+ */
170
+ export function containsTrackedChanges(xmlDoc)
171
+ ```
172
+
173
+ Use `getElementsByTagNameNS(NS_W, localName)` per element name (see how
174
+ `services/revision-comment-management.js` does namespace-safe lookups with
175
+ `getWordElementsByLocalName`).
176
+
177
+ ### 2.2 Engine policy gate
178
+
179
+ In `engine/oxml-engine.js`, immediately after the existing parse-error check in
180
+ `applyRedlineToOxml`, add:
181
+
182
+ 1. Call `containsTrackedChanges(xmlDoc)`.
183
+ 2. If true, behavior is controlled by a new option
184
+ `options.existingRevisions` with values:
185
+ - `'reject-input'` (default): return
186
+ `{ oxml, hasChanges: false, status: 'error', error: { code: 'EXISTING_REVISIONS', message: ... } }`
187
+ (the `status` field is introduced in Phase 6.1 — if Phase 6.1 is not yet done,
188
+ implement the `status`/`error` fields now as part of this step; Phase 6.1
189
+ then only extends them to other early-return paths).
190
+ - `'accept-all-first'`: run `acceptTrackedChangesInOoxml(oxml, { allAuthors: true })`,
191
+ re-parse, and proceed with the cleaned document. The caller's `original` text
192
+ must then match the post-accept text (the normal targeting logic already
193
+ verifies this and falls back to no-change if it doesn't).
194
+ 3. Log which path was taken via the `log` adapter.
195
+
196
+ Do NOT attempt to diff *through* existing revisions in this phase. Normalizing
197
+ first (or refusing clearly) is the reliable behavior; transparent merge of new
198
+ revisions into already-revised text is out of scope.
199
+
200
+ ### 2.3 Audit ingestion's treatment of `w:del`
201
+
202
+ `pipeline/ingestion-paragraph.js` (around lines 320–340) collects `w:delText`
203
+ content when flattening runs. Audit every caller of that code path and confirm
204
+ deleted text is **excluded** from the plain text used for diffing and from
205
+ `ingestWordOoxmlToPlainText` output (deleted text is invisible in Word's
206
+ "accepted" view and must not appear in `original` matching). If it is currently
207
+ included anywhere, fix it and add a regression test. If it is intentionally
208
+ included for some revision-management path, add a comment at the collection site
209
+ stating which caller needs it and why.
210
+
211
+ ### 2.4 Tests
212
+
213
+ Create `tests/existing_revisions_policy_tests.mjs`:
214
+
215
+ - Paragraph containing a `w:del` + `w:ins` pair → default call returns
216
+ `status: 'error'`, code `EXISTING_REVISIONS`, original oxml unchanged.
217
+ - Same input with `existingRevisions: 'accept-all-first'` and `original` set to
218
+ the post-accept text → succeeds, and the result passes `assertRoundTrip`
219
+ structural checks from Phase 1.
220
+ - `ingestWordOoxmlToPlainText` on a paragraph with `w:del` returns text WITHOUT
221
+ the deleted content, and WITH `w:ins` content.
222
+ - `containsTrackedChanges` unit tests: positive for each marker type listed in
223
+ 2.1 (including paragraph-mark `w:del` inside `w:pPr/w:rPr`), negative for a
224
+ clean paragraph and for a paragraph with only comments/bookmarks.
225
+
226
+ **Acceptance:** new tests pass; case 11 from Phase 1 still passes; README API
227
+ table gains a row for the `existingRevisions` option.
228
+
229
+ ---
230
+
231
+ ## Phase 3 — Paragraph-mark revisions
232
+
233
+ **Status:** Complete.
234
+
235
+ - Added `markParagraphMarkInserted(...)` and `markParagraphMarkDeleted(...)` in `engine/run-builders.js`.
236
+ - Wired reconstruction-mode paragraph-boundary insert/delete output to add `w:pPr/w:rPr/w:ins` and `w:pPr/w:rPr/w:del` paragraph-mark revisions.
237
+ - Fixed reconstruction writer paragraph state propagation across newline boundaries while wiring paragraph marks.
238
+ - Wired text-to-table transformation source paragraphs to mark deleted paragraph marks when redlines are generated.
239
+ - Extended `acceptTrackedChangesInOoxml` / `rejectTrackedChangesInOoxml` to handle paragraph-mark `w:ins`/`w:del` separately from normal run-level revisions.
240
+ - Added `tests/paragraph_mark_revision_tests.mjs` for inserted/deleted paragraph round trips and structural assertions.
241
+ - Final verification: `npm test` passed (21/21); `npm run test:isolation` passed.
242
+
243
+ **Why:** When an edit inserts or deletes whole paragraphs (or splits/merges
244
+ them), the paragraph *mark* itself must be revised. A deleted paragraph's mark
245
+ needs `w:pPr > w:rPr > w:del`; an inserted paragraph's mark needs
246
+ `w:pPr > w:rPr > w:ins`. Without this, accept/reject in Word leaves stray empty
247
+ paragraphs or fails to merge paragraphs — highly visible breakage.
248
+
249
+ ### 3.1 Builders
250
+
251
+ Add to `engine/run-builders.js`:
252
+
253
+ ```js
254
+ /** Marks a paragraph's mark as inserted: ensures w:pPr exists, ensures w:rPr
255
+ * inside it, appends <w:ins w:id w:author w:date/> (empty element). */
256
+ export function markParagraphMarkInserted(xmlDoc, paragraph, author)
257
+
258
+ /** Marks a paragraph's mark as deleted: same shape with <w:del/>. */
259
+ export function markParagraphMarkDeleted(xmlDoc, paragraph, author)
260
+ ```
261
+
262
+ Rules:
263
+ - `w:pPr` must be the FIRST child of `w:p`; `w:rPr` must be the LAST child of
264
+ `w:pPr` per schema order (check existing pPr handling in
265
+ `engine/reconstruction-writer.js` for how the codebase orders pPr children,
266
+ and reuse any existing ordering helper).
267
+ - Use `createRevisionMetadata` for attributes. Remove any pre-existing
268
+ `w:ins`/`w:del` in that `w:rPr` before adding (idempotent).
269
+
270
+ ### 3.2 Wire into the engine
271
+
272
+ Find every site that inserts or removes a whole `w:p` while
273
+ `generateRedlines` is true. Search hints:
274
+ `grep -n "createElement.*w:p\b\|appendChild(paragraph\|removeChild(paragraph" engine/ pipeline/`
275
+ plus read `engine/reconstruction-mode.js`, `engine/reconstruction-writer.js`,
276
+ `pipeline/list-generation.js`, and `engine/table-mode.js` (row/cell paragraph
277
+ creation). At each site:
278
+
279
+ - New paragraph created as part of a redline → call `markParagraphMarkInserted`.
280
+ - Paragraph whose entire content is wrapped in `w:del` (paragraph is going away
281
+ on accept) → call `markParagraphMarkDeleted` instead of removing the `w:p` node.
282
+ The paragraph node must REMAIN in the document with its content in `w:del` —
283
+ Word removes it on accept.
284
+
285
+ ### 3.3 Accept/reject support
286
+
287
+ In `services/revision-comment-management.js`, verify (and fix if missing) that:
288
+
289
+ - **Accept** of a paragraph-mark `w:del` merges the paragraph with the following
290
+ paragraph (move this paragraph's remaining children, except `w:pPr`, into the
291
+ next `w:p`, then remove this `w:p`; if it is the last paragraph in its parent,
292
+ just remove the mark revision).
293
+ - **Reject** of a paragraph-mark `w:del` simply removes the `w:del` element
294
+ from `w:pPr/w:rPr`.
295
+ - **Accept** of a paragraph-mark `w:ins` removes the `w:ins` element.
296
+ - **Reject** of a paragraph-mark `w:ins` merges the paragraph into the next one
297
+ (inverse of accept-del).
298
+
299
+ ### 3.4 Tests
300
+
301
+ Create `tests/paragraph_mark_revision_tests.mjs`:
302
+
303
+ - Modified text adds a new paragraph (`"one"` → `"one\n\ntwo"` or via markdown
304
+ list): output's new `w:p` has `w:pPr/w:rPr/w:ins`; accept-all yields two
305
+ paragraphs; reject-all yields one paragraph with original text.
306
+ - Modified text deletes a paragraph (`"one\n\ntwo"` → `"one"`): the second `w:p`
307
+ still exists in the redlined output, its runs are in `w:del`, its mark has
308
+ `w:pPr/w:rPr/w:del`; accept-all yields one paragraph; reject-all yields two.
309
+ - Round-trip via `assertRoundTrip` for both, with multi-paragraph-aware text
310
+ comparison (join paragraphs with `\n`).
311
+
312
+ **Acceptance:** new tests pass; no existing test regresses (list generation
313
+ tests in `tests/list_tests.mjs` are the most likely to be affected — if they
314
+ assert exact XML, update expectations to include the new mark revisions).
315
+
316
+ ---
317
+
318
+ ## Phase 4 — Inert and structural markup safety
319
+
320
+ **Status:** Complete.
321
+
322
+ - Added reliability coverage for hyperlink-contained revisions, bookmark/comment marker preservation, `w:tab` survival, and footnote reference preservation.
323
+ - De-duplicated zero-width comment marker replay in reconstruction mode.
324
+ - Updated run builders to synthesize visible `w:tab`, `w:br`, and `w:noBreakHyphen` elements instead of writing those characters into plain `w:t` text.
325
+ - Added reconstruction preservation for missing footnote/endnote placeholder references when modified text edits adjacent content without explicitly including internal tokens.
326
+ - Added README packaging note for hyperlink/bookmark/comment/tab/break/footnote safety.
327
+ - Final verification: `npm test` passed (21/21); `npm run test:isolation` passed.
328
+
329
+ **Why:** README already documents stripping `w:fldChar`/`w:instrText`/`w:proofErr`
330
+ from the matched paragraph before diffing. The same care is needed for other
331
+ non-text and container markup, or redlines split/orphan them and Word repairs
332
+ (or rejects) the file.
333
+
334
+ Work through these sub-items one at a time, each with its own tests appended to
335
+ `tests/engine_reliability_tests.mjs` (this file already exists):
336
+
337
+ ### 4.1 Hyperlinks (`w:hyperlink`)
338
+
339
+ - **Invariant:** runs created by splitting a run that lives inside a
340
+ `w:hyperlink` must remain inside that same `w:hyperlink` element. New `w:ins`/
341
+ `w:del` wrappers go INSIDE the hyperlink, wrapping the runs.
342
+ - Audit `engine/surgical-run-splitting.js` and `engine/surgical-diff-application.js`:
343
+ wherever a new node is inserted with `insertBefore(node, run)` or appended to
344
+ `run.parentNode`, the parent may be `w:hyperlink`, not `w:p`. Verify insertion
345
+ uses `run.parentNode` (correct) and never hoists to the paragraph level
346
+ (incorrect). Fix any site that assumes the run's parent is `w:p`.
347
+ - Test: paragraph `before [link text] after`; edit `link` → `hyperlink`. Assert
348
+ the `w:ins`/`w:del` elements are descendants of `w:hyperlink`, `r:id`
349
+ attribute is untouched, and round-trip holds. Also test an edit that spans
350
+ from before the hyperlink into it (this may legitimately fall back to
351
+ reconstruction mode — then assert the hyperlink element survives in output).
352
+
353
+ ### 4.2 Bookmarks and comment range markers
354
+
355
+ - `w:bookmarkStart`/`w:bookmarkEnd`/`w:commentRangeStart`/`w:commentRangeEnd`/
356
+ `w:commentReference` must SURVIVE the edit (never deleted, never wrapped in
357
+ `w:del`) and must not contribute characters to diff text.
358
+ - Audit `pipeline/ingestion-paragraph.js` (text extraction) and the surgical
359
+ splitting path. When a run range being replaced contains such markers as
360
+ siblings, the markers must be left in place between the `w:del` and `w:ins`
361
+ output.
362
+ - Test: paragraph with a comment range spanning a word that gets edited; assert
363
+ `commentRangeStart/End/Reference` still present exactly once each, and
364
+ `tests/comment_tests.mjs` still passes.
365
+
366
+ ### 4.3 `w:lastRenderedPageBreak`, `w:tab`, `w:br`, `w:noBreakHyphen`
367
+
368
+ - `w:lastRenderedPageBreak` is render cache: safe to strip from the matched
369
+ paragraph before diffing (extend the existing fldChar/proofErr stripping site —
370
+ find it with `grep -rn "proofErr" engine/ pipeline/`).
371
+ - `w:tab` and `w:br` are VISIBLE content. Decide and document one mapping in
372
+ ingestion: `w:tab` → `\t`, `w:br` → `\n` in extracted plain text, and ensure
373
+ the diff/patch path can reproduce them (if the engine cannot synthesize them
374
+ on the insert side, at minimum it must not corrupt runs containing them:
375
+ verify a no-op edit elsewhere in the paragraph leaves them intact).
376
+ - Test: paragraph `A<w:tab/>B`, edit `B`→`C`; assert `w:tab` survives and
377
+ round-trip holds on the textual parts.
378
+
379
+ ### 4.4 Footnote/endnote references
380
+
381
+ - A run containing `w:footnoteReference`/`w:endnoteReference` must never be
382
+ split through, deleted, or duplicated by the diff (deleting it would orphan
383
+ the footnote part in a way this package cannot clean up).
384
+ - Implement: treat such runs like field scaffolding — exclude them from the
385
+ editable span ranges in `engine/surgical-spans.js` (anchor text before/after
386
+ them, same approach as existing field handling).
387
+ - Test: edit text after a footnote reference; assert exactly one
388
+ `w:footnoteReference` with unchanged `w:id` in output.
389
+
390
+ **Acceptance:** all Phase 4 tests pass; un-skip Phase 1 corpus cases marked
391
+ `KNOWN-GAP: Phase 4`; add a sentence to README's packaging Do/Don't section
392
+ noting hyperlink/bookmark/footnote safety.
393
+
394
+ ---
395
+
396
+ ## Phase 5 — Move revision (`w:moveFrom` / `w:moveTo`) consumption
397
+
398
+ **Status:** Complete for move consumption.
399
+
400
+ - Ingestion treats `w:moveFrom` as deleted/invisible text and `w:moveTo` as inserted/visible text.
401
+ - `ingestWordOoxmlToPlainText` excludes moved-from text while retaining moved-to text.
402
+ - `acceptTrackedChangesInOoxml` now removes `w:moveFrom`, unwraps `w:moveTo`, and removes matching move range markers.
403
+ - `rejectTrackedChangesInOoxml` now unwraps `w:moveFrom` after converting `w:delText` back to `w:t`, removes `w:moveTo`, and removes matching move range markers.
404
+ - `containsTrackedChanges` detects move range markers in addition to `w:moveFrom`/`w:moveTo`.
405
+ - Added `tests/move_revision_tests.mjs`.
406
+ - Final verification: `npm test` passed (21/21); `npm run test:isolation` passed.
407
+
408
+ **Why:** Documents from human reviewers contain move revisions. The library
409
+ currently has zero handling, so ingestion, accept/reject, and the Phase 2
410
+ detection gate would mis-handle them. Goal of this phase is to CONSUME moves
411
+ safely. (Emitting moves from the differ is a stretch goal — see 5.4 — do not
412
+ start it unless 5.1–5.3 are done and green.)
413
+
414
+ ### 5.1 Ingestion
415
+
416
+ In `pipeline/ingestion-paragraph.js` (and the table equivalent if it reads runs
417
+ independently): treat `w:moveFrom` content as deleted (excluded from plain
418
+ text, consistent with Phase 2.3) and `w:moveTo` content as inserted (included).
419
+ `w:moveFromRangeStart/End`, `w:moveToRangeStart/End` are markers — ignore for
420
+ text, preserve as nodes (Phase 4.2 rules).
421
+
422
+ ### 5.2 Accept/reject
423
+
424
+ In `services/revision-comment-management.js` extend both transforms:
425
+
426
+ - **Accept:** `w:moveFrom` → remove element and contents (like `w:del`);
427
+ `w:moveTo` → unwrap (like `w:ins`); remove all four range marker types for the
428
+ matched author.
429
+ - **Reject:** `w:moveFrom` → unwrap, converting any `w:delText` inside back to
430
+ `w:t`; `w:moveTo` → remove element and contents; remove range markers.
431
+ - Reuse the existing `removeNode`/`unwrapNode` helpers and the author-filter
432
+ machinery (`resolveAuthorFilter`/`authorMatchesNode`) already in that file.
433
+ Note: move *range markers* carry their author on the `RangeStart` element;
434
+ the matching `RangeEnd` has only an id — match ends to starts by `w:id`.
435
+
436
+ ### 5.3 Tests
437
+
438
+ Create `tests/move_revision_tests.mjs`: a two-paragraph fixture where a sentence
439
+ is wrapped in `w:moveFrom` (+ range markers) in paragraph 1 and `w:moveTo`
440
+ (+ markers) in paragraph 2.
441
+
442
+ - Accept-all → sentence appears only in paragraph 2; no move markup remains.
443
+ - Reject-all → sentence appears only in paragraph 1; no move markup remains.
444
+ - Author-filtered accept with a non-matching author → fixture unchanged.
445
+ - `ingestWordOoxmlToPlainText` shows the moved sentence exactly once (at the
446
+ moveTo location).
447
+ - `containsTrackedChanges` (Phase 2) returns true for this fixture.
448
+
449
+ ### 5.4 (Stretch, separate PR) Move emission from the differ
450
+
451
+ Only after 5.1–5.3: in `pipeline/diff-engine.js`, post-process the diff to find
452
+ delete/insert pairs with identical normalized text ≥ 15 characters; emit them as
453
+ `w:moveFrom`/`w:moveTo` pairs sharing a `w:name` attribute and linked range
454
+ markers. Verify via the Phase 1 harness plus manual Word inspection. If this
455
+ proves unstable, ship 5.1–5.3 alone — consumption is the safety-critical half.
456
+
457
+ **Acceptance:** 5.1–5.3 tests pass; README revision-management rows updated to
458
+ mention move support.
459
+
460
+ ---
461
+
462
+ ## Phase 6 — Hardening details
463
+
464
+ **Status:** Complete.
465
+
466
+ - Added non-breaking `status` / `error` fields to the engine result path, including `PARSE_ERROR`, `TARGET_NOT_FOUND`, and `EXISTING_REVISIONS`.
467
+ - Seeded generated revision IDs from existing document IDs via `seedRevisionIdsFromDocument(xmlDoc)`.
468
+ - Seeded revision IDs in `applyRedlineToOxml` and the standalone redline operation runner.
469
+ - Added `tests/hardening_status_tests.mjs`.
470
+ - Added `index.d.ts`, package `"types"` metadata, and `npm run check:types`.
471
+ - Added `scripts/export-validation-fixtures.mjs` and `docs/VALIDATION.md`, linked from README.
472
+ - README now documents `status`/`error`, move revision consumption, included types, and validation workflow.
473
+ - Final verification: `npm test` passed (21/21); `npm run test:isolation` passed; `npm run check:types` passed; `node scripts/export-validation-fixtures.mjs` passed.
474
+
475
+ ### 6.1 Explicit error status instead of silent no-op
476
+
477
+ `applyRedlineToOxml` in `engine/oxml-engine.js` returns `{ oxml, hasChanges: false }`
478
+ on XML parse failure (the `noChanges()` early returns near the top), which is
479
+ indistinguishable from "nothing to change."
480
+
481
+ - Add optional result fields, non-breaking:
482
+ `status: 'ok' | 'no-op' | 'error'` and
483
+ `error?: { code: string, message: string }`.
484
+ - Error codes to introduce: `PARSE_ERROR`, `TARGET_NOT_FOUND` (where targeting
485
+ fails and the engine currently logs + returns unchanged), `EXISTING_REVISIONS`
486
+ (Phase 2). Successful-but-unchanged paths return `status: 'no-op'`.
487
+ - Thread the same fields through `applyRedlineToOxmlWithListFallback`,
488
+ `reconcileMarkdownTableOoxml`, and
489
+ `services/standalone-operation-runner.js` (`applyOperationToDocumentXml`
490
+ result already has its own shape — add `status`/`error` alongside, do not
491
+ rename existing fields).
492
+ - Tests: feed malformed XML → `status: 'error'`, code `PARSE_ERROR`; feed
493
+ `original` text that doesn't exist in the document → `status` is `'error'` or
494
+ `'no-op'` per the chosen semantics (pick one, document it in the JSDoc).
495
+ - Update README API tables to document `status`/`error`.
496
+
497
+ ### 6.2 Document-unique revision IDs
498
+
499
+ `core/types.js` uses a module-global counter starting at 1000
500
+ (`revisionIdCounter`). A source document that already contains `w:id` values
501
+ ≥ 1000 can collide with newly generated ids.
502
+
503
+ - Add `export function seedRevisionIdsFromDocument(xmlDoc)` in `core/types.js`:
504
+ scan all elements for a `w:id` attribute, parse as integer, and if
505
+ `maxFound >= revisionIdCounter`, set `revisionIdCounter = maxFound + 1`.
506
+ - Call it once in `applyRedlineToOxml` right after successful parse, and at the
507
+ equivalent spot in `services/standalone-operation-runner.js`.
508
+ - Test: input fragment containing `w:ins w:id="5000"`; assert every generated
509
+ revision id in the output is > 5000 and `assertUniqueRevisionIds` (Phase 1)
510
+ passes.
511
+
512
+ ### 6.3 TypeScript declarations
513
+
514
+ - Create `index.d.ts` at the repo root typing every export of `index.js`.
515
+ Key shapes: the options bag for `applyRedlineToOxml`
516
+ (`{ generateRedlines?, author?, targetParagraphId?, existingRevisions? }`),
517
+ the result (`{ oxml, hasChanges, sourceType?, status?, error? }`), the
518
+ accept/reject options (`{ author?, allAuthors? }`) and their result shape
519
+ (read the actual return in `services/revision-comment-management.js` —
520
+ it includes warnings), and the config functions.
521
+ - Add `"types": "index.d.ts"` to `package.json` and include it in the published
522
+ `files` list if one exists.
523
+ - Verification: add a `scripts/check-types.mjs` step or simply run
524
+ `npx tsc --noEmit --checkJs false index.d.ts` once locally; at minimum ensure
525
+ the file parses (`npx tsc index.d.ts --noEmit`). Do not convert the codebase
526
+ to TypeScript.
527
+
528
+ ### 6.4 Cross-consumer fixture validation (manual, documented)
529
+
530
+ - Add `scripts/export-validation-fixtures.mjs`: runs a handful of Phase 1 corpus
531
+ cases through `applyOperationToDocumentXml`, wraps each result in a minimal
532
+ `.docx` using the existing package-builder/plumbing helpers
533
+ (`services/package-builder.js`, `validateDocxPackage`), and writes them to
534
+ `tmp/validation-docx/`. Use only existing dependencies — if zip writing isn't
535
+ possible with what's in `package.json`, write the `word/document.xml` parts
536
+ plus a README instructing how to assemble, instead of adding a dependency.
537
+ - If LibreOffice is installed locally, `soffice --headless --convert-to pdf`
538
+ over the folder is a cheap "does another consumer parse it" check — document
539
+ this (and the Word COM script from 1.3) in a new `docs/VALIDATION.md`, linked
540
+ from README. This stays a manual/release-time step, not CI.
541
+
542
+ **Acceptance:** 6.1 and 6.2 tests pass; `index.d.ts` exists and parses;
543
+ `docs/VALIDATION.md` exists; README updated.
544
+
545
+ ### Final verification
546
+
547
+ - `npm test` passed (21/21).
548
+ - `npm run test:isolation` passed.
549
+ - `npm run check:types` passed.
550
+ - `node scripts/export-validation-fixtures.mjs` passed and wrote fixtures to
551
+ `tmp/validation-docx/`.
552
+ - Convention audit passed for `core/`, `engine/`, `pipeline/`, `services/`, and
553
+ `index.js`: no direct `document.createElement('w:*')` or
554
+ `createElementNS(NS_W, 'w:*')` call sites remain outside `createWordElement`,
555
+ and tracked-change revision metadata is routed through `createRevisionMetadata`.
556
+ - Word COM validation remains a manual/release-time smoke step because it needs
557
+ Microsoft Word and an exported `.docx` path: `npm run smoke:word -- path/to/file.docx`.
558
+
559
+ ---
560
+
561
+ ## Execution order and dependencies
562
+
563
+ ```
564
+ Phase 1 (harness) ← everything depends on this
565
+ Phase 2 (existing revisions) ← needs 1; introduces status/error early if 6.1 not done
566
+ Phase 3 (paragraph marks) ← needs 1
567
+ Phase 4 (inert markup) ← needs 1; un-skips Phase 1 KNOWN-GAP cases
568
+ Phase 5 (moves) ← needs 1, 2
569
+ Phase 6 (hardening) ← 6.1/6.2 anytime after 1; 6.3/6.4 last
570
+ ```
571
+
572
+ Each phase should be a separate commit (or PR) with the test suite green.
573
+ Suggested commit messages: `test: add accept/reject round-trip harness`,
574
+ `feat: gate redlining on pre-existing revisions`, `feat: revise paragraph marks
575
+ on paragraph insert/delete`, `fix: preserve hyperlinks, bookmarks, and footnote
576
+ refs across redlines`, `feat: consume w:moveFrom/w:moveTo in ingestion and
577
+ accept/reject`, `feat: explicit status/error result fields and seeded revision ids`.
578
+
579
+ ## Global guardrails
580
+
581
+ - Never emit `w:t` inside `w:del` (must be `w:delText`) — covered by
582
+ `assertDelUsesDelText`; run it on every new output-producing test.
583
+ - Never nest `w:ins`/`w:del` inside each other.
584
+ - Never produce an empty `w:t`/`w:delText` element or an empty `w:ins`/`w:del`
585
+ wrapper.
586
+ - Word merges adjacent same-author revisions in its review pane only when the
587
+ author strings are byte-identical — always source the author through
588
+ `createRevisionMetadata`, never trim/transform it at call sites.
589
+ - When in doubt about element ordering inside `w:rPr`, use `RPR_SCHEMA_ORDER`
590
+ from `engine/rpr-helpers.js`; for `w:pPr`, `w:pPr` is first child of `w:p` and
591
+ `w:rPr` is its last child.
@@ -8,13 +8,13 @@
8
8
  import { mergeFormats } from '../pipeline/markdown-processor.js';
9
9
  import { applyFormatOverridesToRPr, extractFormatFromRPr } from './rpr-helpers.js';
10
10
  import { snapshotAndAttachRPrChange, injectFormattingToRPr } from './run-builders.js';
11
- import { getDocumentParagraphs, buildTextSpansFromParagraphs } from './format-extraction.js';
12
- import { buildParagraphInfos, findTargetParagraphInfo } from './format-paragraph-targeting.js';
13
- import { splitSpansAtBoundaries, applyFormatHintsToSpansRobust } from './format-span-application.js';
14
- import { getRevisionTimestamp } from '../core/types.js';
11
+ import { getDocumentParagraphs, buildTextSpansFromParagraphs } from './format-extraction.js';
12
+ import { buildParagraphInfos, findTargetParagraphInfo } from './format-paragraph-targeting.js';
13
+ import { splitSpansAtBoundaries, applyFormatHintsToSpansRobust } from './format-span-application.js';
15
14
  import { warn, log } from '../adapters/logger.js';
16
15
  import { getFirstElementByTag } from '../core/xml-query.js';
17
16
  import { getDefaultAuthor } from '../adapters/config.js';
17
+ import { createWordElement } from '../core/word-xml.js';
18
18
 
19
19
  const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
20
20
 
@@ -62,9 +62,8 @@ function normalizePrecomputedFormatContext(precomputedContext) {
62
62
  */
63
63
  export function applyFormatRemovalAsSurgicalReplacement(xmlDoc, textSpans, existingFormatHints, serializer, author, generateRedlines = true) {
64
64
  void textSpans;
65
- let hasAnyChanges = false;
66
- const processedRuns = new Set();
67
- const dateStr = getRevisionTimestamp();
65
+ let hasAnyChanges = false;
66
+ const processedRuns = new Set();
68
67
 
69
68
  log(`[OxmlEngine] Surgical format removal: ${existingFormatHints.length} hints to process (using w:rPrChange)`);
70
69
 
@@ -76,14 +75,14 @@ export function applyFormatRemovalAsSurgicalReplacement(xmlDoc, textSpans, exist
76
75
 
77
76
  log('[OxmlEngine] Processing run for surgical format removal, format:', hint.format);
78
77
 
79
- let rPr = getFirstElementByTag(run, 'w:rPr');
80
- if (!rPr) {
81
- rPr = xmlDoc.createElement('w:rPr');
82
- run.insertBefore(rPr, run.firstChild);
83
- }
84
-
78
+ let rPr = getFirstElementByTag(run, 'w:rPr');
79
+ if (!rPr) {
80
+ rPr = createWordElement(xmlDoc, 'w:rPr');
81
+ run.insertBefore(rPr, run.firstChild);
82
+ }
83
+
85
84
  if (generateRedlines) {
86
- snapshotAndAttachRPrChange(xmlDoc, rPr, author || getDefaultAuthor(), dateStr);
85
+ snapshotAndAttachRPrChange(xmlDoc, rPr, author || getDefaultAuthor());
87
86
  }
88
87
 
89
88
  applyFormatOverridesToRPr(xmlDoc, rPr, hint.format);
@@ -5,8 +5,9 @@
5
5
  * text spans without owning paragraph targeting concerns.
6
6
  */
7
7
 
8
- import { mergeFormats } from '../pipeline/markdown-processor.js';
9
- import { injectFormattingToRPr, createTextRun } from './run-builders.js';
8
+ import { mergeFormats } from '../pipeline/markdown-processor.js';
9
+ import { injectFormattingToRPr, createTextRun } from './run-builders.js';
10
+ import { createWordElement } from '../core/word-xml.js';
10
11
 
11
12
  /**
12
13
  * Splits spans at all supplied absolute boundaries.
@@ -166,10 +167,10 @@ function addFormattingToRun(xmlDoc, run, format, author, generateRedlines) {
166
167
  let rPr = run.getElementsByTagName('w:rPr')[0];
167
168
  const baseRPr = rPr ? rPr.cloneNode(true) : null;
168
169
 
169
- if (!rPr) {
170
- rPr = xmlDoc.createElement('w:rPr');
171
- run.insertBefore(rPr, run.firstChild);
172
- }
170
+ if (!rPr) {
171
+ rPr = createWordElement(xmlDoc, 'w:rPr');
172
+ run.insertBefore(rPr, run.firstChild);
173
+ }
173
174
 
174
175
  const newRPr = injectFormattingToRPr(xmlDoc, baseRPr, format, author, generateRedlines);
175
176