@ansonlai/docx-redline-js 0.4.0 → 0.5.1

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 (104) hide show
  1. package/AGENTS.md +646 -288
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -1,43 +1,115 @@
1
- import { readdirSync } from 'fs';
2
- import { join, dirname } from 'path';
3
- import { execSync } from 'child_process';
4
- import { fileURLToPath } from 'url';
5
-
6
- const __dirname = dirname(fileURLToPath(import.meta.url));
7
- const testDir = join(__dirname, '..', 'tests');
8
- const testFiles = readdirSync(testDir)
9
- .filter(f => f.endsWith('.mjs') && f !== 'setup-xml-provider.mjs')
10
- .sort();
11
-
12
- let passed = 0;
13
- let failed = 0;
14
- const failures = [];
1
+ import { readdirSync } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import { join, dirname, resolve } from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const scriptPath = fileURLToPath(import.meta.url);
8
+ const scriptDir = dirname(scriptPath);
9
+ const defaultTestDir = join(scriptDir, '..', 'tests');
15
10
  const failOutputPattern = /(?:❌\s*(?:FAIL|FAILED|FAILURE|TEST FAILED)|\bTEST FAILED\b)/i;
16
11
 
17
- for (const file of testFiles) {
12
+ export function discoverTestFiles(testDir = defaultTestDir) {
13
+ return readdirSync(testDir)
14
+ .filter(file => file.endsWith('.mjs') && file !== 'setup-xml-provider.mjs')
15
+ .sort();
16
+ }
17
+
18
+ export function resolveTestConcurrency(value = process.env.DOCX_TEST_CONCURRENCY) {
19
+ if (value == null || value === '') {
20
+ const available = os.availableParallelism?.() ?? os.cpus().length;
21
+ return Math.max(1, Math.min(4, available));
22
+ }
23
+ if (!/^\d+$/.test(String(value)) || Number(value) < 1) {
24
+ throw new Error('DOCX_TEST_CONCURRENCY must be a positive integer.');
25
+ }
26
+ return Number(value);
27
+ }
28
+
29
+ export function runTestFile(file, options = {}) {
30
+ const testDir = options.testDir || defaultTestDir;
31
+ const timeout = options.timeout ?? 30000;
18
32
  const filePath = join(testDir, file);
19
- process.stdout.write(` ${file} ... `);
20
- try {
21
- const output = execSync(`node "${filePath}"`, { stdio: 'pipe', timeout: 30000 });
22
- const outputText = output.toString();
23
- if (failOutputPattern.test(outputText)) {
24
- throw new Error(`Test printed a failure marker while exiting successfully:\n${outputText}`);
33
+
34
+ return new Promise(resolveResult => {
35
+ execFile(process.execPath, [filePath], {
36
+ cwd: options.cwd || join(scriptDir, '..'),
37
+ env: options.env || process.env,
38
+ timeout,
39
+ maxBuffer: options.maxBuffer ?? 1024 * 1024,
40
+ encoding: 'utf8',
41
+ windowsHide: true
42
+ }, (error, stdout = '', stderr = '') => {
43
+ let executionError = error;
44
+ if (!executionError && failOutputPattern.test(stdout)) {
45
+ executionError = new Error(`Test printed a failure marker while exiting successfully:\n${stdout}`);
46
+ }
47
+ resolveResult({
48
+ file,
49
+ passed: !executionError,
50
+ stdout: String(stdout),
51
+ stderr: String(stderr),
52
+ error: executionError || null
53
+ });
54
+ });
55
+ });
56
+ }
57
+
58
+ export async function runTestFiles(files, options = {}) {
59
+ const orderedFiles = [...files].sort();
60
+ const concurrency = Math.min(resolveTestConcurrency(options.concurrency), Math.max(orderedFiles.length, 1));
61
+ const execute = options.runFile || runTestFile;
62
+ const results = new Array(orderedFiles.length);
63
+ let nextIndex = 0;
64
+
65
+ async function worker() {
66
+ while (true) {
67
+ const index = nextIndex++;
68
+ if (index >= orderedFiles.length) return;
69
+ results[index] = await execute(orderedFiles[index], options);
25
70
  }
26
- console.log('PASS');
27
- passed++;
28
- } catch (err) {
29
- console.log('FAIL');
30
- const stdout = err.stdout?.toString() || '';
31
- const stderr = err.stderr?.toString() || '';
32
- failures.push({ file, stderr: [stdout, stderr, err.message].filter(Boolean).join('\n') });
33
- failed++;
34
71
  }
72
+
73
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
74
+ return results;
75
+ }
76
+
77
+ export function formatTestRun(results) {
78
+ const lines = results.map(result => ` ${result.file} ... ${result.passed ? 'PASS' : 'FAIL'}`);
79
+ const failures = results.filter(result => !result.passed);
80
+ const passed = results.length - failures.length;
81
+ lines.push('', `${passed} passed, ${failures.length} failed out of ${results.length} tests`);
82
+ const failureLines = [];
83
+ for (const failure of failures) {
84
+ const detail = [failure.stdout, failure.stderr, failure.error?.message].filter(Boolean).join('\n');
85
+ failureLines.push('', `--- ${failure.file} ---`, detail);
86
+ }
87
+ return {
88
+ output: `${lines.join('\n')}\n`,
89
+ errorOutput: failureLines.length > 0 ? `${failureLines.join('\n')}\n` : '',
90
+ passed,
91
+ failed: failures.length
92
+ };
93
+ }
94
+
95
+ export async function main(options = {}) {
96
+ const testDir = options.testDir || defaultTestDir;
97
+ const files = options.files || discoverTestFiles(testDir);
98
+ const results = await runTestFiles(files, { ...options, testDir });
99
+ const summary = formatTestRun(results);
100
+ const write = options.write || (text => process.stdout.write(text));
101
+ const writeError = options.writeError || (text => process.stderr.write(text));
102
+ write(summary.output);
103
+ if (summary.errorOutput) writeError(summary.errorOutput);
104
+ return summary;
35
105
  }
36
106
 
37
- console.log(`\n${passed} passed, ${failed} failed out of ${passed + failed} tests`);
38
- if (failures.length > 0) {
39
- for (const f of failures) {
40
- console.error(`\n--- ${f.file} ---\n${f.stderr}`);
107
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(scriptPath)) {
108
+ try {
109
+ const summary = await main();
110
+ if (summary.failed > 0) process.exitCode = 1;
111
+ } catch (error) {
112
+ console.error(error?.stack || error?.message || String(error));
113
+ process.exitCode = 1;
41
114
  }
42
- process.exit(1);
43
- }
115
+ }
@@ -37,6 +37,9 @@ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
37
37
  -SourcesDir $sourcesPath
38
38
  if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
39
39
 
40
+ # Clean up any orphaned background Word processes from prior aborted runs
41
+ Get-Process WINWORD -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -eq 0 } | Stop-Process -Force -ErrorAction SilentlyContinue
42
+
40
43
  & (Join-Path $repoRoot 'scripts\word-com-differential.ps1') `
41
44
  -FixturesDir $fixturesPath `
42
45
  -SourcesDir $sourcesPath
@@ -141,6 +141,62 @@ function Get-ScopeRevisionCount($document, [string]$scope) {
141
141
  return $count
142
142
  }
143
143
 
144
+ function Close-WordDocumentSafely($doc) {
145
+ if ($null -eq $doc) { return }
146
+ for ($attempt = 1; $attempt -le 5; $attempt++) {
147
+ try {
148
+ $doc.Close(0) | Out-Null
149
+ break
150
+ }
151
+ catch [System.Runtime.InteropServices.COMException] {
152
+ $hr = $_.Exception.HResult
153
+ # 0x80010001 = RPC_E_CALL_REJECTED (-2147418111)
154
+ if ($hr -eq -2147418111 -and $attempt -lt 5) {
155
+ Start-Sleep -Milliseconds 200
156
+ continue
157
+ }
158
+ break
159
+ }
160
+ catch {
161
+ break
162
+ }
163
+ }
164
+ try {
165
+ [System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
166
+ } catch {}
167
+ }
168
+
169
+ function Stop-WordSafely($application) {
170
+ if ($null -eq $application) { return }
171
+ for ($attempt = 1; $attempt -le 5; $attempt++) {
172
+ try {
173
+ $application.Quit(0) | Out-Null
174
+ break
175
+ }
176
+ catch [System.Runtime.InteropServices.COMException] {
177
+ $hr = $_.Exception.HResult
178
+ # 0x80010001 = RPC_E_CALL_REJECTED (-2147418111)
179
+ # 0x80010108 = RPC_E_DISCONNECTED (-2147417848)
180
+ if ($hr -eq -2147417848) {
181
+ break
182
+ }
183
+ if ($hr -eq -2147418111 -and $attempt -lt 5) {
184
+ Start-Sleep -Milliseconds 300
185
+ continue
186
+ }
187
+ break
188
+ }
189
+ catch {
190
+ break
191
+ }
192
+ }
193
+ try {
194
+ [System.Runtime.InteropServices.Marshal]::ReleaseComObject($application) | Out-Null
195
+ } catch {}
196
+ [System.GC]::Collect()
197
+ [System.GC]::WaitForPendingFinalizers()
198
+ }
199
+
144
200
  try {
145
201
  $word = New-Object -ComObject Word.Application
146
202
  $word.Visible = $false
@@ -173,7 +229,8 @@ try {
173
229
  $expectedRejected = Get-ScopeText $sourceDocument $assertionScope $fidelity
174
230
  }
175
231
  finally {
176
- if ($null -ne $sourceDocument) { $sourceDocument.Close(0) | Out-Null }
232
+ Close-WordDocumentSafely $sourceDocument
233
+ $sourceDocument = $null
177
234
  }
178
235
  $replacements = if ($expected.replacements) { @($expected.replacements) } else { @(@{
179
236
  originalTarget = $expected.originalTarget
@@ -226,7 +283,8 @@ try {
226
283
  $caseFailed = $true
227
284
  }
228
285
  finally {
229
- if ($null -ne $document) { $document.Close(0) | Out-Null; $document = $null }
286
+ Close-WordDocumentSafely $document
287
+ $document = $null
230
288
  }
231
289
 
232
290
  # Phase 2: fresh open, reject-all restores the original text.
@@ -252,7 +310,8 @@ try {
252
310
  $caseFailed = $true
253
311
  }
254
312
  finally {
255
- if ($null -ne $document) { $document.Close(0) | Out-Null; $document = $null }
313
+ Close-WordDocumentSafely $document
314
+ $document = $null
256
315
  }
257
316
  }
258
317
 
@@ -267,7 +326,8 @@ try {
267
326
  }
268
327
  }
269
328
  finally {
270
- if ($null -ne $word) { $word.Quit() | Out-Null }
329
+ Stop-WordSafely $word
330
+ $word = $null
271
331
  }
272
332
 
273
333
  Write-Output ""
@@ -8,6 +8,9 @@ $exportScript = Join-Path $PSScriptRoot 'export-validation-fixtures.mjs'
8
8
  $differentialScript = Join-Path $PSScriptRoot 'word-com-differential.ps1'
9
9
  Push-Location $repoRoot
10
10
  try {
11
+ # Clean up any orphaned background Word processes from prior aborted runs
12
+ Get-Process WINWORD -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -eq 0 } | Stop-Process -Force -ErrorAction SilentlyContinue
13
+
11
14
  & node $exportScript --output-dir $OutputDir
12
15
  if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
13
16