@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
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
resolveDocumentOperationAuthor,
|
|
5
5
|
validateDocumentOperation
|
|
6
6
|
} from './document-operation-contract.js';
|
|
7
|
-
import { DocumentOperationSession } from './document-operation-session.js';
|
|
7
|
+
import { DocumentOperationSession } from './document-operation-session.js';
|
|
8
|
+
import { compileOperationBatch } from './operation-batch-compiler.js';
|
|
8
9
|
import {
|
|
9
10
|
validateRevisionToken,
|
|
10
11
|
computeDocumentPartsRevisionToken,
|
|
@@ -31,6 +32,8 @@ import {
|
|
|
31
32
|
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
32
33
|
import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
|
|
33
34
|
import { normalizeErrorWithRecovery } from './error-recovery.js';
|
|
35
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
36
|
+
import { buildLocalizedReplacementChange } from './localized-replacement-compiler.js';
|
|
34
37
|
|
|
35
38
|
export function normalizeOperationError(error, context = {}) {
|
|
36
39
|
return normalizeErrorWithRecovery(error, context);
|
|
@@ -60,7 +63,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
let operation = validation.operation || normalizeDocumentOperation(op);
|
|
64
67
|
const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
|
|
65
68
|
|
|
66
69
|
if (
|
|
@@ -144,7 +147,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
144
147
|
const session = options?._documentOperationSession instanceof DocumentOperationSession
|
|
145
148
|
? options._documentOperationSession
|
|
146
149
|
: new DocumentOperationSession(documentXml, options);
|
|
147
|
-
if (!session.valid) {
|
|
150
|
+
if (!session.valid) {
|
|
148
151
|
return {
|
|
149
152
|
documentXml,
|
|
150
153
|
hasChanges: false,
|
|
@@ -154,10 +157,48 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
154
157
|
operationType: operation.operationKind,
|
|
155
158
|
authorUsed,
|
|
156
159
|
receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
Array.isArray(operation.replacements)
|
|
165
|
+
|| (operation.operationKind === 'restore' && op?.modified === undefined)
|
|
166
|
+
) {
|
|
167
|
+
const compilation = compileOperationBatch(session.document, [op], {
|
|
168
|
+
strictTargets: options.strictTargets !== false,
|
|
169
|
+
onInfo: options.onInfo,
|
|
170
|
+
onWarn: options.onWarn
|
|
171
|
+
});
|
|
172
|
+
const compilationError = compilation.bindings[0]?.error || compilation.conflicts[0] || null;
|
|
173
|
+
if (compilationError) {
|
|
174
|
+
return {
|
|
175
|
+
documentXml,
|
|
176
|
+
hasChanges: false,
|
|
177
|
+
status: 'error',
|
|
178
|
+
error: normalizeOperationError(compilationError, errorContext),
|
|
179
|
+
operationType: operation.operationKind,
|
|
180
|
+
authorUsed,
|
|
181
|
+
receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const compiledValidation = validateDocumentOperation(compilation.compiledOperations[0]);
|
|
185
|
+
if (!compiledValidation.valid) {
|
|
186
|
+
return {
|
|
187
|
+
documentXml,
|
|
188
|
+
hasChanges: false,
|
|
189
|
+
status: 'error',
|
|
190
|
+
error: normalizeOperationError(compiledValidation.error, errorContext),
|
|
191
|
+
operationType: operation.operationKind,
|
|
192
|
+
authorUsed,
|
|
193
|
+
receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
session.sourceTargetRegistry = compilation.registry;
|
|
197
|
+
operation = compiledValidation.operation;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const resolutionCapture = {};
|
|
201
|
+
let localizedChange = null;
|
|
161
202
|
const savepoint = session.createSavepoint();
|
|
162
203
|
session.receiptCollector?.beginOperation(
|
|
163
204
|
operationIndex,
|
|
@@ -375,6 +416,33 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
375
416
|
operationIndex
|
|
376
417
|
);
|
|
377
418
|
}
|
|
419
|
+
if (operation._localizedReplacementCompilation) {
|
|
420
|
+
let outputText = null;
|
|
421
|
+
if (operation.operationKind === 'restore') {
|
|
422
|
+
const restoredParagraph = operationOptions._mutationLiveNodes.find(node => node?.localName === 'p');
|
|
423
|
+
if (restoredParagraph) {
|
|
424
|
+
outputText = extractCanonicalParagraphText(restoredParagraph, {
|
|
425
|
+
revisionView: 'accepted'
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
} else if (operation._compiledSourceId && session.sourceTargetRegistry) {
|
|
429
|
+
const bound = session.sourceTargetRegistry.resolve(
|
|
430
|
+
operation._compiledSourceId,
|
|
431
|
+
session.document
|
|
432
|
+
);
|
|
433
|
+
if (bound.paragraph) {
|
|
434
|
+
outputText = extractCanonicalParagraphText(bound.paragraph, {
|
|
435
|
+
revisionView: 'accepted'
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
localizedChange = buildLocalizedReplacementChange(
|
|
440
|
+
operation._localizedReplacementCompilation,
|
|
441
|
+
outputText,
|
|
442
|
+
resolutionCapture.resolvedTarget || {},
|
|
443
|
+
operation._speculativeContext || null
|
|
444
|
+
);
|
|
445
|
+
}
|
|
378
446
|
if (operation.captureKey && session.captureTable) {
|
|
379
447
|
session.captureTable.set(
|
|
380
448
|
operation.captureKey,
|
|
@@ -426,8 +494,9 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
426
494
|
...result,
|
|
427
495
|
operationType: operation.operationKind,
|
|
428
496
|
authorUsed,
|
|
429
|
-
receipt: operationReceipt,
|
|
430
|
-
...
|
|
497
|
+
receipt: operationReceipt,
|
|
498
|
+
...(localizedChange ? { change: localizedChange } : {}),
|
|
499
|
+
...resolutionCapture
|
|
431
500
|
};
|
|
432
501
|
} catch (error) {
|
|
433
502
|
session.restoreSavepoint(savepoint);
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* logic can operate on one internal shape.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { validateExactReplacementRequests } from './localized-replacement-compiler.js';
|
|
9
|
+
|
|
8
10
|
const SUPPORTED_OPERATION_TYPES = new Set([
|
|
9
11
|
'redline',
|
|
10
12
|
'replace',
|
|
@@ -215,11 +217,60 @@ export function validateDocumentOperation(operation) {
|
|
|
215
217
|
};
|
|
216
218
|
}
|
|
217
219
|
|
|
218
|
-
if (normalized.operationKind === 'redline'
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
if (normalized.operationKind === 'redline') {
|
|
221
|
+
const hasModified = typeof normalized.modified === 'string';
|
|
222
|
+
const hasReplacements = normalized.replacements !== undefined;
|
|
223
|
+
if (hasModified === hasReplacements) {
|
|
224
|
+
return {
|
|
225
|
+
valid: false,
|
|
226
|
+
error: {
|
|
227
|
+
code: 'INVALID_OPERATION',
|
|
228
|
+
message: 'Redline operations require exactly one of string "modified" or non-empty "replacements".'
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (hasReplacements) {
|
|
233
|
+
if (rawType != null && rawType !== '' && !['redline', 'replace'].includes(rawType)) {
|
|
234
|
+
return {
|
|
235
|
+
valid: false,
|
|
236
|
+
error: {
|
|
237
|
+
code: 'INVALID_OPERATION',
|
|
238
|
+
message: 'Localized replacements are supported only for redline or replace operations.'
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
target.revisionView !== 'accepted'
|
|
244
|
+
|| target.captureRef
|
|
245
|
+
|| target.occurrence != null
|
|
246
|
+
|| normalized.captureKey
|
|
247
|
+
|| normalized.targetEndDescriptor
|
|
248
|
+
|| normalized.targetEndRef != null
|
|
249
|
+
|| /\r|\n/.test(target.text || '')
|
|
250
|
+
) {
|
|
251
|
+
return {
|
|
252
|
+
valid: false,
|
|
253
|
+
error: {
|
|
254
|
+
code: 'INVALID_OPERATION',
|
|
255
|
+
message: 'Localized replacements v1 require one batch-start accepted-view paragraph target without captures, target occurrence, or a target range.'
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const replacementValidation = validateExactReplacementRequests(normalized.replacements);
|
|
260
|
+
if (!replacementValidation.ok) {
|
|
261
|
+
return { valid: false, error: replacementValidation.error };
|
|
262
|
+
}
|
|
263
|
+
if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
|
|
264
|
+
return {
|
|
265
|
+
valid: false,
|
|
266
|
+
error: {
|
|
267
|
+
code: 'INVALID_OPERATION',
|
|
268
|
+
message: 'Localized replacements v1 cannot insert paragraph breaks.'
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
normalized.replacements = replacementValidation.replacements;
|
|
273
|
+
}
|
|
223
274
|
}
|
|
224
275
|
|
|
225
276
|
if (normalized.operationKind === 'rejected-insert') {
|
|
@@ -256,19 +307,66 @@ export function validateDocumentOperation(operation) {
|
|
|
256
307
|
}
|
|
257
308
|
|
|
258
309
|
if (normalized.operationKind === 'restore') {
|
|
310
|
+
const hasModified = operation.modified !== undefined;
|
|
311
|
+
const hasReplacements = operation.replacements !== undefined;
|
|
259
312
|
const validSingle = nonEmptyString(normalized.modified);
|
|
260
313
|
const validRange = Array.isArray(normalized.modified)
|
|
261
314
|
&& normalized.modified.length > 0
|
|
262
315
|
&& normalized.modified.every(nonEmptyString);
|
|
263
|
-
if (
|
|
316
|
+
if (hasModified && hasReplacements) {
|
|
317
|
+
return {
|
|
318
|
+
valid: false,
|
|
319
|
+
error: {
|
|
320
|
+
code: 'INVALID_OPERATION',
|
|
321
|
+
message: 'Restore operations accept modified or replacements, not both.'
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (hasModified && !validSingle && !validRange) {
|
|
264
326
|
return {
|
|
265
327
|
valid: false,
|
|
266
328
|
error: {
|
|
267
329
|
code: 'INVALID_OPERATION',
|
|
268
|
-
message: 'Restore
|
|
330
|
+
message: 'Restore modified must be a non-empty string or non-empty string array.'
|
|
269
331
|
}
|
|
270
332
|
};
|
|
271
333
|
}
|
|
334
|
+
if (!hasModified) {
|
|
335
|
+
if (
|
|
336
|
+
target.revisionView !== 'rejected'
|
|
337
|
+
|| (!target.paragraphId && !target.fingerprint)
|
|
338
|
+
|| target.captureRef
|
|
339
|
+
|| target.occurrence != null
|
|
340
|
+
|| normalized.captureKey
|
|
341
|
+
|| normalized.targetEndDescriptor
|
|
342
|
+
|| normalized.targetEndRef != null
|
|
343
|
+
|| /\r|\n/.test(target.text || '')
|
|
344
|
+
) {
|
|
345
|
+
return {
|
|
346
|
+
valid: false,
|
|
347
|
+
error: {
|
|
348
|
+
code: 'INVALID_OPERATION',
|
|
349
|
+
message: 'Verbatim and localized restore require one strong batch-start rejected-view paragraph target without captures, target occurrence, or a target range.'
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (hasReplacements) {
|
|
355
|
+
const replacementValidation = validateExactReplacementRequests(normalized.replacements);
|
|
356
|
+
if (!replacementValidation.ok) {
|
|
357
|
+
return { valid: false, error: replacementValidation.error };
|
|
358
|
+
}
|
|
359
|
+
if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
|
|
360
|
+
return {
|
|
361
|
+
valid: false,
|
|
362
|
+
error: {
|
|
363
|
+
code: 'INVALID_OPERATION',
|
|
364
|
+
message: 'Localized restore cannot insert paragraph breaks.'
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
normalized.replacements = replacementValidation.replacements;
|
|
369
|
+
}
|
|
272
370
|
if (normalized.generateRedlines === false) {
|
|
273
371
|
return {
|
|
274
372
|
valid: false,
|
|
@@ -223,7 +223,37 @@ function describeTargetTextMatch(actualText, requestedText) {
|
|
|
223
223
|
requestedExcerpt: escapeInvisibleText(requested)
|
|
224
224
|
};
|
|
225
225
|
}
|
|
226
|
-
|
|
226
|
+
|
|
227
|
+
function resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options) {
|
|
228
|
+
const revisionView = options?.targetDescriptor?.revisionView === 'rejected'
|
|
229
|
+
? 'rejected'
|
|
230
|
+
: 'accepted';
|
|
231
|
+
const viewMetadata = metadata?.revisionView === revisionView ? metadata : null;
|
|
232
|
+
const index = viewMetadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1;
|
|
233
|
+
const paragraphId = viewMetadata?.paragraphId ?? getParagraphId(paragraph);
|
|
234
|
+
const inTable = viewMetadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl');
|
|
235
|
+
const text = viewMetadata?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
|
|
236
|
+
const fingerprint = viewMetadata?.fingerprint ?? createParagraphFingerprint(paragraph, {
|
|
237
|
+
text,
|
|
238
|
+
index,
|
|
239
|
+
paragraphId,
|
|
240
|
+
inTable,
|
|
241
|
+
revisionView
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
index,
|
|
245
|
+
paragraphId,
|
|
246
|
+
text,
|
|
247
|
+
fingerprint,
|
|
248
|
+
revisionView,
|
|
249
|
+
inTable,
|
|
250
|
+
targetTextMatch: describeTargetTextMatch(
|
|
251
|
+
text,
|
|
252
|
+
options?.targetDescriptor?.text ?? options?.targetDescriptor?.exactText ?? targetText
|
|
253
|
+
)
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
227
257
|
function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
|
|
228
258
|
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
229
259
|
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
|
|
@@ -239,17 +269,7 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
239
269
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
240
270
|
Object.assign(options._resolutionCapture, {
|
|
241
271
|
resolvedBy: resolved.resolvedBy,
|
|
242
|
-
resolvedTarget:
|
|
243
|
-
index: metadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1,
|
|
244
|
-
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
245
|
-
text: metadata?.text ?? getParagraphText(paragraph),
|
|
246
|
-
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
247
|
-
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
248
|
-
targetTextMatch: describeTargetTextMatch(
|
|
249
|
-
metadata?.text ?? getParagraphText(paragraph),
|
|
250
|
-
options?.targetDescriptor?.exactText ?? targetText
|
|
251
|
-
)
|
|
252
|
-
}
|
|
272
|
+
resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
|
|
253
273
|
});
|
|
254
274
|
}
|
|
255
275
|
return resolved;
|
|
@@ -263,17 +283,7 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
263
283
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
264
284
|
Object.assign(options._resolutionCapture, {
|
|
265
285
|
resolvedBy: resolved.resolvedBy,
|
|
266
|
-
resolvedTarget:
|
|
267
|
-
index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
|
|
268
|
-
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
269
|
-
text: metadata?.text ?? getParagraphText(paragraph),
|
|
270
|
-
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
271
|
-
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
272
|
-
targetTextMatch: describeTargetTextMatch(
|
|
273
|
-
metadata?.text ?? getParagraphText(paragraph),
|
|
274
|
-
options?.targetDescriptor?.exactText ?? targetText
|
|
275
|
-
)
|
|
276
|
-
}
|
|
286
|
+
resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
|
|
277
287
|
});
|
|
278
288
|
}
|
|
279
289
|
return resolved;
|
|
@@ -301,19 +311,9 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
301
311
|
if (options?._resolutionCapture && resolved?.paragraph) {
|
|
302
312
|
const paragraph = resolved.paragraph;
|
|
303
313
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
304
|
-
Object.assign(options._resolutionCapture, {
|
|
305
|
-
resolvedBy: resolved.resolvedBy,
|
|
306
|
-
resolvedTarget:
|
|
307
|
-
index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
|
|
308
|
-
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
309
|
-
text: metadata?.text ?? getParagraphText(paragraph),
|
|
310
|
-
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
311
|
-
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
312
|
-
targetTextMatch: describeTargetTextMatch(
|
|
313
|
-
metadata?.text ?? getParagraphText(paragraph),
|
|
314
|
-
options?.targetDescriptor?.exactText ?? targetText
|
|
315
|
-
)
|
|
316
|
-
}
|
|
314
|
+
Object.assign(options._resolutionCapture, {
|
|
315
|
+
resolvedBy: resolved.resolvedBy,
|
|
316
|
+
resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
|
|
317
317
|
});
|
|
318
318
|
}
|
|
319
319
|
return resolved;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { normalizeErrorWithRecovery } from './error-recovery.js';
|
|
2
|
+
|
|
3
|
+
function invalidReplacement(message, field, invalidCode) {
|
|
4
|
+
return normalizeErrorWithRecovery({
|
|
5
|
+
code: invalidCode,
|
|
6
|
+
message,
|
|
7
|
+
...(field ? { field } : {}),
|
|
8
|
+
recovery: {
|
|
9
|
+
action: 'change_request',
|
|
10
|
+
sameArgumentsSafe: false,
|
|
11
|
+
requiresReinspection: false,
|
|
12
|
+
requiresUserAuthorization: false
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function patchError(code, message, field, recoveryAction, details = {}) {
|
|
18
|
+
return normalizeErrorWithRecovery({
|
|
19
|
+
code,
|
|
20
|
+
message,
|
|
21
|
+
field,
|
|
22
|
+
...details,
|
|
23
|
+
recovery: {
|
|
24
|
+
action: recoveryAction,
|
|
25
|
+
sameArgumentsSafe: false,
|
|
26
|
+
requiresReinspection: false,
|
|
27
|
+
requiresUserAuthorization: false
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function occurrenceOffsets(sourceText, find) {
|
|
33
|
+
const offsets = [];
|
|
34
|
+
let from = 0;
|
|
35
|
+
while (from <= sourceText.length - find.length) {
|
|
36
|
+
const start = sourceText.indexOf(find, from);
|
|
37
|
+
if (start < 0) break;
|
|
38
|
+
offsets.push(start);
|
|
39
|
+
from = start + 1;
|
|
40
|
+
}
|
|
41
|
+
return offsets;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function offsetCandidate(sourceText, start, length) {
|
|
45
|
+
const excerptStart = Math.max(0, start - 40);
|
|
46
|
+
const excerptEnd = Math.min(sourceText.length, start + length + 40);
|
|
47
|
+
return {
|
|
48
|
+
start,
|
|
49
|
+
end: start + length,
|
|
50
|
+
excerpt: sourceText.slice(excerptStart, excerptEnd)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function boundedExcerpt(text, start, end, limit = 120) {
|
|
55
|
+
const source = String(text ?? '');
|
|
56
|
+
const safeStart = Math.max(0, Math.min(source.length, start));
|
|
57
|
+
const safeEnd = Math.max(safeStart, Math.min(source.length, end));
|
|
58
|
+
const center = Math.floor((safeStart + safeEnd) / 2);
|
|
59
|
+
let excerptStart = Math.max(0, center - Math.floor(limit / 2));
|
|
60
|
+
let excerptEnd = Math.min(source.length, excerptStart + limit);
|
|
61
|
+
excerptStart = Math.max(0, excerptEnd - limit);
|
|
62
|
+
const prefix = excerptStart > 0 ? '…' : '';
|
|
63
|
+
const suffix = excerptEnd < source.length ? '…' : '';
|
|
64
|
+
const available = Math.max(0, limit - prefix.length - suffix.length);
|
|
65
|
+
return `${prefix}${source.slice(excerptStart, excerptStart + available)}${suffix}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Validate and normalize exact replacement requests without resolving source ranges. */
|
|
69
|
+
export function validateExactReplacementRequests(
|
|
70
|
+
replacements,
|
|
71
|
+
field = 'replacements',
|
|
72
|
+
{ invalidCode = 'INVALID_OPERATION' } = {}
|
|
73
|
+
) {
|
|
74
|
+
if (!Array.isArray(replacements) || replacements.length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: invalidReplacement('replacements must be a non-empty array.', field, invalidCode)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const normalized = [];
|
|
82
|
+
for (let index = 0; index < replacements.length; index += 1) {
|
|
83
|
+
const replacement = replacements[index];
|
|
84
|
+
const itemField = `${field}[${index}]`;
|
|
85
|
+
if (!replacement || typeof replacement !== 'object' || Array.isArray(replacement)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: invalidReplacement(`${itemField} must be an object.`, itemField, invalidCode)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (typeof replacement.find !== 'string' || replacement.find.length === 0) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: invalidReplacement(
|
|
95
|
+
`${itemField}.find must be a non-empty string.`,
|
|
96
|
+
`${itemField}.find`,
|
|
97
|
+
invalidCode
|
|
98
|
+
)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (typeof replacement.replace !== 'string') {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: invalidReplacement(
|
|
105
|
+
`${itemField}.replace must be a string.`,
|
|
106
|
+
`${itemField}.replace`,
|
|
107
|
+
invalidCode
|
|
108
|
+
)
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (replacement.find === replacement.replace) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: invalidReplacement(
|
|
115
|
+
`${itemField} must change the matched text.`,
|
|
116
|
+
itemField,
|
|
117
|
+
invalidCode
|
|
118
|
+
)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const occurrence = replacement.occurrence == null ? null : Number(replacement.occurrence);
|
|
122
|
+
if (occurrence != null && (!Number.isInteger(occurrence) || occurrence < 1)) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: invalidReplacement(
|
|
126
|
+
`${itemField}.occurrence must be a positive integer.`,
|
|
127
|
+
`${itemField}.occurrence`,
|
|
128
|
+
invalidCode
|
|
129
|
+
)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
normalized.push({
|
|
133
|
+
find: replacement.find,
|
|
134
|
+
replace: replacement.replace,
|
|
135
|
+
...(occurrence == null ? {} : { occurrence })
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { ok: true, replacements: normalized };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compile exact, source-relative replacement intents into complete desired text.
|
|
144
|
+
* All source ranges are resolved before mutation, so replacements are simultaneous.
|
|
145
|
+
*/
|
|
146
|
+
export function compileExactReplacements(
|
|
147
|
+
sourceText,
|
|
148
|
+
replacements,
|
|
149
|
+
field = 'replacements',
|
|
150
|
+
options = {}
|
|
151
|
+
) {
|
|
152
|
+
const invalidCode = options.invalidCode || 'INVALID_OPERATION';
|
|
153
|
+
if (typeof sourceText !== 'string') {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: invalidReplacement(
|
|
157
|
+
'Localized replacements require string source text.',
|
|
158
|
+
field,
|
|
159
|
+
invalidCode
|
|
160
|
+
)
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const validation = validateExactReplacementRequests(replacements, field, { invalidCode });
|
|
164
|
+
if (!validation.ok) return validation;
|
|
165
|
+
|
|
166
|
+
const resolved = [];
|
|
167
|
+
for (let index = 0; index < validation.replacements.length; index += 1) {
|
|
168
|
+
const replacement = validation.replacements[index];
|
|
169
|
+
const itemField = `${field}[${index}]`;
|
|
170
|
+
const occurrence = replacement.occurrence ?? null;
|
|
171
|
+
const offsets = occurrenceOffsets(sourceText, replacement.find);
|
|
172
|
+
if (offsets.length === 0 || (occurrence != null && occurrence > offsets.length)) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: patchError(
|
|
176
|
+
'PATCH_SOURCE_NOT_FOUND',
|
|
177
|
+
occurrence == null
|
|
178
|
+
? `Exact patch source was not found: "${replacement.find}".`
|
|
179
|
+
: `Occurrence ${occurrence} of exact patch source was not found: "${replacement.find}".`,
|
|
180
|
+
itemField,
|
|
181
|
+
'change_patch',
|
|
182
|
+
{ matchCount: offsets.length }
|
|
183
|
+
)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (occurrence == null && offsets.length > 1) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: patchError(
|
|
190
|
+
'AMBIGUOUS_PATCH_SOURCE',
|
|
191
|
+
`Exact patch source matched ${offsets.length} locations; provide occurrence.`,
|
|
192
|
+
itemField,
|
|
193
|
+
'choose_occurrence',
|
|
194
|
+
{ candidates: offsets.map(start => offsetCandidate(sourceText, start, replacement.find.length)) }
|
|
195
|
+
)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const start = offsets[(occurrence || 1) - 1];
|
|
200
|
+
resolved.push({
|
|
201
|
+
requestIndex: index,
|
|
202
|
+
start,
|
|
203
|
+
end: start + replacement.find.length,
|
|
204
|
+
find: replacement.find,
|
|
205
|
+
replace: replacement.replace,
|
|
206
|
+
occurrence: occurrence || 1
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
resolved.sort((left, right) => left.start - right.start || left.end - right.end || left.requestIndex - right.requestIndex);
|
|
211
|
+
const unique = [];
|
|
212
|
+
for (const replacement of resolved) {
|
|
213
|
+
const previous = unique.at(-1);
|
|
214
|
+
if (previous && replacement.start === previous.start && replacement.end === previous.end) {
|
|
215
|
+
if (replacement.replace !== previous.replace) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: patchError(
|
|
219
|
+
'CONFLICTING_PATCHES',
|
|
220
|
+
`Localized replacements ${previous.requestIndex + 1} and ${replacement.requestIndex + 1} assign different text to the same source range.`,
|
|
221
|
+
field,
|
|
222
|
+
'combine_patches',
|
|
223
|
+
{ replacementIndexes: [previous.requestIndex + 1, replacement.requestIndex + 1] }
|
|
224
|
+
)
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (previous && replacement.start < previous.end) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: patchError(
|
|
233
|
+
'OVERLAPPING_PATCHES',
|
|
234
|
+
`Localized replacements ${previous.requestIndex + 1} and ${replacement.requestIndex + 1} overlap.`,
|
|
235
|
+
field,
|
|
236
|
+
'combine_patches',
|
|
237
|
+
{ replacementIndexes: [previous.requestIndex + 1, replacement.requestIndex + 1] }
|
|
238
|
+
)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
unique.push(replacement);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let cursor = 0;
|
|
245
|
+
let desiredText = '';
|
|
246
|
+
for (const replacement of unique) {
|
|
247
|
+
desiredText += sourceText.slice(cursor, replacement.start);
|
|
248
|
+
desiredText += replacement.replace;
|
|
249
|
+
cursor = replacement.end;
|
|
250
|
+
}
|
|
251
|
+
desiredText += sourceText.slice(cursor);
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
desiredText,
|
|
256
|
+
replacements: unique.map(replacement => ({
|
|
257
|
+
requestIndex: replacement.requestIndex + 1,
|
|
258
|
+
start: replacement.start,
|
|
259
|
+
end: replacement.end,
|
|
260
|
+
find: replacement.find,
|
|
261
|
+
replace: replacement.replace,
|
|
262
|
+
occurrence: replacement.occurrence,
|
|
263
|
+
removedLength: replacement.end - replacement.start,
|
|
264
|
+
insertedLength: replacement.replace.length
|
|
265
|
+
}))
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Build a compact receipt-like summary from actual source and output text. */
|
|
270
|
+
export function buildLocalizedReplacementChange(compilation, outputText, target = {}, context = null) {
|
|
271
|
+
if (!compilation || typeof compilation !== 'object') return null;
|
|
272
|
+
const sourceText = String(compilation.sourceText ?? '');
|
|
273
|
+
const desiredText = String(compilation.desiredText ?? '');
|
|
274
|
+
const replacements = Array.isArray(compilation.replacements) ? compilation.replacements : [];
|
|
275
|
+
let delta = 0;
|
|
276
|
+
const applied = replacements.map(replacement => {
|
|
277
|
+
const afterStart = replacement.start + delta;
|
|
278
|
+
const afterEnd = afterStart + replacement.replace.length;
|
|
279
|
+
delta += replacement.replace.length - (replacement.end - replacement.start);
|
|
280
|
+
return {
|
|
281
|
+
find: replacement.find,
|
|
282
|
+
replace: replacement.replace,
|
|
283
|
+
occurrence: replacement.occurrence,
|
|
284
|
+
beforeExcerpt: boundedExcerpt(sourceText, replacement.start, replacement.end),
|
|
285
|
+
afterExcerpt: boundedExcerpt(desiredText, afterStart, afterEnd)
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
kind: 'localized_replacement',
|
|
290
|
+
committed: true,
|
|
291
|
+
finalDisposition: 'applied',
|
|
292
|
+
target: {
|
|
293
|
+
...(target.paragraphId ? { paragraphId: target.paragraphId } : {}),
|
|
294
|
+
...(Number.isInteger(target.index) ? { index: target.index } : {}),
|
|
295
|
+
...(target.fingerprint ? { fingerprint: target.fingerprint } : {}),
|
|
296
|
+
...(context?.humanReference ? { humanReference: context.humanReference } : {})
|
|
297
|
+
},
|
|
298
|
+
...(context ? { context: { ...context } } : {}),
|
|
299
|
+
replacements: applied,
|
|
300
|
+
verification: {
|
|
301
|
+
acceptedViewMatchesCompiledText: typeof outputText === 'string' && outputText === desiredText
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|