pi-readseek 0.3.19 → 0.3.21
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/index.ts +11 -11
- package/package.json +1 -1
- package/src/edit.ts +427 -410
- package/src/grep.ts +338 -475
- package/src/read.ts +360 -357
- package/src/readseek-value.ts +10 -2
- package/src/refs.ts +88 -97
- package/src/session-anchors.ts +20 -0
- package/src/sg.ts +128 -139
- package/src/tool-types.ts +9 -0
- package/src/tui-render-utils.ts +55 -1
- package/src/write.ts +2 -1
package/src/edit.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createPatch } from "diff";
|
|
|
7
7
|
import { detectLineEnding, generateCompactOrFullDiff, normalizeToLF, replaceText, restoreLineEndings, stripBom } from "./edit-diff.js";
|
|
8
8
|
import { HashlineMismatchError, applyHashlineEdits, computeLineHash, ensureHashInit, parseLineRef, type HashlineEditItem, escapeControlCharsForDisplay } from "./hashline.js";
|
|
9
9
|
import { resolveToCwd } from "./path-utils.js";
|
|
10
|
+
import { looksLikeBinary } from "./binary-detect.js";
|
|
10
11
|
import { throwIfAborted } from "./runtime.js";
|
|
11
12
|
import { buildEditOutput } from "./edit-output.js";
|
|
12
13
|
import { classifyEdit, isDifftAvailable, runDifftastic } from "./edit-classify.js";
|
|
@@ -21,6 +22,7 @@ import { buildEditPreviewKey, buildPendingEditPreviewData, resolvePendingDiffPre
|
|
|
21
22
|
import { buildDiffData, type DiffBlockRange } from "./diff-data.js";
|
|
22
23
|
import { clampLineToWidth, clampLinesToWidth, linkToolPath, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
|
|
23
24
|
import { DiffPreviewComponent } from "./tui-diff-component.js";
|
|
25
|
+
import type { FreshAnchorsPredicate } from "./tool-types.js";
|
|
24
26
|
|
|
25
27
|
import { resolveEditDiffDisplay } from "./readseek-settings.js";
|
|
26
28
|
|
|
@@ -41,10 +43,6 @@ export function wrapWriteError(err: any, path: string): Error {
|
|
|
41
43
|
return new Error(`Failed to write file: ${path}`);
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
export function isBinaryBuffer(buf: Buffer): boolean {
|
|
45
|
-
return buf.includes(0);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
46
|
|
|
49
47
|
const hashlineEditItemSchema = Type.Union([
|
|
50
48
|
Type.Object({ set_line: Type.Object({ anchor: Type.String(), new_text: Type.String() }) }, { additionalProperties: true }),
|
|
@@ -137,443 +135,462 @@ function mapEditFileError(err: any, filePath: string, displayPath: string, phase
|
|
|
137
135
|
}
|
|
138
136
|
|
|
139
137
|
export interface EditToolOptions {
|
|
140
|
-
wasReadInSession?:
|
|
138
|
+
wasReadInSession?: FreshAnchorsPredicate;
|
|
141
139
|
syntaxValidate?: SyntaxValidateOptions["syntaxValidate"];
|
|
142
140
|
}
|
|
143
141
|
|
|
142
|
+
export interface ExecuteEditOptions {
|
|
143
|
+
params: unknown;
|
|
144
|
+
signal: AbortSignal | undefined;
|
|
145
|
+
cwd: string;
|
|
146
|
+
wasReadInSession?: FreshAnchorsPredicate;
|
|
147
|
+
syntaxValidate?: SyntaxValidateOptions["syntaxValidate"];
|
|
148
|
+
}
|
|
144
149
|
|
|
145
|
-
export function
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
message,
|
|
184
|
-
`Call read(${JSON.stringify(rawPath)}) first, or use grep, search, or write to produce fresh anchors for this file.`,
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
const hasTopLevelReplaceInput =
|
|
188
|
-
typeof input.oldText === "string" ||
|
|
189
|
-
typeof input.newText === "string" ||
|
|
190
|
-
typeof input.old_text === "string" ||
|
|
191
|
-
typeof input.new_text === "string";
|
|
192
|
-
if (hasTopLevelReplaceInput) {
|
|
193
|
-
return buildEditError(
|
|
194
|
-
absolutePath,
|
|
195
|
-
"invalid-edit-variant",
|
|
196
|
-
"Top-level oldText/newText and old_text/new_text are no longer supported. Use edits[0].replace instead.",
|
|
197
|
-
"Use edits: [{ replace: { old_text, new_text } }].",
|
|
198
|
-
);
|
|
199
|
-
}
|
|
150
|
+
export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
|
|
151
|
+
const { params, signal, cwd, wasReadInSession, syntaxValidate } = opts;
|
|
152
|
+
await ensureHashInit();
|
|
153
|
+
const parsed = params as HashlineParams;
|
|
154
|
+
const input = params as Record<string, unknown>;
|
|
155
|
+
const rawPath = parsed.path;
|
|
156
|
+
const path = rawPath.replace(/^@/, "");
|
|
157
|
+
const absolutePath = resolveToCwd(path, cwd);
|
|
158
|
+
throwIfAborted(signal);
|
|
159
|
+
try {
|
|
160
|
+
return await withFileMutationQueue(absolutePath, async () => {
|
|
161
|
+
throwIfAborted(signal);
|
|
162
|
+
if (wasReadInSession && !wasReadInSession(absolutePath)) {
|
|
163
|
+
const message = [
|
|
164
|
+
`You must get fresh anchors for ${absolutePath} before editing it.`,
|
|
165
|
+
`Call read(${JSON.stringify(rawPath)}) first, or use grep, search, or write to produce fresh anchors for this file.`,
|
|
166
|
+
"edit requires fresh LINE:HASH anchors from read, grep, search, or write so the hashes match the current file contents.",
|
|
167
|
+
].join(" ");
|
|
168
|
+
return buildEditError(
|
|
169
|
+
absolutePath,
|
|
170
|
+
"file-not-read",
|
|
171
|
+
message,
|
|
172
|
+
`Call read(${JSON.stringify(rawPath)}) first, or use grep, search, or write to produce fresh anchors for this file.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const hasTopLevelReplaceInput =
|
|
176
|
+
typeof input.oldText === "string" ||
|
|
177
|
+
typeof input.newText === "string" ||
|
|
178
|
+
typeof input.old_text === "string" ||
|
|
179
|
+
typeof input.new_text === "string";
|
|
180
|
+
if (hasTopLevelReplaceInput) {
|
|
181
|
+
return buildEditError(
|
|
182
|
+
absolutePath,
|
|
183
|
+
"invalid-edit-variant",
|
|
184
|
+
"Top-level oldText/newText and old_text/new_text are no longer supported. Use edits[0].replace instead.",
|
|
185
|
+
"Use edits: [{ replace: { old_text, new_text } }].",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
200
188
|
|
|
201
|
-
|
|
189
|
+
const edits = parsed.edits ?? [];
|
|
202
190
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
191
|
+
if (!edits.length) {
|
|
192
|
+
return buildEditError(absolutePath, "invalid-edit-variant", "No edits provided.");
|
|
193
|
+
}
|
|
206
194
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
195
|
+
// Validate edit variant keys
|
|
196
|
+
for (let i = 0; i < edits.length; i++) {
|
|
197
|
+
throwIfAborted(signal);
|
|
198
|
+
const e = edits[i] as Record<string, unknown>;
|
|
199
|
+
if (("old_text" in e || "new_text" in e) && !("replace" in e)) {
|
|
200
|
+
const message = `edits[${i}] has top-level 'old_text'/'new_text'. Use {replace: {old_text, new_text}} or {set_line}, {replace_lines}, {insert_after}.`;
|
|
201
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
202
|
+
}
|
|
203
|
+
if ("diff" in e) {
|
|
204
|
+
const message = `edits[${i}] contains 'diff' from patch mode. Hashline edit expects one of: {set_line}, {replace_lines}, {insert_after}, {replace}.`;
|
|
205
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
206
|
+
}
|
|
207
|
+
const variantCount =
|
|
208
|
+
Number("set_line" in e) +
|
|
209
|
+
Number("replace_lines" in e) +
|
|
210
|
+
Number("insert_after" in e) +
|
|
211
|
+
Number("replace" in e) +
|
|
212
|
+
Number("replace_symbol" in e);
|
|
213
|
+
if (variantCount !== 1) {
|
|
214
|
+
const message = `edits[${i}] must contain exactly one of: 'set_line', 'replace_lines', 'insert_after', 'replace', 'replace_symbol'. Got: [${Object.keys(e).join(", ")}].`;
|
|
215
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
230
218
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
219
|
+
const anchorEdits = edits.filter(
|
|
220
|
+
(e): e is HashlineEditItem => "set_line" in e || "replace_lines" in e || "insert_after" in e,
|
|
221
|
+
);
|
|
222
|
+
const replaceEdits = edits.filter(
|
|
223
|
+
(e): e is { replace: { old_text: string; new_text: string; all?: boolean; fuzzy?: boolean } } => "replace" in e,
|
|
224
|
+
);
|
|
225
|
+
const replaceSymbolEdits = edits.filter(
|
|
226
|
+
(e): e is { replace_symbol: { symbol: string; new_body: string } } => "replace_symbol" in e,
|
|
227
|
+
);
|
|
228
|
+
for (const rs of replaceSymbolEdits) {
|
|
229
|
+
if (!rs.replace_symbol.new_body.trim()) {
|
|
230
|
+
return buildEditError(absolutePath, "invalid-edit-variant", "replace_symbol.new_body must not be empty or whitespace-only.");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
245
233
|
|
|
246
|
-
|
|
234
|
+
let rawBuffer: Buffer;
|
|
235
|
+
try {
|
|
236
|
+
rawBuffer = await fsReadFile(absolutePath);
|
|
237
|
+
} catch (err: any) {
|
|
238
|
+
return mapEditFileError(err, absolutePath, path, "read");
|
|
239
|
+
}
|
|
240
|
+
if (looksLikeBinary(rawBuffer)) {
|
|
241
|
+
const message = `Cannot edit binary file: ${path}`;
|
|
242
|
+
return buildEditError(absolutePath, "binary-file", message);
|
|
243
|
+
}
|
|
244
|
+
throwIfAborted(signal);
|
|
245
|
+
const raw = rawBuffer.toString("utf-8");
|
|
246
|
+
const { bom, text: content } = stripBom(raw);
|
|
247
|
+
const originalEnding = detectLineEnding(content);
|
|
248
|
+
const originalNormalized = normalizeToLF(content);
|
|
249
|
+
const origLines = originalNormalized.split("\n");
|
|
250
|
+
const anchorSnapshots = new Map<string, string>();
|
|
251
|
+
for (const edit of anchorEdits) {
|
|
252
|
+
const refs: string[] = [];
|
|
253
|
+
if ("set_line" in edit) refs.push(edit.set_line.anchor);
|
|
254
|
+
else if ("replace_lines" in edit) {
|
|
255
|
+
refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor);
|
|
256
|
+
} else if ("insert_after" in edit) refs.push(edit.insert_after.anchor);
|
|
257
|
+
for (const ref of refs) {
|
|
247
258
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const message = `Cannot edit binary file: ${path}`;
|
|
254
|
-
return buildEditError(absolutePath, "binary-file", message);
|
|
255
|
-
}
|
|
256
|
-
throwIfAborted(signal);
|
|
257
|
-
const raw = rawBuffer.toString("utf-8");
|
|
258
|
-
const { bom, text: content } = stripBom(raw);
|
|
259
|
-
const originalEnding = detectLineEnding(content);
|
|
260
|
-
const originalNormalized = normalizeToLF(content);
|
|
261
|
-
const origLines = originalNormalized.split("\n");
|
|
262
|
-
const anchorSnapshots = new Map<string, string>();
|
|
263
|
-
for (const edit of anchorEdits) {
|
|
264
|
-
const refs: string[] = [];
|
|
265
|
-
if ("set_line" in edit) refs.push(edit.set_line.anchor);
|
|
266
|
-
else if ("replace_lines" in edit) {
|
|
267
|
-
refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor);
|
|
268
|
-
} else if ("insert_after" in edit) refs.push(edit.insert_after.anchor);
|
|
269
|
-
for (const ref of refs) {
|
|
270
|
-
try {
|
|
271
|
-
const parsed = parseLineRef(ref);
|
|
272
|
-
if (parsed.line >= 1 && parsed.line <= origLines.length) {
|
|
273
|
-
const lineContent = origLines[parsed.line - 1];
|
|
274
|
-
const hash = computeLineHash(parsed.line, lineContent);
|
|
275
|
-
anchorSnapshots.set(ref, `${parsed.line}:${hash}|${escapeControlCharsForDisplay(lineContent)}`);
|
|
276
|
-
}
|
|
277
|
-
} catch {
|
|
278
|
-
/* skip malformed refs */
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
let preAnchorContent = originalNormalized;
|
|
283
|
-
// AC 26: reject anchored edits that target a line inside any replace_symbol
|
|
284
|
-
// pre-replace range. Resolve each target against the ORIGINAL content so the
|
|
285
|
-
// user-provided anchor line numbers (which reference the file as read) are
|
|
286
|
-
// compared against the pre-replace coordinates.
|
|
287
|
-
//
|
|
288
|
-
// F2: surface replace_symbol symbol-resolution errors (not-found, ambiguous)
|
|
289
|
-
// BEFORE the AC 26 overlap check and before any write (C1 preserved).
|
|
290
|
-
// Error-precedence order: replace_symbol resolution > anchor-overlap > anchored-edit.
|
|
291
|
-
//
|
|
292
|
-
// AC 4: store successful probe results and reuse them in the apply loop so
|
|
293
|
-
// readseekMapContent is invoked at most once per replace_symbol edit.
|
|
294
|
-
const replaceSymbolRanges: { start: number; end: number }[] = [];
|
|
295
|
-
const rsProbeResults: { type: "ok"; content: string; replacement: string; warnings: string[]; range: { start: number; end: number } }[] = [];
|
|
296
|
-
for (const rs of replaceSymbolEdits) {
|
|
297
|
-
const probe = await replaceSymbol({
|
|
298
|
-
filePath: absolutePath,
|
|
299
|
-
content: originalNormalized,
|
|
300
|
-
symbol: rs.replace_symbol.symbol,
|
|
301
|
-
newBody: rs.replace_symbol.new_body,
|
|
302
|
-
});
|
|
303
|
-
if (probe.type !== "ok") {
|
|
304
|
-
// F2: symbol-resolution errors surface before AC 26 overlap check.
|
|
305
|
-
return buildEditError(absolutePath, "invalid-edit-variant", probe.message);
|
|
259
|
+
const parsed = parseLineRef(ref);
|
|
260
|
+
if (parsed.line >= 1 && parsed.line <= origLines.length) {
|
|
261
|
+
const lineContent = origLines[parsed.line - 1];
|
|
262
|
+
const hash = computeLineHash(parsed.line, lineContent);
|
|
263
|
+
anchorSnapshots.set(ref, `${parsed.line}:${hash}|${escapeControlCharsForDisplay(lineContent)}`);
|
|
306
264
|
}
|
|
307
|
-
|
|
308
|
-
|
|
265
|
+
} catch {
|
|
266
|
+
/* skip malformed refs */
|
|
309
267
|
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
let preAnchorContent = originalNormalized;
|
|
271
|
+
// AC 26: reject anchored edits that target a line inside any replace_symbol
|
|
272
|
+
// pre-replace range. Resolve each target against the ORIGINAL content so the
|
|
273
|
+
// user-provided anchor line numbers (which reference the file as read) are
|
|
274
|
+
// compared against the pre-replace coordinates.
|
|
275
|
+
//
|
|
276
|
+
// F2: surface replace_symbol symbol-resolution errors (not-found, ambiguous)
|
|
277
|
+
// BEFORE the AC 26 overlap check and before any write (C1 preserved).
|
|
278
|
+
// Error-precedence order: replace_symbol resolution > anchor-overlap > anchored-edit.
|
|
279
|
+
//
|
|
280
|
+
// AC 4: store successful probe results and reuse them in the apply loop so
|
|
281
|
+
// readseekMapContent is invoked at most once per replace_symbol edit.
|
|
282
|
+
const replaceSymbolRanges: { start: number; end: number }[] = [];
|
|
283
|
+
const rsProbeResults: { type: "ok"; content: string; replacement: string; warnings: string[]; range: { start: number; end: number } }[] = [];
|
|
284
|
+
for (const rs of replaceSymbolEdits) {
|
|
285
|
+
const probe = await replaceSymbol({
|
|
286
|
+
filePath: absolutePath,
|
|
287
|
+
content: originalNormalized,
|
|
288
|
+
symbol: rs.replace_symbol.symbol,
|
|
289
|
+
newBody: rs.replace_symbol.new_body,
|
|
290
|
+
});
|
|
291
|
+
if (probe.type !== "ok") {
|
|
292
|
+
// F2: symbol-resolution errors surface before AC 26 overlap check.
|
|
293
|
+
return buildEditError(absolutePath, "invalid-edit-variant", probe.message);
|
|
294
|
+
}
|
|
295
|
+
rsProbeResults.push(probe);
|
|
296
|
+
replaceSymbolRanges.push(probe.range);
|
|
297
|
+
}
|
|
310
298
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
299
|
+
const sortedReplaceSymbolRanges = [...replaceSymbolRanges].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
300
|
+
for (let i = 1; i < sortedReplaceSymbolRanges.length; i++) {
|
|
301
|
+
const prev = sortedReplaceSymbolRanges[i - 1];
|
|
302
|
+
const current = sortedReplaceSymbolRanges[i];
|
|
303
|
+
if (current.start <= prev.end) {
|
|
304
|
+
const message = `replace_symbol ranges overlap or duplicate (lines ${prev.start}-${prev.end} and ${current.start}-${current.end}).`;
|
|
305
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (replaceSymbolRanges.length > 0) {
|
|
309
|
+
for (const edit of anchorEdits) {
|
|
310
|
+
if ("replace_lines" in edit) {
|
|
311
|
+
let startLine: number | undefined;
|
|
312
|
+
let endLine: number | undefined;
|
|
313
|
+
try {
|
|
314
|
+
startLine = parseLineRef(edit.replace_lines.start_anchor).line;
|
|
315
|
+
endLine = parseLineRef(edit.replace_lines.end_anchor).line;
|
|
316
|
+
} catch {
|
|
317
|
+
// Let the normal anchored edit validation report malformed anchors later.
|
|
318
318
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
startLine = parseLineRef(edit.replace_lines.start_anchor).line;
|
|
327
|
-
endLine = parseLineRef(edit.replace_lines.end_anchor).line;
|
|
328
|
-
} catch {
|
|
329
|
-
// Let the normal anchored edit validation report malformed anchors later.
|
|
330
|
-
}
|
|
331
|
-
if (startLine !== undefined && endLine !== undefined) {
|
|
332
|
-
const lo = Math.min(startLine, endLine);
|
|
333
|
-
const hi = Math.max(startLine, endLine);
|
|
334
|
-
for (const range of replaceSymbolRanges) {
|
|
335
|
-
if (lo <= range.end && hi >= range.start) {
|
|
336
|
-
const message = `replace_lines range ${lo}-${hi} overlaps a replace_symbol range (lines ${range.start}-${range.end}).`;
|
|
337
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
const refs: string[] = [];
|
|
343
|
-
if ("set_line" in edit) refs.push(edit.set_line.anchor);
|
|
344
|
-
else if ("replace_lines" in edit) {
|
|
345
|
-
refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor);
|
|
346
|
-
} else if ("insert_after" in edit) refs.push(edit.insert_after.anchor);
|
|
347
|
-
for (const ref of refs) {
|
|
348
|
-
let parsedLine: number | undefined;
|
|
349
|
-
try {
|
|
350
|
-
parsedLine = parseLineRef(ref).line;
|
|
351
|
-
} catch {
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
for (const range of replaceSymbolRanges) {
|
|
355
|
-
if (parsedLine >= range.start && parsedLine <= range.end) {
|
|
356
|
-
const message = `Anchor at line ${parsedLine} falls inside a replace_symbol range (lines ${range.start}-${range.end}).`;
|
|
357
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
358
|
-
}
|
|
319
|
+
if (startLine !== undefined && endLine !== undefined) {
|
|
320
|
+
const lo = Math.min(startLine, endLine);
|
|
321
|
+
const hi = Math.max(startLine, endLine);
|
|
322
|
+
for (const range of replaceSymbolRanges) {
|
|
323
|
+
if (lo <= range.end && hi >= range.start) {
|
|
324
|
+
const message = `replace_lines range ${lo}-${hi} overlaps a replace_symbol range (lines ${range.start}-${range.end}).`;
|
|
325
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
359
326
|
}
|
|
360
327
|
}
|
|
361
328
|
}
|
|
362
329
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
330
|
+
const refs: string[] = [];
|
|
331
|
+
if ("set_line" in edit) refs.push(edit.set_line.anchor);
|
|
332
|
+
else if ("replace_lines" in edit) {
|
|
333
|
+
refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor);
|
|
334
|
+
} else if ("insert_after" in edit) refs.push(edit.insert_after.anchor);
|
|
335
|
+
for (const ref of refs) {
|
|
336
|
+
let parsedLine: number | undefined;
|
|
337
|
+
try {
|
|
338
|
+
parsedLine = parseLineRef(ref).line;
|
|
339
|
+
} catch {
|
|
340
|
+
continue;
|
|
372
341
|
}
|
|
373
|
-
for (const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
);
|
|
342
|
+
for (const range of replaceSymbolRanges) {
|
|
343
|
+
if (parsedLine >= range.start && parsedLine <= range.end) {
|
|
344
|
+
const message = `Anchor at line ${parsedLine} falls inside a replace_symbol range (lines ${range.start}-${range.end}).`;
|
|
345
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
346
|
+
}
|
|
379
347
|
}
|
|
380
|
-
preAnchorContent = lines.join("\n");
|
|
381
348
|
}
|
|
382
|
-
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Apply pass: reuse all probe results (AC 4). The probe pass resolved every
|
|
352
|
+
// replace_symbol against originalNormalized; apply those replacements in
|
|
353
|
+
// reverse source order so original line ranges stay valid and no second
|
|
354
|
+
// replaceSymbol/readseekMapContent call is needed.
|
|
355
|
+
const replaceSymbolWarnings: string[] = [];
|
|
356
|
+
if (rsProbeResults.length > 0) {
|
|
357
|
+
const lines = originalNormalized.split("\n");
|
|
358
|
+
for (const probe of rsProbeResults) {
|
|
359
|
+
replaceSymbolWarnings.push(...probe.warnings);
|
|
360
|
+
}
|
|
361
|
+
for (const probe of [...rsProbeResults].sort((a, b) => b.range.start - a.range.start)) {
|
|
362
|
+
lines.splice(
|
|
363
|
+
probe.range.start - 1,
|
|
364
|
+
probe.range.end - probe.range.start + 1,
|
|
365
|
+
...probe.replacement.split("\n"),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
preAnchorContent = lines.join("\n");
|
|
369
|
+
}
|
|
370
|
+
let result = preAnchorContent;
|
|
371
|
+
|
|
372
|
+
let anchorResult;
|
|
373
|
+
try {
|
|
374
|
+
anchorResult = applyHashlineEdits(result, anchorEdits, signal);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err instanceof HashlineMismatchError) {
|
|
377
|
+
return buildEditError(absolutePath, "hash-mismatch", err.message, undefined, {
|
|
378
|
+
updatedAnchors: err.updatedAnchors,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
result = anchorResult.content;
|
|
384
|
+
|
|
385
|
+
const replaceWarnings: string[] = [];
|
|
386
|
+
for (const r of replaceEdits) {
|
|
387
|
+
throwIfAborted(signal);
|
|
388
|
+
if (!r.replace.old_text.length) {
|
|
389
|
+
const message = "replace.old_text must not be empty.";
|
|
390
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
391
|
+
}
|
|
392
|
+
const rep = replaceText(result, r.replace.old_text, r.replace.new_text, {
|
|
393
|
+
all: r.replace.all ?? false,
|
|
394
|
+
fuzzy: r.replace.fuzzy ?? false,
|
|
395
|
+
});
|
|
396
|
+
if (!rep.count) {
|
|
397
|
+
const message = `Could not find exact text to replace in ${path}.`;
|
|
398
|
+
const hint =
|
|
399
|
+
"Re-read the file and prefer set_line/replace_lines/insert_after for hash-verified edits. " +
|
|
400
|
+
"The replace variant is exact-only by default because fuzzy fallback is unverified.";
|
|
401
|
+
return buildEditError(absolutePath, "text-not-found", message, hint);
|
|
402
|
+
}
|
|
403
|
+
if (rep.usedFuzzyMatch) {
|
|
404
|
+
replaceWarnings.push(
|
|
405
|
+
"replace used fuzzy matching because exact old_text was not found; re-read the file and prefer set_line/replace_lines/insert_after for hash-verified edits.",
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
result = rep.content;
|
|
409
|
+
}
|
|
383
410
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const message = "replace.old_text must not be empty.";
|
|
402
|
-
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
403
|
-
}
|
|
404
|
-
const rep = replaceText(result, r.replace.old_text, r.replace.new_text, {
|
|
405
|
-
all: r.replace.all ?? false,
|
|
406
|
-
fuzzy: r.replace.fuzzy ?? false,
|
|
407
|
-
});
|
|
408
|
-
if (!rep.count) {
|
|
409
|
-
const message = `Could not find exact text to replace in ${path}.`;
|
|
410
|
-
const hint =
|
|
411
|
-
"Re-read the file and prefer set_line/replace_lines/insert_after for hash-verified edits. " +
|
|
412
|
-
"The replace variant is exact-only by default because fuzzy fallback is unverified.";
|
|
413
|
-
return buildEditError(absolutePath, "text-not-found", message, hint);
|
|
414
|
-
}
|
|
415
|
-
if (rep.usedFuzzyMatch) {
|
|
416
|
-
replaceWarnings.push(
|
|
417
|
-
"replace used fuzzy matching because exact old_text was not found; re-read the file and prefer set_line/replace_lines/insert_after for hash-verified edits.",
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
result = rep.content;
|
|
411
|
+
if (originalNormalized === result) {
|
|
412
|
+
let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
|
|
413
|
+
if (anchorResult.noopEdits?.length) {
|
|
414
|
+
diagnostic +=
|
|
415
|
+
"\n" +
|
|
416
|
+
anchorResult.noopEdits
|
|
417
|
+
.map(
|
|
418
|
+
(e) =>
|
|
419
|
+
`Edit ${e.editIndex}: replacement for ${e.loc} is identical to current content:\n ${e.loc}| ${escapeControlCharsForDisplay(e.currentContent)}`,
|
|
420
|
+
)
|
|
421
|
+
.join("\n");
|
|
422
|
+
diagnostic += "\nRe-read the file to see the current state.";
|
|
423
|
+
} else {
|
|
424
|
+
const targetLines = [...new Set(anchorSnapshots.values())];
|
|
425
|
+
if (targetLines.length > 0) {
|
|
426
|
+
const preview = targetLines.slice(0, 5).join("\n");
|
|
427
|
+
diagnostic += `\nThe file currently contains:\n${preview}\nYour edits were normalized back to the original content. Ensure your replacement changes actual code, not just formatting.`;
|
|
421
428
|
}
|
|
429
|
+
}
|
|
430
|
+
return buildEditError(absolutePath, "no-op", diagnostic);
|
|
431
|
+
}
|
|
422
432
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
return buildEditError(absolutePath, "no-op", diagnostic);
|
|
433
|
+
throwIfAborted(signal);
|
|
434
|
+
|
|
435
|
+
// Syntax-regression validator (warn/block/off)
|
|
436
|
+
const syntaxMode = resolveSyntaxValidateMode({ syntaxValidate: syntaxValidate });
|
|
437
|
+
let syntaxWarning: string | undefined;
|
|
438
|
+
if (syntaxMode !== "off") {
|
|
439
|
+
const regression = await validateSyntaxRegression({
|
|
440
|
+
filePath: absolutePath,
|
|
441
|
+
before: originalNormalized,
|
|
442
|
+
after: result,
|
|
443
|
+
});
|
|
444
|
+
if (regression) {
|
|
445
|
+
const lines = regression.errorLines.join(", ");
|
|
446
|
+
const message = `syntax-regression: lines ${lines}`;
|
|
447
|
+
// Task 7 (AC 12): block mode aborts with syntax-regression code; file is left untouched.
|
|
448
|
+
if (syntaxMode === "block") {
|
|
449
|
+
return buildEditError(absolutePath, "syntax-regression", message);
|
|
443
450
|
}
|
|
451
|
+
syntaxWarning = message;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const writeContent = bom + restoreLineEndings(result, originalEnding);
|
|
455
|
+
try {
|
|
456
|
+
await fsWriteFile(absolutePath, writeContent, "utf-8");
|
|
457
|
+
} catch (err: any) {
|
|
458
|
+
return mapEditFileError(err, absolutePath, path, "write");
|
|
459
|
+
}
|
|
444
460
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
try {
|
|
468
|
-
await fsWriteFile(absolutePath, writeContent, "utf-8");
|
|
469
|
-
} catch (err: any) {
|
|
470
|
-
return mapEditFileError(err, absolutePath, path, "write");
|
|
471
|
-
}
|
|
461
|
+
if (input.postEditVerify === true) {
|
|
462
|
+
let verifiedContent: string;
|
|
463
|
+
try {
|
|
464
|
+
const verified = await fsReadFile(absolutePath, "utf-8");
|
|
465
|
+
verifiedContent = verified;
|
|
466
|
+
} catch (err: any) {
|
|
467
|
+
const message = `Edit write completed but post-edit verification failed: could not read ${path} after writing.`;
|
|
468
|
+
return buildEditError(absolutePath, "post-edit-verification-read-failed", message, undefined, {
|
|
469
|
+
fsCode: err?.code,
|
|
470
|
+
fsMessage: err?.message,
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (verifiedContent !== writeContent) {
|
|
475
|
+
const message = `Edit write completed but post-edit verification did not confirm the intended content for ${path}. Re-read the file before making follow-up edits.`;
|
|
476
|
+
return buildEditError(absolutePath, "post-edit-verification-mismatch", message, undefined, {
|
|
477
|
+
expectedLength: writeContent.length,
|
|
478
|
+
actualLength: verifiedContent.length,
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
472
483
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
484
|
+
const diffResult = generateCompactOrFullDiff(originalNormalized, result);
|
|
485
|
+
const patch = createPatch(path, originalNormalized, result);
|
|
486
|
+
const blockRanges: DiffBlockRange[] = rsProbeResults.map((probe) => ({
|
|
487
|
+
kind: "remove" as const,
|
|
488
|
+
startLine: probe.range.start,
|
|
489
|
+
endLine: probe.range.end,
|
|
490
|
+
}));
|
|
491
|
+
const diffData = buildDiffData({
|
|
492
|
+
path: absolutePath,
|
|
493
|
+
oldContent: originalNormalized,
|
|
494
|
+
newContent: result,
|
|
495
|
+
diff: diffResult.diff,
|
|
496
|
+
...(blockRanges.length ? { blockRanges } : {}),
|
|
497
|
+
});
|
|
498
|
+
const warnings: string[] = [];
|
|
499
|
+
if (anchorResult.warnings?.length) warnings.push(...anchorResult.warnings);
|
|
500
|
+
if (replaceWarnings.length) warnings.push(...replaceWarnings);
|
|
501
|
+
if (replaceSymbolWarnings.length) warnings.push(...replaceSymbolWarnings);
|
|
502
|
+
if (syntaxWarning) warnings.push(syntaxWarning);
|
|
503
|
+
// Semantic classification
|
|
504
|
+
const internalClassification = classifyEdit(originalNormalized, result);
|
|
505
|
+
const difftAvailable = await isDifftAvailable();
|
|
506
|
+
let semanticSummary: SemanticSummary = {
|
|
507
|
+
classification: internalClassification.classification,
|
|
508
|
+
difftasticAvailable: difftAvailable,
|
|
509
|
+
};
|
|
495
510
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
path: absolutePath,
|
|
505
|
-
oldContent: originalNormalized,
|
|
506
|
-
newContent: result,
|
|
507
|
-
diff: diffResult.diff,
|
|
508
|
-
...(blockRanges.length ? { blockRanges } : {}),
|
|
509
|
-
});
|
|
510
|
-
const warnings: string[] = [];
|
|
511
|
-
if (anchorResult.warnings?.length) warnings.push(...anchorResult.warnings);
|
|
512
|
-
if (replaceWarnings.length) warnings.push(...replaceWarnings);
|
|
513
|
-
if (replaceSymbolWarnings.length) warnings.push(...replaceSymbolWarnings);
|
|
514
|
-
if (syntaxWarning) warnings.push(syntaxWarning);
|
|
515
|
-
// Semantic classification
|
|
516
|
-
const internalClassification = classifyEdit(originalNormalized, result);
|
|
517
|
-
const difftAvailable = await isDifftAvailable();
|
|
518
|
-
let semanticSummary: SemanticSummary = {
|
|
519
|
-
classification: internalClassification.classification,
|
|
520
|
-
difftasticAvailable: difftAvailable,
|
|
511
|
+
if (difftAvailable) {
|
|
512
|
+
const ext = path.split(".").pop() ?? "txt";
|
|
513
|
+
const difftResult = await runDifftastic(originalNormalized, result, ext);
|
|
514
|
+
if (difftResult) {
|
|
515
|
+
semanticSummary = {
|
|
516
|
+
classification: difftResult.classification,
|
|
517
|
+
difftasticAvailable: true,
|
|
518
|
+
...(difftResult.movedBlocks > 0 ? { movedBlocks: difftResult.movedBlocks } : {}),
|
|
521
519
|
};
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const builtOutput = buildEditOutput({
|
|
523
|
+
path: absolutePath,
|
|
524
|
+
displayPath: path,
|
|
525
|
+
diff: diffResult.diff,
|
|
526
|
+
patch,
|
|
527
|
+
diffData,
|
|
528
|
+
firstChangedLine: anchorResult.firstChangedLine ?? diffResult.firstChangedLine,
|
|
529
|
+
warnings,
|
|
530
|
+
noopEdits: anchorResult.noopEdits ?? [],
|
|
531
|
+
edits,
|
|
532
|
+
semanticSummary,
|
|
533
|
+
});
|
|
522
534
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
diffData
|
|
540
|
-
firstChangedLine:
|
|
541
|
-
warnings
|
|
542
|
-
noopEdits:
|
|
543
|
-
edits,
|
|
544
|
-
semanticSummary,
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
return {
|
|
548
|
-
content: [{ type: "text", text: builtOutput.text }],
|
|
549
|
-
details: {
|
|
550
|
-
diff: diffResult.diff,
|
|
551
|
-
patch: builtOutput.patch,
|
|
552
|
-
diffData,
|
|
553
|
-
firstChangedLine: anchorResult.firstChangedLine ?? diffResult.firstChangedLine,
|
|
554
|
-
readseekValue: builtOutput.readseekValue,
|
|
555
|
-
} as EditToolDetails & {
|
|
556
|
-
diffData: typeof diffData;
|
|
557
|
-
readseekValue: {
|
|
558
|
-
tool: string;
|
|
559
|
-
ok: boolean;
|
|
560
|
-
path: string;
|
|
561
|
-
summary: string;
|
|
562
|
-
diff: string;
|
|
563
|
-
diffData: typeof diffData;
|
|
564
|
-
firstChangedLine: number | undefined;
|
|
565
|
-
warnings: string[];
|
|
566
|
-
noopEdits: unknown[];
|
|
567
|
-
};
|
|
568
|
-
},
|
|
535
|
+
return {
|
|
536
|
+
content: [{ type: "text", text: builtOutput.text }],
|
|
537
|
+
details: {
|
|
538
|
+
diff: diffResult.diff,
|
|
539
|
+
patch: builtOutput.patch,
|
|
540
|
+
diffData,
|
|
541
|
+
firstChangedLine: anchorResult.firstChangedLine ?? diffResult.firstChangedLine,
|
|
542
|
+
readseekValue: builtOutput.readseekValue,
|
|
543
|
+
} as EditToolDetails & {
|
|
544
|
+
diffData: typeof diffData;
|
|
545
|
+
readseekValue: {
|
|
546
|
+
tool: string;
|
|
547
|
+
ok: boolean;
|
|
548
|
+
path: string;
|
|
549
|
+
summary: string;
|
|
550
|
+
diff: string;
|
|
551
|
+
diffData: typeof diffData;
|
|
552
|
+
firstChangedLine: number | undefined;
|
|
553
|
+
warnings: string[];
|
|
554
|
+
noopEdits: unknown[];
|
|
569
555
|
};
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
});
|
|
559
|
+
} catch (err: any) {
|
|
560
|
+
if (typeof err?.code === "string") {
|
|
561
|
+
return mapEditFileError(err, absolutePath, path, "read");
|
|
562
|
+
}
|
|
563
|
+
throw err;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}) {
|
|
569
|
+
const toolConfig = {
|
|
570
|
+
callable: true,
|
|
571
|
+
enabled: true,
|
|
572
|
+
policy: "mutating" as const,
|
|
573
|
+
readOnly: false,
|
|
574
|
+
pythonName: "edit",
|
|
575
|
+
defaultExposure: "not-safe-by-default" as const,
|
|
576
|
+
};
|
|
577
|
+
const tool = {
|
|
578
|
+
name: "edit",
|
|
579
|
+
label: "Edit",
|
|
580
|
+
description: EDIT_PROMPT_METADATA.description,
|
|
581
|
+
promptSnippet: EDIT_PROMPT_METADATA.promptSnippet,
|
|
582
|
+
promptGuidelines: EDIT_PROMPT_METADATA.promptGuidelines,
|
|
583
|
+
parameters: hashlineEditSchema,
|
|
584
|
+
ptc: toolConfig,
|
|
585
|
+
renderShell: "default" as const,
|
|
586
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
587
|
+
return executeEdit({
|
|
588
|
+
params,
|
|
589
|
+
signal,
|
|
590
|
+
cwd: ctx.cwd,
|
|
591
|
+
wasReadInSession: options.wasReadInSession,
|
|
592
|
+
syntaxValidate: options.syntaxValidate,
|
|
593
|
+
});
|
|
577
594
|
},
|
|
578
595
|
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
579
596
|
const context: { argsComplete?: boolean; executionStarted?: boolean; lastComponent?: any; cwd?: string; state?: Record<string, any>; invalidate?: () => void; width?: number; expanded?: boolean } = rest[0] ?? {};
|