@ansonlai/docx-redline-js 0.4.0 → 0.5.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 (100) hide show
  1. package/AGENTS.md +589 -287
  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/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -0,0 +1,137 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
4
+ import { configureXmlProvider, parseOoxmlSafe } from '../adapters/xml-adapter.js';
5
+ import { configureLogger } from '../adapters/logger.js';
6
+ import { buildTargetReferenceSnapshot } from '../core/paragraph-targeting.js';
7
+ import {
8
+ applyOperationToDocumentXml,
9
+ applyOperationsToDocumentXml
10
+ } from '../services/standalone-operation-runner.js';
11
+
12
+ configureXmlProvider({ DOMParser, XMLSerializer });
13
+ configureLogger({ info() {}, warn() {}, error() {} });
14
+
15
+ const paragraphCount = Math.max(100, Number.parseInt(process.env.DOCX_BENCH_PARAGRAPHS || '1000', 10));
16
+ const operationCount = Math.max(1, Number.parseInt(process.env.DOCX_BENCH_OPERATIONS || '10', 10));
17
+ const iterations = Math.max(3, Number.parseInt(process.env.DOCX_BENCH_ITERATIONS || '7', 10));
18
+ const warmups = Math.max(1, Number.parseInt(process.env.DOCX_BENCH_WARMUPS || '2', 10));
19
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
20
+ const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
21
+
22
+ const paragraphs = Array.from({ length: paragraphCount }, (_, index) => {
23
+ const id = (index + 1).toString(16).toUpperCase().padStart(8, '0');
24
+ return `<w:p w14:paraId="${id}"><w:r><w:t>Benchmark paragraph ${index + 1} with stable unique content.</w:t></w:r></w:p>`;
25
+ }).join('');
26
+ const source = `<w:document xmlns:w="${NS_W}" xmlns:w14="${NS_W14}"><w:body>${paragraphs}<w:sectPr/></w:body></w:document>`;
27
+ const step = Math.max(1, Math.floor(paragraphCount / (operationCount + 1)));
28
+ const operations = Array.from({ length: operationCount }, (_, index) => {
29
+ const paragraphNumber = Math.min(paragraphCount, step * (index + 1));
30
+ return {
31
+ type: 'replace',
32
+ target: {
33
+ paragraphId: paragraphNumber.toString(16).toUpperCase().padStart(8, '0'),
34
+ exactText: `Benchmark paragraph ${paragraphNumber} with stable unique content.`
35
+ },
36
+ modified: `Benchmark paragraph ${paragraphNumber} with verified updated content.`,
37
+ author: 'Session Benchmark'
38
+ };
39
+ });
40
+
41
+ function percentile(values, ratio) {
42
+ const sorted = [...values].sort((a, b) => a - b);
43
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
44
+ }
45
+
46
+ async function runBatch() {
47
+ const instrumentation = { parses: 0, serializations: 0 };
48
+ const heapBefore = process.memoryUsage().heapUsed;
49
+ const started = performance.now();
50
+ const result = await applyOperationsToDocumentXml(source, operations, 'Session Benchmark', null, {
51
+ generateRedlines: false,
52
+ _sessionInstrumentation: {
53
+ onDocumentParse: () => { instrumentation.parses += 1; },
54
+ onDocumentSerialize: () => { instrumentation.serializations += 1; }
55
+ }
56
+ });
57
+ const elapsedMs = performance.now() - started;
58
+ if (!result.hasChanges || result.results.some(item => item.status !== 'applied')) {
59
+ throw new Error('Live-session benchmark batch did not apply every operation.');
60
+ }
61
+ return {
62
+ elapsedMs,
63
+ heapDeltaBytes: process.memoryUsage().heapUsed - heapBefore,
64
+ outputBytes: Buffer.byteLength(result.documentXml),
65
+ ...instrumentation
66
+ };
67
+ }
68
+
69
+ async function runSequential() {
70
+ const parsed = parseOoxmlSafe(source, 'application/xml');
71
+ const context = { targetRefSnapshot: buildTargetReferenceSnapshot(parsed.doc) };
72
+ const instrumentation = { parses: 0, serializations: 0 };
73
+ const heapBefore = process.memoryUsage().heapUsed;
74
+ const started = performance.now();
75
+ let documentXml = source;
76
+ for (const operation of operations) {
77
+ const result = await applyOperationToDocumentXml(documentXml, operation, 'Session Benchmark', context, {
78
+ generateRedlines: false,
79
+ _sessionInstrumentation: {
80
+ onDocumentParse: () => { instrumentation.parses += 1; },
81
+ onDocumentSerialize: () => { instrumentation.serializations += 1; }
82
+ }
83
+ });
84
+ if (!result.hasChanges) throw new Error('Sequential benchmark operation did not apply.');
85
+ documentXml = result.documentXml;
86
+ }
87
+ return {
88
+ elapsedMs: performance.now() - started,
89
+ heapDeltaBytes: process.memoryUsage().heapUsed - heapBefore,
90
+ outputBytes: Buffer.byteLength(documentXml),
91
+ ...instrumentation
92
+ };
93
+ }
94
+
95
+ for (let index = 0; index < warmups; index += 1) {
96
+ await runBatch();
97
+ await runSequential();
98
+ }
99
+
100
+ const batchSamples = [];
101
+ const sequentialSamples = [];
102
+ for (let index = 0; index < iterations; index += 1) {
103
+ batchSamples.push(await runBatch());
104
+ sequentialSamples.push(await runSequential());
105
+ }
106
+
107
+ function summarize(samples) {
108
+ const timings = samples.map(sample => sample.elapsedMs);
109
+ return {
110
+ medianMs: Number(percentile(timings, 0.5).toFixed(2)),
111
+ p95Ms: Number(percentile(timings, 0.95).toFixed(2)),
112
+ medianHeapDeltaBytes: percentile(samples.map(sample => sample.heapDeltaBytes), 0.5),
113
+ parseCount: samples[0].parses,
114
+ serializeCount: samples[0].serializations,
115
+ outputBytes: samples[0].outputBytes
116
+ };
117
+ }
118
+
119
+ const batch = summarize(batchSamples);
120
+ const sequential = summarize(sequentialSamples);
121
+ const report = {
122
+ generatedAt: new Date().toISOString(),
123
+ environment: { node: process.version, platform: process.platform, arch: process.arch },
124
+ fixture: { paragraphCount, operationCount, iterations, warmups },
125
+ batch,
126
+ sequential,
127
+ medianSpeedup: Number((sequential.medianMs / batch.medianMs).toFixed(2)),
128
+ note: 'Timing and heap figures are observational. Semantic correctness tests remain the release gate.'
129
+ };
130
+
131
+ await mkdir(new URL('../tmp/benchmarks/', import.meta.url), { recursive: true });
132
+ await writeFile(
133
+ new URL('../tmp/benchmarks/operation-session-latest.json', import.meta.url),
134
+ `${JSON.stringify(report, null, 2)}\n`,
135
+ 'utf8'
136
+ );
137
+ console.log(JSON.stringify(report, null, 2));
@@ -0,0 +1,74 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Paragraph targeting browser benchmark</title>
6
+ </head>
7
+ <body>
8
+ <pre id="result">Running…</pre>
9
+ <script type="module">
10
+ import {
11
+ buildParagraphMetadataIndex,
12
+ buildTargetReferenceSnapshot,
13
+ resolveTargetParagraph
14
+ } from '../core/paragraph-targeting.js';
15
+
16
+ const params = new URLSearchParams(location.search);
17
+ const paragraphCount = Number.parseInt(params.get('paragraphs') || '10000', 10);
18
+ const operationCount = Number.parseInt(params.get('operations') || '1', 10);
19
+ const measuredIterations = Number.parseInt(params.get('iterations') || '7', 10);
20
+ const warmups = 2;
21
+ const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
22
+ const W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
23
+ const body = Array.from({ length: paragraphCount }, (_, index) =>
24
+ `<w:p w14:paraId="${String(index + 1).padStart(8, '0')}"><w:r><w:t>Benchmark paragraph ${index + 1}</w:t></w:r></w:p>`
25
+ ).join('');
26
+ const xml = `<w:document xmlns:w="${W}" xmlns:w14="${W14}"><w:body>${body}<w:sectPr/></w:body></w:document>`;
27
+ const targets = Array.from({ length: operationCount }, (_, index) => {
28
+ const paragraphIndex = 1 + Math.floor(index * (paragraphCount - 1) / Math.max(1, operationCount - 1));
29
+ return { text: `Benchmark paragraph ${paragraphIndex}`, index: paragraphIndex };
30
+ });
31
+
32
+ function percentile(values, ratio) {
33
+ const sorted = values.slice().sort((a, b) => a - b);
34
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
35
+ }
36
+
37
+ function measure(useCache) {
38
+ const doc = new DOMParser().parseFromString(xml, 'application/xml');
39
+ const started = performance.now();
40
+ const paragraphMetadataIndex = useCache ? buildParagraphMetadataIndex(doc) : null;
41
+ buildTargetReferenceSnapshot(doc, paragraphMetadataIndex);
42
+ for (const targetDescriptor of targets) {
43
+ resolveTargetParagraph(doc, {
44
+ targetDescriptor,
45
+ strictAmbiguity: true,
46
+ paragraphMetadataIndex
47
+ });
48
+ }
49
+ return performance.now() - started;
50
+ }
51
+
52
+ for (let index = 0; index < warmups; index++) {
53
+ measure(false);
54
+ measure(true);
55
+ }
56
+ const uncached = [];
57
+ const cached = [];
58
+ for (let index = 0; index < measuredIterations; index++) {
59
+ uncached.push(measure(false));
60
+ cached.push(measure(true));
61
+ }
62
+ const result = {
63
+ runtime: { userAgent: navigator.userAgent },
64
+ fixture: { paragraphCount, operationCount, warmups, measuredIterations },
65
+ uncachedMs: { median: percentile(uncached, 0.5), p95: percentile(uncached, 0.95) },
66
+ sessionCachedMs: { median: percentile(cached, 0.5), p95: percentile(cached, 0.95) }
67
+ };
68
+ result.medianSpeedup = result.uncachedMs.median / result.sessionCachedMs.median;
69
+ window.benchmarkResult = result;
70
+ document.querySelector('#result').textContent = JSON.stringify(result, null, 2);
71
+ document.title = 'Target benchmark complete';
72
+ </script>
73
+ </body>
74
+ </html>
@@ -0,0 +1,67 @@
1
+ import { performance } from 'node:perf_hooks';
2
+ import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
3
+ import { configureXmlProvider } from '../adapters/xml-adapter.js';
4
+ import {
5
+ buildParagraphMetadataIndex,
6
+ buildTargetReferenceSnapshot,
7
+ resolveTargetParagraph
8
+ } from '../core/paragraph-targeting.js';
9
+
10
+ configureXmlProvider({ DOMParser, XMLSerializer });
11
+
12
+ const paragraphCount = Number.parseInt(process.env.DOCX_TARGET_BENCH_PARAGRAPHS || '10000', 10);
13
+ const operationCount = Number.parseInt(process.env.DOCX_TARGET_BENCH_OPERATIONS || '100', 10);
14
+ const measuredIterations = Number.parseInt(process.env.DOCX_TARGET_BENCH_ITERATIONS || '7', 10);
15
+ const warmups = 2;
16
+ const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
17
+ const W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
18
+
19
+ const body = Array.from({ length: paragraphCount }, (_, index) =>
20
+ `<w:p w14:paraId="${String(index + 1).padStart(8, '0')}"><w:r><w:t>Benchmark paragraph ${index + 1}</w:t></w:r></w:p>`
21
+ ).join('');
22
+ const xml = `<w:document xmlns:w="${W}" xmlns:w14="${W14}"><w:body>${body}<w:sectPr/></w:body></w:document>`;
23
+ const targets = Array.from({ length: operationCount }, (_, index) => {
24
+ const paragraphIndex = 1 + Math.floor(index * (paragraphCount - 1) / Math.max(1, operationCount - 1));
25
+ return { text: `Benchmark paragraph ${paragraphIndex}`, index: paragraphIndex };
26
+ });
27
+
28
+ function percentile(values, ratio) {
29
+ const sorted = values.slice().sort((a, b) => a - b);
30
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
31
+ }
32
+
33
+ function measure(useCache) {
34
+ const doc = new DOMParser().parseFromString(xml, 'application/xml');
35
+ const started = performance.now();
36
+ const paragraphMetadataIndex = useCache ? buildParagraphMetadataIndex(doc) : null;
37
+ buildTargetReferenceSnapshot(doc, paragraphMetadataIndex);
38
+ for (const targetDescriptor of targets) {
39
+ resolveTargetParagraph(doc, {
40
+ targetDescriptor,
41
+ strictAmbiguity: true,
42
+ paragraphMetadataIndex
43
+ });
44
+ }
45
+ return performance.now() - started;
46
+ }
47
+
48
+ for (let index = 0; index < warmups; index++) {
49
+ measure(false);
50
+ measure(true);
51
+ }
52
+
53
+ const uncached = [];
54
+ const cached = [];
55
+ for (let index = 0; index < measuredIterations; index++) {
56
+ uncached.push(measure(false));
57
+ cached.push(measure(true));
58
+ }
59
+
60
+ const result = {
61
+ runtime: { node: process.version, platform: process.platform, arch: process.arch },
62
+ fixture: { paragraphCount, operationCount, warmups, measuredIterations },
63
+ uncachedMs: { median: percentile(uncached, 0.5), p95: percentile(uncached, 0.95) },
64
+ sessionCachedMs: { median: percentile(cached, 0.5), p95: percentile(cached, 0.95) }
65
+ };
66
+ result.medianSpeedup = result.uncachedMs.median / result.sessionCachedMs.median;
67
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -0,0 +1,59 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { performance } from 'node:perf_hooks';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
9
+ const measuredIterations = Number.parseInt(process.env.DOCX_TEST_BENCH_ITERATIONS || '3', 10);
10
+ const warmups = 1;
11
+ const parallelConcurrency = Math.max(1, Math.min(4, os.availableParallelism?.() ?? os.cpus().length));
12
+
13
+ function run(concurrency) {
14
+ const started = performance.now();
15
+ const result = spawnSync(process.execPath, ['scripts/run-tests.mjs'], {
16
+ cwd: repoRoot,
17
+ env: { ...process.env, DOCX_TEST_CONCURRENCY: String(concurrency) },
18
+ encoding: 'utf8',
19
+ windowsHide: true,
20
+ maxBuffer: 10 * 1024 * 1024
21
+ });
22
+ if (result.status !== 0) {
23
+ process.stderr.write(result.stdout || '');
24
+ process.stderr.write(result.stderr || '');
25
+ throw new Error(`Test runner failed in concurrency=${concurrency} benchmark mode.`);
26
+ }
27
+ return performance.now() - started;
28
+ }
29
+
30
+ function percentile(values, ratio) {
31
+ const sorted = values.slice().sort((a, b) => a - b);
32
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
33
+ }
34
+
35
+ for (let index = 0; index < warmups; index++) {
36
+ run(1);
37
+ run(parallelConcurrency);
38
+ }
39
+
40
+ const serial = [];
41
+ const parallel = [];
42
+ for (let index = 0; index < measuredIterations; index++) {
43
+ serial.push(run(1));
44
+ parallel.push(run(parallelConcurrency));
45
+ }
46
+
47
+ const result = {
48
+ runtime: { node: process.version, platform: process.platform, arch: process.arch },
49
+ fixture: { warmups, measuredIterations, parallelConcurrency },
50
+ serialMs: { median: percentile(serial, 0.5), p95: percentile(serial, 0.95), samples: serial },
51
+ parallelMs: { median: percentile(parallel, 0.5), p95: percentile(parallel, 0.95), samples: parallel }
52
+ };
53
+ result.medianSpeedup = result.serialMs.median / result.parallelMs.median;
54
+ result.medianReductionPercent = (1 - (result.parallelMs.median / result.serialMs.median)) * 100;
55
+
56
+ const outputDir = join(repoRoot, 'tmp', 'benchmarks');
57
+ mkdirSync(outputDir, { recursive: true });
58
+ writeFileSync(join(outputDir, 'test-runner-latest.json'), `${JSON.stringify(result, null, 2)}\n`);
59
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -38,6 +38,29 @@ if (corpusReady && suppliedCorpusFixturesDir) {
38
38
  console.warn('Run npm run test:corpus:word once to fetch and validate the pinned corpus.');
39
39
  }
40
40
 
41
+ const lane1FixturesArgIndex = process.argv.indexOf('--lane1-fixtures-dir');
42
+ if (lane1FixturesArgIndex >= 0 && !process.argv[lane1FixturesArgIndex + 1]) {
43
+ throw new Error('--lane1-fixtures-dir requires a path');
44
+ }
45
+ const suppliedLane1FixturesDir = lane1FixturesArgIndex >= 0;
46
+ const lane1FixturesDir = suppliedLane1FixturesDir
47
+ ? resolve(repoRoot, process.argv[lane1FixturesArgIndex + 1])
48
+ : join(repoRoot, 'tmp', 'lane1-docx');
49
+
50
+ // The dashboard is self-contained: once generated, it keeps the exact DOCX
51
+ // bytes that were embedded at build time. Regenerate the default Lane 1
52
+ // fixtures on every dashboard build so an existing manifest cannot pin the
53
+ // report to an obsolete tracked document. An explicitly supplied fixture
54
+ // directory is treated as an immutable caller-selected snapshot.
55
+ if (!suppliedLane1FixturesDir) {
56
+ run('scripts/export-lane1-fixtures.mjs', ['--output-dir', lane1FixturesDir]);
57
+ } else if (!existsSync(join(lane1FixturesDir, 'manifest.json'))) {
58
+ throw new Error(`Supplied Lane 1 fixture directory has no manifest.json: ${lane1FixturesDir}`);
59
+ }
60
+
41
61
  const args = ['scripts/generate-test-dashboard.mjs', '--fixtures-dir', syntheticDir];
42
62
  if (corpusReady) args.push('--corpus-fixtures-dir', corpusFixturesDir);
63
+ if (existsSync(join(lane1FixturesDir, 'manifest.json'))) {
64
+ args.push('--lane1-fixtures-dir', lane1FixturesDir);
65
+ }
43
66
  run(args[0], args.slice(1));