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