@ansonlai/docx-redline-js 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +30 -9
- package/CHANGELOG.md +58 -0
- package/README.md +48 -6
- package/dist/docx-redline-js.esm.js +3 -4
- package/dist/docx-redline-js.esm.js.map +2 -2
- package/dist/docx-redline-js.esm.min.js +51 -51
- package/dist/docx-redline-js.esm.min.js.map +2 -2
- package/docs/AGENT_FAST_START.md +23 -24
- package/docs/AGENT_KNOWLEDGE_BASE.md +36 -14
- package/docs/SKILL_AUTHORING.md +56 -19
- package/docs/releases/0.7.0.md +219 -0
- package/docs/schemas/document-operations.schema.json +14 -1
- package/docs/validation-reports/2026-09-14-localized-patching-rollout.md +90 -0
- package/index.d.ts +2 -0
- package/node/cli-help.js +26 -6
- package/node/cli.js +300 -35
- package/package.json +5 -1
- package/services/batch-operation-orchestrator.js +25 -16
- package/services/document-inspection.js +2 -3
- package/services/document-operation-applier.js +78 -9
- package/services/document-operation-contract.js +105 -7
- package/services/document-operation-mutations.js +36 -36
- package/services/localized-replacement-compiler.js +304 -0
- package/services/operation-batch-compiler.js +42 -1
- package/services/operation-preflight.js +10 -6
- package/services/standalone-operation-runner.d.ts +79 -7
package/node/cli-help.js
CHANGED
|
@@ -12,7 +12,7 @@ const COMPACT = option('compact', '--compact', 'Emit one-line JSON to reduce pro
|
|
|
12
12
|
const INSPECTION_OPTIONS = Object.freeze([
|
|
13
13
|
HELP,
|
|
14
14
|
option('search', '--search <text>', 'Case-insensitive substring search.'),
|
|
15
|
-
option('revised', '--revised', 'Select paragraphs containing tracked revisions.'),
|
|
15
|
+
option('revised', '--revised', 'Select paragraphs containing tracked revisions; extract identifies authors and fully hidden deleted/inserted paragraphs.'),
|
|
16
16
|
option('table', '--table', 'Select paragraphs inside tables.'),
|
|
17
17
|
option('body', '--body', 'Select paragraphs outside tables.'),
|
|
18
18
|
option('nonEmpty', '--non-empty', 'Exclude empty paragraphs; this is not a narrow document scope.'),
|
|
@@ -39,6 +39,14 @@ const APPLY_EXAMPLES = Object.freeze([
|
|
|
39
39
|
modified: 'Revised clause.'
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
|
+
{
|
|
43
|
+
description: 'Localized tracked replacement in one strong accepted-view paragraph target.',
|
|
44
|
+
operation: {
|
|
45
|
+
type: 'redline',
|
|
46
|
+
target: { paragraphId: '1A2B3C4D' },
|
|
47
|
+
replacements: [{ find: 'thirty (30) days', replace: 'sixty (60) days' }]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
42
50
|
{
|
|
43
51
|
description: 'Comment the complete target paragraph.',
|
|
44
52
|
operation: {
|
|
@@ -48,15 +56,14 @@ const APPLY_EXAMPLES = Object.freeze([
|
|
|
48
56
|
}
|
|
49
57
|
},
|
|
50
58
|
{
|
|
51
|
-
description: '
|
|
59
|
+
description: 'Restore one wholly foreign-deleted paragraph verbatim from a strong rejected-view target.',
|
|
52
60
|
operation: {
|
|
53
61
|
type: 'restore',
|
|
54
62
|
target: {
|
|
55
63
|
exactText: 'Deleted source paragraph.',
|
|
56
64
|
paragraphId: '3A2B3C4D',
|
|
57
65
|
revisionView: 'rejected'
|
|
58
|
-
}
|
|
59
|
-
modified: 'Restored and revised paragraph.'
|
|
66
|
+
}
|
|
60
67
|
}
|
|
61
68
|
}
|
|
62
69
|
]);
|
|
@@ -116,7 +123,7 @@ export const CLI_COMMAND_HELP = Object.freeze({
|
|
|
116
123
|
},
|
|
117
124
|
apply: {
|
|
118
125
|
summary: 'Apply canonical document operations and write a derived DOCX.',
|
|
119
|
-
usage: 'docx-redline apply <file.docx> --operations <file.json
|
|
126
|
+
usage: 'docx-redline apply <file.docx> (--operations <file.json|->|<inline operation>) [options]',
|
|
120
127
|
options: [
|
|
121
128
|
HELP,
|
|
122
129
|
option('operations', '--operations <file.json|->, --operations-file <file>', 'Read an operation array/envelope from a UTF-8 file or serializer-backed stdin.'),
|
|
@@ -124,7 +131,15 @@ export const CLI_COMMAND_HELP = Object.freeze({
|
|
|
124
131
|
option('noClobber', '--no-clobber', 'Alias of --no-overwrite.'),
|
|
125
132
|
option('expectedRevision', '--expected-revision <token|json>', 'Reject a stale package revision.'),
|
|
126
133
|
option('target', '--target <text>', 'Inline one-operation target text.'),
|
|
134
|
+
option('targetId', '--target-id <paragraphId>', 'Strong inline target using a paragraphId from fresh inspection.'),
|
|
135
|
+
option('restore', '--restore', 'Restore one rejected-view paragraph identified by --target-id; omit --modified for verbatim restoration.'),
|
|
127
136
|
option('modified', '--modified <text>', 'Complete desired accepted-view target content.'),
|
|
137
|
+
option('find', '--find <exact text>', 'Exact case-sensitive source span for one localized replacement.'),
|
|
138
|
+
option('replace', '--replace <text>', 'Replacement text for --find; an empty string deletes the span.'),
|
|
139
|
+
option('occurrence', '--occurrence <N>', 'Positive 1-based --find occurrence within the uniquely resolved target paragraph.'),
|
|
140
|
+
option('search', '--search <text>', 'Case-insensitive context anchor for speculative find/replace; prefer a longer, fairly unique phrase because all matches contribute scope.'),
|
|
141
|
+
option('contextRange', '--context-range <START:END>', 'Signed inclusive paragraph offsets (-20..20) from each --search anchor; 1:3 means strictly the next three paragraphs.'),
|
|
142
|
+
option('around', '--around <N>', 'With --search, shorthand for symmetric --context-range -N:N (0-20).'),
|
|
128
143
|
option('comment', '--comment <text>', 'Create an inline comment operation.'),
|
|
129
144
|
option('textToComment', '--text-to-comment <text>', 'Anchor an inline comment to an exact subspan.'),
|
|
130
145
|
option('targetRef', '--target-ref <N>', 'Disambiguate an inline target with a 1-based machine index.'),
|
|
@@ -138,6 +153,12 @@ export const CLI_COMMAND_HELP = Object.freeze({
|
|
|
138
153
|
],
|
|
139
154
|
notes: [
|
|
140
155
|
'modified is complete desired accepted-view content, not only inserted words.',
|
|
156
|
+
'Use either modified or find/replace. A strong accepted-view target is preferred when already known.',
|
|
157
|
+
'--restore requires --target-id from rejected-view extraction; omit modified for verbatim restore or use one inline find/replace.',
|
|
158
|
+
'Without a strong target, find/replace may resolve globally or within --search plus a directional context range; it fails unless exactly one paragraph is eligible.',
|
|
159
|
+
'Generic or repeated search anchors widen the unioned scope and may cause AMBIGUOUS_TARGET; on success, anchorMatchCount greater than 1 signals that the returned location and excerpts should be confirmed.',
|
|
160
|
+
'--occurrence disambiguates repeated text only within one uniquely selected paragraph, never between paragraphs.',
|
|
161
|
+
'Inline find/replace accepts one replacement; use operations JSON for multiple simultaneous replacements.',
|
|
141
162
|
'Use a structured JSON file or serializer-backed stdin; never interpolate legal text through raw shell quoting.',
|
|
142
163
|
'The source is never overwritten unless --in-place is explicit.',
|
|
143
164
|
'The agent profile keeps progressive execution unless --atomic is explicit and does not change existing-revision policy.',
|
|
@@ -215,4 +236,3 @@ export function buildCliHelp(command = null) {
|
|
|
215
236
|
documentation: [...CLI_DOCUMENTATION_URLS]
|
|
216
237
|
};
|
|
217
238
|
}
|
|
218
|
-
|
package/node/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { normalizeErrorWithRecovery } from '../services/error-recovery.js';
|
|
|
10
10
|
import { buildCliHelp, CLI_COMMANDS, commandOptionKeys } from './cli-help.js';
|
|
11
11
|
|
|
12
12
|
const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
|
|
13
|
-
const CLI_CONTRACT_VERSION =
|
|
13
|
+
const CLI_CONTRACT_VERSION = 8;
|
|
14
14
|
const DEFAULT_INSPECTION_LIMIT = 20;
|
|
15
15
|
const INSPECTION_SOFT_BYTE_LIMIT = 48 * 1024;
|
|
16
16
|
const CLI_CAPABILITIES = [
|
|
@@ -28,12 +28,26 @@ const CLI_CAPABILITIES = [
|
|
|
28
28
|
'inspection-context-v1',
|
|
29
29
|
'bounded-inspection-v1',
|
|
30
30
|
'human-document-references-v1',
|
|
31
|
+
'localized-replacements-v1',
|
|
32
|
+
'speculative-search-apply-v1',
|
|
33
|
+
'localized-change-summary-v1',
|
|
34
|
+
'restore-shortcuts-v1',
|
|
31
35
|
'deduplicated-cli-receipts',
|
|
32
36
|
'compact-cli-json-v1'
|
|
33
37
|
];
|
|
34
38
|
const commandOptions = Object.fromEntries(CLI_COMMANDS.map(command => [command, new Set(commandOptionKeys(command))]));
|
|
35
39
|
|
|
36
|
-
function cliError(code, message, exitCode = 2, details) {
|
|
40
|
+
function cliError(code, message, exitCode = 2, details) {
|
|
41
|
+
return {
|
|
42
|
+
status: 'error',
|
|
43
|
+
error: normalizeErrorWithRecovery({
|
|
44
|
+
...(details && typeof details === 'object' ? details : {}),
|
|
45
|
+
code,
|
|
46
|
+
message
|
|
47
|
+
}),
|
|
48
|
+
exitCode
|
|
49
|
+
};
|
|
50
|
+
}
|
|
37
51
|
const optionAliases = new Map([
|
|
38
52
|
['operationsFile', 'operations'],
|
|
39
53
|
['o', 'output'],
|
|
@@ -59,7 +73,7 @@ function parseArgs(argv) {
|
|
|
59
73
|
const normalizedKey = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
60
74
|
const key = optionAliases.get(normalizedKey) || normalizedKey;
|
|
61
75
|
if (inline !== undefined) flags[key] = inline;
|
|
62
|
-
else if (argv[index + 1] && (argv[index + 1] === '-' || !argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
|
|
76
|
+
else if (argv[index + 1] !== undefined && (argv[index + 1] === '-' || !argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
|
|
63
77
|
else flags[key] = true;
|
|
64
78
|
}
|
|
65
79
|
return { command: positionals[0], input: positionals[1], extraPositionals: positionals.slice(2), flags };
|
|
@@ -150,31 +164,259 @@ async function readUtf8Stream(stream) {
|
|
|
150
164
|
return Buffer.concat(chunks).toString('utf8');
|
|
151
165
|
}
|
|
152
166
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
167
|
+
function parseContextRange(value) {
|
|
168
|
+
const match = String(value).match(/^\s*(-?\d+)\s*:\s*(-?\d+)\s*$/);
|
|
169
|
+
if (!match) throw invalidFilter('--context-range must use START:END with signed integer offsets from -20 through 20.');
|
|
170
|
+
const start = Number(match[1]);
|
|
171
|
+
const end = Number(match[2]);
|
|
172
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < -20 || end > 20 || start > end) {
|
|
173
|
+
throw invalidFilter('--context-range requires START <= END and both offsets from -20 through 20.');
|
|
174
|
+
}
|
|
175
|
+
return { start, end, text: `${start}:${end}` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function speculativeCandidate(paragraph) {
|
|
179
|
+
return {
|
|
180
|
+
excerpt: boundedText(paragraph.exactText, 240),
|
|
181
|
+
index: paragraph.index,
|
|
182
|
+
ref: paragraph.ref,
|
|
183
|
+
paragraphId: paragraph.paragraphId,
|
|
184
|
+
fingerprint: paragraph.fingerprint,
|
|
185
|
+
revisionView: 'accepted',
|
|
186
|
+
inTable: paragraph.inTable,
|
|
187
|
+
humanReference: paragraph.humanReference,
|
|
188
|
+
provision: paragraph.provision,
|
|
189
|
+
nearestHeading: paragraph.nearestHeading
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function speculativeError(code, message, details = {}) {
|
|
194
|
+
return Object.assign(new Error(message), { code, ...details });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveSpeculativePatchTarget(document, flags) {
|
|
198
|
+
if (!document) throw speculativeError('INVALID_OPERATION', 'Speculative localized replacement requires an open document.');
|
|
199
|
+
if (flags.contextRange !== undefined && flags.around !== undefined) {
|
|
200
|
+
throw invalidFilter('Use --context-range or --around, not both.');
|
|
201
|
+
}
|
|
202
|
+
const search = flags.search === undefined ? null : String(flags.search).trim();
|
|
203
|
+
if (flags.search !== undefined && (!search || flags.search === true)) {
|
|
204
|
+
throw invalidFilter('--search must be non-empty text.');
|
|
205
|
+
}
|
|
206
|
+
if ((flags.contextRange !== undefined || flags.around !== undefined) && !search) {
|
|
207
|
+
throw invalidFilter('--context-range and --around require --search.');
|
|
208
|
+
}
|
|
209
|
+
let range = { start: 0, end: 0, text: '0:0' };
|
|
210
|
+
if (flags.contextRange !== undefined) range = parseContextRange(flags.contextRange);
|
|
211
|
+
if (flags.around !== undefined) {
|
|
212
|
+
const text = String(flags.around).trim();
|
|
213
|
+
if (!/^\d+$/.test(text) || Number(text) > 20) {
|
|
214
|
+
throw invalidFilter('--around must be an integer from 0 through 20.');
|
|
215
|
+
}
|
|
216
|
+
const around = Number(text);
|
|
217
|
+
range = { start: -around, end: around, text: `${-around}:${around}` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const inspected = document.inspect({ revisionView: 'accepted' });
|
|
221
|
+
const paragraphs = Array.isArray(inspected.paragraphs) ? inspected.paragraphs : [];
|
|
222
|
+
let anchors = [];
|
|
223
|
+
let eligible = paragraphs;
|
|
224
|
+
if (search) {
|
|
225
|
+
const folded = search.toLowerCase();
|
|
226
|
+
anchors = paragraphs.filter(paragraph => paragraph.exactText.toLowerCase().includes(folded));
|
|
227
|
+
if (anchors.length === 0) {
|
|
228
|
+
throw speculativeError('TARGET_NOT_FOUND', `No paragraph matched search query: "${search}".`, {
|
|
229
|
+
context: { search, range: range.text },
|
|
230
|
+
recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const eligibleIndexes = new Set();
|
|
234
|
+
for (const anchor of anchors) {
|
|
235
|
+
const anchorPosition = anchor.index - 1;
|
|
236
|
+
for (let offset = range.start; offset <= range.end; offset += 1) {
|
|
237
|
+
const position = anchorPosition + offset;
|
|
238
|
+
if (position >= 0 && position < paragraphs.length) eligibleIndexes.add(position);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
eligible = [...eligibleIndexes].sort((a, b) => a - b).map(position => paragraphs[position]);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const find = String(flags.find);
|
|
245
|
+
const matches = eligible.filter(paragraph => paragraph.exactText.includes(find));
|
|
246
|
+
const context = {
|
|
247
|
+
...(search ? { search, range: range.text, anchorMatchCount: anchors.length } : {})
|
|
248
|
+
};
|
|
249
|
+
if (matches.length === 0) {
|
|
250
|
+
throw speculativeError('PATCH_SOURCE_NOT_FOUND', search
|
|
251
|
+
? `No paragraph in the contextual range contains the exact patch source: "${find}".`
|
|
252
|
+
: `No paragraph contains the exact patch source: "${find}".`, {
|
|
253
|
+
context,
|
|
254
|
+
...(anchors.length ? { candidates: anchors.map(speculativeCandidate) } : {}),
|
|
255
|
+
recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (matches.length > 1) {
|
|
259
|
+
throw speculativeError('AMBIGUOUS_TARGET', `The exact patch source matched ${matches.length} eligible paragraphs.`, {
|
|
260
|
+
context,
|
|
261
|
+
candidates: matches.map(speculativeCandidate),
|
|
262
|
+
recovery: { action: 'choose_candidate', requiresReinspection: false, sameArgumentsSafe: false }
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const selected = matches[0];
|
|
266
|
+
return {
|
|
267
|
+
target: {
|
|
268
|
+
exactText: selected.exactText,
|
|
269
|
+
...(selected.paragraphId ? { paragraphId: selected.paragraphId } : {}),
|
|
270
|
+
fingerprint: selected.fingerprint,
|
|
271
|
+
revisionView: 'accepted',
|
|
272
|
+
inTable: selected.inTable
|
|
273
|
+
},
|
|
274
|
+
context: {
|
|
275
|
+
...context,
|
|
276
|
+
humanReference: selected.humanReference
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function readOperations(file, flags = {}, stdin = process.stdin, document = null) {
|
|
282
|
+
const inlineRestore = flags?.restore !== undefined;
|
|
283
|
+
const hasInlineTarget = flags?.target !== undefined
|
|
284
|
+
|| flags?.targetId !== undefined
|
|
285
|
+
|| flags?.targetRef !== undefined;
|
|
286
|
+
const hasInlinePatch = flags?.find !== undefined
|
|
287
|
+
|| flags?.replace !== undefined
|
|
288
|
+
|| flags?.occurrence !== undefined;
|
|
289
|
+
const hasSpeculativeScope = flags?.search !== undefined
|
|
290
|
+
|| flags?.contextRange !== undefined
|
|
291
|
+
|| flags?.around !== undefined;
|
|
292
|
+
if (file && (hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
|
|
293
|
+
throw Object.assign(new Error('Use --operations or inline restore/find/replace options, not both.'), {
|
|
294
|
+
code: 'INVALID_OPERATION'
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (hasInlineTarget && hasSpeculativeScope) {
|
|
298
|
+
throw Object.assign(new Error('Use a strong inline target or --search/--context-range, not both.'), {
|
|
299
|
+
code: 'INVALID_OPERATION'
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (!file && (hasInlineTarget || hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
|
|
303
|
+
if (inlineRestore && flags.restore !== true) {
|
|
304
|
+
throw Object.assign(new Error('--restore is a boolean flag and does not take a value.'), {
|
|
305
|
+
code: 'INVALID_OPERATION'
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (hasSpeculativeScope && !hasInlinePatch) {
|
|
309
|
+
throw Object.assign(new Error('--search, --context-range, and --around are only valid with inline --find/--replace.'), {
|
|
310
|
+
code: 'INVALID_OPERATION'
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const targetRef = flags.targetRef === undefined ? null : positiveInteger(flags.targetRef);
|
|
314
|
+
if (flags.targetRef !== undefined && targetRef == null) {
|
|
315
|
+
throw Object.assign(new Error('--target-ref must be a positive 1-based integer.'), {
|
|
316
|
+
code: 'INVALID_OPERATION'
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const targetId = flags.targetId === undefined ? null : String(flags.targetId).trim();
|
|
320
|
+
if (flags.targetId !== undefined && (!targetId || flags.targetId === true)) {
|
|
321
|
+
throw Object.assign(new Error('--target-id must be a non-empty paragraphId.'), {
|
|
322
|
+
code: 'INVALID_OPERATION'
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const targetText = flags.target === undefined ? null : String(flags.target);
|
|
326
|
+
let target = targetId
|
|
327
|
+
? { ...(targetText == null ? {} : { exactText: targetText }), paragraphId: targetId }
|
|
328
|
+
: targetText;
|
|
329
|
+
if (inlineRestore) {
|
|
330
|
+
if (!targetId) {
|
|
331
|
+
throw Object.assign(new Error('Inline --restore requires --target-id from rejected-view extraction.'), {
|
|
332
|
+
code: 'INVALID_OPERATION'
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
target = { ...target, revisionView: 'rejected' };
|
|
336
|
+
}
|
|
337
|
+
let speculativeContext = null;
|
|
338
|
+
let op;
|
|
339
|
+
if (flags.comment) {
|
|
340
|
+
if (inlineRestore) {
|
|
341
|
+
throw Object.assign(new Error('Use --restore or --comment, not both.'), {
|
|
342
|
+
code: 'INVALID_OPERATION'
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (hasInlinePatch) {
|
|
346
|
+
throw Object.assign(new Error('Inline comments cannot be combined with --find/--replace.'), {
|
|
347
|
+
code: 'INVALID_OPERATION'
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
op = {
|
|
351
|
+
type: 'comment',
|
|
352
|
+
...(target == null ? {} : { target }),
|
|
353
|
+
commentContent: String(flags.comment),
|
|
354
|
+
...(flags.textToComment ? { textToComment: String(flags.textToComment) } : {}),
|
|
355
|
+
...(targetRef == null ? {} : { targetRef }),
|
|
356
|
+
...(flags.author ? { author: String(flags.author) } : {})
|
|
357
|
+
};
|
|
358
|
+
} else if (hasInlinePatch) {
|
|
359
|
+
if (flags.modified !== undefined) {
|
|
360
|
+
throw Object.assign(new Error('Use --modified or --find/--replace, not both.'), {
|
|
361
|
+
code: 'INVALID_OPERATION'
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (
|
|
365
|
+
flags.find === undefined
|
|
366
|
+
|| flags.find === true
|
|
367
|
+
|| String(flags.find).length === 0
|
|
368
|
+
|| flags.replace === undefined
|
|
369
|
+
|| flags.replace === true
|
|
370
|
+
) {
|
|
371
|
+
throw Object.assign(new Error('Inline localized replacement requires non-empty --find and string --replace values.'), {
|
|
372
|
+
code: 'INVALID_OPERATION'
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
const occurrence = flags.occurrence === undefined ? null : positiveInteger(flags.occurrence);
|
|
376
|
+
if (flags.occurrence !== undefined && occurrence == null) {
|
|
377
|
+
throw Object.assign(new Error('--occurrence must be a positive 1-based integer.'), {
|
|
378
|
+
code: 'INVALID_OPERATION'
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
if (!hasInlineTarget) {
|
|
382
|
+
const resolution = resolveSpeculativePatchTarget(document, flags);
|
|
383
|
+
target = resolution.target;
|
|
384
|
+
speculativeContext = resolution.context;
|
|
385
|
+
}
|
|
386
|
+
op = {
|
|
387
|
+
type: inlineRestore ? 'restore' : 'redline',
|
|
388
|
+
...(target == null ? {} : { target }),
|
|
389
|
+
...(targetRef == null ? {} : { targetRef }),
|
|
390
|
+
replacements: [{
|
|
391
|
+
find: String(flags.find),
|
|
392
|
+
replace: String(flags.replace),
|
|
393
|
+
...(occurrence == null ? {} : { occurrence })
|
|
394
|
+
}],
|
|
395
|
+
...(speculativeContext ? { _speculativeContext: speculativeContext } : {}),
|
|
396
|
+
...(flags.author ? { author: String(flags.author) } : {}),
|
|
397
|
+
...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
|
|
398
|
+
};
|
|
399
|
+
} else if (inlineRestore) {
|
|
400
|
+
op = {
|
|
401
|
+
type: 'restore',
|
|
402
|
+
target,
|
|
403
|
+
...(flags.modified === undefined ? {} : { modified: String(flags.modified) }),
|
|
404
|
+
...(flags.author ? { author: String(flags.author) } : {}),
|
|
405
|
+
...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
|
|
406
|
+
};
|
|
407
|
+
} else {
|
|
408
|
+
op = {
|
|
409
|
+
type: 'replace',
|
|
410
|
+
...(target == null ? {} : { target }),
|
|
411
|
+
modified: flags.modified !== undefined ? String(flags.modified) : '',
|
|
412
|
+
...(targetRef == null ? {} : { targetRef }),
|
|
413
|
+
...(flags.author ? { author: String(flags.author) } : {}),
|
|
414
|
+
...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
return { operations: [op], expectedRevision: null };
|
|
418
|
+
}
|
|
419
|
+
if (!file) throw Object.assign(new Error('Use --operations <file.json> or an inline operation.'), { code: 'OPERATIONS_REQUIRED' });
|
|
178
420
|
let parsed;
|
|
179
421
|
try {
|
|
180
422
|
const source = file === '-' ? await readUtf8Stream(stdin) : await readFile(file, 'utf8');
|
|
@@ -216,8 +458,14 @@ function compactExtractParagraph(paragraph) {
|
|
|
216
458
|
const {
|
|
217
459
|
humanReference, provision, nearestHeading, index, ref, paragraphId,
|
|
218
460
|
fingerprint, revisionView, exactText, inTable, list, selectionRole,
|
|
219
|
-
contextFor
|
|
461
|
+
contextFor, hasRevisions, revisionAuthors, segments
|
|
220
462
|
} = paragraph;
|
|
463
|
+
const hiddenRevisionContent = exactText.length === 0 && Array.isArray(segments)
|
|
464
|
+
&& segments.some(segment => segment.text.length > 0 && (
|
|
465
|
+
revisionView === 'rejected'
|
|
466
|
+
? segment.acceptedStart !== null
|
|
467
|
+
: segment.rejectedStart !== null
|
|
468
|
+
));
|
|
221
469
|
return {
|
|
222
470
|
humanReference,
|
|
223
471
|
provision,
|
|
@@ -230,6 +478,12 @@ function compactExtractParagraph(paragraph) {
|
|
|
230
478
|
exactText,
|
|
231
479
|
inTable,
|
|
232
480
|
list,
|
|
481
|
+
...(hasRevisions ? {
|
|
482
|
+
hasRevisions: true,
|
|
483
|
+
revisionAuthors,
|
|
484
|
+
...(hiddenRevisionContent && revisionView === 'accepted' ? { deleted: true } : {}),
|
|
485
|
+
...(hiddenRevisionContent && revisionView === 'rejected' ? { inserted: true } : {})
|
|
486
|
+
} : {}),
|
|
233
487
|
...(selectionRole ? { selectionRole } : {}),
|
|
234
488
|
...(Array.isArray(contextFor) ? { contextFor } : {})
|
|
235
489
|
};
|
|
@@ -470,6 +724,11 @@ function compactMutationResult(value) {
|
|
|
470
724
|
} = serialized;
|
|
471
725
|
const results = Array.isArray(compact.results) ? compact.results.map(compactOperationResult) : [];
|
|
472
726
|
const status = compact.status || 'ok';
|
|
727
|
+
const localizedChangesVerified = results.every(result => !result?.change || (
|
|
728
|
+
result.change.committed === true
|
|
729
|
+
&& result.change.finalDisposition === 'applied'
|
|
730
|
+
&& result.change.verification?.acceptedViewMatchesCompiledText === true
|
|
731
|
+
));
|
|
473
732
|
return {
|
|
474
733
|
...compact,
|
|
475
734
|
...(Array.isArray(compact.results) ? { results } : {}),
|
|
@@ -486,6 +745,7 @@ function compactMutationResult(value) {
|
|
|
486
745
|
&& status !== 'error'
|
|
487
746
|
&& status !== 'partial'
|
|
488
747
|
&& results.every(result => result?.status !== 'error')
|
|
748
|
+
&& localizedChangesVerified
|
|
489
749
|
};
|
|
490
750
|
}
|
|
491
751
|
|
|
@@ -606,7 +866,7 @@ export async function executeCli(argv, io = process) {
|
|
|
606
866
|
return { status: hasErrors ? 'error' : 'ok', command, input, valid: !hasErrors, issues };
|
|
607
867
|
}
|
|
608
868
|
const opsData = command === 'preflight' || command === 'apply'
|
|
609
|
-
? await readOperations(flags.operations, flags, io.stdin || process.stdin)
|
|
869
|
+
? await readOperations(flags.operations, flags, io.stdin || process.stdin, document)
|
|
610
870
|
: null;
|
|
611
871
|
const operations = opsData?.operations || null;
|
|
612
872
|
let expectedRevision = opsData?.expectedRevision || null;
|
|
@@ -663,8 +923,13 @@ export async function executeCli(argv, io = process) {
|
|
|
663
923
|
generateRedlines,
|
|
664
924
|
...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {}),
|
|
665
925
|
...(expectedRevision ? { expectedRevision } : {})
|
|
666
|
-
});
|
|
667
|
-
const
|
|
926
|
+
});
|
|
927
|
+
const localizedVerificationFailed = (result.results || []).some(item => item?.change && (
|
|
928
|
+
item.change.committed !== true
|
|
929
|
+
|| item.change.finalDisposition !== 'applied'
|
|
930
|
+
|| item.change.verification?.acceptedViewMatchesCompiledText !== true
|
|
931
|
+
));
|
|
932
|
+
const mutationResult = await writeMutation(command, input, flags, result);
|
|
668
933
|
return compactMutationResult({
|
|
669
934
|
command,
|
|
670
935
|
input,
|
|
@@ -672,7 +937,7 @@ export async function executeCli(argv, io = process) {
|
|
|
672
937
|
...(profile ? { executionProfile: profile, effectiveOptions } : {}),
|
|
673
938
|
...(result.status === 'error'
|
|
674
939
|
? { exitCode: 2 }
|
|
675
|
-
: (result.status === 'partial' && requireComplete
|
|
940
|
+
: ((result.status === 'partial' || localizedVerificationFailed) && requireComplete
|
|
676
941
|
? { exitCode: 3 }
|
|
677
942
|
: {}))
|
|
678
943
|
});
|
|
@@ -681,8 +946,8 @@ export async function executeCli(argv, io = process) {
|
|
|
681
946
|
if (!filter) return cliError('AUTHOR_REQUIRED', 'Use --author <name> or --all-authors.');
|
|
682
947
|
const result = command === 'delete-comments' ? await document.deleteComments(filter) : await document.resolveRevisions(command, filter);
|
|
683
948
|
return compactMutationResult({ command, input, ...serializable(await writeMutation(command, input, flags, result)) });
|
|
684
|
-
} catch (error) { return cliError(error.code || 'CLI_FAILED', error.message); }
|
|
685
|
-
}
|
|
949
|
+
} catch (error) { return cliError(error.code || 'CLI_FAILED', error.message, 2, error); }
|
|
950
|
+
}
|
|
686
951
|
|
|
687
952
|
export async function runCli(argv = process.argv.slice(2), io = process) {
|
|
688
953
|
configureLogger({}, { level: 'silent' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ansonlai/docx-redline-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -46,15 +46,18 @@
|
|
|
46
46
|
"scripts/",
|
|
47
47
|
"!scripts/benchmark-agent-workflow.mjs",
|
|
48
48
|
"!scripts/benchmark-agent-cli-discovery.mjs",
|
|
49
|
+
"!scripts/benchmark-localized-turn-reduction.mjs",
|
|
49
50
|
"!scripts/lib/agent-performance-cases.mjs",
|
|
50
51
|
"docs/AGENT_FAST_START.md",
|
|
51
52
|
"docs/AGENT_KNOWLEDGE_BASE.md",
|
|
52
53
|
"docs/SKILL_AUTHORING.md",
|
|
53
54
|
"docs/TESTING.md",
|
|
55
|
+
"docs/releases/0.7.0.md",
|
|
54
56
|
"docs/schemas/document-operations.schema.json",
|
|
55
57
|
"docs/validation-reports/2026-09-12-agent-protocol-rollout.md",
|
|
56
58
|
"docs/validation-reports/2026-09-12-agent-cli-discovery-baseline.md",
|
|
57
59
|
"docs/validation-reports/2026-09-13-agent-cli-efficiency-rollout.md",
|
|
60
|
+
"docs/validation-reports/2026-09-14-localized-patching-rollout.md",
|
|
58
61
|
"index.js",
|
|
59
62
|
"index.d.ts",
|
|
60
63
|
"dist/",
|
|
@@ -93,6 +96,7 @@
|
|
|
93
96
|
"benchmark:session": "node scripts/benchmark-operation-session.mjs",
|
|
94
97
|
"benchmark:agent": "node scripts/benchmark-agent-workflow.mjs",
|
|
95
98
|
"benchmark:agent-cli": "node scripts/benchmark-agent-cli-discovery.mjs",
|
|
99
|
+
"benchmark:localized": "node scripts/benchmark-localized-turn-reduction.mjs",
|
|
96
100
|
"benchmark:targeting": "node scripts/benchmark-targeting-hot-paths.mjs",
|
|
97
101
|
"benchmark:tests": "node scripts/benchmark-test-runner.mjs",
|
|
98
102
|
"profile:routes": "node scripts/profile-route-selection.mjs",
|
|
@@ -450,8 +450,9 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
450
450
|
authorUsed: result.authorUsed,
|
|
451
451
|
...(result.resolvedBy ? { resolvedBy: result.resolvedBy } : {}),
|
|
452
452
|
...(result.resolvedTarget ? { resolvedTarget: result.resolvedTarget } : {}),
|
|
453
|
-
...(result.resolvedAnchor ? { resolvedAnchor: result.resolvedAnchor } : {}),
|
|
454
|
-
...(
|
|
453
|
+
...(result.resolvedAnchor ? { resolvedAnchor: result.resolvedAnchor } : {}),
|
|
454
|
+
...(result.change ? { change: result.change } : {}),
|
|
455
|
+
...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
455
456
|
...(result.error ? { error: result.error } : {}),
|
|
456
457
|
...(result.receipt ? { receipt: result.receipt } : {})
|
|
457
458
|
});
|
|
@@ -531,26 +532,34 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
531
532
|
}
|
|
532
533
|
}
|
|
533
534
|
|
|
534
|
-
if (rolledBack) {
|
|
535
|
-
for (const r of results) {
|
|
536
|
-
if (r.receipt && r.receipt.attemptedDisposition === 'applied') {
|
|
537
|
-
r.receipt.finalDisposition = 'rolled_back';
|
|
538
|
-
r.receipt.committed = false;
|
|
539
|
-
}
|
|
540
|
-
|
|
535
|
+
if (rolledBack) {
|
|
536
|
+
for (const r of results) {
|
|
537
|
+
if (r.receipt && r.receipt.attemptedDisposition === 'applied') {
|
|
538
|
+
r.receipt.finalDisposition = 'rolled_back';
|
|
539
|
+
r.receipt.committed = false;
|
|
540
|
+
}
|
|
541
|
+
if (r.change) {
|
|
542
|
+
r.change.finalDisposition = 'rolled_back';
|
|
543
|
+
r.change.committed = false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
541
546
|
for (const receipt of allReceipts) {
|
|
542
547
|
if (receipt.attemptedDisposition === 'applied') {
|
|
543
548
|
receipt.finalDisposition = 'rolled_back';
|
|
544
549
|
receipt.committed = false;
|
|
545
550
|
}
|
|
546
551
|
}
|
|
547
|
-
} else {
|
|
548
|
-
for (const r of results) {
|
|
549
|
-
if (r.receipt && r.receipt.attemptedDisposition === 'applied') {
|
|
550
|
-
r.receipt.committed = true;
|
|
551
|
-
r.receipt.finalDisposition = 'applied';
|
|
552
|
-
}
|
|
553
|
-
|
|
552
|
+
} else {
|
|
553
|
+
for (const r of results) {
|
|
554
|
+
if (r.receipt && r.receipt.attemptedDisposition === 'applied') {
|
|
555
|
+
r.receipt.committed = true;
|
|
556
|
+
r.receipt.finalDisposition = 'applied';
|
|
557
|
+
}
|
|
558
|
+
if (r.change) {
|
|
559
|
+
r.change.finalDisposition = 'applied';
|
|
560
|
+
r.change.committed = true;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
554
563
|
for (const receipt of allReceipts) {
|
|
555
564
|
if (receipt.attemptedDisposition === 'applied') {
|
|
556
565
|
receipt.committed = true;
|
|
@@ -324,9 +324,8 @@ export function inspectDocumentParts(parts, options = {}) {
|
|
|
324
324
|
if (options.range && (index < rangeStart || index > rangeEnd)) continue;
|
|
325
325
|
if (Array.isArray(options.indexes) && !options.indexes.includes(index)) continue;
|
|
326
326
|
const pNode = paragraphNodes[i];
|
|
327
|
-
if (options.
|
|
328
|
-
if (options.
|
|
329
|
-
if (options.revised && revisionAuthors(pNode).length === 0) continue;
|
|
327
|
+
if (options.inTable != null && hasAncestor(pNode, 'tc') !== !!options.inTable) continue;
|
|
328
|
+
if (options.revisedOnly && revisionAuthors(pNode).length === 0) continue;
|
|
330
329
|
const altText = extractCanonicalParagraphText(pNode, { revisionView: alternateView });
|
|
331
330
|
if (options.skipEmpty && altText.length === 0) continue;
|
|
332
331
|
if (altText.toLowerCase().includes(needle)) {
|