@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
package/node/cli.js CHANGED
@@ -6,28 +6,35 @@ import { validateDocxPackage } from '../services/standalone-docx-plumbing.js';
6
6
  import { validateRedlineOoxml } from '../core/redline-validation.js';
7
7
  import { configureLogger } from '../adapters/logger.js';
8
8
  import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
9
+ import { normalizeErrorWithRecovery } from '../services/error-recovery.js';
9
10
 
10
11
  const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
11
- const CLI_CONTRACT_VERSION = 2;
12
+ const CLI_CONTRACT_VERSION = 5;
12
13
  const CLI_CAPABILITIES = [
13
14
  'atomic-batch-results-on-package-failure',
14
15
  'baseline-aware-validation',
16
+ 'compact-mutation-results',
15
17
  'cross-author-revision-slicing',
16
- 'document-scoped-list-revision-ids'
17
- ];
18
+ 'document-scoped-list-revision-ids',
19
+ 'batch-start-source-binding',
20
+ 'recovery-envelope-v1',
21
+ 'require-complete-exit',
22
+ 'operations-stdin',
23
+ 'agent-profile-v1'
24
+ ];
18
25
  const commandOptions = {
19
26
  version: new Set(['help']),
20
27
  inspect: new Set(['help', 'search', 'revised', 'table', 'body', 'nonEmpty', 'index', 'indexes', 'range', 'view']),
21
28
  extract: new Set(['help', 'search', 'revised', 'table', 'body', 'nonEmpty', 'index', 'indexes', 'range', 'view']),
22
29
  preflight: new Set(['help', 'operations', 'author', 'strictTargets', 'target', 'modified', 'comment', 'textToComment', 'targetRef', 'existingRevisions']),
23
- apply: new Set(['help', 'operations', 'author', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber', 'expectedRevision', 'target', 'modified', 'comment', 'textToComment', 'targetRef', 'existingRevisions', 'atomic', 'generateRedlines', 'noRedlines']),
30
+ apply: new Set(['help', 'operations', 'author', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber', 'expectedRevision', 'target', 'modified', 'comment', 'textToComment', 'targetRef', 'existingRevisions', 'atomic', 'generateRedlines', 'noRedlines', 'requireComplete', 'profile']),
24
31
  accept: new Set(['help', 'author', 'allAuthors', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber']),
25
32
  reject: new Set(['help', 'author', 'allAuthors', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber']),
26
33
  'delete-comments': new Set(['help', 'author', 'allAuthors', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber']),
27
34
  validate: new Set(['help', 'baseline'])
28
35
  };
29
36
 
30
- function cliError(code, message, exitCode = 2, details) { return { status: 'error', error: { code, message, ...(details ? { details } : {}) }, exitCode }; }
37
+ function cliError(code, message, exitCode = 2, details) { return { status: 'error', error: normalizeErrorWithRecovery({ code, message, ...(details ? { details } : {}) }), exitCode }; }
31
38
  const optionAliases = new Map([
32
39
  ['operationsFile', 'operations'],
33
40
  ['o', 'output'],
@@ -38,7 +45,8 @@ const optionAliases = new Map([
38
45
  ['no-overwrite', 'noOverwrite'],
39
46
  ['no-clobber', 'noClobber'],
40
47
  ['no-redlines', 'noRedlines'],
41
- ['generate-redlines', 'generateRedlines']
48
+ ['generate-redlines', 'generateRedlines'],
49
+ ['require-complete', 'requireComplete']
42
50
  ]);
43
51
  function parseArgs(argv) {
44
52
  const positionals = []; const flags = {};
@@ -50,7 +58,7 @@ function parseArgs(argv) {
50
58
  const normalizedKey = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
51
59
  const key = optionAliases.get(normalizedKey) || normalizedKey;
52
60
  if (inline !== undefined) flags[key] = inline;
53
- else if (argv[index + 1] && (!argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
61
+ else if (argv[index + 1] && (argv[index + 1] === '-' || !argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
54
62
  else flags[key] = true;
55
63
  }
56
64
  return { command: positionals[0], input: positionals[1], extraPositionals: positionals.slice(2), flags };
@@ -112,7 +120,16 @@ function inspectionOptions(flags) {
112
120
  }
113
121
  return options;
114
122
  }
115
- async function readOperations(file, flags = {}) {
123
+ async function readUtf8Stream(stream) {
124
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
125
+ throw new Error('No readable stdin stream was provided.');
126
+ }
127
+ const chunks = [];
128
+ for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
129
+ return Buffer.concat(chunks).toString('utf8');
130
+ }
131
+
132
+ async function readOperations(file, flags = {}, stdin = process.stdin) {
116
133
  if (!file && flags?.target) {
117
134
  let op;
118
135
  if (flags.comment) {
@@ -137,7 +154,14 @@ async function readOperations(file, flags = {}) {
137
154
  return { operations: [op], expectedRevision: null };
138
155
  }
139
156
  if (!file) throw Object.assign(new Error('Use --operations <file.json> or --target <text>.'), { code: 'OPERATIONS_REQUIRED' });
140
- let parsed; try { parsed = JSON.parse(await readFile(file, 'utf8')); } catch (error) { throw Object.assign(new Error(`Could not read operations JSON: ${error.message}`), { code: 'INVALID_OPERATIONS_FILE' }); }
157
+ let parsed;
158
+ try {
159
+ const source = file === '-' ? await readUtf8Stream(stdin) : await readFile(file, 'utf8');
160
+ parsed = JSON.parse(source);
161
+ } catch (error) {
162
+ const location = file === '-' ? ' from stdin' : '';
163
+ throw Object.assign(new Error(`Could not read operations JSON${location}: ${error.message}`), { code: 'INVALID_OPERATIONS_FILE' });
164
+ }
141
165
  const operations = Array.isArray(parsed) ? parsed : (parsed?.operations || parsed?.changes);
142
166
  if (!Array.isArray(operations)) throw Object.assign(new Error('Operations JSON must be an array or an object with an operations or changes array.'), { code: 'INVALID_OPERATIONS_FILE' });
143
167
  return { operations, expectedRevision: parsed?.expectedRevision || null };
@@ -162,10 +186,156 @@ async function writeMutation(command, input, flags, result) {
162
186
  await writeFile(destination, result.toBuffer());
163
187
  return { status: result.status || 'ok', ...result, outputPath: destination };
164
188
  }
165
- function serializable(value) {
166
- const { buffer: _buffer, toBuffer: _toBuffer, ...rest } = value || {};
167
- return rest;
168
- }
189
+ function serializable(value) {
190
+ const { buffer: _buffer, toBuffer: _toBuffer, ...rest } = value || {};
191
+ return rest;
192
+ }
193
+
194
+ function boundedText(value, limit = 512) {
195
+ const text = String(value ?? '');
196
+ return text.length > limit ? `${text.slice(0, limit)}…` : text;
197
+ }
198
+
199
+ function compactError(error) {
200
+ if (!error || typeof error !== 'object') return error;
201
+ const fields = [
202
+ 'code', 'stage', 'mismatchOffset', 'expectedExcerpt', 'actualExcerpt',
203
+ 'expectedCodePoint', 'actualCodePoint', 'ownerAuthor', 'commentIds',
204
+ 'recoveryVersion', 'category', 'field', 'captureRef', 'operationIndexes',
205
+ 'consumedByOperation', 'expectedScope', 'actualScope'
206
+ ];
207
+ const compact = {};
208
+ for (const field of fields) {
209
+ if (error[field] !== undefined) compact[field] = error[field];
210
+ }
211
+ if (Array.isArray(error.comments)) {
212
+ compact.comments = error.comments.map(comment => ({
213
+ ...(comment?.id !== undefined ? { id: comment.id } : {}),
214
+ ...(comment?.author !== undefined ? { author: boundedText(comment.author, 160) } : {}),
215
+ ...(comment?.text !== undefined ? { text: boundedText(comment.text, 512) } : {})
216
+ }));
217
+ }
218
+ if (Array.isArray(error.candidates)) {
219
+ compact.candidates = error.candidates.map(candidate => {
220
+ if (!candidate || typeof candidate !== 'object') return candidate;
221
+ const excerpt = boundedText(candidate.excerpt ?? candidate.exactText ?? candidate.text ?? '', 240);
222
+ return { ...compactResolvedTarget(candidate), ...(excerpt ? { excerpt } : {}) };
223
+ });
224
+ }
225
+ for (const field of ['recovery', 'issueSummary', 'expectedRevision', 'currentRevision']) {
226
+ if (error[field] !== undefined) compact[field] = error[field];
227
+ }
228
+ if (error.context && typeof error.context === 'object') {
229
+ compact.context = {
230
+ ...error.context,
231
+ ...(error.context.currentTarget ? {
232
+ currentTarget: {
233
+ ...compactResolvedTarget(error.context.currentTarget),
234
+ excerpt: boundedText(
235
+ error.context.currentTarget.excerpt
236
+ ?? error.context.currentTarget.exactText
237
+ ?? error.context.currentTarget.text
238
+ ?? '',
239
+ 240
240
+ )
241
+ }
242
+ } : {})
243
+ };
244
+ }
245
+ if (error.sourceTarget && typeof error.sourceTarget === 'object') {
246
+ compact.sourceTarget = {
247
+ ...compactResolvedTarget(error.sourceTarget),
248
+ excerpt: boundedText(error.sourceTarget.text ?? error.sourceTarget.exactText ?? '', 240)
249
+ };
250
+ }
251
+ compact.message = boundedText(error.message || String(error));
252
+ return compact;
253
+ }
254
+
255
+ function compactResolvedTarget(target) {
256
+ if (!target || typeof target !== 'object') return target;
257
+ const { text: _text, exactText: _exactText, ...compact } = target;
258
+ return compact;
259
+ }
260
+
261
+ function compactReceipt(receipt) {
262
+ if (!receipt || typeof receipt !== 'object') return receipt;
263
+ return {
264
+ ...receipt,
265
+ affectedTargets: Array.isArray(receipt.affectedTargets)
266
+ ? receipt.affectedTargets.map(compactResolvedTarget)
267
+ : [],
268
+ warnings: Array.isArray(receipt.warnings) ? receipt.warnings.map(warning => boundedText(warning)) : []
269
+ };
270
+ }
271
+
272
+ function compactOperationResult(result) {
273
+ if (!result || typeof result !== 'object') return result;
274
+ return {
275
+ ...result,
276
+ ...(result.resolvedTarget ? { resolvedTarget: compactResolvedTarget(result.resolvedTarget) } : {}),
277
+ ...(result.resolvedAnchor ? { resolvedAnchor: compactResolvedTarget(result.resolvedAnchor) } : {}),
278
+ ...(result.error ? { error: compactError(result.error) } : {}),
279
+ ...(result.receipt ? { receipt: compactReceipt(result.receipt) } : {}),
280
+ ...(Array.isArray(result.warnings) ? { warnings: result.warnings.map(warning => boundedText(warning)) } : {})
281
+ };
282
+ }
283
+
284
+ function summarizeIssues(issues) {
285
+ const list = Array.isArray(issues) ? issues : [];
286
+ const grouped = new Map();
287
+ for (const issue of list) {
288
+ const code = issue?.code || 'UNKNOWN';
289
+ const source = issue?.source || 'unknown';
290
+ const severity = issue?.severity || 'error';
291
+ const key = `${source}:${severity}:${code}`;
292
+ const current = grouped.get(key) || { source, severity, code, count: 0 };
293
+ current.count++;
294
+ grouped.set(key, current);
295
+ }
296
+ return {
297
+ total: list.length,
298
+ errors: list.filter(issue => issue?.severity === 'error').length,
299
+ warnings: list.filter(issue => issue?.severity === 'warning').length,
300
+ byCode: Array.from(grouped.values()).sort((a, b) => (
301
+ a.source.localeCompare(b.source) || a.code.localeCompare(b.code) || a.severity.localeCompare(b.severity)
302
+ ))
303
+ };
304
+ }
305
+
306
+ function compactMutationResult(value) {
307
+ const serialized = serializable(value);
308
+ const {
309
+ documentXml: _documentXml,
310
+ oxml: _oxml,
311
+ commentsXml: _commentsXml,
312
+ commentsExtendedXml: _commentsExtendedXml,
313
+ numberingXml: _numberingXml,
314
+ numberingXmlParts: _numberingXmlParts,
315
+ inspection: _inspection,
316
+ issues: _issues,
317
+ ...compact
318
+ } = serialized;
319
+ const results = Array.isArray(compact.results) ? compact.results.map(compactOperationResult) : [];
320
+ const status = compact.status || 'ok';
321
+ return {
322
+ ...compact,
323
+ ...(Array.isArray(compact.results) ? { results } : {}),
324
+ ...(Array.isArray(compact.receipts) ? { receipts: compact.receipts.map(compactReceipt) } : {}),
325
+ ...(compact.error ? { error: compactError(compact.error) } : {}),
326
+ ...(Array.isArray(compact.warnings) ? { warnings: compact.warnings.map(warning => boundedText(warning)) } : {}),
327
+ ...(compact.validation ? {
328
+ validation: {
329
+ originalIssues: summarizeIssues(compact.validation.originalIssues),
330
+ generatedIssues: summarizeIssues(compact.validation.generatedIssues)
331
+ }
332
+ } : {}),
333
+ completion: compact.written === true
334
+ && status !== 'error'
335
+ && status !== 'partial'
336
+ && results.every(result => result?.status !== 'error')
337
+ };
338
+ }
169
339
 
170
340
  async function collectValidationIssues(buffer) {
171
341
  const entries = unzipDocx(buffer);
@@ -199,7 +369,7 @@ function subtractValidationIssues(issues, baselineIssues) {
199
369
  });
200
370
  }
201
371
 
202
- export async function executeCli(argv) {
372
+ export async function executeCli(argv, io = process) {
203
373
  const { command, input: rawInput, extraPositionals, flags } = parseArgs(argv);
204
374
  if (command === 'help' || flags.help) return { status: 'ok', command: 'help', usage: 'docx-redline <version|inspect|extract|preflight|apply|accept|reject|delete-comments|validate> [file.docx] [options]' };
205
375
  if (!command) return cliError('COMMAND_REQUIRED', 'A command is required.');
@@ -221,6 +391,10 @@ export async function executeCli(argv) {
221
391
  if (flags.existingRevisions != null && !isExistingRevisionsPolicy(flags.existingRevisions)) {
222
392
  return cliError('INVALID_OPERATION', `Unsupported existing-revisions policy: "${String(flags.existingRevisions)}".`);
223
393
  }
394
+ const profile = flags.profile == null ? null : String(flags.profile);
395
+ if (profile && profile !== 'agent') {
396
+ return cliError('INVALID_PROFILE', `Unknown execution profile: "${profile}". Supported profiles: agent.`);
397
+ }
224
398
  let inspectOptions = null;
225
399
  if (command === 'inspect' || command === 'extract') {
226
400
  try { inspectOptions = inspectionOptions(flags); }
@@ -259,7 +433,9 @@ export async function executeCli(argv) {
259
433
  const hasErrors = issues.some(issue => issue.severity === 'error');
260
434
  return { status: hasErrors ? 'error' : 'ok', command, input, valid: !hasErrors, issues };
261
435
  }
262
- const opsData = command === 'preflight' || command === 'apply' ? await readOperations(flags.operations, flags) : null;
436
+ const opsData = command === 'preflight' || command === 'apply'
437
+ ? await readOperations(flags.operations, flags, io.stdin || process.stdin)
438
+ : null;
263
439
  const operations = opsData?.operations || null;
264
440
  let expectedRevision = opsData?.expectedRevision || null;
265
441
  if (flags.expectedRevision) {
@@ -285,15 +461,31 @@ export async function executeCli(argv) {
285
461
  }),
286
462
  command,
287
463
  input
288
- };
289
- if (command === 'apply') {
290
- const author = flags.author || process.env.DOCX_REDLINE_AUTHOR || 'AI Redliner';
291
- const generateRedlines = flags.generateRedlines !== undefined
292
- ? (flags.generateRedlines !== 'false' && flags.generateRedlines !== false)
293
- : (!flags.noRedlines);
294
- const result = await document.applyOperations(operations, {
295
- author,
296
- atomic: flags.atomic === true || flags.atomic === 'true',
464
+ };
465
+ if (command === 'apply') {
466
+ const agentProfile = profile === 'agent';
467
+ const author = flags.author || process.env.DOCX_REDLINE_AUTHOR || 'AI Redliner';
468
+ const generateRedlines = flags.generateRedlines !== undefined
469
+ ? (flags.generateRedlines !== 'false' && flags.generateRedlines !== false)
470
+ : (!flags.noRedlines);
471
+ const atomic = flags.atomic !== undefined
472
+ ? (flags.atomic === true || flags.atomic === 'true')
473
+ : agentProfile;
474
+ const requireComplete = flags.requireComplete !== undefined
475
+ ? (flags.requireComplete === true || flags.requireComplete === 'true')
476
+ : agentProfile;
477
+ const effectiveOptions = {
478
+ author,
479
+ atomic,
480
+ strictTargets: true,
481
+ validate: true,
482
+ generateRedlines,
483
+ existingRevisions: flags.existingRevisions || 'merge-same-author',
484
+ requireComplete
485
+ };
486
+ const result = await document.applyOperations(operations, {
487
+ author,
488
+ atomic,
297
489
  validate: true,
298
490
  strictTargets: true,
299
491
  generateRedlines,
@@ -301,22 +493,29 @@ export async function executeCli(argv) {
301
493
  ...(expectedRevision ? { expectedRevision } : {})
302
494
  });
303
495
  const mutationResult = await writeMutation(command, input, flags, result);
304
- return {
305
- command,
306
- input,
307
- ...serializable(mutationResult),
308
- ...(result.status === 'error' || result.error ? { exitCode: 2 } : {})
309
- };
496
+ return compactMutationResult({
497
+ command,
498
+ input,
499
+ ...serializable(mutationResult),
500
+ ...(profile ? { executionProfile: profile, effectiveOptions } : {}),
501
+ ...(result.status === 'error'
502
+ ? { exitCode: 2 }
503
+ : (result.status === 'partial' && requireComplete
504
+ ? { exitCode: 3 }
505
+ : {}))
506
+ });
310
507
  }
311
508
  const filter = flags.allAuthors ? { allAuthors: true } : flags.author ? { author: String(flags.author) } : null;
312
509
  if (!filter) return cliError('AUTHOR_REQUIRED', 'Use --author <name> or --all-authors.');
313
510
  const result = command === 'delete-comments' ? await document.deleteComments(filter) : await document.resolveRevisions(command, filter);
314
- return { command, input, ...serializable(await writeMutation(command, input, flags, result)) };
511
+ return compactMutationResult({ command, input, ...serializable(await writeMutation(command, input, flags, result)) });
315
512
  } catch (error) { return cliError(error.code || 'CLI_FAILED', error.message); }
316
513
  }
317
514
 
318
- export async function runCli(argv = process.argv.slice(2), io = process) {
319
- configureLogger({}, { level: 'silent' });
320
- const result = await executeCli(argv); io.stdout.write(`${JSON.stringify(serializable(result), null, 2)}\n`);
321
- return result.status === 'error' ? (result.exitCode || 1) : 0;
322
- }
515
+ export async function runCli(argv = process.argv.slice(2), io = process) {
516
+ configureLogger({}, { level: 'silent' });
517
+ const result = await executeCli(argv, io); io.stdout.write(`${JSON.stringify(serializable(result), null, 2)}\n`);
518
+ return Number.isInteger(result.exitCode) && result.exitCode !== 0
519
+ ? result.exitCode
520
+ : (result.status === 'error' ? 1 : 0);
521
+ }
@@ -5,16 +5,48 @@ import { inspectDocumentParts } from '../services/document-inspection.js';
5
5
  import { applyOperationsToDocumentXml, preflightOperations } from '../services/standalone-operation-runner.js';
6
6
  import { createDynamicNumberingIdState, mergeNumberingXmlBySchemaOrder } from '../services/numbering-helpers.js';
7
7
  import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
8
- import { validateRedlineOoxml } from '../core/redline-validation.js';
8
+ import { validateRedlineOoxml } from '../core/redline-validation.js';
9
+ import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
9
10
  import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, deleteCommentsByAuthorInOoxml } from '../services/revision-comment-management.js';
10
11
  import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
11
12
  import { createHash } from 'node:crypto';
12
13
  import { MemoryZip, unzipDocx, zipDocx } from './zip-archive.js';
13
- import { computeRevisionTokenSync, validateRevisionToken, areRevisionTokensEqual } from '../services/revision-token.js';
14
+ import { computeRevisionTokenSync, validateRevisionToken, areRevisionTokensEqual } from '../services/revision-token.js';
15
+ import { createRetryPlan, normalizeErrorWithRecovery } from '../services/error-recovery.js';
14
16
 
15
17
  configureXmlProvider({ DOMParser, XMLSerializer });
16
- const text = (entries, path) => entries.get(path)?.toString('utf8') || null;
17
- const cloneEntries = entries => new Map([...entries].map(([name, data]) => [name, Buffer.from(data)]));
18
+ const text = (entries, path) => entries.get(path)?.toString('utf8') || null;
19
+ const cloneEntries = entries => new Map([...entries].map(([name, data]) => [name, Buffer.from(data)]));
20
+
21
+ function rolledBackOperationPayload(operationResult, operationCount) {
22
+ const rollbackReceipt = receipt => {
23
+ if (!receipt || typeof receipt !== 'object') return receipt;
24
+ if (receipt.attemptedDisposition !== 'applied' && receipt.committed !== true) return { ...receipt };
25
+ return { ...receipt, committed: false, finalDisposition: 'rolled_back' };
26
+ };
27
+ const receipts = (operationResult?.receipts || []).map(rollbackReceipt);
28
+ const receiptByIndex = new Map(receipts.map(receipt => [receipt.operationIndex, receipt]));
29
+ const results = (operationResult?.results || []).map(result => ({
30
+ ...result,
31
+ ...(result.receipt ? {
32
+ receipt: receiptByIndex.get(result.index) || rollbackReceipt(result.receipt)
33
+ } : {})
34
+ }));
35
+ return {
36
+ results,
37
+ receipts,
38
+ executionOrder: operationResult?.executionOrder || [],
39
+ authorsUsed: [],
40
+ rolledBack: true,
41
+ retryPlan: createRetryPlan({
42
+ atomic: true,
43
+ rolledBack: true,
44
+ results,
45
+ receipts,
46
+ operationCount
47
+ })
48
+ };
49
+ }
18
50
 
19
51
  /**
20
52
  * Computes a package-scoped revision token over all uncompressed entries in a DOCX archive.
@@ -83,60 +115,55 @@ export class DocxDocument {
83
115
  get revisionToken() { return this.getRevisionToken(); }
84
116
  preflight(operations, author = getDefaultAuthor(), options = {}) { return preflightOperations(text(this.entries, 'word/document.xml'), operations, author || getDefaultAuthor(), { ...options, _existingCommentDetails: existingCommentDetails(this.entries) }); }
85
117
  toBuffer() { return zipDocx(this.entries); }
86
- async applyOperations(operations, options = {}) {
87
- if (options?.expectedRevision) {
118
+ async applyOperations(operations, options = {}) {
119
+ const failedApply = error => {
120
+ const results = [];
121
+ const receipts = [];
122
+ return {
123
+ status: 'error',
124
+ hasChanges: false,
125
+ written: false,
126
+ rolledBack: true,
127
+ results,
128
+ receipts,
129
+ artifactsChanged: [],
130
+ error: normalizeErrorWithRecovery(error),
131
+ retryPlan: createRetryPlan({
132
+ atomic: true,
133
+ rolledBack: true,
134
+ results,
135
+ receipts,
136
+ operationCount: Array.isArray(operations) ? operations.length : 0
137
+ }),
138
+ validation: { originalIssues: [], generatedIssues: [] },
139
+ buffer: Buffer.from(this.originalBuffer),
140
+ toBuffer: () => Buffer.from(this.originalBuffer)
141
+ };
142
+ };
143
+ if (options?.expectedRevision) {
88
144
  const tokenValidation = validateRevisionToken(options.expectedRevision);
89
145
  if (!tokenValidation.valid) {
90
- return {
91
- status: 'error',
92
- hasChanges: false,
93
- written: false,
94
- rolledBack: true,
95
- results: [],
96
- artifactsChanged: [],
97
- error: {
98
- code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
99
- message: tokenValidation.error?.message || 'Invalid revision token.'
100
- },
101
- validation: { originalIssues: [], generatedIssues: [] },
102
- buffer: Buffer.from(this.originalBuffer),
103
- toBuffer: () => Buffer.from(this.originalBuffer)
104
- };
146
+ return failedApply({
147
+ code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
148
+ message: tokenValidation.error?.message || 'Invalid revision token.'
149
+ });
105
150
  }
106
151
  if (options.expectedRevision.scope !== 'package') {
107
- return {
108
- status: 'error',
109
- hasChanges: false,
110
- written: false,
111
- rolledBack: true,
112
- results: [],
113
- artifactsChanged: [],
114
- error: {
115
- code: 'REVISION_TOKEN_SCOPE_MISMATCH',
116
- message: `Revision token scope mismatch: expected 'package', got '${options.expectedRevision.scope}'.`
117
- },
118
- validation: { originalIssues: [], generatedIssues: [] },
119
- buffer: Buffer.from(this.originalBuffer),
120
- toBuffer: () => Buffer.from(this.originalBuffer)
121
- };
152
+ return failedApply({
153
+ code: 'REVISION_TOKEN_SCOPE_MISMATCH',
154
+ message: `Revision token scope mismatch: expected 'package', got '${options.expectedRevision.scope}'.`,
155
+ expectedScope: 'package',
156
+ actualScope: options.expectedRevision.scope
157
+ });
122
158
  }
123
159
  const currentToken = computePackageRevisionToken(this.entries);
124
160
  if (!areRevisionTokensEqual(currentToken.value, options.expectedRevision.value)) {
125
- return {
126
- status: 'error',
127
- hasChanges: false,
128
- written: false,
129
- rolledBack: true,
130
- results: [],
131
- artifactsChanged: [],
132
- error: {
133
- code: 'REVISION_MISMATCH',
134
- message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`
135
- },
136
- validation: { originalIssues: [], generatedIssues: [] },
137
- buffer: Buffer.from(this.originalBuffer),
138
- toBuffer: () => Buffer.from(this.originalBuffer)
139
- };
161
+ return failedApply({
162
+ code: 'REVISION_MISMATCH',
163
+ message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`,
164
+ expectedRevision: options.expectedRevision,
165
+ currentRevision: currentToken
166
+ });
140
167
  }
141
168
  }
142
169
 
@@ -162,7 +189,17 @@ export class DocxDocument {
162
189
  _existingCommentDetails: existingCommentDetails(working),
163
190
  commentIdAllocator: nextCommentId(working)
164
191
  });
165
- if (result.rolledBack || result.status === 'error') return { ...result, written: false, artifactsChanged: [], validation: { originalIssues, generatedIssues: [] }, buffer: this.originalBuffer, toBuffer: () => Buffer.from(this.originalBuffer) };
192
+ if (result.rolledBack || result.status === 'error') {
193
+ return {
194
+ ...result,
195
+ ...rolledBackOperationPayload(result, Array.isArray(operations) ? operations.length : 0),
196
+ written: false,
197
+ artifactsChanged: [],
198
+ validation: { originalIssues, generatedIssues: [] },
199
+ buffer: this.originalBuffer,
200
+ toBuffer: () => Buffer.from(this.originalBuffer)
201
+ };
202
+ }
166
203
  if (!result.hasChanges) return { ...result, status: result.status || 'ok', written: false, artifactsChanged: [], validation: { originalIssues, generatedIssues: [] }, buffer: Buffer.from(this.originalBuffer), toBuffer: () => Buffer.from(this.originalBuffer) };
167
204
  working.set('word/document.xml', Buffer.from(result.documentXml));
168
205
  await ensureNumberingArtifactsInZip(zip, result.numberingXmlParts, { mergeNumberingXml: mergeNumberingXmlBySchemaOrder });
@@ -176,41 +213,46 @@ export class DocxDocument {
176
213
  await ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXmlForPackaging, {
177
214
  replaceExisting: result.commentsExtendedXmlMode === 'replace' || (!result.commentsExtendedXml && !!existingCommentsExtendedXml)
178
215
  });
179
- if (options.validate !== false) {
180
- const generated = validateRedlineOoxml(result.documentXml);
181
- const baselineErrors = new Set(baseline.issues.filter(i => i.severity === 'error').map(i => `${i.code}:${i.message}`));
182
- const introduced = generated.issues.filter(i => i.severity === 'error' && !baselineErrors.has(`${i.code}:${i.message}`));
183
- if (introduced.length) {
184
- const codes = [...new Set(introduced.map(issue => issue.code))].join(', ');
185
- throw Object.assign(
186
- new Error(`Applied operations introduced invalid revision markup (${codes}); these are generated-output issues, not pre-existing input issues.`),
187
- { issues: introduced }
188
- );
189
- }
190
- await validateDocxPackage(zip);
191
- }
216
+ if (options.validate !== false) {
217
+ const generated = validateRedlineOoxml(result.documentXml);
218
+ const outputIssues = generated.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
219
+ try { await validateDocxPackage(zip); }
220
+ catch (error) { outputIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message }); }
221
+ const introduced = subtractValidationIssueMultiset(outputIssues, originalIssues);
222
+ const introducedErrors = validationErrors(introduced);
223
+ if (introducedErrors.length) {
224
+ const codes = [...new Set(introducedErrors.map(issue => issue.code))].join(', ');
225
+ throw Object.assign(
226
+ new Error(`Applied operations introduced invalid revision markup (${codes}); these are generated-output issues, not pre-existing input issues.`),
227
+ { issues: introducedErrors }
228
+ );
229
+ }
230
+ }
192
231
  this.entries = working;
193
232
  const output = this.toBuffer();
194
233
  this.originalBuffer = Buffer.from(output);
195
234
  const artifactsChanged = [...working].filter(([name, data]) => !originalEntries.has(name) || !data.equals(originalEntries.get(name))).map(([name]) => name);
196
235
  return { ...result, status: result.status || 'ok', written: true, artifactsChanged, validation: { originalIssues, generatedIssues: [] }, buffer: output, inspection: this.inspect(), toBuffer: () => Buffer.from(output) };
197
- } catch (error) {
198
- this.entries = originalEntries;
199
- const generatedIssues = error.issues || [{ source: 'package', code: 'PACKAGE_OPERATION_FAILED', severity: 'error', message: error.message }];
200
- return {
201
- ...(operationResult ? {
202
- results: operationResult.results || [],
203
- receipts: operationResult.receipts || [],
204
- executionOrder: operationResult.executionOrder || [],
205
- authorsUsed: operationResult.authorsUsed || []
206
- } : { results: [] }),
207
- status: 'error',
208
- hasChanges: false,
209
- written: false,
210
- rolledBack: true,
211
- artifactsChanged: [],
212
- error: { code: 'PACKAGE_OPERATION_FAILED', message: error.message },
213
- validation: { originalIssues, generatedIssues },
236
+ } catch (error) {
237
+ this.entries = originalEntries;
238
+ const generatedIssues = error.issues || [{ source: 'package', code: 'PACKAGE_OPERATION_FAILED', severity: 'error', message: error.message }];
239
+ const rollbackPayload = rolledBackOperationPayload(
240
+ operationResult,
241
+ Array.isArray(operations) ? operations.length : 0
242
+ );
243
+ return {
244
+ ...rollbackPayload,
245
+ status: 'error',
246
+ hasChanges: false,
247
+ written: false,
248
+ artifactsChanged: [],
249
+ error: normalizeErrorWithRecovery({
250
+ code: 'PACKAGE_OPERATION_FAILED',
251
+ message: error.message,
252
+ stage: 'package',
253
+ issues: generatedIssues
254
+ }),
255
+ validation: { originalIssues, generatedIssues },
214
256
  issues: generatedIssues,
215
257
  buffer: Buffer.from(this.originalBuffer),
216
258
  toBuffer: () => Buffer.from(this.originalBuffer)
@@ -298,5 +340,17 @@ export class DocxDocument {
298
340
  }
299
341
  }
300
342
 
301
- function packageFailure(source, code, message) { return { status: 'error', hasChanges: false, written: false, rolledBack: true, error: { code, message }, artifactsChanged: [], buffer: Buffer.from(source), toBuffer: () => Buffer.from(source) }; }
343
+ function packageFailure(source, code, message) {
344
+ return {
345
+ status: 'error',
346
+ hasChanges: false,
347
+ written: false,
348
+ rolledBack: true,
349
+ error: normalizeErrorWithRecovery({ code, message }),
350
+ retryPlan: createRetryPlan({ atomic: true, rolledBack: true }),
351
+ artifactsChanged: [],
352
+ buffer: Buffer.from(source),
353
+ toBuffer: () => Buffer.from(source)
354
+ };
355
+ }
302
356
  export function openDocx(input) { return new DocxDocument(input); }
package/node/index.d.ts CHANGED
@@ -27,5 +27,9 @@ export class DocxDocument {
27
27
  }
28
28
  export function computePackageRevisionToken(input: unknown): RevisionToken;
29
29
  export function openDocx(input: Uint8Array): DocxDocument;
30
- export function executeCli(argv: string[]): Promise<Record<string, unknown>>;
31
- export function runCli(argv?: string[], io?: { stdout: { write(value: string): unknown } }): Promise<number>;
30
+ export interface CliIo {
31
+ stdin?: AsyncIterable<string | Uint8Array>;
32
+ stdout: { write(value: string): unknown };
33
+ }
34
+ export function executeCli(argv: string[], io?: Partial<CliIo>): Promise<Record<string, unknown>>;
35
+ export function runCli(argv?: string[], io?: CliIo): Promise<number>;