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