@ansonlai/docx-redline-js 0.6.1 → 0.7.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.
- package/AGENTS.md +19 -8
- package/CHANGELOG.md +43 -0
- package/README.md +57 -18
- package/dist/docx-redline-js.esm.js +29 -1
- package/dist/docx-redline-js.esm.js.map +2 -2
- package/dist/docx-redline-js.esm.min.js +66 -66
- package/dist/docx-redline-js.esm.min.js.map +3 -3
- package/docs/AGENT_FAST_START.md +22 -24
- package/docs/AGENT_KNOWLEDGE_BASE.md +33 -16
- package/docs/SKILL_AUTHORING.md +61 -22
- package/docs/releases/0.7.0.md +199 -0
- package/docs/schemas/document-operations.schema.json +13 -0
- package/docs/validation-reports/2026-09-14-localized-patching-rollout.md +90 -0
- package/index.d.ts +2 -0
- package/node/cli-help.js +30 -3
- package/node/cli.js +259 -34
- package/package.json +5 -1
- package/services/batch-operation-orchestrator.js +25 -16
- package/services/document-inspection.js +30 -0
- package/services/document-operation-applier.js +68 -9
- package/services/document-operation-contract.js +56 -5
- package/services/localized-replacement-compiler.js +304 -0
- package/services/operation-batch-compiler.js +33 -1
- package/services/operation-preflight.js +10 -6
- package/services/standalone-operation-runner.d.ts +55 -3
package/node/cli-help.js
CHANGED
|
@@ -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: {
|
|
@@ -116,7 +124,7 @@ export const CLI_COMMAND_HELP = Object.freeze({
|
|
|
116
124
|
},
|
|
117
125
|
apply: {
|
|
118
126
|
summary: 'Apply canonical document operations and write a derived DOCX.',
|
|
119
|
-
usage: 'docx-redline apply <file.docx> --operations <file.json
|
|
127
|
+
usage: 'docx-redline apply <file.docx> (--operations <file.json|->|<inline operation>) [options]',
|
|
120
128
|
options: [
|
|
121
129
|
HELP,
|
|
122
130
|
option('operations', '--operations <file.json|->, --operations-file <file>', 'Read an operation array/envelope from a UTF-8 file or serializer-backed stdin.'),
|
|
@@ -124,7 +132,14 @@ export const CLI_COMMAND_HELP = Object.freeze({
|
|
|
124
132
|
option('noClobber', '--no-clobber', 'Alias of --no-overwrite.'),
|
|
125
133
|
option('expectedRevision', '--expected-revision <token|json>', 'Reject a stale package revision.'),
|
|
126
134
|
option('target', '--target <text>', 'Inline one-operation target text.'),
|
|
135
|
+
option('targetId', '--target-id <paragraphId>', 'Strong inline target using a paragraphId from fresh inspection.'),
|
|
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; all matching anchors are considered.'),
|
|
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,10 @@ 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
|
+
'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.',
|
|
158
|
+
'--occurrence disambiguates repeated text only within one uniquely selected paragraph, never between paragraphs.',
|
|
159
|
+
'Inline find/replace accepts one replacement; use operations JSON for multiple simultaneous replacements.',
|
|
141
160
|
'Use a structured JSON file or serializer-backed stdin; never interpolate legal text through raw shell quoting.',
|
|
142
161
|
'The source is never overwritten unless --in-place is explicit.',
|
|
143
162
|
'The agent profile keeps progressive execution unless --atomic is explicit and does not change existing-revision policy.',
|
|
@@ -182,6 +201,14 @@ export function commandOptionKeys(command) {
|
|
|
182
201
|
return (CLI_COMMAND_HELP[command]?.options || []).map(item => item.key);
|
|
183
202
|
}
|
|
184
203
|
|
|
204
|
+
const DOCS_BASE_URL = 'https://github.com/AnsonLai/docx-redline-js/blob/main/';
|
|
205
|
+
export const CLI_DOCUMENTATION_URLS = Object.freeze([
|
|
206
|
+
`${DOCS_BASE_URL}AGENTS.md`,
|
|
207
|
+
`${DOCS_BASE_URL}docs/AGENT_FAST_START.md`,
|
|
208
|
+
`${DOCS_BASE_URL}docs/SKILL_AUTHORING.md`,
|
|
209
|
+
`${DOCS_BASE_URL}docs/schemas/document-operations.schema.json`
|
|
210
|
+
]);
|
|
211
|
+
|
|
185
212
|
export function buildCliHelp(command = null) {
|
|
186
213
|
if (!command) {
|
|
187
214
|
return {
|
|
@@ -190,7 +217,7 @@ export function buildCliHelp(command = null) {
|
|
|
190
217
|
usage: 'docx-redline <command> [file.docx] [options]',
|
|
191
218
|
commands: CLI_COMMANDS.map(name => ({ name, summary: CLI_COMMAND_HELP[name].summary })),
|
|
192
219
|
notes: ['Run docx-redline <command> --help for flags, semantics, and bounded examples.'],
|
|
193
|
-
documentation: [
|
|
220
|
+
documentation: [...CLI_DOCUMENTATION_URLS]
|
|
194
221
|
};
|
|
195
222
|
}
|
|
196
223
|
const entry = CLI_COMMAND_HELP[command];
|
|
@@ -204,6 +231,6 @@ export function buildCliHelp(command = null) {
|
|
|
204
231
|
options: entry.options.map(({ key: _key, ...publicOption }) => publicOption),
|
|
205
232
|
notes: entry.notes,
|
|
206
233
|
examples: entry.examples,
|
|
207
|
-
documentation: [
|
|
234
|
+
documentation: [...CLI_DOCUMENTATION_URLS]
|
|
208
235
|
};
|
|
209
236
|
}
|
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,25 @@ 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',
|
|
31
34
|
'deduplicated-cli-receipts',
|
|
32
35
|
'compact-cli-json-v1'
|
|
33
36
|
];
|
|
34
37
|
const commandOptions = Object.fromEntries(CLI_COMMANDS.map(command => [command, new Set(commandOptionKeys(command))]));
|
|
35
38
|
|
|
36
|
-
function cliError(code, message, exitCode = 2, details) {
|
|
39
|
+
function cliError(code, message, exitCode = 2, details) {
|
|
40
|
+
return {
|
|
41
|
+
status: 'error',
|
|
42
|
+
error: normalizeErrorWithRecovery({
|
|
43
|
+
...(details && typeof details === 'object' ? details : {}),
|
|
44
|
+
code,
|
|
45
|
+
message
|
|
46
|
+
}),
|
|
47
|
+
exitCode
|
|
48
|
+
};
|
|
49
|
+
}
|
|
37
50
|
const optionAliases = new Map([
|
|
38
51
|
['operationsFile', 'operations'],
|
|
39
52
|
['o', 'output'],
|
|
@@ -59,7 +72,7 @@ function parseArgs(argv) {
|
|
|
59
72
|
const normalizedKey = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
60
73
|
const key = optionAliases.get(normalizedKey) || normalizedKey;
|
|
61
74
|
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];
|
|
75
|
+
else if (argv[index + 1] !== undefined && (argv[index + 1] === '-' || !argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
|
|
63
76
|
else flags[key] = true;
|
|
64
77
|
}
|
|
65
78
|
return { command: positionals[0], input: positionals[1], extraPositionals: positionals.slice(2), flags };
|
|
@@ -150,31 +163,232 @@ async function readUtf8Stream(stream) {
|
|
|
150
163
|
return Buffer.concat(chunks).toString('utf8');
|
|
151
164
|
}
|
|
152
165
|
|
|
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
|
-
|
|
166
|
+
function parseContextRange(value) {
|
|
167
|
+
const match = String(value).match(/^\s*(-?\d+)\s*:\s*(-?\d+)\s*$/);
|
|
168
|
+
if (!match) throw invalidFilter('--context-range must use START:END with signed integer offsets from -20 through 20.');
|
|
169
|
+
const start = Number(match[1]);
|
|
170
|
+
const end = Number(match[2]);
|
|
171
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < -20 || end > 20 || start > end) {
|
|
172
|
+
throw invalidFilter('--context-range requires START <= END and both offsets from -20 through 20.');
|
|
173
|
+
}
|
|
174
|
+
return { start, end, text: `${start}:${end}` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function speculativeCandidate(paragraph) {
|
|
178
|
+
return {
|
|
179
|
+
excerpt: boundedText(paragraph.exactText, 240),
|
|
180
|
+
index: paragraph.index,
|
|
181
|
+
ref: paragraph.ref,
|
|
182
|
+
paragraphId: paragraph.paragraphId,
|
|
183
|
+
fingerprint: paragraph.fingerprint,
|
|
184
|
+
revisionView: 'accepted',
|
|
185
|
+
inTable: paragraph.inTable,
|
|
186
|
+
humanReference: paragraph.humanReference,
|
|
187
|
+
provision: paragraph.provision,
|
|
188
|
+
nearestHeading: paragraph.nearestHeading
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function speculativeError(code, message, details = {}) {
|
|
193
|
+
return Object.assign(new Error(message), { code, ...details });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolveSpeculativePatchTarget(document, flags) {
|
|
197
|
+
if (!document) throw speculativeError('INVALID_OPERATION', 'Speculative localized replacement requires an open document.');
|
|
198
|
+
if (flags.contextRange !== undefined && flags.around !== undefined) {
|
|
199
|
+
throw invalidFilter('Use --context-range or --around, not both.');
|
|
200
|
+
}
|
|
201
|
+
const search = flags.search === undefined ? null : String(flags.search).trim();
|
|
202
|
+
if (flags.search !== undefined && (!search || flags.search === true)) {
|
|
203
|
+
throw invalidFilter('--search must be non-empty text.');
|
|
204
|
+
}
|
|
205
|
+
if ((flags.contextRange !== undefined || flags.around !== undefined) && !search) {
|
|
206
|
+
throw invalidFilter('--context-range and --around require --search.');
|
|
207
|
+
}
|
|
208
|
+
let range = { start: 0, end: 0, text: '0:0' };
|
|
209
|
+
if (flags.contextRange !== undefined) range = parseContextRange(flags.contextRange);
|
|
210
|
+
if (flags.around !== undefined) {
|
|
211
|
+
const text = String(flags.around).trim();
|
|
212
|
+
if (!/^\d+$/.test(text) || Number(text) > 20) {
|
|
213
|
+
throw invalidFilter('--around must be an integer from 0 through 20.');
|
|
214
|
+
}
|
|
215
|
+
const around = Number(text);
|
|
216
|
+
range = { start: -around, end: around, text: `${-around}:${around}` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const inspected = document.inspect({ revisionView: 'accepted' });
|
|
220
|
+
const paragraphs = Array.isArray(inspected.paragraphs) ? inspected.paragraphs : [];
|
|
221
|
+
let anchors = [];
|
|
222
|
+
let eligible = paragraphs;
|
|
223
|
+
if (search) {
|
|
224
|
+
const folded = search.toLowerCase();
|
|
225
|
+
anchors = paragraphs.filter(paragraph => paragraph.exactText.toLowerCase().includes(folded));
|
|
226
|
+
if (anchors.length === 0) {
|
|
227
|
+
throw speculativeError('TARGET_NOT_FOUND', `No paragraph matched search query: "${search}".`, {
|
|
228
|
+
context: { search, range: range.text },
|
|
229
|
+
recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const eligibleIndexes = new Set();
|
|
233
|
+
for (const anchor of anchors) {
|
|
234
|
+
const anchorPosition = anchor.index - 1;
|
|
235
|
+
for (let offset = range.start; offset <= range.end; offset += 1) {
|
|
236
|
+
const position = anchorPosition + offset;
|
|
237
|
+
if (position >= 0 && position < paragraphs.length) eligibleIndexes.add(position);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
eligible = [...eligibleIndexes].sort((a, b) => a - b).map(position => paragraphs[position]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const find = String(flags.find);
|
|
244
|
+
const matches = eligible.filter(paragraph => paragraph.exactText.includes(find));
|
|
245
|
+
const context = {
|
|
246
|
+
...(search ? { search, range: range.text, anchorMatchCount: anchors.length } : {})
|
|
247
|
+
};
|
|
248
|
+
if (matches.length === 0) {
|
|
249
|
+
throw speculativeError('PATCH_SOURCE_NOT_FOUND', search
|
|
250
|
+
? `No paragraph in the contextual range contains the exact patch source: "${find}".`
|
|
251
|
+
: `No paragraph contains the exact patch source: "${find}".`, {
|
|
252
|
+
context,
|
|
253
|
+
...(anchors.length ? { candidates: anchors.map(speculativeCandidate) } : {}),
|
|
254
|
+
recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (matches.length > 1) {
|
|
258
|
+
throw speculativeError('AMBIGUOUS_TARGET', `The exact patch source matched ${matches.length} eligible paragraphs.`, {
|
|
259
|
+
context,
|
|
260
|
+
candidates: matches.map(speculativeCandidate),
|
|
261
|
+
recovery: { action: 'choose_candidate', requiresReinspection: false, sameArgumentsSafe: false }
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const selected = matches[0];
|
|
265
|
+
return {
|
|
266
|
+
target: {
|
|
267
|
+
exactText: selected.exactText,
|
|
268
|
+
...(selected.paragraphId ? { paragraphId: selected.paragraphId } : {}),
|
|
269
|
+
fingerprint: selected.fingerprint,
|
|
270
|
+
revisionView: 'accepted',
|
|
271
|
+
inTable: selected.inTable
|
|
272
|
+
},
|
|
273
|
+
context: {
|
|
274
|
+
...context,
|
|
275
|
+
humanReference: selected.humanReference
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function readOperations(file, flags = {}, stdin = process.stdin, document = null) {
|
|
281
|
+
const hasInlineTarget = flags?.target !== undefined
|
|
282
|
+
|| flags?.targetId !== undefined
|
|
283
|
+
|| flags?.targetRef !== undefined;
|
|
284
|
+
const hasInlinePatch = flags?.find !== undefined
|
|
285
|
+
|| flags?.replace !== undefined
|
|
286
|
+
|| flags?.occurrence !== undefined;
|
|
287
|
+
const hasSpeculativeScope = flags?.search !== undefined
|
|
288
|
+
|| flags?.contextRange !== undefined
|
|
289
|
+
|| flags?.around !== undefined;
|
|
290
|
+
if (file && (hasInlinePatch || hasSpeculativeScope)) {
|
|
291
|
+
throw Object.assign(new Error('Use --operations or inline speculative find/replace options, not both.'), {
|
|
292
|
+
code: 'INVALID_OPERATION'
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (hasInlineTarget && hasSpeculativeScope) {
|
|
296
|
+
throw Object.assign(new Error('Use a strong inline target or --search/--context-range, not both.'), {
|
|
297
|
+
code: 'INVALID_OPERATION'
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (!file && (hasInlineTarget || hasInlinePatch || hasSpeculativeScope)) {
|
|
301
|
+
if (hasSpeculativeScope && !hasInlinePatch) {
|
|
302
|
+
throw Object.assign(new Error('--search, --context-range, and --around are only valid with inline --find/--replace.'), {
|
|
303
|
+
code: 'INVALID_OPERATION'
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const targetRef = flags.targetRef === undefined ? null : positiveInteger(flags.targetRef);
|
|
307
|
+
if (flags.targetRef !== undefined && targetRef == null) {
|
|
308
|
+
throw Object.assign(new Error('--target-ref must be a positive 1-based integer.'), {
|
|
309
|
+
code: 'INVALID_OPERATION'
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const targetId = flags.targetId === undefined ? null : String(flags.targetId).trim();
|
|
313
|
+
if (flags.targetId !== undefined && (!targetId || flags.targetId === true)) {
|
|
314
|
+
throw Object.assign(new Error('--target-id must be a non-empty paragraphId.'), {
|
|
315
|
+
code: 'INVALID_OPERATION'
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const targetText = flags.target === undefined ? null : String(flags.target);
|
|
319
|
+
let target = targetId
|
|
320
|
+
? { ...(targetText == null ? {} : { exactText: targetText }), paragraphId: targetId }
|
|
321
|
+
: targetText;
|
|
322
|
+
let speculativeContext = null;
|
|
323
|
+
let op;
|
|
324
|
+
if (flags.comment) {
|
|
325
|
+
if (hasInlinePatch) {
|
|
326
|
+
throw Object.assign(new Error('Inline comments cannot be combined with --find/--replace.'), {
|
|
327
|
+
code: 'INVALID_OPERATION'
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
op = {
|
|
331
|
+
type: 'comment',
|
|
332
|
+
...(target == null ? {} : { target }),
|
|
333
|
+
commentContent: String(flags.comment),
|
|
334
|
+
...(flags.textToComment ? { textToComment: String(flags.textToComment) } : {}),
|
|
335
|
+
...(targetRef == null ? {} : { targetRef }),
|
|
336
|
+
...(flags.author ? { author: String(flags.author) } : {})
|
|
337
|
+
};
|
|
338
|
+
} else if (hasInlinePatch) {
|
|
339
|
+
if (flags.modified !== undefined) {
|
|
340
|
+
throw Object.assign(new Error('Use --modified or --find/--replace, not both.'), {
|
|
341
|
+
code: 'INVALID_OPERATION'
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (
|
|
345
|
+
flags.find === undefined
|
|
346
|
+
|| flags.find === true
|
|
347
|
+
|| String(flags.find).length === 0
|
|
348
|
+
|| flags.replace === undefined
|
|
349
|
+
|| flags.replace === true
|
|
350
|
+
) {
|
|
351
|
+
throw Object.assign(new Error('Inline localized replacement requires non-empty --find and string --replace values.'), {
|
|
352
|
+
code: 'INVALID_OPERATION'
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const occurrence = flags.occurrence === undefined ? null : positiveInteger(flags.occurrence);
|
|
356
|
+
if (flags.occurrence !== undefined && occurrence == null) {
|
|
357
|
+
throw Object.assign(new Error('--occurrence must be a positive 1-based integer.'), {
|
|
358
|
+
code: 'INVALID_OPERATION'
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (!hasInlineTarget) {
|
|
362
|
+
const resolution = resolveSpeculativePatchTarget(document, flags);
|
|
363
|
+
target = resolution.target;
|
|
364
|
+
speculativeContext = resolution.context;
|
|
365
|
+
}
|
|
366
|
+
op = {
|
|
367
|
+
type: 'redline',
|
|
368
|
+
...(target == null ? {} : { target }),
|
|
369
|
+
...(targetRef == null ? {} : { targetRef }),
|
|
370
|
+
replacements: [{
|
|
371
|
+
find: String(flags.find),
|
|
372
|
+
replace: String(flags.replace),
|
|
373
|
+
...(occurrence == null ? {} : { occurrence })
|
|
374
|
+
}],
|
|
375
|
+
...(speculativeContext ? { _speculativeContext: speculativeContext } : {}),
|
|
376
|
+
...(flags.author ? { author: String(flags.author) } : {}),
|
|
377
|
+
...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
|
|
378
|
+
};
|
|
379
|
+
} else {
|
|
380
|
+
op = {
|
|
381
|
+
type: 'replace',
|
|
382
|
+
...(target == null ? {} : { target }),
|
|
383
|
+
modified: flags.modified !== undefined ? String(flags.modified) : '',
|
|
384
|
+
...(targetRef == null ? {} : { targetRef }),
|
|
385
|
+
...(flags.author ? { author: String(flags.author) } : {}),
|
|
386
|
+
...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
return { operations: [op], expectedRevision: null };
|
|
390
|
+
}
|
|
391
|
+
if (!file) throw Object.assign(new Error('Use --operations <file.json> or an inline operation.'), { code: 'OPERATIONS_REQUIRED' });
|
|
178
392
|
let parsed;
|
|
179
393
|
try {
|
|
180
394
|
const source = file === '-' ? await readUtf8Stream(stdin) : await readFile(file, 'utf8');
|
|
@@ -470,6 +684,11 @@ function compactMutationResult(value) {
|
|
|
470
684
|
} = serialized;
|
|
471
685
|
const results = Array.isArray(compact.results) ? compact.results.map(compactOperationResult) : [];
|
|
472
686
|
const status = compact.status || 'ok';
|
|
687
|
+
const localizedChangesVerified = results.every(result => !result?.change || (
|
|
688
|
+
result.change.committed === true
|
|
689
|
+
&& result.change.finalDisposition === 'applied'
|
|
690
|
+
&& result.change.verification?.acceptedViewMatchesCompiledText === true
|
|
691
|
+
));
|
|
473
692
|
return {
|
|
474
693
|
...compact,
|
|
475
694
|
...(Array.isArray(compact.results) ? { results } : {}),
|
|
@@ -486,6 +705,7 @@ function compactMutationResult(value) {
|
|
|
486
705
|
&& status !== 'error'
|
|
487
706
|
&& status !== 'partial'
|
|
488
707
|
&& results.every(result => result?.status !== 'error')
|
|
708
|
+
&& localizedChangesVerified
|
|
489
709
|
};
|
|
490
710
|
}
|
|
491
711
|
|
|
@@ -606,7 +826,7 @@ export async function executeCli(argv, io = process) {
|
|
|
606
826
|
return { status: hasErrors ? 'error' : 'ok', command, input, valid: !hasErrors, issues };
|
|
607
827
|
}
|
|
608
828
|
const opsData = command === 'preflight' || command === 'apply'
|
|
609
|
-
? await readOperations(flags.operations, flags, io.stdin || process.stdin)
|
|
829
|
+
? await readOperations(flags.operations, flags, io.stdin || process.stdin, document)
|
|
610
830
|
: null;
|
|
611
831
|
const operations = opsData?.operations || null;
|
|
612
832
|
let expectedRevision = opsData?.expectedRevision || null;
|
|
@@ -663,8 +883,13 @@ export async function executeCli(argv, io = process) {
|
|
|
663
883
|
generateRedlines,
|
|
664
884
|
...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {}),
|
|
665
885
|
...(expectedRevision ? { expectedRevision } : {})
|
|
666
|
-
});
|
|
667
|
-
const
|
|
886
|
+
});
|
|
887
|
+
const localizedVerificationFailed = (result.results || []).some(item => item?.change && (
|
|
888
|
+
item.change.committed !== true
|
|
889
|
+
|| item.change.finalDisposition !== 'applied'
|
|
890
|
+
|| item.change.verification?.acceptedViewMatchesCompiledText !== true
|
|
891
|
+
));
|
|
892
|
+
const mutationResult = await writeMutation(command, input, flags, result);
|
|
668
893
|
return compactMutationResult({
|
|
669
894
|
command,
|
|
670
895
|
input,
|
|
@@ -672,7 +897,7 @@ export async function executeCli(argv, io = process) {
|
|
|
672
897
|
...(profile ? { executionProfile: profile, effectiveOptions } : {}),
|
|
673
898
|
...(result.status === 'error'
|
|
674
899
|
? { exitCode: 2 }
|
|
675
|
-
: (result.status === 'partial' && requireComplete
|
|
900
|
+
: ((result.status === 'partial' || localizedVerificationFailed) && requireComplete
|
|
676
901
|
? { exitCode: 3 }
|
|
677
902
|
: {}))
|
|
678
903
|
});
|
|
@@ -681,8 +906,8 @@ export async function executeCli(argv, io = process) {
|
|
|
681
906
|
if (!filter) return cliError('AUTHOR_REQUIRED', 'Use --author <name> or --all-authors.');
|
|
682
907
|
const result = command === 'delete-comments' ? await document.deleteComments(filter) : await document.resolveRevisions(command, filter);
|
|
683
908
|
return compactMutationResult({ command, input, ...serializable(await writeMutation(command, input, flags, result)) });
|
|
684
|
-
} catch (error) { return cliError(error.code || 'CLI_FAILED', error.message); }
|
|
685
|
-
}
|
|
909
|
+
} catch (error) { return cliError(error.code || 'CLI_FAILED', error.message, 2, error); }
|
|
910
|
+
}
|
|
686
911
|
|
|
687
912
|
export async function runCli(argv = process.argv.slice(2), io = process) {
|
|
688
913
|
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.0",
|
|
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;
|
|
@@ -309,6 +309,36 @@ export function inspectDocumentParts(parts, options = {}) {
|
|
|
309
309
|
comments.set(id, definition);
|
|
310
310
|
}
|
|
311
311
|
const selected = selectInspectionParagraphs(paragraphs, options);
|
|
312
|
+
if (options.search && selected.selection && selected.selection.totalMatches === 0) {
|
|
313
|
+
const needle = String(options.search).toLowerCase();
|
|
314
|
+
const alternateView = revisionView === 'accepted' ? 'rejected' : 'accepted';
|
|
315
|
+
let rangeStart = 1;
|
|
316
|
+
let rangeEnd = paragraphNodes.length;
|
|
317
|
+
if (options.range) {
|
|
318
|
+
rangeStart = Number(options.range.start ?? options.range[0]);
|
|
319
|
+
rangeEnd = Number(options.range.end ?? options.range[1]);
|
|
320
|
+
}
|
|
321
|
+
let alternateMatches = 0;
|
|
322
|
+
for (let i = 0; i < paragraphNodes.length; i++) {
|
|
323
|
+
const index = i + 1;
|
|
324
|
+
if (options.range && (index < rangeStart || index > rangeEnd)) continue;
|
|
325
|
+
if (Array.isArray(options.indexes) && !options.indexes.includes(index)) continue;
|
|
326
|
+
const pNode = paragraphNodes[i];
|
|
327
|
+
if (options.table && !hasAncestor(pNode, 'tc')) continue;
|
|
328
|
+
if (options.body && hasAncestor(pNode, 'tc')) continue;
|
|
329
|
+
if (options.revised && revisionAuthors(pNode).length === 0) continue;
|
|
330
|
+
const altText = extractCanonicalParagraphText(pNode, { revisionView: alternateView });
|
|
331
|
+
if (options.skipEmpty && altText.length === 0) continue;
|
|
332
|
+
if (altText.toLowerCase().includes(needle)) {
|
|
333
|
+
alternateMatches++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (alternateMatches > 0) {
|
|
337
|
+
selected.selection.hint = revisionView === 'accepted'
|
|
338
|
+
? `0 matches in accepted view, but ${alternateMatches} match${alternateMatches === 1 ? '' : 'es'} found in --view rejected. If searching for deleted text to restore, re-run with --view rejected.`
|
|
339
|
+
: `0 matches in rejected view, but ${alternateMatches} match${alternateMatches === 1 ? '' : 'es'} found in --view accepted. Re-run with --view accepted.`;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
312
342
|
paragraphs = selected.paragraphs;
|
|
313
343
|
const allRevisionAuthors = [...new Set(paragraphs.flatMap(item => item.revisionAuthors))].sort();
|
|
314
344
|
const coveredEntries = extractDocumentPartsEntries(parts);
|