@prestyj/cli 5.29.0 → 5.30.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/assets/motion/skills/app-walkthrough/SKILL.md +4 -0
- package/assets/motion/skills/before-after/SKILL.md +4 -0
- package/assets/motion/skills/dev-tool-video/SKILL.md +4 -0
- package/assets/motion/skills/launch-video/SKILL.md +4 -0
- package/assets/motion/skills/match-reference/SKILL.md +4 -0
- package/assets/motion/skills/motion/SKILL.md +78 -21
- package/assets/motion/skills/website-video/SKILL.md +47 -30
- package/assets/skills/bulletproof/SKILL.md +1 -1
- package/assets/skills/compliance-guard/references/us.md +1 -1
- package/assets/skills/lean/SKILL.md +1 -1
- package/assets/skills/refactoring/SKILL.md +3 -1
- package/dist/app-error.d.ts +21 -0
- package/dist/app-error.js +39 -0
- package/dist/app-error.js.map +1 -0
- package/dist/app-sidecar.js +42 -35
- package/dist/app-sidecar.js.map +1 -1
- package/dist/cli/thinking-arg.d.ts +6 -0
- package/dist/cli/thinking-arg.js +13 -0
- package/dist/cli/thinking-arg.js.map +1 -0
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +5 -8
- package/dist/cli.js.map +1 -1
- package/dist/core/agent-session.d.ts +3 -2
- package/dist/core/agent-session.js +34 -10
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/checklist-detection.d.ts +11 -0
- package/dist/core/checklist-detection.js +238 -0
- package/dist/core/checklist-detection.js.map +1 -0
- package/dist/core/checklist-items.d.ts +180 -0
- package/dist/core/checklist-items.js +204 -0
- package/dist/core/checklist-items.js.map +1 -0
- package/dist/core/checklist-prompt.d.ts +3 -0
- package/dist/core/checklist-prompt.js +34 -0
- package/dist/core/checklist-prompt.js.map +1 -0
- package/dist/core/checklist-snapshot.d.ts +13 -0
- package/dist/core/checklist-snapshot.js +29 -0
- package/dist/core/checklist-snapshot.js.map +1 -0
- package/dist/core/checklist-store.d.ts +59 -0
- package/dist/core/checklist-store.js +242 -0
- package/dist/core/checklist-store.js.map +1 -0
- package/dist/core/codex-request-shape.d.ts +12 -0
- package/dist/core/codex-request-shape.js +31 -0
- package/dist/core/codex-request-shape.js.map +1 -0
- package/dist/core/compaction/compactor.js +6 -0
- package/dist/core/compaction/compactor.js.map +1 -1
- package/dist/core/lsp/client.d.ts +4 -0
- package/dist/core/lsp/client.js +71 -3
- package/dist/core/lsp/client.js.map +1 -1
- package/dist/core/lsp/manager.d.ts +1 -0
- package/dist/core/lsp/manager.js +9 -3
- package/dist/core/lsp/manager.js.map +1 -1
- package/dist/core/lsp/project-root.d.ts +9 -0
- package/dist/core/lsp/project-root.js +75 -0
- package/dist/core/lsp/project-root.js.map +1 -0
- package/dist/core/lsp/reference-project.d.ts +6 -0
- package/dist/core/lsp/reference-project.js +110 -0
- package/dist/core/lsp/reference-project.js.map +1 -0
- package/dist/core/lsp/servers.js +1 -0
- package/dist/core/lsp/servers.js.map +1 -1
- package/dist/core/nolan-context.js +7 -3
- package/dist/core/nolan-context.js.map +1 -1
- package/dist/core/prompt-commands.js +95 -0
- package/dist/core/prompt-commands.js.map +1 -1
- package/dist/core/settings-manager.d.ts +10 -0
- package/dist/core/settings-manager.js +10 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/subagent-manager.d.ts +2 -1
- package/dist/core/subagent-manager.js +8 -5
- package/dist/core/subagent-manager.js.map +1 -1
- package/dist/core/subagent-receipt.js +3 -1
- package/dist/core/subagent-receipt.js.map +1 -1
- package/dist/modes/acp-mode.d.ts +2 -1
- package/dist/modes/acp-mode.js +4 -1
- package/dist/modes/acp-mode.js.map +1 -1
- package/dist/modes/subagent-worker-mode.d.ts +2 -0
- package/dist/modes/subagent-worker-mode.js +1 -3
- package/dist/modes/subagent-worker-mode.js.map +1 -1
- package/dist/motion-agent/motion-prompt.d.ts +1 -1
- package/dist/motion-agent/motion-prompt.js +4 -3
- package/dist/motion-agent/motion-prompt.js.map +1 -1
- package/dist/motion-agent/motion-review-session.js +7 -3
- package/dist/motion-agent/motion-review-session.js.map +1 -1
- package/dist/system-prompt.js +15 -13
- package/dist/system-prompt.js.map +1 -1
- package/dist/tools/bash.d.ts +4 -1
- package/dist/tools/bash.js +32 -7
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/checklist.d.ts +56 -0
- package/dist/tools/checklist.js +178 -0
- package/dist/tools/checklist.js.map +1 -0
- package/dist/tools/code-nav.d.ts +1 -1
- package/dist/tools/code-nav.js +3 -2
- package/dist/tools/code-nav.js.map +1 -1
- package/dist/tools/edit-targets.d.ts +10 -0
- package/dist/tools/edit-targets.js +31 -0
- package/dist/tools/edit-targets.js.map +1 -0
- package/dist/tools/edit.d.ts +11 -3
- package/dist/tools/edit.js +386 -312
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/exit-plan.d.ts +2 -2
- package/dist/tools/exit-plan.js +7 -1
- package/dist/tools/exit-plan.js.map +1 -1
- package/dist/tools/index.d.ts +5 -3
- package/dist/tools/index.js +5 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/prompt-hints.js +6 -1
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/tools/skill.js +6 -3
- package/dist/tools/skill.js.map +1 -1
- package/dist/tools/subagent-shared.d.ts +6 -8
- package/dist/tools/subagent-shared.js +17 -11
- package/dist/tools/subagent-shared.js.map +1 -1
- package/dist/tools/subagent.d.ts +8 -1
- package/dist/tools/subagent.js +21 -9
- package/dist/tools/subagent.js.map +1 -1
- package/dist/tools/tasks.d.ts +1 -1
- package/dist/tools/tool-tiers.d.ts +6 -0
- package/dist/tools/tool-tiers.js +8 -1
- package/dist/tools/tool-tiers.js.map +1 -1
- package/dist/tools/verification-review.d.ts +2 -0
- package/dist/tools/verification-review.js +41 -0
- package/dist/tools/verification-review.js.map +1 -0
- package/dist/ui/App.js +8 -3
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/components/ToolExecution.js +3 -2
- package/dist/ui/components/ToolExecution.js.map +1 -1
- package/dist/ui/hooks/useModeState.js +15 -0
- package/dist/ui/hooks/useModeState.js.map +1 -1
- package/dist/ui/terminal-history.js +3 -1
- package/dist/ui/terminal-history.js.map +1 -1
- package/dist/ui/tool-line-summary.js +14 -2
- package/dist/ui/tool-line-summary.js.map +1 -1
- package/dist/ui/transcript/tool-presentation.js +1 -1
- package/dist/ui/transcript/tool-presentation.js.map +1 -1
- package/dist/utils/format.js +5 -1
- package/dist/utils/format.js.map +1 -1
- package/package.json +4 -4
package/dist/tools/edit.js
CHANGED
|
@@ -85,27 +85,48 @@ const STRINGIFIED_EDITS_ERROR = "`edits` arrived as a JSON-encoded string and co
|
|
|
85
85
|
"Send `edits` as a real JSON array of objects, never as a string. " +
|
|
86
86
|
"Re-sending the same large payload usually breaks the same way: split the work into " +
|
|
87
87
|
"several `edit` calls carrying one or two smaller edits each.";
|
|
88
|
-
const
|
|
88
|
+
const EditList = z
|
|
89
|
+
.preprocess(coerceStringifiedEdits, z
|
|
90
|
+
.array(EditItem, {
|
|
91
|
+
// Narrow to "an array was expected, a string arrived" so a nested
|
|
92
|
+
// string-typed mistake (`anchor: "x"`, `replace_all: "true"`) keeps
|
|
93
|
+
// its own accurate message. Any non-matching issue returns undefined
|
|
94
|
+
// and falls through to Zod's default.
|
|
95
|
+
error: (issue) => issue.code === "invalid_type" &&
|
|
96
|
+
issue.expected === "array" &&
|
|
97
|
+
typeof issue.input === "string"
|
|
98
|
+
? STRINGIFIED_EDITS_ERROR
|
|
99
|
+
: undefined,
|
|
100
|
+
})
|
|
101
|
+
.min(1))
|
|
102
|
+
.describe("One or more edits applied in order. Each edit operates on the result of the previous one.");
|
|
103
|
+
// Multi-file entries take the text form only: a replay test batched all 7
|
|
104
|
+
// files 8/8 times either way, and dropping the anchor/span forms here keeps
|
|
105
|
+
// ~2k chars of duplicated schema out of every request.
|
|
106
|
+
const TextEditItem = EditItem.pick({ old_text: true, new_text: true, replace_all: true });
|
|
107
|
+
const FileEdits = z.object({
|
|
89
108
|
file_path: z.string().describe("The file path to edit"),
|
|
90
109
|
edits: z
|
|
91
|
-
.preprocess(coerceStringifiedEdits, z
|
|
92
|
-
.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
.min(1)
|
|
104
|
-
.
|
|
110
|
+
.preprocess(coerceStringifiedEdits, z.array(TextEditItem).min(1))
|
|
111
|
+
.describe("Text-form edits for this file, applied in order."),
|
|
112
|
+
});
|
|
113
|
+
// `files` lets one call cover a multi-file change. Measured on gpt-6-astra
|
|
114
|
+
// (replay of a 7-file refactor at the point where every file had been read):
|
|
115
|
+
// with it the model sent all 7 files in one call 8/8 times; without it 5/8
|
|
116
|
+
// responses carried every edit and the rest fell back to one file per turn.
|
|
117
|
+
const EditParams = z.object({
|
|
118
|
+
file_path: z.string().optional().describe("The file path to edit (single-file form)"),
|
|
119
|
+
edits: EditList.optional(),
|
|
120
|
+
files: z
|
|
121
|
+
.array(FileEdits)
|
|
122
|
+
.min(1)
|
|
123
|
+
.optional()
|
|
124
|
+
.describe("Several files in ONE call: one entry per file, each with text-form edits (old_text/new_text). " +
|
|
125
|
+
"Use instead of file_path/edits when a change spans files."),
|
|
105
126
|
atomic: z
|
|
106
127
|
.boolean()
|
|
107
128
|
.optional()
|
|
108
|
-
.describe("If true, fail
|
|
129
|
+
.describe("If true, fail a file's whole batch when any of its edits fails — that file is left unchanged. " +
|
|
109
130
|
"Default false: partial-apply, keep every successful edit and report failures " +
|
|
110
131
|
"for retry. Use atomic only when later edits depend on earlier ones in a way " +
|
|
111
132
|
"where a half-applied state would be worse than nothing."),
|
|
@@ -175,10 +196,11 @@ export function createEditTool(cwd, readFiles, ops = localOperations, planModeRe
|
|
|
175
196
|
"text edits then run on the result.\n" +
|
|
176
197
|
"Partial-apply by default: failed edits are listed for retry, successful ones are still written — " +
|
|
177
198
|
"re-issue ONLY the listed failures, not the whole batch. " +
|
|
178
|
-
"Returns a unified diff
|
|
199
|
+
"Returns a unified diff.\n" +
|
|
200
|
+
"Multi-file: pass `files` (one {file_path, edits} entry per file) to change several files in a single call.",
|
|
179
201
|
parameters: EditParams,
|
|
180
202
|
executionMode: "sequential",
|
|
181
|
-
async execute({ file_path, edits, atomic = false }) {
|
|
203
|
+
async execute({ file_path, edits, files, atomic = false }) {
|
|
182
204
|
const goalMode = getActiveGoalMode(goalModeRef);
|
|
183
205
|
if (goalMode !== "off") {
|
|
184
206
|
return goalModeMutationRestriction("edit", goalMode);
|
|
@@ -186,333 +208,385 @@ export function createEditTool(cwd, readFiles, ops = localOperations, planModeRe
|
|
|
186
208
|
if (isPlanModeActive(planModeRef)) {
|
|
187
209
|
return planModeRestriction("edit");
|
|
188
210
|
}
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return
|
|
195
|
-
}
|
|
196
|
-
// No prior read is required: every old_text, span and anchor is matched
|
|
197
|
-
// against the live content below, which proves the model knows the text
|
|
198
|
-
// it replaces (it may have seen it via `cat`, or changed the file itself
|
|
199
|
-
// with a script). A read that no longer matches disk is forgotten, so the
|
|
200
|
-
// file counts as unread again: the edit still validates against live
|
|
201
|
-
// content, but a later full-file `write` needs a fresh read.
|
|
202
|
-
if (readFiles?.has(resolved) && !(await isFresh(readFiles, resolved, ops))) {
|
|
203
|
-
readFiles.delete(resolved);
|
|
211
|
+
const targets = resolveEditTargets(file_path, edits, files);
|
|
212
|
+
if (!targets.ok)
|
|
213
|
+
throw new Error(targets.error);
|
|
214
|
+
if (targets.value.length === 1) {
|
|
215
|
+
const only = targets.value[0];
|
|
216
|
+
return editOneFile(only.file_path, only.edits, atomic);
|
|
204
217
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
outcomes[i] = {
|
|
224
|
-
ok: false,
|
|
225
|
-
failure: {
|
|
226
|
-
reason: "invalid",
|
|
227
|
-
detail: "provide either old_text+new_text, or span+lines — this edit has neither",
|
|
228
|
-
},
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
continue;
|
|
218
|
+
// Files apply in order, each independently: one file's failure never
|
|
219
|
+
// rolls back another, and the result says exactly which to re-issue.
|
|
220
|
+
const parts = [];
|
|
221
|
+
const diffs = [];
|
|
222
|
+
let failed = 0;
|
|
223
|
+
for (const target of targets.value) {
|
|
224
|
+
try {
|
|
225
|
+
const result = await editOneFile(target.file_path, target.edits, atomic);
|
|
226
|
+
const content = typeof result === "string" ? result : result.content;
|
|
227
|
+
if (typeof result !== "string" && result.details.diff)
|
|
228
|
+
diffs.push(result.details.diff);
|
|
229
|
+
if (typeof result === "string" && result.startsWith("Error:"))
|
|
230
|
+
failed++;
|
|
231
|
+
parts.push(`### ${target.file_path}\n${content}`);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
failed++;
|
|
235
|
+
parts.push(`### ${target.file_path}\nFAILED — ${error instanceof Error ? error.message : String(error)}`);
|
|
232
236
|
}
|
|
233
|
-
|
|
237
|
+
}
|
|
238
|
+
const total = targets.value.length;
|
|
239
|
+
if (failed === total)
|
|
240
|
+
throw new Error(`All ${total} files failed; no changes written.\n\n${parts.join("\n\n")}`);
|
|
241
|
+
const header = failed === 0
|
|
242
|
+
? `Edited ${total} files.`
|
|
243
|
+
: `Edited ${total - failed} of ${total} files. ${failed} failed — re-issue ONLY the failed files (the rest are already done).`;
|
|
244
|
+
return { content: `${header}\n\n${parts.join("\n\n")}`, details: { diff: diffs.join("\n") } };
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
async function editOneFile(file_path, edits, atomic) {
|
|
248
|
+
const resolved = resolvePath(cwd, file_path);
|
|
249
|
+
await rejectSymlink(resolved);
|
|
250
|
+
// Workspace write guard: outside cwd/tmp/~/.ezcoder requires user approval.
|
|
251
|
+
const guard = resolveWriteGuard(cwd, resolved, getWriteGuardSettings?.());
|
|
252
|
+
if (!guard.allowed) {
|
|
253
|
+
return `Error: ${guard.reason}`;
|
|
254
|
+
}
|
|
255
|
+
// No prior read is required: every old_text, span and anchor is matched
|
|
256
|
+
// against the live content below, which proves the model knows the text
|
|
257
|
+
// it replaces (it may have seen it via `cat`, or changed the file itself
|
|
258
|
+
// with a script). A read that no longer matches disk is forgotten, so the
|
|
259
|
+
// file counts as unread again: the edit still validates against live
|
|
260
|
+
// content, but a later full-file `write` needs a fresh read.
|
|
261
|
+
if (readFiles?.has(resolved) && !(await isFresh(readFiles, resolved, ops))) {
|
|
262
|
+
readFiles.delete(resolved);
|
|
263
|
+
}
|
|
264
|
+
const original = await ops.readFile(resolved);
|
|
265
|
+
const hasCRLF = original.includes("\r\n");
|
|
266
|
+
const originalNormalized = hasCRLF ? original.replace(/\r\n/g, "\n") : original;
|
|
267
|
+
// Anchors pin lines in the file AS READ, so they always verify against the
|
|
268
|
+
// original (pre-edit) line array — earlier edits in the batch don't shift
|
|
269
|
+
// what an anchor refers to.
|
|
270
|
+
const originalLines = originalNormalized.split("\n");
|
|
271
|
+
const fileName = path.basename(resolved);
|
|
272
|
+
const outcomes = new Array(edits.length);
|
|
273
|
+
// ── Phase 1: span-form edits (hash-anchored replacement). Spans resolve
|
|
274
|
+
// against the file AS READ and apply bottom-up so indices stay valid.
|
|
275
|
+
// Text-form edits then run on the result in phase 2.
|
|
276
|
+
const isSpanForm = (e) => e.span !== undefined || e.lines !== undefined;
|
|
277
|
+
const spanResolved = [];
|
|
278
|
+
for (let i = 0; i < edits.length; i++) {
|
|
279
|
+
const e = edits[i];
|
|
280
|
+
if (!isSpanForm(e)) {
|
|
281
|
+
if (e.old_text === undefined || e.new_text === undefined) {
|
|
234
282
|
outcomes[i] = {
|
|
235
283
|
ok: false,
|
|
236
284
|
failure: {
|
|
237
285
|
reason: "invalid",
|
|
238
|
-
detail: "
|
|
286
|
+
detail: "provide either old_text+new_text, or span+lines — this edit has neither",
|
|
239
287
|
},
|
|
240
288
|
};
|
|
241
|
-
continue;
|
|
242
289
|
}
|
|
243
|
-
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (!e.span || !e.lines || e.old_text !== undefined || e.new_text !== undefined) {
|
|
293
|
+
outcomes[i] = {
|
|
294
|
+
ok: false,
|
|
295
|
+
failure: {
|
|
296
|
+
reason: "invalid",
|
|
297
|
+
detail: "span form requires BOTH span and lines, and must not mix with old_text/new_text",
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const res = resolveAnchoredEdit(originalLines, e.span);
|
|
303
|
+
if (!res.ok) {
|
|
304
|
+
outcomes[i] = { ok: false, failure: { reason: "stale_anchor" } };
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
spanResolved.push({ index: i, start: res.startIndex, end: res.endIndex, lines: e.lines });
|
|
308
|
+
}
|
|
309
|
+
// Reject overlapping spans (keep the first, fail the rest) — overlap means
|
|
310
|
+
// the model double-addressed the same region and the result is undefined.
|
|
311
|
+
spanResolved.sort((a, b) => a.start - b.start || a.index - b.index);
|
|
312
|
+
const spanApplied = [];
|
|
313
|
+
let lastEnd = -1;
|
|
314
|
+
for (const s of spanResolved) {
|
|
315
|
+
if (s.start <= lastEnd) {
|
|
316
|
+
outcomes[s.index] = { ok: false, failure: { reason: "overlap" } };
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
spanApplied.push(s);
|
|
320
|
+
lastEnd = s.end;
|
|
321
|
+
}
|
|
322
|
+
const workingLines = [...originalLines];
|
|
323
|
+
for (let i = spanApplied.length - 1; i >= 0; i--) {
|
|
324
|
+
const s = spanApplied[i];
|
|
325
|
+
workingLines.splice(s.start, s.end - s.start + 1, ...s.lines);
|
|
326
|
+
outcomes[s.index] = { ok: true, source: "span" };
|
|
327
|
+
}
|
|
328
|
+
let working = workingLines.join("\n");
|
|
329
|
+
// ── Phase 2: text-form edits, sequential on the working buffer.
|
|
330
|
+
for (let i = 0; i < edits.length; i++) {
|
|
331
|
+
if (outcomes[i] !== undefined)
|
|
332
|
+
continue; // span-form or invalid, already settled
|
|
333
|
+
const { old_text, new_text, replace_all, anchor } = edits[i];
|
|
334
|
+
if (old_text === undefined || new_text === undefined)
|
|
335
|
+
continue; // settled above
|
|
336
|
+
// Optional staleness guard (opt-in). Runs BEFORE the fuzzy match ladder:
|
|
337
|
+
// if the model supplied an anchor and the file drifted since it read it,
|
|
338
|
+
// reject this edit instead of risking a misplaced fuzzy match. The fuzzy
|
|
339
|
+
// path below is byte-identical to today when `anchor` is absent.
|
|
340
|
+
if (anchor) {
|
|
341
|
+
const res = resolveAnchoredEdit(originalLines, anchor);
|
|
244
342
|
if (!res.ok) {
|
|
245
343
|
outcomes[i] = { ok: false, failure: { reason: "stale_anchor" } };
|
|
246
344
|
continue;
|
|
247
345
|
}
|
|
248
|
-
spanResolved.push({ index: i, start: res.startIndex, end: res.endIndex, lines: e.lines });
|
|
249
346
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
spanApplied.push(s);
|
|
261
|
-
lastEnd = s.end;
|
|
347
|
+
const normalizedOld = hasCRLF ? old_text.replace(/\r\n/g, "\n") : old_text;
|
|
348
|
+
const normalizedNew = hasCRLF ? new_text.replace(/\r\n/g, "\n") : new_text;
|
|
349
|
+
const replaceAll = replace_all ?? false;
|
|
350
|
+
// Identical replacements are explicit no-op successes. They should not
|
|
351
|
+
// block atomic batches that contain real edits, and all-no-op batches
|
|
352
|
+
// should report success without writing.
|
|
353
|
+
if (normalizedOld === normalizedNew) {
|
|
354
|
+
outcomes[i] = { ok: true };
|
|
355
|
+
continue;
|
|
262
356
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
357
|
+
if (normalizedOld.length === 0 ||
|
|
358
|
+
(!working.includes(normalizedOld) && normalizeForFuzzyMatch(normalizedOld).length === 0)) {
|
|
359
|
+
outcomes[i] = {
|
|
360
|
+
ok: false,
|
|
361
|
+
failure: {
|
|
362
|
+
reason: "invalid",
|
|
363
|
+
detail: "old_text is empty after normalization; provide visible surrounding context.",
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
continue;
|
|
268
367
|
}
|
|
269
|
-
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
368
|
+
// Aider's full fallback ladder, run only when the primary match
|
|
369
|
+
// returns "not_found". Ambiguous matches deliberately don't fall
|
|
370
|
+
// through — the model needs to add context, not paraphrase further.
|
|
371
|
+
// Order mirrors aider/coders/editblock_coder.py:
|
|
372
|
+
// 1. exact + smart-quote/dash fuzzy (in tryMatch)
|
|
373
|
+
// 2. indent-flex (model omitted/shortened leading whitespace)
|
|
374
|
+
// 3. drop spurious leading/trailing blank lines, retry 1+2
|
|
375
|
+
// 4. dotdotdots (`...` elision with preserved middle)
|
|
376
|
+
let source = "text";
|
|
377
|
+
let result = tryMatch(working, normalizedOld, normalizedNew, replaceAll);
|
|
378
|
+
const tryFallbacks = (oldText) => {
|
|
379
|
+
const flexed = applyMissingLeadingWhitespace(working, oldText, normalizedNew);
|
|
380
|
+
if (flexed !== null)
|
|
381
|
+
return flexed;
|
|
382
|
+
// Re-run primary matcher on the stripped variant as a cheap retry.
|
|
383
|
+
const exact = tryMatch(working, oldText, normalizedNew, replaceAll);
|
|
384
|
+
if (exact.ok)
|
|
385
|
+
return exact.newWorking;
|
|
386
|
+
return null;
|
|
387
|
+
};
|
|
388
|
+
if (!result.ok && result.reason === "not_found") {
|
|
389
|
+
const indentFlexed = applyMissingLeadingWhitespace(working, normalizedOld, normalizedNew);
|
|
390
|
+
if (indentFlexed !== null) {
|
|
391
|
+
result = { ok: true, newWorking: indentFlexed };
|
|
392
|
+
source = "indent_flex";
|
|
287
393
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
394
|
+
}
|
|
395
|
+
if (!result.ok && result.reason === "not_found") {
|
|
396
|
+
const stripped = stripBlankEdges(normalizedOld);
|
|
397
|
+
if (stripped !== null) {
|
|
398
|
+
const candidate = tryFallbacks(stripped);
|
|
399
|
+
if (candidate !== null) {
|
|
400
|
+
result = { ok: true, newWorking: candidate };
|
|
401
|
+
source = "blank_edges";
|
|
402
|
+
}
|
|
297
403
|
}
|
|
298
|
-
|
|
299
|
-
|
|
404
|
+
}
|
|
405
|
+
if (!result.ok && result.reason === "not_found") {
|
|
406
|
+
if (replaceAll && /^[ \t]*\.\.\.[ \t]*$/m.test(normalizedOld)) {
|
|
300
407
|
outcomes[i] = {
|
|
301
408
|
ok: false,
|
|
302
409
|
failure: {
|
|
303
410
|
reason: "invalid",
|
|
304
|
-
detail: "
|
|
411
|
+
detail: "replace_all does not support ... elision; use complete matching text or separate uniquely anchored edits.",
|
|
305
412
|
},
|
|
306
413
|
};
|
|
307
414
|
continue;
|
|
308
415
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
// 1. exact + smart-quote/dash fuzzy (in tryMatch)
|
|
314
|
-
// 2. indent-flex (model omitted/shortened leading whitespace)
|
|
315
|
-
// 3. drop spurious leading/trailing blank lines, retry 1+2
|
|
316
|
-
// 4. dotdotdots (`...` elision with preserved middle)
|
|
317
|
-
let source = "text";
|
|
318
|
-
let result = tryMatch(working, normalizedOld, normalizedNew, replaceAll);
|
|
319
|
-
const tryFallbacks = (oldText) => {
|
|
320
|
-
const flexed = applyMissingLeadingWhitespace(working, oldText, normalizedNew);
|
|
321
|
-
if (flexed !== null)
|
|
322
|
-
return flexed;
|
|
323
|
-
// Re-run primary matcher on the stripped variant as a cheap retry.
|
|
324
|
-
const exact = tryMatch(working, oldText, normalizedNew, replaceAll);
|
|
325
|
-
if (exact.ok)
|
|
326
|
-
return exact.newWorking;
|
|
327
|
-
return null;
|
|
328
|
-
};
|
|
329
|
-
if (!result.ok && result.reason === "not_found") {
|
|
330
|
-
const indentFlexed = applyMissingLeadingWhitespace(working, normalizedOld, normalizedNew);
|
|
331
|
-
if (indentFlexed !== null) {
|
|
332
|
-
result = { ok: true, newWorking: indentFlexed };
|
|
333
|
-
source = "indent_flex";
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
if (!result.ok && result.reason === "not_found") {
|
|
337
|
-
const stripped = stripBlankEdges(normalizedOld);
|
|
338
|
-
if (stripped !== null) {
|
|
339
|
-
const candidate = tryFallbacks(stripped);
|
|
340
|
-
if (candidate !== null) {
|
|
341
|
-
result = { ok: true, newWorking: candidate };
|
|
342
|
-
source = "blank_edges";
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
if (!result.ok && result.reason === "not_found") {
|
|
347
|
-
if (replaceAll && /^[ \t]*\.\.\.[ \t]*$/m.test(normalizedOld)) {
|
|
348
|
-
outcomes[i] = {
|
|
349
|
-
ok: false,
|
|
350
|
-
failure: {
|
|
351
|
-
reason: "invalid",
|
|
352
|
-
detail: "replace_all does not support ... elision; use complete matching text or separate uniquely anchored edits.",
|
|
353
|
-
},
|
|
354
|
-
};
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
const elided = applyDotdotdots(working, normalizedOld, normalizedNew);
|
|
358
|
-
if (elided !== null) {
|
|
359
|
-
result = { ok: true, newWorking: elided };
|
|
360
|
-
source = "dotdotdot";
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
if (result.ok) {
|
|
364
|
-
working = result.newWorking;
|
|
365
|
-
outcomes[i] = { ok: true, source };
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
if (result.reason === "not_found") {
|
|
369
|
-
// Capture the closest-match snippet eagerly against the current
|
|
370
|
-
// working buffer; we'll decide whether to render it post-loop based
|
|
371
|
-
// on whether other edits in this batch succeeded.
|
|
372
|
-
const closest = findClosestSnippet(working, normalizedOld);
|
|
373
|
-
outcomes[i] = {
|
|
374
|
-
ok: false,
|
|
375
|
-
failure: {
|
|
376
|
-
reason: "not_found",
|
|
377
|
-
closestSnippet: closest?.snippet ?? null,
|
|
378
|
-
closestLine: closest?.topLine ?? null,
|
|
379
|
-
redactedHint: redactedOldTextHint(normalizedOld),
|
|
380
|
-
},
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
else {
|
|
384
|
-
const occurrences = result.occurrences ?? 0;
|
|
385
|
-
const matches = findOccurrenceLines(working, normalizedOld);
|
|
386
|
-
const matchLines = matches.map((m) => ` line ${m.line}: ${m.preview}`).join("\n");
|
|
387
|
-
const more = occurrences > matches.length ? `\n …and ${occurrences - matches.length} more` : "";
|
|
388
|
-
outcomes[i] = {
|
|
389
|
-
ok: false,
|
|
390
|
-
failure: { reason: "ambiguous", occurrences, matchLines, more },
|
|
391
|
-
};
|
|
416
|
+
const elided = applyDotdotdots(working, normalizedOld, normalizedNew);
|
|
417
|
+
if (elided !== null) {
|
|
418
|
+
result = { ok: true, newWorking: elided };
|
|
419
|
+
source = "dotdotdot";
|
|
392
420
|
}
|
|
393
421
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
422
|
+
if (result.ok) {
|
|
423
|
+
working = result.newWorking;
|
|
424
|
+
outcomes[i] = { ok: true, source };
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (result.reason === "not_found") {
|
|
428
|
+
// Capture the closest-match snippet eagerly against the current
|
|
429
|
+
// working buffer; we'll decide whether to render it post-loop based
|
|
430
|
+
// on whether other edits in this batch succeeded.
|
|
431
|
+
const closest = findClosestSnippet(working, normalizedOld);
|
|
432
|
+
outcomes[i] = {
|
|
433
|
+
ok: false,
|
|
434
|
+
failure: {
|
|
435
|
+
reason: "not_found",
|
|
436
|
+
closestSnippet: closest?.snippet ?? null,
|
|
437
|
+
closestLine: closest?.topLine ?? null,
|
|
438
|
+
redactedHint: redactedOldTextHint(normalizedOld),
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
const occurrences = result.occurrences ?? 0;
|
|
444
|
+
const matches = findOccurrenceLines(working, normalizedOld);
|
|
445
|
+
const matchLines = matches.map((m) => ` line ${m.line}: ${m.preview}`).join("\n");
|
|
446
|
+
const more = occurrences > matches.length ? `\n …and ${occurrences - matches.length} more` : "";
|
|
447
|
+
outcomes[i] = {
|
|
448
|
+
ok: false,
|
|
449
|
+
failure: { reason: "ambiguous", occurrences, matchLines, more },
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const failures = outcomes
|
|
454
|
+
.map((o, i) => (o.ok || !o.failure ? null : { index: i, failure: o.failure }))
|
|
455
|
+
.filter((x) => x !== null);
|
|
456
|
+
const successCount = outcomes.length - failures.length;
|
|
457
|
+
// Closest-match snippets only get suppressed when successes will ACTUALLY
|
|
458
|
+
// be persisted (partial-apply with at least one win). In atomic mode we
|
|
459
|
+
// throw before writing, so the model retries against an unchanged file
|
|
460
|
+
// and the snippet is its only guidance — keep it.
|
|
461
|
+
const willPersistSuccesses = successCount > 0 && !atomic;
|
|
462
|
+
const formatFailureMessage = (f) => {
|
|
463
|
+
if (f.reason === "stale_anchor") {
|
|
464
|
+
return `the file changed since you read it (anchor mismatch) — re-read \`${file_path}\` and retry`;
|
|
465
|
+
}
|
|
466
|
+
if (f.reason === "noop") {
|
|
467
|
+
return `old_text and new_text are identical in ${fileName} — this edit would be a no-op. Either fix new_text or drop this edit.`;
|
|
468
|
+
}
|
|
469
|
+
if (f.reason === "invalid") {
|
|
470
|
+
return `invalid edit: ${f.detail}.`;
|
|
471
|
+
}
|
|
472
|
+
if (f.reason === "overlap") {
|
|
473
|
+
return (`span overlaps another span edit in this batch — the overlapping region was addressed twice. ` +
|
|
474
|
+
`Merge the overlapping spans into one edit and retry.`);
|
|
475
|
+
}
|
|
476
|
+
if (f.reason === "ambiguous") {
|
|
477
|
+
return (`old_text found ${f.occurrences} times in ${fileName}. ` +
|
|
478
|
+
"Include more surrounding context to make the match unique, " +
|
|
479
|
+
"or set replace_all: true to swap every occurrence.\n" +
|
|
480
|
+
"Matches at:\n" +
|
|
481
|
+
f.matchLines +
|
|
482
|
+
f.more);
|
|
483
|
+
}
|
|
484
|
+
const base = `old_text not found in ${fileName}. ` +
|
|
485
|
+
"Text must match verbatim — do not paraphrase. " +
|
|
486
|
+
"Fix this edit's old_text to match the file exactly (re-read the region below if unsure); " +
|
|
487
|
+
"the file is unchanged, so successful edits and prior reads are still valid." +
|
|
488
|
+
f.redactedHint;
|
|
489
|
+
// Build a bounded read suggestion around the closest-match line so the
|
|
490
|
+
// model can re-read just that region (e.g. ±25 lines) instead of the
|
|
491
|
+
// whole file. Skipped when willPersistSuccesses — see comment above.
|
|
492
|
+
const readHint = f.closestLine !== null && !willPersistSuccesses
|
|
493
|
+
? `\nSuggested re-read: \`read file_path="${file_path}" offset=${Math.max(1, f.closestLine - 25)} limit=50\``
|
|
494
|
+
: "";
|
|
495
|
+
if (willPersistSuccesses || !f.closestSnippet)
|
|
496
|
+
return base + readHint;
|
|
497
|
+
return `${base}${readHint}\nClosest match in file:\n${f.closestSnippet}`;
|
|
498
|
+
};
|
|
499
|
+
const formatFailures = () => {
|
|
500
|
+
if (failures.length === 1 && edits.length === 1) {
|
|
501
|
+
return formatFailureMessage(failures[0].failure);
|
|
502
|
+
}
|
|
503
|
+
return failures
|
|
504
|
+
.map((f) => `[edit ${f.index + 1}/${edits.length}] ${formatFailureMessage(f.failure)}`)
|
|
505
|
+
.join("\n\n");
|
|
506
|
+
};
|
|
507
|
+
// Atomic-mode failure, OR partial-mode failure where literally nothing
|
|
508
|
+
// succeeded. Either way nothing should be written and we throw to make
|
|
509
|
+
// the model retry the whole batch.
|
|
510
|
+
if (failures.length > 0 && (atomic || successCount === 0)) {
|
|
511
|
+
// Nothing was written — the file is byte-identical to the last read, so
|
|
512
|
+
// the read tracker stays valid. We deliberately do NOT invalidate it
|
|
513
|
+
// here: doing so turned a precise "old_text not found (closest match
|
|
514
|
+
// below)" error into a misleading "File must be read first" on every
|
|
515
|
+
// following edit, hiding the real fix from the model. A genuine on-disk
|
|
516
|
+
// change (formatter/external edit) is caught by the freshness check.
|
|
517
|
+
const header = atomic && failures.length > 0
|
|
518
|
+
? `${failures.length} of ${edits.length} edit${edits.length === 1 ? "" : "s"} failed; no changes written (atomic).\n\n`
|
|
519
|
+
: edits.length > 1
|
|
520
|
+
? `${failures.length} of ${edits.length} edits failed; no changes written.\n\n`
|
|
435
521
|
: "";
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
522
|
+
throw new Error(header + formatFailures());
|
|
523
|
+
}
|
|
524
|
+
// Never replace real secrets with the placeholder the model was shown.
|
|
525
|
+
const lossError = redactionLossError(originalNormalized, working, fileName);
|
|
526
|
+
if (lossError)
|
|
527
|
+
throw new Error(lossError);
|
|
528
|
+
const relPath = path.relative(cwd, resolved);
|
|
529
|
+
const diff = generateDiff(originalNormalized, working, relPath);
|
|
530
|
+
const changed = working !== originalNormalized;
|
|
531
|
+
// LSP diagnostics for the just-written content. Best-effort enhancement:
|
|
532
|
+
// any failure (or an opted-out provider) leaves output identical to today.
|
|
533
|
+
let diagnosticsNote = "";
|
|
534
|
+
if (changed) {
|
|
535
|
+
const finalContent = hasCRLF ? working.replace(/\n/g, "\r\n") : working;
|
|
536
|
+
// Snapshot the pre-mutation on-disk state for /rewind before writing.
|
|
537
|
+
await onPreFileMutation?.(resolved);
|
|
538
|
+
await ops.writeFile(resolved, finalContent);
|
|
539
|
+
await recordWrite(readFiles, resolved, finalContent, ops, "edit");
|
|
540
|
+
await mutationCallback?.(resolved);
|
|
541
|
+
// recordWrite refreshed the tracker to the just-written content, so the
|
|
542
|
+
// next edit (including retries of the skipped ones) validates against an
|
|
543
|
+
// accurate snapshot. No invalidation — the failure message already
|
|
544
|
+
// carries the closest match and a bounded re-read hint.
|
|
545
|
+
if (getDiagnostics) {
|
|
546
|
+
try {
|
|
547
|
+
diagnosticsNote = await getDiagnostics(resolved, finalContent, riskiestSource(outcomes));
|
|
443
548
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
.join("\n\n");
|
|
447
|
-
};
|
|
448
|
-
// Atomic-mode failure, OR partial-mode failure where literally nothing
|
|
449
|
-
// succeeded. Either way nothing should be written and we throw to make
|
|
450
|
-
// the model retry the whole batch.
|
|
451
|
-
if (failures.length > 0 && (atomic || successCount === 0)) {
|
|
452
|
-
// Nothing was written — the file is byte-identical to the last read, so
|
|
453
|
-
// the read tracker stays valid. We deliberately do NOT invalidate it
|
|
454
|
-
// here: doing so turned a precise "old_text not found (closest match
|
|
455
|
-
// below)" error into a misleading "File must be read first" on every
|
|
456
|
-
// following edit, hiding the real fix from the model. A genuine on-disk
|
|
457
|
-
// change (formatter/external edit) is caught by the freshness check.
|
|
458
|
-
const header = atomic && failures.length > 0
|
|
459
|
-
? `${failures.length} of ${edits.length} edit${edits.length === 1 ? "" : "s"} failed; no changes written (atomic).\n\n`
|
|
460
|
-
: edits.length > 1
|
|
461
|
-
? `${failures.length} of ${edits.length} edits failed; no changes written.\n\n`
|
|
462
|
-
: "";
|
|
463
|
-
throw new Error(header + formatFailures());
|
|
464
|
-
}
|
|
465
|
-
// Never replace real secrets with the placeholder the model was shown.
|
|
466
|
-
const lossError = redactionLossError(originalNormalized, working, fileName);
|
|
467
|
-
if (lossError)
|
|
468
|
-
throw new Error(lossError);
|
|
469
|
-
const relPath = path.relative(cwd, resolved);
|
|
470
|
-
const diff = generateDiff(originalNormalized, working, relPath);
|
|
471
|
-
const changed = working !== originalNormalized;
|
|
472
|
-
// LSP diagnostics for the just-written content. Best-effort enhancement:
|
|
473
|
-
// any failure (or an opted-out provider) leaves output identical to today.
|
|
474
|
-
let diagnosticsNote = "";
|
|
475
|
-
if (changed) {
|
|
476
|
-
const finalContent = hasCRLF ? working.replace(/\n/g, "\r\n") : working;
|
|
477
|
-
// Snapshot the pre-mutation on-disk state for /rewind before writing.
|
|
478
|
-
await onPreFileMutation?.(resolved);
|
|
479
|
-
await ops.writeFile(resolved, finalContent);
|
|
480
|
-
await recordWrite(readFiles, resolved, finalContent, ops, "edit");
|
|
481
|
-
await mutationCallback?.(resolved);
|
|
482
|
-
// recordWrite refreshed the tracker to the just-written content, so the
|
|
483
|
-
// next edit (including retries of the skipped ones) validates against an
|
|
484
|
-
// accurate snapshot. No invalidation — the failure message already
|
|
485
|
-
// carries the closest match and a bounded re-read hint.
|
|
486
|
-
if (getDiagnostics) {
|
|
487
|
-
try {
|
|
488
|
-
diagnosticsNote = await getDiagnostics(resolved, finalContent, riskiestSource(outcomes));
|
|
489
|
-
}
|
|
490
|
-
catch {
|
|
491
|
-
// Diagnostics must never break a successful edit.
|
|
492
|
-
}
|
|
549
|
+
catch {
|
|
550
|
+
// Diagnostics must never break a successful edit.
|
|
493
551
|
}
|
|
494
552
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
? `No changes needed in ${relPath}; ${edits.length} edits were no-ops.`
|
|
499
|
-
: `No changes needed in ${relPath}; edit was a no-op.`;
|
|
500
|
-
return { content: summary, details: { diff } };
|
|
501
|
-
}
|
|
553
|
+
}
|
|
554
|
+
if (failures.length === 0) {
|
|
555
|
+
if (!changed) {
|
|
502
556
|
const summary = edits.length > 1
|
|
503
|
-
? `
|
|
504
|
-
: `
|
|
505
|
-
return { content: summary
|
|
557
|
+
? `No changes needed in ${relPath}; ${edits.length} edits were no-ops.`
|
|
558
|
+
: `No changes needed in ${relPath}; edit was a no-op.`;
|
|
559
|
+
return { content: summary, details: { diff } };
|
|
506
560
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
|
|
561
|
+
const summary = edits.length > 1
|
|
562
|
+
? `Successfully applied ${edits.length} edits to ${relPath}.`
|
|
563
|
+
: `Successfully replaced text in ${relPath}.`;
|
|
564
|
+
return { content: summary + diagnosticsNote, details: { diff } };
|
|
565
|
+
}
|
|
566
|
+
// Partial success — the loud header is deliberate: the model has to know
|
|
567
|
+
// that work was saved AND that only the listed edits need to be retried.
|
|
568
|
+
const noun = failures.length === 1 ? "edit" : "edits";
|
|
569
|
+
const content = `Applied ${successCount} of ${edits.length} edits to ${relPath}.\n` +
|
|
570
|
+
`${failures.length} ${noun} skipped — re-issue ONLY these (the rest are already done, do not redo them):\n\n` +
|
|
571
|
+
formatFailures() +
|
|
572
|
+
diagnosticsNote;
|
|
573
|
+
return { content, details: { diff } };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/** Exactly one form: `file_path`+`edits`, or `files`. */
|
|
577
|
+
function resolveEditTargets(file_path, edits, files) {
|
|
578
|
+
const single = file_path !== undefined || edits !== undefined;
|
|
579
|
+
if (files !== undefined && single) {
|
|
580
|
+
return { ok: false, error: "Use either file_path+edits or files, not both." };
|
|
581
|
+
}
|
|
582
|
+
if (files !== undefined)
|
|
583
|
+
return { ok: true, value: files };
|
|
584
|
+
if (file_path === undefined || edits === undefined) {
|
|
585
|
+
return {
|
|
586
|
+
ok: false,
|
|
587
|
+
error: "Provide file_path and edits (one file), or files (several files).",
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
return { ok: true, value: [{ file_path, edits }] };
|
|
517
591
|
}
|
|
518
592
|
//# sourceMappingURL=edit.js.map
|