@ansonlai/docx-redline-js 0.8.3 → 0.9.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 (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
package/node/cli.js CHANGED
@@ -1,974 +1,992 @@
1
- import { access, readFile, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { openDocx } from './docx-document.js';
4
- import { MemoryZip, unzipDocx } from './zip-archive.js';
5
- import { validateDocxPackage } from '../services/standalone-docx-plumbing.js';
6
- import { validateRedlineOoxml } from '../core/redline-validation.js';
7
- import { configureLogger } from '../adapters/logger.js';
8
- import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
9
- import { normalizeErrorWithRecovery } from '../services/error-recovery.js';
10
- import { localizedReplacementOffsets } from '../services/localized-replacement-compiler.js';
11
- import { buildCliHelp, CLI_COMMANDS, commandOptionKeys } from './cli-help.js';
12
-
13
- const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
14
- const CLI_CONTRACT_VERSION = 8;
15
- const DEFAULT_INSPECTION_LIMIT = 20;
16
- const INSPECTION_SOFT_BYTE_LIMIT = 48 * 1024;
17
- const CLI_CAPABILITIES = [
18
- 'atomic-batch-results-on-package-failure',
19
- 'baseline-aware-validation',
20
- 'compact-mutation-results',
21
- 'cross-author-revision-slicing',
22
- 'document-scoped-list-revision-ids',
23
- 'batch-start-source-binding',
24
- 'recovery-envelope-v1',
25
- 'require-complete-exit',
26
- 'operations-stdin',
27
- 'agent-safety-profile-v2',
28
- 'command-help-v1',
29
- 'inspection-context-v1',
30
- 'bounded-inspection-v1',
31
- 'human-document-references-v1',
32
- 'localized-replacements-v1',
33
- 'speculative-search-apply-v1',
34
- 'localized-change-summary-v1',
35
- 'localized-space-equivalence-v1',
36
- 'restore-shortcuts-v1',
37
- 'deduplicated-cli-receipts',
38
- 'compact-cli-json-v1',
39
- 'comment-resolve-operation-v1',
40
- 'delete-comments-by-id-v1',
41
- 'header-footer-parts-v1'
42
- ];
43
- const commandOptions = Object.fromEntries(CLI_COMMANDS.map(command => [command, new Set(commandOptionKeys(command))]));
44
-
45
- function cliError(code, message, exitCode = 2, details) {
46
- return {
47
- status: 'error',
48
- error: normalizeErrorWithRecovery({
49
- ...(details && typeof details === 'object' ? details : {}),
50
- code,
51
- message
52
- }),
53
- exitCode
54
- };
55
- }
56
- const optionAliases = new Map([
57
- ['operationsFile', 'operations'],
58
- ['o', 'output'],
59
- ['a', 'author'],
60
- ['i', 'inPlace'],
61
- ['f', 'force'],
62
- ['h', 'help'],
63
- ['no-overwrite', 'noOverwrite'],
64
- ['no-clobber', 'noClobber'],
65
- ['no-redlines', 'noRedlines'],
66
- ['generate-redlines', 'generateRedlines'],
67
- ['require-complete', 'requireComplete'],
68
- ['context', 'around'],
69
- ['C', 'around']
70
- ]);
71
- function parseArgs(argv) {
72
- const positionals = []; const flags = {};
73
- for (let index = 0; index < argv.length; index++) {
74
- const token = argv[index];
75
- if (!token.startsWith('-') || token === '-') { positionals.push(token); continue; }
76
- const prefixLength = token.startsWith('--') ? 2 : 1;
77
- const [rawKey, inline] = token.slice(prefixLength).split(/=(.*)/s);
78
- const normalizedKey = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
79
- const key = optionAliases.get(normalizedKey) || normalizedKey;
80
- if (inline !== undefined) flags[key] = inline;
81
- else if (argv[index + 1] !== undefined && (argv[index + 1] === '-' || !argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
82
- else flags[key] = true;
83
- }
84
- return { command: positionals[0], input: positionals[1], extraPositionals: positionals.slice(2), flags };
85
- }
86
- function positiveInteger(value) {
87
- const text = String(value).trim();
88
- const parsed = /^\d+$/.test(text) ? Number(text) : null;
89
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
90
- }
91
- function invalidFilter(message) {
92
- const error = new Error(message);
93
- error.code = 'INVALID_FILTER';
94
- return error;
95
- }
96
- function boundedPositiveInteger(value, optionName, maximum = Number.MAX_SAFE_INTEGER) {
97
- const parsed = positiveInteger(value);
98
- if (parsed == null || parsed > maximum) {
99
- const upperBound = maximum < Number.MAX_SAFE_INTEGER ? ` no greater than ${maximum}` : '';
100
- throw invalidFilter(`${optionName} must be a positive integer${upperBound}.`);
101
- }
102
- return parsed;
103
- }
104
- function parseIndexes(value) {
105
- const tokens = String(value).split(',');
106
- if (!tokens.length || tokens.some(token => positiveInteger(token) == null)) {
107
- throw invalidFilter('--indexes must be a comma-separated list of positive 1-based integers.');
108
- }
109
- return tokens.map(positiveInteger);
110
- }
111
- function parseRange(value) {
112
- const match = String(value).match(/^\s*(\d+)\s*([:,\-])\s*(\d+)\s*$/);
113
- if (!match) throw invalidFilter('--range must use START:END with positive 1-based integers.');
114
- const start = positiveInteger(match[1]);
115
- const end = positiveInteger(match[3]);
116
- if (start == null || end == null || end < start) {
117
- throw invalidFilter('--range must have positive 1-based endpoints with END greater than or equal to START.');
118
- }
119
- return { start, end };
120
- }
121
- function validateCommandOptions(command, flags, extraPositionals) {
122
- if (extraPositionals.length > 0) return cliError('UNEXPECTED_ARGUMENT', `Unexpected argument: ${extraPositionals[0]}`);
123
- const allowed = commandOptions[command];
124
- const unknown = Object.keys(flags).find(option => !allowed.has(option));
125
- return unknown ? cliError('UNKNOWN_OPTION', `Unknown option for ${command}: --${unknown.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}`) : null;
126
- }
127
- function inspectionOptions(flags) {
128
- const options = {};
129
- if (flags.search) options.search = flags.search;
130
- if (flags.revised) options.revisedOnly = true;
131
- if (flags.table) options.inTable = true;
132
- if (flags.body) options.inTable = false;
133
- if (flags.nonEmpty) options.skipEmpty = true;
134
- const selectors = ['index', 'indexes', 'range'].filter(name => flags[name] !== undefined);
135
- if (selectors.length > 1) throw invalidFilter('Use only one of --index, --indexes, or --range.');
136
- if (flags.index !== undefined) {
137
- const index = positiveInteger(flags.index);
138
- if (index == null) throw invalidFilter('--index must be a positive 1-based integer.');
139
- options.indexes = [index];
140
- }
141
- if (flags.indexes !== undefined) options.indexes = parseIndexes(flags.indexes);
142
- if (flags.range !== undefined) options.range = parseRange(flags.range);
143
- if (flags.around !== undefined) {
144
- const text = String(flags.around).trim();
145
- if (!/^\d+$/.test(text) || Number(text) > 20) {
146
- throw invalidFilter('--around must be an integer from 0 through 20.');
147
- }
148
- options.around = Number(text);
149
- if (!flags.search) throw invalidFilter('--around requires --search.');
150
- }
151
- if (flags.limit !== undefined) options.limit = boundedPositiveInteger(flags.limit, '--limit', 200);
152
- if (flags.after !== undefined) options.after = boundedPositiveInteger(flags.after, '--after');
153
- if (flags.all && flags.limit !== undefined) throw invalidFilter('Use --all or --limit, not both.');
154
- if (!flags.all && flags.limit === undefined && selectors.length === 0) options.limit = DEFAULT_INSPECTION_LIMIT;
155
- if (flags.view) {
156
- if (!['accepted', 'rejected', 'current'].includes(String(flags.view))) {
157
- throw invalidFilter('--view must be accepted, rejected, or current.');
158
- }
159
- options.revisionView = flags.view;
160
- }
161
- return options;
162
- }
163
- async function readUtf8Stream(stream) {
164
- if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
165
- throw new Error('No readable stdin stream was provided.');
166
- }
167
- const chunks = [];
168
- for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
169
- return Buffer.concat(chunks).toString('utf8');
170
- }
171
-
172
- function parseContextRange(value) {
173
- const match = String(value).match(/^\s*(-?\d+)\s*:\s*(-?\d+)\s*$/);
174
- if (!match) throw invalidFilter('--context-range must use START:END with signed integer offsets from -20 through 20.');
175
- const start = Number(match[1]);
176
- const end = Number(match[2]);
177
- if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < -20 || end > 20 || start > end) {
178
- throw invalidFilter('--context-range requires START <= END and both offsets from -20 through 20.');
179
- }
180
- return { start, end, text: `${start}:${end}` };
181
- }
182
-
183
- function speculativeCandidate(paragraph) {
184
- return {
185
- excerpt: boundedText(paragraph.exactText, 240),
186
- index: paragraph.index,
187
- ref: paragraph.ref,
188
- paragraphId: paragraph.paragraphId,
189
- fingerprint: paragraph.fingerprint,
190
- revisionView: 'accepted',
191
- inTable: paragraph.inTable,
192
- humanReference: paragraph.humanReference,
193
- provision: paragraph.provision,
194
- nearestHeading: paragraph.nearestHeading
195
- };
196
- }
197
-
198
- function speculativeError(code, message, details = {}) {
199
- return Object.assign(new Error(message), { code, ...details });
200
- }
201
-
202
- function resolveSpeculativePatchTarget(document, flags) {
203
- if (!document) throw speculativeError('INVALID_OPERATION', 'Speculative localized replacement requires an open document.');
204
- if (flags.contextRange !== undefined && flags.around !== undefined) {
205
- throw invalidFilter('Use --context-range or --around, not both.');
206
- }
207
- const search = flags.search === undefined ? null : String(flags.search).trim();
208
- if (flags.search !== undefined && (!search || flags.search === true)) {
209
- throw invalidFilter('--search must be non-empty text.');
210
- }
211
- if ((flags.contextRange !== undefined || flags.around !== undefined) && !search) {
212
- throw invalidFilter('--context-range and --around require --search.');
213
- }
214
- let range = { start: 0, end: 0, text: '0:0' };
215
- if (flags.contextRange !== undefined) range = parseContextRange(flags.contextRange);
216
- if (flags.around !== undefined) {
217
- const text = String(flags.around).trim();
218
- if (!/^\d+$/.test(text) || Number(text) > 20) {
219
- throw invalidFilter('--around must be an integer from 0 through 20.');
220
- }
221
- const around = Number(text);
222
- range = { start: -around, end: around, text: `${-around}:${around}` };
223
- }
224
-
225
- const inspected = document.inspect({ revisionView: 'accepted' });
226
- const paragraphs = Array.isArray(inspected.paragraphs) ? inspected.paragraphs : [];
227
- let anchors = [];
228
- let eligible = paragraphs;
229
- if (search) {
230
- const folded = search.toLowerCase();
231
- anchors = paragraphs.filter(paragraph => paragraph.exactText.toLowerCase().includes(folded));
232
- if (anchors.length === 0) {
233
- throw speculativeError('TARGET_NOT_FOUND', `No paragraph matched search query: "${search}".`, {
234
- context: { search, range: range.text },
235
- recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
236
- });
237
- }
238
- const eligibleIndexes = new Set();
239
- for (const anchor of anchors) {
240
- const anchorPosition = anchor.index - 1;
241
- for (let offset = range.start; offset <= range.end; offset += 1) {
242
- const position = anchorPosition + offset;
243
- if (position >= 0 && position < paragraphs.length) eligibleIndexes.add(position);
244
- }
245
- }
246
- eligible = [...eligibleIndexes].sort((a, b) => a - b).map(position => paragraphs[position]);
247
- }
248
-
249
- const find = String(flags.find);
250
- const patchCandidates = eligible.map(paragraph => ({
251
- paragraph,
252
- match: localizedReplacementOffsets(paragraph.exactText, find)
253
- })).filter(candidate => candidate.match.offsets.length > 0);
254
- const exactCandidates = patchCandidates.filter(candidate => candidate.match.matchMode === 'exact');
255
- const matches = (exactCandidates.length > 0 ? exactCandidates : patchCandidates)
256
- .map(candidate => candidate.paragraph);
257
- const context = {
258
- ...(search ? { search, range: range.text, anchorMatchCount: anchors.length } : {})
259
- };
260
- if (matches.length === 0) {
261
- throw speculativeError('PATCH_SOURCE_NOT_FOUND', search
262
- ? `No paragraph in the contextual range contains the patch source: "${find}".`
263
- : `No paragraph contains the patch source: "${find}".`, {
264
- context,
265
- ...(anchors.length ? { candidates: anchors.map(speculativeCandidate) } : {}),
266
- recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
267
- });
268
- }
269
- if (matches.length > 1) {
270
- throw speculativeError('AMBIGUOUS_TARGET', `The patch source matched ${matches.length} eligible paragraphs.`, {
271
- context,
272
- candidates: matches.map(speculativeCandidate),
273
- recovery: { action: 'choose_candidate', requiresReinspection: false, sameArgumentsSafe: false }
274
- });
275
- }
276
- const selected = matches[0];
277
- return {
278
- target: {
279
- exactText: selected.exactText,
280
- ...(selected.paragraphId ? { paragraphId: selected.paragraphId } : {}),
281
- fingerprint: selected.fingerprint,
282
- revisionView: 'accepted',
283
- inTable: selected.inTable
284
- },
285
- context: {
286
- ...context,
287
- humanReference: selected.humanReference
288
- }
289
- };
290
- }
291
-
292
- async function readOperations(file, flags = {}, stdin = process.stdin, document = null) {
293
- const inlineRestore = flags?.restore !== undefined;
294
- const hasInlineTarget = flags?.target !== undefined
295
- || flags?.targetId !== undefined
296
- || flags?.targetRef !== undefined;
297
- const hasInlinePatch = flags?.find !== undefined
298
- || flags?.replace !== undefined
299
- || flags?.occurrence !== undefined;
300
- const hasSpeculativeScope = flags?.search !== undefined
301
- || flags?.contextRange !== undefined
302
- || flags?.around !== undefined;
303
- if (file && (hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
304
- throw Object.assign(new Error('Use --operations or inline restore/find/replace options, not both.'), {
305
- code: 'INVALID_OPERATION'
306
- });
307
- }
308
- if (hasInlineTarget && hasSpeculativeScope) {
309
- throw Object.assign(new Error('Use a strong inline target or --search/--context-range, not both.'), {
310
- code: 'INVALID_OPERATION'
311
- });
312
- }
313
- if (!file && (hasInlineTarget || hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
314
- if (inlineRestore && flags.restore !== true) {
315
- throw Object.assign(new Error('--restore is a boolean flag and does not take a value.'), {
316
- code: 'INVALID_OPERATION'
317
- });
318
- }
319
- if (hasSpeculativeScope && !hasInlinePatch) {
320
- throw Object.assign(new Error('--search, --context-range, and --around are only valid with inline --find/--replace.'), {
321
- code: 'INVALID_OPERATION'
322
- });
323
- }
324
- const targetRef = flags.targetRef === undefined ? null : positiveInteger(flags.targetRef);
325
- if (flags.targetRef !== undefined && targetRef == null) {
326
- throw Object.assign(new Error('--target-ref must be a positive 1-based integer.'), {
327
- code: 'INVALID_OPERATION'
328
- });
329
- }
330
- const targetId = flags.targetId === undefined ? null : String(flags.targetId).trim();
331
- if (flags.targetId !== undefined && (!targetId || flags.targetId === true)) {
332
- throw Object.assign(new Error('--target-id must be a non-empty paragraphId.'), {
333
- code: 'INVALID_OPERATION'
334
- });
335
- }
336
- const targetText = flags.target === undefined ? null : String(flags.target);
337
- let target = targetId
338
- ? { ...(targetText == null ? {} : { exactText: targetText }), paragraphId: targetId }
339
- : targetText;
340
- if (inlineRestore) {
341
- if (!targetId) {
342
- throw Object.assign(new Error('Inline --restore requires --target-id from rejected-view extraction.'), {
343
- code: 'INVALID_OPERATION'
344
- });
345
- }
346
- target = { ...target, revisionView: 'rejected' };
347
- }
348
- let speculativeContext = null;
349
- let op;
350
- if (flags.comment) {
351
- if (inlineRestore) {
352
- throw Object.assign(new Error('Use --restore or --comment, not both.'), {
353
- code: 'INVALID_OPERATION'
354
- });
355
- }
356
- if (hasInlinePatch) {
357
- throw Object.assign(new Error('Inline comments cannot be combined with --find/--replace.'), {
358
- code: 'INVALID_OPERATION'
359
- });
360
- }
361
- op = {
362
- type: 'comment',
363
- ...(target == null ? {} : { target }),
364
- commentContent: String(flags.comment),
365
- ...(flags.textToComment ? { textToComment: String(flags.textToComment) } : {}),
366
- ...(targetRef == null ? {} : { targetRef }),
367
- ...(flags.author ? { author: String(flags.author) } : {})
368
- };
369
- } else if (hasInlinePatch) {
370
- if (flags.modified !== undefined) {
371
- throw Object.assign(new Error('Use --modified or --find/--replace, not both.'), {
372
- code: 'INVALID_OPERATION'
373
- });
374
- }
375
- if (
376
- flags.find === undefined
377
- || flags.find === true
378
- || String(flags.find).length === 0
379
- || flags.replace === undefined
380
- || flags.replace === true
381
- ) {
382
- throw Object.assign(new Error('Inline localized replacement requires non-empty --find and string --replace values.'), {
383
- code: 'INVALID_OPERATION'
384
- });
385
- }
386
- const occurrence = flags.occurrence === undefined ? null : positiveInteger(flags.occurrence);
387
- if (flags.occurrence !== undefined && occurrence == null) {
388
- throw Object.assign(new Error('--occurrence must be a positive 1-based integer.'), {
389
- code: 'INVALID_OPERATION'
390
- });
391
- }
392
- if (!hasInlineTarget) {
393
- const resolution = resolveSpeculativePatchTarget(document, flags);
394
- target = resolution.target;
395
- speculativeContext = resolution.context;
396
- }
397
- op = {
398
- type: inlineRestore ? 'restore' : 'redline',
399
- ...(target == null ? {} : { target }),
400
- ...(targetRef == null ? {} : { targetRef }),
401
- replacements: [{
402
- find: String(flags.find),
403
- replace: String(flags.replace),
404
- ...(occurrence == null ? {} : { occurrence })
405
- }],
406
- ...(speculativeContext ? { _speculativeContext: speculativeContext } : {}),
407
- ...(flags.author ? { author: String(flags.author) } : {}),
408
- ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
409
- };
410
- } else if (inlineRestore) {
411
- op = {
412
- type: 'restore',
413
- target,
414
- ...(flags.modified === undefined ? {} : { modified: String(flags.modified) }),
415
- ...(flags.author ? { author: String(flags.author) } : {}),
416
- ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
417
- };
418
- } else {
419
- op = {
420
- type: 'replace',
421
- ...(target == null ? {} : { target }),
422
- modified: flags.modified !== undefined ? String(flags.modified) : '',
423
- ...(targetRef == null ? {} : { targetRef }),
424
- ...(flags.author ? { author: String(flags.author) } : {}),
425
- ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
426
- };
427
- }
428
- return { operations: [op], expectedRevision: null };
429
- }
430
- if (!file) throw Object.assign(new Error('Use --operations <file.json> or an inline operation.'), { code: 'OPERATIONS_REQUIRED' });
431
- let parsed;
432
- try {
433
- const source = file === '-' ? await readUtf8Stream(stdin) : await readFile(file, 'utf8');
434
- parsed = JSON.parse(source);
435
- } catch (error) {
436
- const location = file === '-' ? ' from stdin' : '';
437
- throw Object.assign(new Error(`Could not read operations JSON${location}: ${error.message}`), { code: 'INVALID_OPERATIONS_FILE' });
438
- }
439
- const operations = Array.isArray(parsed) ? parsed : (parsed?.operations || parsed?.changes);
440
- 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' });
441
- return { operations, expectedRevision: parsed?.expectedRevision || null };
442
- }
443
- function outputPath(command, input, flags) {
444
- if (flags.inPlace) return input;
445
- if (flags.output) return path.resolve(String(flags.output));
446
- const parsed = path.parse(input); return path.join(parsed.dir, `${parsed.name}.${suffixes[command]}${parsed.ext || '.docx'}`);
447
- }
448
- async function writeMutation(command, input, flags, result) {
449
- if (!result.written) return { status: result.status || 'ok', ...result, outputPath: null };
450
- const destination = outputPath(command, input, flags);
451
- const protectExisting = Boolean(flags.noOverwrite || flags.noClobber) && !flags.force;
452
- if (!flags.inPlace && protectExisting) {
453
- try {
454
- await access(destination);
455
- throw Object.assign(new Error(`Output already exists: ${destination}`), { code: 'OUTPUT_EXISTS' });
456
- } catch (error) {
457
- if (error.code !== 'ENOENT') throw error;
458
- }
459
- }
460
- await writeFile(destination, result.toBuffer());
461
- return { status: result.status || 'ok', ...result, outputPath: destination };
462
- }
463
- function serializable(value) {
464
- const { buffer: _buffer, toBuffer: _toBuffer, ...rest } = value || {};
465
- return rest;
466
- }
467
-
468
- function compactExtractParagraph(paragraph) {
469
- const {
470
- humanReference, provision, nearestHeading, index, ref, paragraphId,
471
- fingerprint, revisionView, exactText, inTable, list, selectionRole,
472
- contextFor, hasRevisions, revisionAuthors, segments
473
- } = paragraph;
474
- const hiddenRevisionContent = exactText.length === 0 && Array.isArray(segments)
475
- && segments.some(segment => segment.text.length > 0 && (
476
- revisionView === 'rejected'
477
- ? segment.acceptedStart !== null
478
- : segment.rejectedStart !== null
479
- ));
480
- return {
481
- humanReference,
482
- provision,
483
- nearestHeading,
484
- index,
485
- ref,
486
- paragraphId,
487
- fingerprint,
488
- revisionView,
489
- exactText,
490
- inTable,
491
- list,
492
- ...(hasRevisions ? {
493
- hasRevisions: true,
494
- revisionAuthors,
495
- ...(hiddenRevisionContent && revisionView === 'accepted' ? { deleted: true } : {}),
496
- ...(hiddenRevisionContent && revisionView === 'rejected' ? { inserted: true } : {})
497
- } : {}),
498
- ...(selectionRole ? { selectionRole } : {}),
499
- ...(Array.isArray(contextFor) ? { contextFor } : {})
500
- };
501
- }
502
-
503
- function inspectionResponseBytes(value) {
504
- return Buffer.byteLength(JSON.stringify(value, null, 2), 'utf8') + 1;
505
- }
506
-
507
- function boundInspectionResponse(value, { bypass = false, broadDetailed = false } = {}) {
508
- const base = {
509
- ...value,
510
- machineReferencesAreNotUserLocations: true,
511
- ...(broadDetailed ? {
512
- notes: [
513
- 'This detailed inspection was bounded. Prefer extract --search or an explicit --range for ordinary targeting.'
514
- ]
515
- } : {})
516
- };
517
- if (bypass) return base;
518
-
519
- let paragraphs = [...(base.paragraphs || [])];
520
- const sourceSelection = base.selection || {};
521
- const totalMatches = Number.isInteger(sourceSelection.totalMatches)
522
- ? sourceSelection.totalMatches
523
- : paragraphs.filter(item => item.selectionRole !== 'context').length;
524
- const initialReturnedMatches = Number.isInteger(sourceSelection.returnedMatches)
525
- ? sourceSelection.returnedMatches
526
- : paragraphs.filter(item => item.selectionRole !== 'context').length;
527
- let contextTruncated = false;
528
-
529
- const assemble = (oversizeItem = false) => {
530
- const directIndexes = new Set(paragraphs
531
- .filter(item => item.selectionRole !== 'context')
532
- .map(item => item.index));
533
- paragraphs = paragraphs
534
- .map(item => item.selectionRole === 'context'
535
- ? { ...item, contextFor: (item.contextFor || []).filter(index => directIndexes.has(index)) }
536
- : item)
537
- .filter(item => item.selectionRole !== 'context' || item.contextFor.length > 0);
538
- const direct = paragraphs.filter(item => item.selectionRole !== 'context');
539
- const lastDirect = direct[direct.length - 1] || null;
540
- const paragraphIndexes = new Set(paragraphs.map(item => item.index));
541
- const comments = Array.isArray(base.comments)
542
- ? base.comments.filter(comment => paragraphIndexes.has(comment.paragraphIndex))
543
- : base.comments;
544
- const budgetTruncated = direct.length < initialReturnedMatches;
545
- return {
546
- ...base,
547
- paragraphs,
548
- ...(Array.isArray(base.comments) ? { comments } : {}),
549
- selection: {
550
- ...sourceSelection,
551
- totalMatches,
552
- returnedMatches: direct.length,
553
- returnedParagraphs: paragraphs.length,
554
- truncated: sourceSelection.truncated === true || budgetTruncated || contextTruncated,
555
- nextAfter: sourceSelection.truncated === true || budgetTruncated
556
- ? (lastDirect?.index ?? sourceSelection.nextAfter ?? null)
557
- : null,
558
- softByteLimit: INSPECTION_SOFT_BYTE_LIMIT,
559
- oversizeItem,
560
- ...(contextTruncated ? { contextTruncated: true } : {})
561
- }
562
- };
563
- };
564
-
565
- let result = assemble();
566
- while (inspectionResponseBytes(result) > INSPECTION_SOFT_BYTE_LIMIT) {
567
- const directPositions = paragraphs
568
- .map((item, position) => item.selectionRole !== 'context' ? position : -1)
569
- .filter(position => position >= 0);
570
- if (directPositions.length > 1) {
571
- paragraphs.splice(directPositions[directPositions.length - 1], 1);
572
- result = assemble();
573
- continue;
574
- }
575
- const contextPosition = paragraphs.findLastIndex(item => item.selectionRole === 'context');
576
- if (contextPosition >= 0) {
577
- paragraphs.splice(contextPosition, 1);
578
- contextTruncated = true;
579
- result = assemble();
580
- continue;
581
- }
582
- result = assemble(true);
583
- break;
584
- }
585
- return result;
586
- }
587
-
588
- function boundedText(value, limit = 512) {
589
- const text = String(value ?? '');
590
- return text.length > limit ? `${text.slice(0, limit)}…` : text;
591
- }
592
-
593
- function compactError(error) {
594
- if (!error || typeof error !== 'object') return error;
595
- const fields = [
596
- 'code', 'stage', 'mismatchOffset', 'expectedExcerpt', 'actualExcerpt',
597
- 'expectedCodePoint', 'actualCodePoint', 'ownerAuthor', 'commentIds',
598
- 'recoveryVersion', 'category', 'field', 'captureRef', 'operationIndexes',
599
- 'consumedByOperation', 'expectedScope', 'actualScope'
600
- ];
601
- const compact = {};
602
- for (const field of fields) {
603
- if (error[field] !== undefined) compact[field] = error[field];
604
- }
605
- if (Array.isArray(error.comments)) {
606
- compact.comments = error.comments.map(comment => ({
607
- ...(comment?.id !== undefined ? { id: comment.id } : {}),
608
- ...(comment?.author !== undefined ? { author: boundedText(comment.author, 160) } : {}),
609
- ...(comment?.text !== undefined ? { text: boundedText(comment.text, 512) } : {})
610
- }));
611
- }
612
- if (Array.isArray(error.candidates)) {
613
- compact.candidates = error.candidates.map(candidate => {
614
- if (!candidate || typeof candidate !== 'object') return candidate;
615
- const excerpt = boundedText(candidate.excerpt ?? candidate.exactText ?? candidate.text ?? '', 240);
616
- return { ...compactResolvedTarget(candidate, { preserveMatchDetails: true }), ...(excerpt ? { excerpt } : {}) };
617
- });
618
- }
619
- for (const field of ['recovery', 'issueSummary', 'expectedRevision', 'currentRevision']) {
620
- if (error[field] !== undefined) compact[field] = error[field];
621
- }
622
- if (error.context && typeof error.context === 'object') {
623
- compact.context = {
624
- ...error.context,
625
- ...(error.context.currentTarget ? {
626
- currentTarget: {
627
- ...compactResolvedTarget(error.context.currentTarget, { preserveMatchDetails: true }),
628
- excerpt: boundedText(
629
- error.context.currentTarget.excerpt
630
- ?? error.context.currentTarget.exactText
631
- ?? error.context.currentTarget.text
632
- ?? '',
633
- 240
634
- )
635
- }
636
- } : {})
637
- };
638
- }
639
- if (error.sourceTarget && typeof error.sourceTarget === 'object') {
640
- compact.sourceTarget = {
641
- ...compactResolvedTarget(error.sourceTarget, { preserveMatchDetails: true }),
642
- excerpt: boundedText(error.sourceTarget.text ?? error.sourceTarget.exactText ?? '', 240)
643
- };
644
- }
645
- compact.message = boundedText(error.message || String(error));
646
- return compact;
647
- }
648
-
649
- function compactTargetTextMatch(match, preserveDetails = false) {
650
- if (!match || typeof match !== 'object') return match;
651
- if (preserveDetails) return match;
652
- if (match.mode === 'exact') return undefined;
653
- const differenceCount = Number.isInteger(match.differenceCount)
654
- ? match.differenceCount
655
- : (Array.isArray(match.differences) ? match.differences.length : 0);
656
- return {
657
- ...(match.mode ? { mode: match.mode } : {}),
658
- differenceCount
659
- };
660
- }
661
-
662
- function compactResolvedTarget(target, { preserveMatchDetails = false } = {}) {
663
- if (!target || typeof target !== 'object') return target;
664
- const {
665
- text: _text,
666
- exactText: _exactText,
667
- targetTextMatch,
668
- ...compact
669
- } = target;
670
- const compactMatch = compactTargetTextMatch(targetTextMatch, preserveMatchDetails);
671
- return {
672
- ...compact,
673
- ...(compactMatch ? { targetTextMatch: compactMatch } : {})
674
- };
675
- }
676
-
677
- function compactReceipt(receipt) {
678
- if (!receipt || typeof receipt !== 'object') return receipt;
679
- return {
680
- ...receipt,
681
- affectedTargets: Array.isArray(receipt.affectedTargets)
682
- ? receipt.affectedTargets.map(compactResolvedTarget)
683
- : [],
684
- warnings: Array.isArray(receipt.warnings) ? receipt.warnings.map(warning => boundedText(warning)) : []
685
- };
686
- }
687
-
688
- function compactOperationResult(result) {
689
- if (!result || typeof result !== 'object') return result;
690
- const { receipt: _receipt, ...withoutReceipt } = result;
691
- const preserveMatchDetails = result.status === 'error' || !!result.error;
692
- return {
693
- ...withoutReceipt,
694
- ...(result.resolvedTarget ? { resolvedTarget: compactResolvedTarget(result.resolvedTarget, { preserveMatchDetails }) } : {}),
695
- ...(result.resolvedAnchor ? { resolvedAnchor: compactResolvedTarget(result.resolvedAnchor, { preserveMatchDetails }) } : {}),
696
- ...(result.error ? { error: compactError(result.error) } : {}),
697
- ...(Array.isArray(result.warnings) ? { warnings: result.warnings.map(warning => boundedText(warning)) } : {})
698
- };
699
- }
700
-
701
- function summarizeIssues(issues) {
702
- const list = Array.isArray(issues) ? issues : [];
703
- const grouped = new Map();
704
- for (const issue of list) {
705
- const code = issue?.code || 'UNKNOWN';
706
- const source = issue?.source || 'unknown';
707
- const severity = issue?.severity || 'error';
708
- const key = `${source}:${severity}:${code}`;
709
- const current = grouped.get(key) || { source, severity, code, count: 0 };
710
- current.count++;
711
- grouped.set(key, current);
712
- }
713
- return {
714
- total: list.length,
715
- errors: list.filter(issue => issue?.severity === 'error').length,
716
- warnings: list.filter(issue => issue?.severity === 'warning').length,
717
- byCode: Array.from(grouped.values()).sort((a, b) => (
718
- a.source.localeCompare(b.source) || a.code.localeCompare(b.code) || a.severity.localeCompare(b.severity)
719
- ))
720
- };
721
- }
722
-
723
- function compactMutationResult(value) {
724
- const serialized = serializable(value);
725
- const {
726
- documentXml: _documentXml,
727
- oxml: _oxml,
728
- commentsXml: _commentsXml,
729
- commentsExtendedXml: _commentsExtendedXml,
730
- numberingXml: _numberingXml,
731
- numberingXmlParts: _numberingXmlParts,
732
- inspection: _inspection,
733
- issues: _issues,
734
- ...compact
735
- } = serialized;
736
- const results = Array.isArray(compact.results) ? compact.results.map(compactOperationResult) : [];
737
- const status = compact.status || 'ok';
738
- const localizedChangesVerified = results.every(result => !result?.change || (
739
- result.change.committed === true
740
- && result.change.finalDisposition === 'applied'
741
- && result.change.verification?.acceptedViewMatchesCompiledText === true
742
- ));
743
- return {
744
- ...compact,
745
- ...(Array.isArray(compact.results) ? { results } : {}),
746
- ...(Array.isArray(compact.receipts) ? { receipts: compact.receipts.map(compactReceipt) } : {}),
747
- ...(compact.error ? { error: compactError(compact.error) } : {}),
748
- ...(Array.isArray(compact.warnings) ? { warnings: compact.warnings.map(warning => boundedText(warning)) } : {}),
749
- ...(compact.validation ? {
750
- validation: {
751
- originalIssues: summarizeIssues(compact.validation.originalIssues),
752
- generatedIssues: summarizeIssues(compact.validation.generatedIssues)
753
- }
754
- } : {}),
755
- completion: compact.written === true
756
- && status !== 'error'
757
- && status !== 'partial'
758
- && results.every(result => result?.status !== 'error')
759
- && localizedChangesVerified
760
- };
761
- }
762
-
763
- async function collectValidationIssues(buffer) {
764
- const entries = unzipDocx(buffer);
765
- const documentXml = entries.get('word/document.xml')?.toString('utf8') || '';
766
- const revision = validateRedlineOoxml(documentXml);
767
- const issues = revision.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
768
- try {
769
- await validateDocxPackage(new MemoryZip(entries));
770
- } catch (error) {
771
- issues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
772
- }
773
- return issues;
774
- }
775
-
776
- function validationIssueKey(issue) {
777
- return `${issue.source || ''}:${issue.code}:${issue.message}`;
778
- }
779
-
780
- function subtractValidationIssues(issues, baselineIssues) {
781
- const remainingBaseline = new Map();
782
- for (const issue of baselineIssues) {
783
- const key = validationIssueKey(issue);
784
- remainingBaseline.set(key, (remainingBaseline.get(key) || 0) + 1);
785
- }
786
- return issues.filter(issue => {
787
- const key = validationIssueKey(issue);
788
- const remaining = remainingBaseline.get(key) || 0;
789
- if (remaining === 0) return true;
790
- remainingBaseline.set(key, remaining - 1);
791
- return false;
792
- });
793
- }
794
-
795
- export async function executeCli(argv, io = process) {
796
- const { command, input: rawInput, extraPositionals, flags } = parseArgs(argv);
797
- if (command === 'help' || flags.help) {
798
- const requestedCommand = command === 'help' ? rawInput : command;
799
- const help = buildCliHelp(requestedCommand || null);
800
- return help || cliError('UNKNOWN_COMMAND', `Unknown command: ${requestedCommand}`);
801
- }
802
- if (!command) return cliError('COMMAND_REQUIRED', 'A command is required.');
803
- if (!CLI_COMMANDS.includes(command)) return cliError('UNKNOWN_COMMAND', `Unknown command: ${command}`);
804
- if (command === 'version') {
805
- if (rawInput || extraPositionals.length > 0) return cliError('UNEXPECTED_ARGUMENT', `Unexpected argument: ${rawInput || extraPositionals[0]}`);
806
- const optionError = validateCommandOptions(command, flags, []);
807
- if (optionError) return optionError;
808
- return {
809
- status: 'ok',
810
- command,
811
- contractVersion: CLI_CONTRACT_VERSION,
812
- capabilities: CLI_CAPABILITIES
813
- };
814
- }
815
- if (!rawInput) return cliError('INPUT_REQUIRED', 'An input .docx path is required.');
816
- const optionError = validateCommandOptions(command, flags, extraPositionals);
817
- if (optionError) return optionError;
818
- if (flags.existingRevisions != null && !isExistingRevisionsPolicy(flags.existingRevisions)) {
819
- return cliError('INVALID_OPERATION', `Unsupported existing-revisions policy: "${String(flags.existingRevisions)}".`);
820
- }
821
- const profile = flags.profile == null ? null : String(flags.profile);
822
- if (profile && profile !== 'agent') {
823
- return cliError('INVALID_PROFILE', `Unknown execution profile: "${profile}". Supported profiles: agent.`);
824
- }
825
- let inspectOptions = null;
826
- if (command === 'inspect' || command === 'extract') {
827
- try { inspectOptions = inspectionOptions(flags); }
828
- catch (error) { return cliError(error.code || 'INVALID_FILTER', error.message); }
829
- }
830
- const input = path.resolve(rawInput);
831
- let buffer; try { buffer = await readFile(input); } catch (error) { return cliError('INPUT_READ_FAILED', error.message); }
832
- try {
833
- const document = openDocx(buffer);
834
- if (command === 'inspect') {
835
- const inspected = document.inspect(inspectOptions);
836
- const broadDetailed = !flags.search && flags.index === undefined
837
- && flags.indexes === undefined && flags.range === undefined;
838
- return boundInspectionResponse(
839
- { ...inspected, command, input, indexBase: 1 },
840
- { bypass: !!flags.all, broadDetailed }
841
- );
842
- }
843
- if (command === 'extract') {
844
- const inspected = document.inspect(inspectOptions);
845
- return boundInspectionResponse({
846
- status: inspected.status,
847
- command,
848
- input,
849
- indexBase: 1,
850
- paragraphs: inspected.paragraphs.map(compactExtractParagraph),
851
- ...(inspected.selection ? { selection: inspected.selection } : {}),
852
- warnings: inspected.warnings
853
- }, { bypass: !!flags.all });
854
- }
855
- if (command === 'validate') {
856
- const issues = await collectValidationIssues(buffer);
857
- if (flags.baseline) {
858
- const baseline = path.resolve(String(flags.baseline));
859
- let baselineBuffer;
860
- try { baselineBuffer = await readFile(baseline); }
861
- catch (error) { return cliError('BASELINE_READ_FAILED', error.message); }
862
- const baselineIssues = await collectValidationIssues(baselineBuffer);
863
- const introducedIssues = subtractValidationIssues(issues, baselineIssues);
864
- const hasIntroducedErrors = introducedIssues.some(issue => issue.severity === 'error');
865
- return {
866
- status: hasIntroducedErrors ? 'error' : 'ok',
867
- command,
868
- input,
869
- baseline,
870
- valid: !hasIntroducedErrors,
871
- issues,
872
- baselineIssues,
873
- introducedIssues
874
- };
875
- }
876
- const hasErrors = issues.some(issue => issue.severity === 'error');
877
- return { status: hasErrors ? 'error' : 'ok', command, input, valid: !hasErrors, issues };
878
- }
879
- const opsData = command === 'preflight' || command === 'apply'
880
- ? await readOperations(flags.operations, flags, io.stdin || process.stdin, document)
881
- : null;
882
- const operations = opsData?.operations || null;
883
- let expectedRevision = opsData?.expectedRevision || null;
884
- if (flags.expectedRevision) {
885
- if (typeof flags.expectedRevision === 'string') {
886
- try {
887
- expectedRevision = JSON.parse(flags.expectedRevision);
888
- } catch {
889
- expectedRevision = {
890
- algorithm: 'sha256',
891
- version: 1,
892
- scope: 'package',
893
- value: flags.expectedRevision.trim()
894
- };
895
- }
896
- } else if (typeof flags.expectedRevision === 'object') {
897
- expectedRevision = flags.expectedRevision;
898
- }
899
- }
900
- if (command === 'preflight') return {
901
- ...document.preflight(operations, flags.author, {
902
- strictTargets: flags.strictTargets !== 'false',
903
- ...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {})
904
- }),
905
- command,
906
- input
907
- };
908
- if (command === 'apply') {
909
- const agentProfile = profile === 'agent';
910
- const author = flags.author || process.env.DOCX_REDLINE_AUTHOR || 'AI Redliner';
911
- const generateRedlines = flags.generateRedlines !== undefined
912
- ? (flags.generateRedlines !== 'false' && flags.generateRedlines !== false)
913
- : (!flags.noRedlines);
914
- const atomic = flags.atomic !== undefined
915
- ? (flags.atomic === true || flags.atomic === 'true')
916
- : false;
917
- const requireComplete = flags.requireComplete !== undefined
918
- ? (flags.requireComplete === true || flags.requireComplete === 'true')
919
- : agentProfile;
920
- const effectiveOptions = {
921
- author,
922
- atomic,
923
- strictTargets: true,
924
- validate: true,
925
- generateRedlines,
926
- existingRevisions: flags.existingRevisions || 'merge-same-author',
927
- requireComplete
928
- };
929
- const result = await document.applyOperations(operations, {
930
- author,
931
- atomic,
932
- validate: true,
933
- strictTargets: true,
934
- generateRedlines,
935
- ...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {}),
936
- ...(expectedRevision ? { expectedRevision } : {})
937
- });
938
- const localizedVerificationFailed = (result.results || []).some(item => item?.change && (
939
- item.change.committed !== true
940
- || item.change.finalDisposition !== 'applied'
941
- || item.change.verification?.acceptedViewMatchesCompiledText !== true
942
- ));
943
- const mutationResult = await writeMutation(command, input, flags, result);
944
- return compactMutationResult({
945
- command,
946
- input,
947
- ...serializable(mutationResult),
948
- ...(profile ? { executionProfile: profile, effectiveOptions } : {}),
949
- ...(result.status === 'error'
950
- ? { exitCode: 2 }
951
- : ((result.status === 'partial' || localizedVerificationFailed) && requireComplete
952
- ? { exitCode: 3 }
953
- : {}))
954
- });
955
- }
956
- const commentIds = command === 'delete-comments' && flags.commentId != null && flags.commentId !== true
957
- ? String(flags.commentId).split(',').map(id => id.trim()).filter(Boolean)
958
- : null;
959
- const filter = commentIds?.length ? { ids: commentIds } : flags.allAuthors ? { allAuthors: true } : flags.author ? { author: String(flags.author) } : null;
960
- if (!filter) return cliError('AUTHOR_REQUIRED', command === 'delete-comments' ? 'Use --author <name>, --all-authors, or --comment-id <id[,id]>.' : 'Use --author <name> or --all-authors.');
961
- const result = command === 'delete-comments' ? await document.deleteComments(filter) : await document.resolveRevisions(command, filter);
962
- return compactMutationResult({ command, input, ...serializable(await writeMutation(command, input, flags, result)) });
963
- } catch (error) { return cliError(error.code || 'CLI_FAILED', error.message, 2, error); }
964
- }
965
-
966
- export async function runCli(argv = process.argv.slice(2), io = process) {
967
- configureLogger({}, { level: 'silent' });
968
- const result = await executeCli(argv, io);
969
- const compactJson = parseArgs(argv).flags.compact === true;
970
- io.stdout.write(`${JSON.stringify(serializable(result), null, compactJson ? 0 : 2)}\n`);
971
- return Number.isInteger(result.exitCode) && result.exitCode !== 0
972
- ? result.exitCode
973
- : (result.status === 'error' ? 1 : 0);
974
- }
1
+ import { access, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { openDocx } from './docx-document.js';
4
+ import { MemoryZip, unzipDocx } from './zip-archive.js';
5
+ import { validateDocxPackage } from '../services/standalone-docx-plumbing.js';
6
+ import { validateRedlineOoxml } from '../core/redline-validation.js';
7
+ import { configureLogger } from '../adapters/logger.js';
8
+ import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
9
+ import { normalizeErrorWithRecovery } from '../services/error-recovery.js';
10
+ import { REVISION_FIDELITY_MODES, resolveRevisionFidelityMode } from '../services/revision-fidelity-verifier.js';
11
+ import { localizedReplacementOffsets } from '../services/localized-replacement-compiler.js';
12
+ import { buildCliHelp, CLI_COMMANDS, commandOptionKeys } from './cli-help.js';
13
+
14
+ const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
15
+ const CLI_CONTRACT_VERSION = 9;
16
+ const DEFAULT_INSPECTION_LIMIT = 20;
17
+ const INSPECTION_SOFT_BYTE_LIMIT = 48 * 1024;
18
+ const CLI_CAPABILITIES = [
19
+ 'atomic-batch-results-on-package-failure',
20
+ 'baseline-aware-validation',
21
+ 'compact-mutation-results',
22
+ 'cross-author-revision-slicing',
23
+ 'document-scoped-list-revision-ids',
24
+ 'batch-start-source-binding',
25
+ 'recovery-envelope-v1',
26
+ 'require-complete-exit',
27
+ 'operations-stdin',
28
+ 'agent-safety-profile-v2',
29
+ 'command-help-v1',
30
+ 'inspection-context-v1',
31
+ 'bounded-inspection-v1',
32
+ 'human-document-references-v1',
33
+ 'localized-replacements-v1',
34
+ 'speculative-search-apply-v1',
35
+ 'localized-change-summary-v1',
36
+ 'localized-space-equivalence-v1',
37
+ 'restore-shortcuts-v1',
38
+ 'deduplicated-cli-receipts',
39
+ 'compact-cli-json-v1',
40
+ 'comment-resolve-operation-v1',
41
+ 'delete-comments-by-id-v1',
42
+ 'header-footer-parts-v1',
43
+ 'format-text-occurrence-v1',
44
+ 'revision-fidelity-v1',
45
+ 'list-conversion-diagnostics-v1',
46
+ 'paragraph-numbering-binding-v1',
47
+ 'list-format-v1',
48
+ 'list-format-change-v1'
49
+ ];
50
+ const commandOptions = Object.fromEntries(CLI_COMMANDS.map(command => [command, new Set(commandOptionKeys(command))]));
51
+
52
+ function cliError(code, message, exitCode = 2, details) {
53
+ return {
54
+ status: 'error',
55
+ error: normalizeErrorWithRecovery({
56
+ ...(details && typeof details === 'object' ? details : {}),
57
+ code,
58
+ message
59
+ }),
60
+ exitCode
61
+ };
62
+ }
63
+ const optionAliases = new Map([
64
+ ['operationsFile', 'operations'],
65
+ ['o', 'output'],
66
+ ['a', 'author'],
67
+ ['i', 'inPlace'],
68
+ ['f', 'force'],
69
+ ['h', 'help'],
70
+ ['no-overwrite', 'noOverwrite'],
71
+ ['no-clobber', 'noClobber'],
72
+ ['no-redlines', 'noRedlines'],
73
+ ['generate-redlines', 'generateRedlines'],
74
+ ['require-complete', 'requireComplete'],
75
+ ['context', 'around'],
76
+ ['C', 'around']
77
+ ]);
78
+ function parseArgs(argv) {
79
+ const positionals = []; const flags = {};
80
+ for (let index = 0; index < argv.length; index++) {
81
+ const token = argv[index];
82
+ if (!token.startsWith('-') || token === '-') { positionals.push(token); continue; }
83
+ const prefixLength = token.startsWith('--') ? 2 : 1;
84
+ const [rawKey, inline] = token.slice(prefixLength).split(/=(.*)/s);
85
+ const normalizedKey = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
86
+ const key = optionAliases.get(normalizedKey) || normalizedKey;
87
+ if (inline !== undefined) flags[key] = inline;
88
+ else if (argv[index + 1] !== undefined && (argv[index + 1] === '-' || !argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
89
+ else flags[key] = true;
90
+ }
91
+ return { command: positionals[0], input: positionals[1], extraPositionals: positionals.slice(2), flags };
92
+ }
93
+ function positiveInteger(value) {
94
+ const text = String(value).trim();
95
+ const parsed = /^\d+$/.test(text) ? Number(text) : null;
96
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
97
+ }
98
+ function invalidFilter(message) {
99
+ const error = new Error(message);
100
+ error.code = 'INVALID_FILTER';
101
+ return error;
102
+ }
103
+ function boundedPositiveInteger(value, optionName, maximum = Number.MAX_SAFE_INTEGER) {
104
+ const parsed = positiveInteger(value);
105
+ if (parsed == null || parsed > maximum) {
106
+ const upperBound = maximum < Number.MAX_SAFE_INTEGER ? ` no greater than ${maximum}` : '';
107
+ throw invalidFilter(`${optionName} must be a positive integer${upperBound}.`);
108
+ }
109
+ return parsed;
110
+ }
111
+ function parseIndexes(value) {
112
+ const tokens = String(value).split(',');
113
+ if (!tokens.length || tokens.some(token => positiveInteger(token) == null)) {
114
+ throw invalidFilter('--indexes must be a comma-separated list of positive 1-based integers.');
115
+ }
116
+ return tokens.map(positiveInteger);
117
+ }
118
+ function parseRange(value) {
119
+ const match = String(value).match(/^\s*(\d+)\s*([:,\-])\s*(\d+)\s*$/);
120
+ if (!match) throw invalidFilter('--range must use START:END with positive 1-based integers.');
121
+ const start = positiveInteger(match[1]);
122
+ const end = positiveInteger(match[3]);
123
+ if (start == null || end == null || end < start) {
124
+ throw invalidFilter('--range must have positive 1-based endpoints with END greater than or equal to START.');
125
+ }
126
+ return { start, end };
127
+ }
128
+ function validateCommandOptions(command, flags, extraPositionals) {
129
+ if (extraPositionals.length > 0) return cliError('UNEXPECTED_ARGUMENT', `Unexpected argument: ${extraPositionals[0]}`);
130
+ const allowed = commandOptions[command];
131
+ const unknown = Object.keys(flags).find(option => !allowed.has(option));
132
+ return unknown ? cliError('UNKNOWN_OPTION', `Unknown option for ${command}: --${unknown.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}`) : null;
133
+ }
134
+ function inspectionOptions(flags) {
135
+ const options = {};
136
+ if (flags.search) options.search = flags.search;
137
+ if (flags.revised) options.revisedOnly = true;
138
+ if (flags.table) options.inTable = true;
139
+ if (flags.body) options.inTable = false;
140
+ if (flags.nonEmpty) options.skipEmpty = true;
141
+ const selectors = ['index', 'indexes', 'range'].filter(name => flags[name] !== undefined);
142
+ if (selectors.length > 1) throw invalidFilter('Use only one of --index, --indexes, or --range.');
143
+ if (flags.index !== undefined) {
144
+ const index = positiveInteger(flags.index);
145
+ if (index == null) throw invalidFilter('--index must be a positive 1-based integer.');
146
+ options.indexes = [index];
147
+ }
148
+ if (flags.indexes !== undefined) options.indexes = parseIndexes(flags.indexes);
149
+ if (flags.range !== undefined) options.range = parseRange(flags.range);
150
+ if (flags.around !== undefined) {
151
+ const text = String(flags.around).trim();
152
+ if (!/^\d+$/.test(text) || Number(text) > 20) {
153
+ throw invalidFilter('--around must be an integer from 0 through 20.');
154
+ }
155
+ options.around = Number(text);
156
+ if (!flags.search) throw invalidFilter('--around requires --search.');
157
+ }
158
+ if (flags.limit !== undefined) options.limit = boundedPositiveInteger(flags.limit, '--limit', 200);
159
+ if (flags.after !== undefined) options.after = boundedPositiveInteger(flags.after, '--after');
160
+ if (flags.all && flags.limit !== undefined) throw invalidFilter('Use --all or --limit, not both.');
161
+ if (!flags.all && flags.limit === undefined && selectors.length === 0) options.limit = DEFAULT_INSPECTION_LIMIT;
162
+ if (flags.view) {
163
+ if (!['accepted', 'rejected', 'current'].includes(String(flags.view))) {
164
+ throw invalidFilter('--view must be accepted, rejected, or current.');
165
+ }
166
+ options.revisionView = flags.view;
167
+ }
168
+ return options;
169
+ }
170
+ async function readUtf8Stream(stream) {
171
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
172
+ throw new Error('No readable stdin stream was provided.');
173
+ }
174
+ const chunks = [];
175
+ for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
176
+ return Buffer.concat(chunks).toString('utf8');
177
+ }
178
+
179
+ function parseContextRange(value) {
180
+ const match = String(value).match(/^\s*(-?\d+)\s*:\s*(-?\d+)\s*$/);
181
+ if (!match) throw invalidFilter('--context-range must use START:END with signed integer offsets from -20 through 20.');
182
+ const start = Number(match[1]);
183
+ const end = Number(match[2]);
184
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < -20 || end > 20 || start > end) {
185
+ throw invalidFilter('--context-range requires START <= END and both offsets from -20 through 20.');
186
+ }
187
+ return { start, end, text: `${start}:${end}` };
188
+ }
189
+
190
+ function speculativeCandidate(paragraph) {
191
+ return {
192
+ excerpt: boundedText(paragraph.exactText, 240),
193
+ index: paragraph.index,
194
+ ref: paragraph.ref,
195
+ paragraphId: paragraph.paragraphId,
196
+ fingerprint: paragraph.fingerprint,
197
+ revisionView: 'accepted',
198
+ inTable: paragraph.inTable,
199
+ humanReference: paragraph.humanReference,
200
+ provision: paragraph.provision,
201
+ nearestHeading: paragraph.nearestHeading
202
+ };
203
+ }
204
+
205
+ function speculativeError(code, message, details = {}) {
206
+ return Object.assign(new Error(message), { code, ...details });
207
+ }
208
+
209
+ function resolveSpeculativePatchTarget(document, flags) {
210
+ if (!document) throw speculativeError('INVALID_OPERATION', 'Speculative localized replacement requires an open document.');
211
+ if (flags.contextRange !== undefined && flags.around !== undefined) {
212
+ throw invalidFilter('Use --context-range or --around, not both.');
213
+ }
214
+ const search = flags.search === undefined ? null : String(flags.search).trim();
215
+ if (flags.search !== undefined && (!search || flags.search === true)) {
216
+ throw invalidFilter('--search must be non-empty text.');
217
+ }
218
+ if ((flags.contextRange !== undefined || flags.around !== undefined) && !search) {
219
+ throw invalidFilter('--context-range and --around require --search.');
220
+ }
221
+ let range = { start: 0, end: 0, text: '0:0' };
222
+ if (flags.contextRange !== undefined) range = parseContextRange(flags.contextRange);
223
+ if (flags.around !== undefined) {
224
+ const text = String(flags.around).trim();
225
+ if (!/^\d+$/.test(text) || Number(text) > 20) {
226
+ throw invalidFilter('--around must be an integer from 0 through 20.');
227
+ }
228
+ const around = Number(text);
229
+ range = { start: -around, end: around, text: `${-around}:${around}` };
230
+ }
231
+
232
+ const inspected = document.inspect({ revisionView: 'accepted' });
233
+ const paragraphs = Array.isArray(inspected.paragraphs) ? inspected.paragraphs : [];
234
+ let anchors = [];
235
+ let eligible = paragraphs;
236
+ if (search) {
237
+ const folded = search.toLowerCase();
238
+ anchors = paragraphs.filter(paragraph => paragraph.exactText.toLowerCase().includes(folded));
239
+ if (anchors.length === 0) {
240
+ throw speculativeError('TARGET_NOT_FOUND', `No paragraph matched search query: "${search}".`, {
241
+ context: { search, range: range.text },
242
+ recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
243
+ });
244
+ }
245
+ const eligibleIndexes = new Set();
246
+ for (const anchor of anchors) {
247
+ const anchorPosition = anchor.index - 1;
248
+ for (let offset = range.start; offset <= range.end; offset += 1) {
249
+ const position = anchorPosition + offset;
250
+ if (position >= 0 && position < paragraphs.length) eligibleIndexes.add(position);
251
+ }
252
+ }
253
+ eligible = [...eligibleIndexes].sort((a, b) => a - b).map(position => paragraphs[position]);
254
+ }
255
+
256
+ const find = String(flags.find);
257
+ const patchCandidates = eligible.map(paragraph => ({
258
+ paragraph,
259
+ match: localizedReplacementOffsets(paragraph.exactText, find)
260
+ })).filter(candidate => candidate.match.offsets.length > 0);
261
+ const exactCandidates = patchCandidates.filter(candidate => candidate.match.matchMode === 'exact');
262
+ const matches = (exactCandidates.length > 0 ? exactCandidates : patchCandidates)
263
+ .map(candidate => candidate.paragraph);
264
+ const context = {
265
+ ...(search ? { search, range: range.text, anchorMatchCount: anchors.length } : {})
266
+ };
267
+ if (matches.length === 0) {
268
+ throw speculativeError('PATCH_SOURCE_NOT_FOUND', search
269
+ ? `No paragraph in the contextual range contains the patch source: "${find}".`
270
+ : `No paragraph contains the patch source: "${find}".`, {
271
+ context,
272
+ ...(anchors.length ? { candidates: anchors.map(speculativeCandidate) } : {}),
273
+ recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
274
+ });
275
+ }
276
+ if (matches.length > 1) {
277
+ throw speculativeError('AMBIGUOUS_TARGET', `The patch source matched ${matches.length} eligible paragraphs.`, {
278
+ context,
279
+ candidates: matches.map(speculativeCandidate),
280
+ recovery: { action: 'choose_candidate', requiresReinspection: false, sameArgumentsSafe: false }
281
+ });
282
+ }
283
+ const selected = matches[0];
284
+ return {
285
+ target: {
286
+ exactText: selected.exactText,
287
+ ...(selected.paragraphId ? { paragraphId: selected.paragraphId } : {}),
288
+ fingerprint: selected.fingerprint,
289
+ revisionView: 'accepted',
290
+ inTable: selected.inTable
291
+ },
292
+ context: {
293
+ ...context,
294
+ humanReference: selected.humanReference
295
+ }
296
+ };
297
+ }
298
+
299
+ async function readOperations(file, flags = {}, stdin = process.stdin, document = null) {
300
+ const inlineRestore = flags?.restore !== undefined;
301
+ const hasInlineTarget = flags?.target !== undefined
302
+ || flags?.targetId !== undefined
303
+ || flags?.targetRef !== undefined;
304
+ const hasInlinePatch = flags?.find !== undefined
305
+ || flags?.replace !== undefined
306
+ || flags?.occurrence !== undefined;
307
+ const hasSpeculativeScope = flags?.search !== undefined
308
+ || flags?.contextRange !== undefined
309
+ || flags?.around !== undefined;
310
+ if (file && (hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
311
+ throw Object.assign(new Error('Use --operations or inline restore/find/replace options, not both.'), {
312
+ code: 'INVALID_OPERATION'
313
+ });
314
+ }
315
+ if (hasInlineTarget && hasSpeculativeScope) {
316
+ throw Object.assign(new Error('Use a strong inline target or --search/--context-range, not both.'), {
317
+ code: 'INVALID_OPERATION'
318
+ });
319
+ }
320
+ if (!file && (hasInlineTarget || hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
321
+ if (inlineRestore && flags.restore !== true) {
322
+ throw Object.assign(new Error('--restore is a boolean flag and does not take a value.'), {
323
+ code: 'INVALID_OPERATION'
324
+ });
325
+ }
326
+ if (hasSpeculativeScope && !hasInlinePatch) {
327
+ throw Object.assign(new Error('--search, --context-range, and --around are only valid with inline --find/--replace.'), {
328
+ code: 'INVALID_OPERATION'
329
+ });
330
+ }
331
+ const targetRef = flags.targetRef === undefined ? null : positiveInteger(flags.targetRef);
332
+ if (flags.targetRef !== undefined && targetRef == null) {
333
+ throw Object.assign(new Error('--target-ref must be a positive 1-based integer.'), {
334
+ code: 'INVALID_OPERATION'
335
+ });
336
+ }
337
+ const targetId = flags.targetId === undefined ? null : String(flags.targetId).trim();
338
+ if (flags.targetId !== undefined && (!targetId || flags.targetId === true)) {
339
+ throw Object.assign(new Error('--target-id must be a non-empty paragraphId.'), {
340
+ code: 'INVALID_OPERATION'
341
+ });
342
+ }
343
+ const targetText = flags.target === undefined ? null : String(flags.target);
344
+ let target = targetId
345
+ ? { ...(targetText == null ? {} : { exactText: targetText }), paragraphId: targetId }
346
+ : targetText;
347
+ if (inlineRestore) {
348
+ if (!targetId) {
349
+ throw Object.assign(new Error('Inline --restore requires --target-id from rejected-view extraction.'), {
350
+ code: 'INVALID_OPERATION'
351
+ });
352
+ }
353
+ target = { ...target, revisionView: 'rejected' };
354
+ }
355
+ let speculativeContext = null;
356
+ let op;
357
+ if (flags.comment) {
358
+ if (inlineRestore) {
359
+ throw Object.assign(new Error('Use --restore or --comment, not both.'), {
360
+ code: 'INVALID_OPERATION'
361
+ });
362
+ }
363
+ if (hasInlinePatch) {
364
+ throw Object.assign(new Error('Inline comments cannot be combined with --find/--replace.'), {
365
+ code: 'INVALID_OPERATION'
366
+ });
367
+ }
368
+ op = {
369
+ type: 'comment',
370
+ ...(target == null ? {} : { target }),
371
+ commentContent: String(flags.comment),
372
+ ...(flags.textToComment ? { textToComment: String(flags.textToComment) } : {}),
373
+ ...(targetRef == null ? {} : { targetRef }),
374
+ ...(flags.author ? { author: String(flags.author) } : {})
375
+ };
376
+ } else if (hasInlinePatch) {
377
+ if (flags.modified !== undefined) {
378
+ throw Object.assign(new Error('Use --modified or --find/--replace, not both.'), {
379
+ code: 'INVALID_OPERATION'
380
+ });
381
+ }
382
+ if (
383
+ flags.find === undefined
384
+ || flags.find === true
385
+ || String(flags.find).length === 0
386
+ || flags.replace === undefined
387
+ || flags.replace === true
388
+ ) {
389
+ throw Object.assign(new Error('Inline localized replacement requires non-empty --find and string --replace values.'), {
390
+ code: 'INVALID_OPERATION'
391
+ });
392
+ }
393
+ const occurrence = flags.occurrence === undefined ? null : positiveInteger(flags.occurrence);
394
+ if (flags.occurrence !== undefined && occurrence == null) {
395
+ throw Object.assign(new Error('--occurrence must be a positive 1-based integer.'), {
396
+ code: 'INVALID_OPERATION'
397
+ });
398
+ }
399
+ if (!hasInlineTarget) {
400
+ const resolution = resolveSpeculativePatchTarget(document, flags);
401
+ target = resolution.target;
402
+ speculativeContext = resolution.context;
403
+ }
404
+ op = {
405
+ type: inlineRestore ? 'restore' : 'redline',
406
+ ...(target == null ? {} : { target }),
407
+ ...(targetRef == null ? {} : { targetRef }),
408
+ replacements: [{
409
+ find: String(flags.find),
410
+ replace: String(flags.replace),
411
+ ...(occurrence == null ? {} : { occurrence })
412
+ }],
413
+ ...(speculativeContext ? { _speculativeContext: speculativeContext } : {}),
414
+ ...(flags.author ? { author: String(flags.author) } : {}),
415
+ ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
416
+ };
417
+ } else if (inlineRestore) {
418
+ op = {
419
+ type: 'restore',
420
+ target,
421
+ ...(flags.modified === undefined ? {} : { modified: String(flags.modified) }),
422
+ ...(flags.author ? { author: String(flags.author) } : {}),
423
+ ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
424
+ };
425
+ } else {
426
+ op = {
427
+ type: 'replace',
428
+ ...(target == null ? {} : { target }),
429
+ modified: flags.modified !== undefined ? String(flags.modified) : '',
430
+ ...(targetRef == null ? {} : { targetRef }),
431
+ ...(flags.author ? { author: String(flags.author) } : {}),
432
+ ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
433
+ };
434
+ }
435
+ return { operations: [op], expectedRevision: null };
436
+ }
437
+ if (!file) throw Object.assign(new Error('Use --operations <file.json> or an inline operation.'), { code: 'OPERATIONS_REQUIRED' });
438
+ let parsed;
439
+ try {
440
+ const source = file === '-' ? await readUtf8Stream(stdin) : await readFile(file, 'utf8');
441
+ parsed = JSON.parse(source);
442
+ } catch (error) {
443
+ const location = file === '-' ? ' from stdin' : '';
444
+ throw Object.assign(new Error(`Could not read operations JSON${location}: ${error.message}`), { code: 'INVALID_OPERATIONS_FILE' });
445
+ }
446
+ const operations = Array.isArray(parsed) ? parsed : (parsed?.operations || parsed?.changes);
447
+ 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' });
448
+ return { operations, expectedRevision: parsed?.expectedRevision || null };
449
+ }
450
+ function outputPath(command, input, flags) {
451
+ if (flags.inPlace) return input;
452
+ if (flags.output) return path.resolve(String(flags.output));
453
+ const parsed = path.parse(input); return path.join(parsed.dir, `${parsed.name}.${suffixes[command]}${parsed.ext || '.docx'}`);
454
+ }
455
+ async function writeMutation(command, input, flags, result) {
456
+ if (!result.written) return { status: result.status || 'ok', ...result, outputPath: null };
457
+ const destination = outputPath(command, input, flags);
458
+ const protectExisting = Boolean(flags.noOverwrite || flags.noClobber) && !flags.force;
459
+ if (!flags.inPlace && protectExisting) {
460
+ try {
461
+ await access(destination);
462
+ throw Object.assign(new Error(`Output already exists: ${destination}`), { code: 'OUTPUT_EXISTS' });
463
+ } catch (error) {
464
+ if (error.code !== 'ENOENT') throw error;
465
+ }
466
+ }
467
+ await writeFile(destination, result.toBuffer());
468
+ return { status: result.status || 'ok', ...result, outputPath: destination };
469
+ }
470
+ function serializable(value) {
471
+ const { buffer: _buffer, toBuffer: _toBuffer, ...rest } = value || {};
472
+ return rest;
473
+ }
474
+
475
+ function compactExtractParagraph(paragraph) {
476
+ const {
477
+ humanReference, provision, nearestHeading, index, ref, paragraphId,
478
+ fingerprint, revisionView, exactText, inTable, list, selectionRole,
479
+ contextFor, hasRevisions, revisionAuthors, segments
480
+ } = paragraph;
481
+ const hiddenRevisionContent = exactText.length === 0 && Array.isArray(segments)
482
+ && segments.some(segment => segment.text.length > 0 && (
483
+ revisionView === 'rejected'
484
+ ? segment.acceptedStart !== null
485
+ : segment.rejectedStart !== null
486
+ ));
487
+ return {
488
+ humanReference,
489
+ provision,
490
+ nearestHeading,
491
+ index,
492
+ ref,
493
+ paragraphId,
494
+ fingerprint,
495
+ revisionView,
496
+ exactText,
497
+ inTable,
498
+ list,
499
+ ...(hasRevisions ? {
500
+ hasRevisions: true,
501
+ revisionAuthors,
502
+ ...(hiddenRevisionContent && revisionView === 'accepted' ? { deleted: true } : {}),
503
+ ...(hiddenRevisionContent && revisionView === 'rejected' ? { inserted: true } : {})
504
+ } : {}),
505
+ ...(selectionRole ? { selectionRole } : {}),
506
+ ...(Array.isArray(contextFor) ? { contextFor } : {})
507
+ };
508
+ }
509
+
510
+ function inspectionResponseBytes(value) {
511
+ return Buffer.byteLength(JSON.stringify(value, null, 2), 'utf8') + 1;
512
+ }
513
+
514
+ function boundInspectionResponse(value, { bypass = false, broadDetailed = false } = {}) {
515
+ const base = {
516
+ ...value,
517
+ machineReferencesAreNotUserLocations: true,
518
+ ...(broadDetailed ? {
519
+ notes: [
520
+ 'This detailed inspection was bounded. Prefer extract --search or an explicit --range for ordinary targeting.'
521
+ ]
522
+ } : {})
523
+ };
524
+ if (bypass) return base;
525
+
526
+ let paragraphs = [...(base.paragraphs || [])];
527
+ const sourceSelection = base.selection || {};
528
+ const totalMatches = Number.isInteger(sourceSelection.totalMatches)
529
+ ? sourceSelection.totalMatches
530
+ : paragraphs.filter(item => item.selectionRole !== 'context').length;
531
+ const initialReturnedMatches = Number.isInteger(sourceSelection.returnedMatches)
532
+ ? sourceSelection.returnedMatches
533
+ : paragraphs.filter(item => item.selectionRole !== 'context').length;
534
+ let contextTruncated = false;
535
+
536
+ const assemble = (oversizeItem = false) => {
537
+ const directIndexes = new Set(paragraphs
538
+ .filter(item => item.selectionRole !== 'context')
539
+ .map(item => item.index));
540
+ paragraphs = paragraphs
541
+ .map(item => item.selectionRole === 'context'
542
+ ? { ...item, contextFor: (item.contextFor || []).filter(index => directIndexes.has(index)) }
543
+ : item)
544
+ .filter(item => item.selectionRole !== 'context' || item.contextFor.length > 0);
545
+ const direct = paragraphs.filter(item => item.selectionRole !== 'context');
546
+ const lastDirect = direct[direct.length - 1] || null;
547
+ const paragraphIndexes = new Set(paragraphs.map(item => item.index));
548
+ const comments = Array.isArray(base.comments)
549
+ ? base.comments.filter(comment => paragraphIndexes.has(comment.paragraphIndex))
550
+ : base.comments;
551
+ const budgetTruncated = direct.length < initialReturnedMatches;
552
+ return {
553
+ ...base,
554
+ paragraphs,
555
+ ...(Array.isArray(base.comments) ? { comments } : {}),
556
+ selection: {
557
+ ...sourceSelection,
558
+ totalMatches,
559
+ returnedMatches: direct.length,
560
+ returnedParagraphs: paragraphs.length,
561
+ truncated: sourceSelection.truncated === true || budgetTruncated || contextTruncated,
562
+ nextAfter: sourceSelection.truncated === true || budgetTruncated
563
+ ? (lastDirect?.index ?? sourceSelection.nextAfter ?? null)
564
+ : null,
565
+ softByteLimit: INSPECTION_SOFT_BYTE_LIMIT,
566
+ oversizeItem,
567
+ ...(contextTruncated ? { contextTruncated: true } : {})
568
+ }
569
+ };
570
+ };
571
+
572
+ let result = assemble();
573
+ while (inspectionResponseBytes(result) > INSPECTION_SOFT_BYTE_LIMIT) {
574
+ const directPositions = paragraphs
575
+ .map((item, position) => item.selectionRole !== 'context' ? position : -1)
576
+ .filter(position => position >= 0);
577
+ if (directPositions.length > 1) {
578
+ paragraphs.splice(directPositions[directPositions.length - 1], 1);
579
+ result = assemble();
580
+ continue;
581
+ }
582
+ const contextPosition = paragraphs.findLastIndex(item => item.selectionRole === 'context');
583
+ if (contextPosition >= 0) {
584
+ paragraphs.splice(contextPosition, 1);
585
+ contextTruncated = true;
586
+ result = assemble();
587
+ continue;
588
+ }
589
+ result = assemble(true);
590
+ break;
591
+ }
592
+ return result;
593
+ }
594
+
595
+ function boundedText(value, limit = 512) {
596
+ const text = String(value ?? '');
597
+ return text.length > limit ? `${text.slice(0, limit)}…` : text;
598
+ }
599
+
600
+ function compactError(error) {
601
+ if (!error || typeof error !== 'object') return error;
602
+ const fields = [
603
+ 'code', 'stage', 'mismatchOffset', 'expectedExcerpt', 'actualExcerpt',
604
+ 'expectedCodePoint', 'actualCodePoint', 'ownerAuthor', 'commentIds',
605
+ 'recoveryVersion', 'category', 'field', 'captureRef', 'operationIndexes',
606
+ 'consumedByOperation', 'expectedScope', 'actualScope',
607
+ 'invariants', 'violations', 'verificationMode', 'baselineView',
608
+ 'listMarker', 'requestedList', 'sourceList', 'targetExcerpt'
609
+ ];
610
+ const compact = {};
611
+ for (const field of fields) {
612
+ if (error[field] !== undefined) compact[field] = error[field];
613
+ }
614
+ if (Array.isArray(error.comments)) {
615
+ compact.comments = error.comments.map(comment => ({
616
+ ...(comment?.id !== undefined ? { id: comment.id } : {}),
617
+ ...(comment?.author !== undefined ? { author: boundedText(comment.author, 160) } : {}),
618
+ ...(comment?.text !== undefined ? { text: boundedText(comment.text, 512) } : {})
619
+ }));
620
+ }
621
+ if (Array.isArray(error.candidates)) {
622
+ compact.candidates = error.candidates.map(candidate => {
623
+ if (!candidate || typeof candidate !== 'object') return candidate;
624
+ const excerpt = boundedText(candidate.excerpt ?? candidate.exactText ?? candidate.text ?? '', 240);
625
+ return { ...compactResolvedTarget(candidate, { preserveMatchDetails: true }), ...(excerpt ? { excerpt } : {}) };
626
+ });
627
+ }
628
+ for (const field of ['recovery', 'issueSummary', 'expectedRevision', 'currentRevision']) {
629
+ if (error[field] !== undefined) compact[field] = error[field];
630
+ }
631
+ if (error.context && typeof error.context === 'object') {
632
+ compact.context = {
633
+ ...error.context,
634
+ ...(error.context.currentTarget ? {
635
+ currentTarget: {
636
+ ...compactResolvedTarget(error.context.currentTarget, { preserveMatchDetails: true }),
637
+ excerpt: boundedText(
638
+ error.context.currentTarget.excerpt
639
+ ?? error.context.currentTarget.exactText
640
+ ?? error.context.currentTarget.text
641
+ ?? '',
642
+ 240
643
+ )
644
+ }
645
+ } : {})
646
+ };
647
+ }
648
+ if (error.sourceTarget && typeof error.sourceTarget === 'object') {
649
+ compact.sourceTarget = {
650
+ ...compactResolvedTarget(error.sourceTarget, { preserveMatchDetails: true }),
651
+ excerpt: boundedText(error.sourceTarget.text ?? error.sourceTarget.exactText ?? '', 240)
652
+ };
653
+ }
654
+ compact.message = boundedText(error.message || String(error));
655
+ return compact;
656
+ }
657
+
658
+ function compactTargetTextMatch(match, preserveDetails = false) {
659
+ if (!match || typeof match !== 'object') return match;
660
+ if (preserveDetails) return match;
661
+ if (match.mode === 'exact') return undefined;
662
+ const differenceCount = Number.isInteger(match.differenceCount)
663
+ ? match.differenceCount
664
+ : (Array.isArray(match.differences) ? match.differences.length : 0);
665
+ return {
666
+ ...(match.mode ? { mode: match.mode } : {}),
667
+ differenceCount
668
+ };
669
+ }
670
+
671
+ function compactResolvedTarget(target, { preserveMatchDetails = false } = {}) {
672
+ if (!target || typeof target !== 'object') return target;
673
+ const {
674
+ text: _text,
675
+ exactText: _exactText,
676
+ targetTextMatch,
677
+ ...compact
678
+ } = target;
679
+ const compactMatch = compactTargetTextMatch(targetTextMatch, preserveMatchDetails);
680
+ return {
681
+ ...compact,
682
+ ...(compactMatch ? { targetTextMatch: compactMatch } : {})
683
+ };
684
+ }
685
+
686
+ function compactReceipt(receipt) {
687
+ if (!receipt || typeof receipt !== 'object') return receipt;
688
+ return {
689
+ ...receipt,
690
+ affectedTargets: Array.isArray(receipt.affectedTargets)
691
+ ? receipt.affectedTargets.map(compactResolvedTarget)
692
+ : [],
693
+ warnings: Array.isArray(receipt.warnings) ? receipt.warnings.map(warning => boundedText(warning)) : []
694
+ };
695
+ }
696
+
697
+ function compactOperationResult(result) {
698
+ if (!result || typeof result !== 'object') return result;
699
+ const { receipt: _receipt, ...withoutReceipt } = result;
700
+ const preserveMatchDetails = result.status === 'error' || !!result.error;
701
+ return {
702
+ ...withoutReceipt,
703
+ ...(result.resolvedTarget ? { resolvedTarget: compactResolvedTarget(result.resolvedTarget, { preserveMatchDetails }) } : {}),
704
+ ...(result.resolvedAnchor ? { resolvedAnchor: compactResolvedTarget(result.resolvedAnchor, { preserveMatchDetails }) } : {}),
705
+ ...(result.error ? { error: compactError(result.error) } : {}),
706
+ ...(Array.isArray(result.warnings) ? { warnings: result.warnings.map(warning => boundedText(warning)) } : {})
707
+ };
708
+ }
709
+
710
+ function summarizeIssues(issues) {
711
+ const list = Array.isArray(issues) ? issues : [];
712
+ const grouped = new Map();
713
+ for (const issue of list) {
714
+ const code = issue?.code || 'UNKNOWN';
715
+ const source = issue?.source || 'unknown';
716
+ const severity = issue?.severity || 'error';
717
+ const key = `${source}:${severity}:${code}`;
718
+ const current = grouped.get(key) || { source, severity, code, count: 0 };
719
+ current.count++;
720
+ grouped.set(key, current);
721
+ }
722
+ return {
723
+ total: list.length,
724
+ errors: list.filter(issue => issue?.severity === 'error').length,
725
+ warnings: list.filter(issue => issue?.severity === 'warning').length,
726
+ byCode: Array.from(grouped.values()).sort((a, b) => (
727
+ a.source.localeCompare(b.source) || a.code.localeCompare(b.code) || a.severity.localeCompare(b.severity)
728
+ ))
729
+ };
730
+ }
731
+
732
+ function compactMutationResult(value) {
733
+ const serialized = serializable(value);
734
+ const {
735
+ documentXml: _documentXml,
736
+ oxml: _oxml,
737
+ commentsXml: _commentsXml,
738
+ commentsExtendedXml: _commentsExtendedXml,
739
+ numberingXml: _numberingXml,
740
+ numberingXmlParts: _numberingXmlParts,
741
+ inspection: _inspection,
742
+ issues: _issues,
743
+ ...compact
744
+ } = serialized;
745
+ const results = Array.isArray(compact.results) ? compact.results.map(compactOperationResult) : [];
746
+ const status = compact.status || 'ok';
747
+ const localizedChangesVerified = results.every(result => !result?.change || (
748
+ result.change.committed === true
749
+ && result.change.finalDisposition === 'applied'
750
+ && result.change.verification?.acceptedViewMatchesCompiledText === true
751
+ ));
752
+ return {
753
+ ...compact,
754
+ ...(Array.isArray(compact.results) ? { results } : {}),
755
+ ...(Array.isArray(compact.receipts) ? { receipts: compact.receipts.map(compactReceipt) } : {}),
756
+ ...(compact.error ? { error: compactError(compact.error) } : {}),
757
+ ...(Array.isArray(compact.warnings) ? { warnings: compact.warnings.map(warning => boundedText(warning)) } : {}),
758
+ ...(compact.validation ? {
759
+ validation: {
760
+ originalIssues: summarizeIssues(compact.validation.originalIssues),
761
+ generatedIssues: summarizeIssues(compact.validation.generatedIssues)
762
+ }
763
+ } : {}),
764
+ completion: compact.written === true
765
+ && status !== 'error'
766
+ && status !== 'partial'
767
+ && results.every(result => result?.status !== 'error')
768
+ && localizedChangesVerified
769
+ };
770
+ }
771
+
772
+ async function collectValidationIssues(buffer) {
773
+ const entries = unzipDocx(buffer);
774
+ const documentXml = entries.get('word/document.xml')?.toString('utf8') || '';
775
+ const revision = validateRedlineOoxml(documentXml);
776
+ const issues = revision.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
777
+ try {
778
+ await validateDocxPackage(new MemoryZip(entries));
779
+ } catch (error) {
780
+ issues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
781
+ }
782
+ return issues;
783
+ }
784
+
785
+ function validationIssueKey(issue) {
786
+ return `${issue.source || ''}:${issue.code}:${issue.message}`;
787
+ }
788
+
789
+ function subtractValidationIssues(issues, baselineIssues) {
790
+ const remainingBaseline = new Map();
791
+ for (const issue of baselineIssues) {
792
+ const key = validationIssueKey(issue);
793
+ remainingBaseline.set(key, (remainingBaseline.get(key) || 0) + 1);
794
+ }
795
+ return issues.filter(issue => {
796
+ const key = validationIssueKey(issue);
797
+ const remaining = remainingBaseline.get(key) || 0;
798
+ if (remaining === 0) return true;
799
+ remainingBaseline.set(key, remaining - 1);
800
+ return false;
801
+ });
802
+ }
803
+
804
+ export async function executeCli(argv, io = process) {
805
+ const { command, input: rawInput, extraPositionals, flags } = parseArgs(argv);
806
+ if (command === 'help' || flags.help) {
807
+ const requestedCommand = command === 'help' ? rawInput : command;
808
+ const help = buildCliHelp(requestedCommand || null);
809
+ return help || cliError('UNKNOWN_COMMAND', `Unknown command: ${requestedCommand}`);
810
+ }
811
+ if (!command) return cliError('COMMAND_REQUIRED', 'A command is required.');
812
+ if (!CLI_COMMANDS.includes(command)) return cliError('UNKNOWN_COMMAND', `Unknown command: ${command}`);
813
+ if (command === 'version') {
814
+ if (rawInput || extraPositionals.length > 0) return cliError('UNEXPECTED_ARGUMENT', `Unexpected argument: ${rawInput || extraPositionals[0]}`);
815
+ const optionError = validateCommandOptions(command, flags, []);
816
+ if (optionError) return optionError;
817
+ return {
818
+ status: 'ok',
819
+ command,
820
+ contractVersion: CLI_CONTRACT_VERSION,
821
+ capabilities: CLI_CAPABILITIES
822
+ };
823
+ }
824
+ if (!rawInput) return cliError('INPUT_REQUIRED', 'An input .docx path is required.');
825
+ const optionError = validateCommandOptions(command, flags, extraPositionals);
826
+ if (optionError) return optionError;
827
+ if (flags.existingRevisions != null && !isExistingRevisionsPolicy(flags.existingRevisions)) {
828
+ return cliError('INVALID_OPERATION', `Unsupported existing-revisions policy: "${String(flags.existingRevisions)}".`);
829
+ }
830
+ const profile = flags.profile == null ? null : String(flags.profile);
831
+ if (profile && profile !== 'agent') {
832
+ return cliError('INVALID_PROFILE', `Unknown execution profile: "${profile}". Supported profiles: agent.`);
833
+ }
834
+ if (flags.revisionFidelity != null && !REVISION_FIDELITY_MODES.includes(String(flags.revisionFidelity))) {
835
+ return cliError('INVALID_OPERATION', `Unsupported revision-fidelity mode: "${String(flags.revisionFidelity)}". Use enforce, warn or off.`);
836
+ }
837
+ if (profile === 'agent' && String(flags.revisionFidelity) === 'off') {
838
+ return cliError('INVALID_PROFILE', 'The agent profile requires Reject All verification; --revision-fidelity off is only for diagnosis outside the agent profile.');
839
+ }
840
+ let inspectOptions = null;
841
+ if (command === 'inspect' || command === 'extract') {
842
+ try { inspectOptions = inspectionOptions(flags); }
843
+ catch (error) { return cliError(error.code || 'INVALID_FILTER', error.message); }
844
+ }
845
+ const input = path.resolve(rawInput);
846
+ let buffer; try { buffer = await readFile(input); } catch (error) { return cliError('INPUT_READ_FAILED', error.message); }
847
+ try {
848
+ const document = openDocx(buffer);
849
+ if (command === 'inspect') {
850
+ const inspected = document.inspect(inspectOptions);
851
+ const broadDetailed = !flags.search && flags.index === undefined
852
+ && flags.indexes === undefined && flags.range === undefined;
853
+ return boundInspectionResponse(
854
+ { ...inspected, command, input, indexBase: 1 },
855
+ { bypass: !!flags.all, broadDetailed }
856
+ );
857
+ }
858
+ if (command === 'extract') {
859
+ const inspected = document.inspect(inspectOptions);
860
+ return boundInspectionResponse({
861
+ status: inspected.status,
862
+ command,
863
+ input,
864
+ indexBase: 1,
865
+ paragraphs: inspected.paragraphs.map(compactExtractParagraph),
866
+ ...(inspected.selection ? { selection: inspected.selection } : {}),
867
+ warnings: inspected.warnings
868
+ }, { bypass: !!flags.all });
869
+ }
870
+ if (command === 'validate') {
871
+ const issues = await collectValidationIssues(buffer);
872
+ if (flags.baseline) {
873
+ const baseline = path.resolve(String(flags.baseline));
874
+ let baselineBuffer;
875
+ try { baselineBuffer = await readFile(baseline); }
876
+ catch (error) { return cliError('BASELINE_READ_FAILED', error.message); }
877
+ const baselineIssues = await collectValidationIssues(baselineBuffer);
878
+ const introducedIssues = subtractValidationIssues(issues, baselineIssues);
879
+ const hasIntroducedErrors = introducedIssues.some(issue => issue.severity === 'error');
880
+ return {
881
+ status: hasIntroducedErrors ? 'error' : 'ok',
882
+ command,
883
+ input,
884
+ baseline,
885
+ valid: !hasIntroducedErrors,
886
+ issues,
887
+ baselineIssues,
888
+ introducedIssues
889
+ };
890
+ }
891
+ const hasErrors = issues.some(issue => issue.severity === 'error');
892
+ return { status: hasErrors ? 'error' : 'ok', command, input, valid: !hasErrors, issues };
893
+ }
894
+ const opsData = command === 'preflight' || command === 'apply'
895
+ ? await readOperations(flags.operations, flags, io.stdin || process.stdin, document)
896
+ : null;
897
+ const operations = opsData?.operations || null;
898
+ let expectedRevision = opsData?.expectedRevision || null;
899
+ if (flags.expectedRevision) {
900
+ if (typeof flags.expectedRevision === 'string') {
901
+ try {
902
+ expectedRevision = JSON.parse(flags.expectedRevision);
903
+ } catch {
904
+ expectedRevision = {
905
+ algorithm: 'sha256',
906
+ version: 1,
907
+ scope: 'package',
908
+ value: flags.expectedRevision.trim()
909
+ };
910
+ }
911
+ } else if (typeof flags.expectedRevision === 'object') {
912
+ expectedRevision = flags.expectedRevision;
913
+ }
914
+ }
915
+ if (command === 'preflight') return {
916
+ ...document.preflight(operations, flags.author, {
917
+ strictTargets: flags.strictTargets !== 'false',
918
+ ...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {})
919
+ }),
920
+ command,
921
+ input
922
+ };
923
+ if (command === 'apply') {
924
+ const agentProfile = profile === 'agent';
925
+ const author = flags.author || process.env.DOCX_REDLINE_AUTHOR || 'AI Redliner';
926
+ const generateRedlines = flags.generateRedlines !== undefined
927
+ ? (flags.generateRedlines !== 'false' && flags.generateRedlines !== false)
928
+ : (!flags.noRedlines);
929
+ const atomic = flags.atomic !== undefined
930
+ ? (flags.atomic === true || flags.atomic === 'true')
931
+ : false;
932
+ const requireComplete = flags.requireComplete !== undefined
933
+ ? (flags.requireComplete === true || flags.requireComplete === 'true')
934
+ : agentProfile;
935
+ const effectiveOptions = {
936
+ author,
937
+ atomic,
938
+ strictTargets: true,
939
+ validate: true,
940
+ generateRedlines,
941
+ existingRevisions: flags.existingRevisions || 'merge-same-author',
942
+ verifyRevisionFidelity: resolveRevisionFidelityMode(flags.revisionFidelity == null ? undefined : String(flags.revisionFidelity)),
943
+ requireComplete
944
+ };
945
+ const result = await document.applyOperations(operations, {
946
+ author,
947
+ atomic,
948
+ validate: true,
949
+ strictTargets: true,
950
+ strictListConversion: agentProfile || atomic,
951
+ generateRedlines,
952
+ ...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {}),
953
+ verifyRevisionFidelity: effectiveOptions.verifyRevisionFidelity,
954
+ ...(expectedRevision ? { expectedRevision } : {})
955
+ });
956
+ const localizedVerificationFailed = (result.results || []).some(item => item?.change && (
957
+ item.change.committed !== true
958
+ || item.change.finalDisposition !== 'applied'
959
+ || item.change.verification?.acceptedViewMatchesCompiledText !== true
960
+ ));
961
+ const mutationResult = await writeMutation(command, input, flags, result);
962
+ return compactMutationResult({
963
+ command,
964
+ input,
965
+ ...serializable(mutationResult),
966
+ ...(profile ? { executionProfile: profile, effectiveOptions } : {}),
967
+ ...(result.status === 'error'
968
+ ? { exitCode: 2 }
969
+ : ((result.status === 'partial' || localizedVerificationFailed) && requireComplete
970
+ ? { exitCode: 3 }
971
+ : {}))
972
+ });
973
+ }
974
+ const commentIds = command === 'delete-comments' && flags.commentId != null && flags.commentId !== true
975
+ ? String(flags.commentId).split(',').map(id => id.trim()).filter(Boolean)
976
+ : null;
977
+ const filter = commentIds?.length ? { ids: commentIds } : flags.allAuthors ? { allAuthors: true } : flags.author ? { author: String(flags.author) } : null;
978
+ if (!filter) return cliError('AUTHOR_REQUIRED', command === 'delete-comments' ? 'Use --author <name>, --all-authors, or --comment-id <id[,id]>.' : 'Use --author <name> or --all-authors.');
979
+ const result = command === 'delete-comments' ? await document.deleteComments(filter) : await document.resolveRevisions(command, filter);
980
+ return compactMutationResult({ command, input, ...serializable(await writeMutation(command, input, flags, result)) });
981
+ } catch (error) { return cliError(error.code || 'CLI_FAILED', error.message, 2, error); }
982
+ }
983
+
984
+ export async function runCli(argv = process.argv.slice(2), io = process) {
985
+ configureLogger({}, { level: 'silent' });
986
+ const result = await executeCli(argv, io);
987
+ const compactJson = parseArgs(argv).flags.compact === true;
988
+ io.stdout.write(`${JSON.stringify(serializable(result), null, compactJson ? 0 : 2)}\n`);
989
+ return Number.isInteger(result.exitCode) && result.exitCode !== 0
990
+ ? result.exitCode
991
+ : (result.status === 'error' ? 1 : 0);
992
+ }