@wrongstack/tools 0.283.1 → 0.284.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/dist/audit.js.map +1 -1
- package/dist/bash.js +4 -0
- package/dist/bash.js.map +1 -1
- package/dist/builtin.js +685 -253
- package/dist/builtin.js.map +1 -1
- package/dist/codebase-index/index.js +2 -2
- package/dist/codebase-index/index.js.map +1 -1
- package/dist/diff.js.map +1 -1
- package/dist/document.js.map +1 -1
- package/dist/edit.d.ts +43 -0
- package/dist/edit.js +386 -60
- package/dist/edit.js.map +1 -1
- package/dist/{exec-Ca3fnpUh.d.ts → exec-2OKoT6tk.d.ts} +1 -1
- package/dist/exec.d.ts +1 -1
- package/dist/exec.js +13 -14
- package/dist/exec.js.map +1 -1
- package/dist/fetch.js.map +1 -1
- package/dist/format.js.map +1 -1
- package/dist/git.js.map +1 -1
- package/dist/glob.js +10 -1
- package/dist/glob.js.map +1 -1
- package/dist/grep.js +4 -0
- package/dist/grep.js.map +1 -1
- package/dist/index.d.ts +13 -2
- package/dist/index.js +730 -268
- package/dist/index.js.map +1 -1
- package/dist/install.js.map +1 -1
- package/dist/json.js.map +1 -1
- package/dist/lint.js.map +1 -1
- package/dist/logs.js.map +1 -1
- package/dist/next-steps.d.ts +66 -0
- package/dist/next-steps.js +116 -0
- package/dist/next-steps.js.map +1 -0
- package/dist/outdated.js.map +1 -1
- package/dist/pack.js +685 -253
- package/dist/pack.js.map +1 -1
- package/dist/patch.js +43 -0
- package/dist/patch.js.map +1 -1
- package/dist/read.js +13 -4
- package/dist/read.js.map +1 -1
- package/dist/replace.js +14 -0
- package/dist/replace.js.map +1 -1
- package/dist/scaffold.js.map +1 -1
- package/dist/test.js.map +1 -1
- package/dist/tool-diff.d.ts +88 -0
- package/dist/tool-diff.js +229 -0
- package/dist/tool-diff.js.map +1 -0
- package/dist/tool-summary.d.ts +24 -0
- package/dist/tool-summary.js +208 -0
- package/dist/tool-summary.js.map +1 -0
- package/dist/tree.js.map +1 -1
- package/dist/typecheck.js.map +1 -1
- package/dist/write.d.ts +6 -0
- package/dist/write.js +118 -19
- package/dist/write.js.map +1 -1
- package/package.json +15 -2
package/dist/edit.js
CHANGED
|
@@ -1,26 +1,316 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as Core from '@wrongstack/core';
|
|
3
3
|
import { ToolValidationError, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff } from '@wrongstack/core';
|
|
4
|
-
import * as
|
|
4
|
+
import * as path2 from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
5
6
|
|
|
6
7
|
// src/edit.ts
|
|
8
|
+
|
|
9
|
+
// src/_edit-match.ts
|
|
10
|
+
var TIER_LABEL = {
|
|
11
|
+
exact: "exact match",
|
|
12
|
+
"trailing-whitespace": "whitespace-normalized match (trailing whitespace ignored)",
|
|
13
|
+
"whitespace-normalized": "whitespace-normalized match (indentation ignored, replacement re-indented)",
|
|
14
|
+
fuzzy: "fuzzy match (block anchors exact, interior \u226590% similar)"
|
|
15
|
+
};
|
|
16
|
+
var TIER_CONFIDENCE = {
|
|
17
|
+
exact: "exact",
|
|
18
|
+
"trailing-whitespace": "high",
|
|
19
|
+
"whitespace-normalized": "medium",
|
|
20
|
+
fuzzy: "low"
|
|
21
|
+
};
|
|
22
|
+
var MIN_NORMALIZED_NEEDLE_CHARS = 16;
|
|
23
|
+
var FUZZY_MIN_SIMILARITY = 0.9;
|
|
24
|
+
var FUZZY_AMBIGUITY_MARGIN = 0.05;
|
|
25
|
+
var FUZZY_MAX_INTERIOR_CHARS = 2e3;
|
|
26
|
+
function findLadderMatches(fileLf, oldLf) {
|
|
27
|
+
const exact = [];
|
|
28
|
+
let idx = fileLf.indexOf(oldLf);
|
|
29
|
+
while (idx !== -1) {
|
|
30
|
+
exact.push({ start: idx, end: idx + oldLf.length, startLine: lineAt(fileLf, idx) });
|
|
31
|
+
idx = fileLf.indexOf(oldLf, idx + 1);
|
|
32
|
+
}
|
|
33
|
+
if (exact.length > 0) return { tier: "exact", matches: exact };
|
|
34
|
+
const fileLines = fileLf.split("\n");
|
|
35
|
+
const needleLines = oldLf.split("\n");
|
|
36
|
+
if (needleLines.length > fileLines.length) return void 0;
|
|
37
|
+
const offsets = lineOffsets(fileLines);
|
|
38
|
+
const trailing = windowScan(
|
|
39
|
+
fileLines,
|
|
40
|
+
needleLines,
|
|
41
|
+
offsets,
|
|
42
|
+
(a, b) => a.trimEnd() === b.trimEnd()
|
|
43
|
+
);
|
|
44
|
+
if (trailing.length > 0) return { tier: "trailing-whitespace", matches: trailing };
|
|
45
|
+
const normalizedLen = needleLines.reduce((n, l) => n + l.trim().length, 0);
|
|
46
|
+
if (normalizedLen < MIN_NORMALIZED_NEEDLE_CHARS) return void 0;
|
|
47
|
+
const normalized = windowScan(fileLines, needleLines, offsets, (a, b) => a.trim() === b.trim());
|
|
48
|
+
if (normalized.length > 0) return { tier: "whitespace-normalized", matches: normalized };
|
|
49
|
+
return fuzzyScan(fileLines, needleLines, offsets);
|
|
50
|
+
}
|
|
51
|
+
function lineAt(text, pos) {
|
|
52
|
+
let line = 1;
|
|
53
|
+
for (let i = 0; i < pos; i++) {
|
|
54
|
+
if (text.charCodeAt(i) === 10) line++;
|
|
55
|
+
}
|
|
56
|
+
return line;
|
|
57
|
+
}
|
|
58
|
+
function lineOffsets(lines) {
|
|
59
|
+
const out = new Array(lines.length);
|
|
60
|
+
let pos = 0;
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
out[i] = pos;
|
|
63
|
+
pos += lines[i].length + 1;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function windowToMatch(fileLines, offsets, start, windowLen) {
|
|
68
|
+
const lastLine = start + windowLen - 1;
|
|
69
|
+
return {
|
|
70
|
+
start: offsets[start],
|
|
71
|
+
end: offsets[lastLine] + fileLines[lastLine].length,
|
|
72
|
+
startLine: start + 1
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function windowScan(fileLines, needleLines, offsets, eq) {
|
|
76
|
+
const n = needleLines.length;
|
|
77
|
+
const out = [];
|
|
78
|
+
for (let i = 0; i + n <= fileLines.length; i++) {
|
|
79
|
+
let all = true;
|
|
80
|
+
for (let j = 0; j < n; j++) {
|
|
81
|
+
if (!eq(fileLines[i + j], needleLines[j])) {
|
|
82
|
+
all = false;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (all) {
|
|
87
|
+
out.push(windowToMatch(fileLines, offsets, i, n));
|
|
88
|
+
i += n - 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
function fuzzyScan(fileLines, needleLines, offsets) {
|
|
94
|
+
const n = needleLines.length;
|
|
95
|
+
if (n < 3) return void 0;
|
|
96
|
+
const firstNeedle = needleLines[0].trim();
|
|
97
|
+
const lastNeedle = needleLines[n - 1].trim();
|
|
98
|
+
const needleInterior = needleLines.slice(1, -1).map((l) => l.trim()).join("\n");
|
|
99
|
+
if (needleInterior.length > FUZZY_MAX_INTERIOR_CHARS) return void 0;
|
|
100
|
+
const candidates = [];
|
|
101
|
+
for (let i = 0; i + n <= fileLines.length; i++) {
|
|
102
|
+
if (fileLines[i].trim() !== firstNeedle) continue;
|
|
103
|
+
if (fileLines[i + n - 1].trim() !== lastNeedle) continue;
|
|
104
|
+
const windowInterior = fileLines.slice(i + 1, i + n - 1).map((l) => l.trim()).join("\n");
|
|
105
|
+
if (windowInterior.length > FUZZY_MAX_INTERIOR_CHARS) continue;
|
|
106
|
+
const score = similarity(needleInterior, windowInterior);
|
|
107
|
+
if (score >= FUZZY_MIN_SIMILARITY) {
|
|
108
|
+
candidates.push({ match: windowToMatch(fileLines, offsets, i, n), score });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (candidates.length === 0) return void 0;
|
|
112
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
113
|
+
const best = candidates[0];
|
|
114
|
+
const runnerUp = candidates[1];
|
|
115
|
+
if (runnerUp && best.score - runnerUp.score < FUZZY_AMBIGUITY_MARGIN) {
|
|
116
|
+
return {
|
|
117
|
+
tier: "fuzzy",
|
|
118
|
+
matches: candidates.map((c) => c.match),
|
|
119
|
+
score: best.score,
|
|
120
|
+
ambiguous: true
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { tier: "fuzzy", matches: [best.match], score: best.score };
|
|
124
|
+
}
|
|
125
|
+
function similarity(a, b) {
|
|
126
|
+
if (a === b) return 1;
|
|
127
|
+
const max = Math.max(a.length, b.length);
|
|
128
|
+
if (max === 0) return 1;
|
|
129
|
+
if (Math.abs(a.length - b.length) / max > 1 - FUZZY_MIN_SIMILARITY + 0.05) {
|
|
130
|
+
return 1 - Math.abs(a.length - b.length) / max;
|
|
131
|
+
}
|
|
132
|
+
return 1 - levenshtein(a, b) / max;
|
|
133
|
+
}
|
|
134
|
+
function levenshtein(a, b) {
|
|
135
|
+
if (a.length === 0) return b.length;
|
|
136
|
+
if (b.length === 0) return a.length;
|
|
137
|
+
let prev = new Array(b.length + 1);
|
|
138
|
+
let cur = new Array(b.length + 1);
|
|
139
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
|
140
|
+
for (let i = 1; i <= a.length; i++) {
|
|
141
|
+
cur[0] = i;
|
|
142
|
+
const ca = a.charCodeAt(i - 1);
|
|
143
|
+
for (let j = 1; j <= b.length; j++) {
|
|
144
|
+
const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
|
|
145
|
+
cur[j] = Math.min(
|
|
146
|
+
prev[j] + 1,
|
|
147
|
+
cur[j - 1] + 1,
|
|
148
|
+
prev[j - 1] + cost
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
[prev, cur] = [cur, prev];
|
|
152
|
+
}
|
|
153
|
+
return prev[b.length];
|
|
154
|
+
}
|
|
155
|
+
function firstLineIndent(text) {
|
|
156
|
+
for (const line of text.split("\n")) {
|
|
157
|
+
if (line.trim() === "") continue;
|
|
158
|
+
return /^[ \t]*/.exec(line)[0];
|
|
159
|
+
}
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
function adjustIndent(newLf, fromIndent, toIndent) {
|
|
163
|
+
if (fromIndent === toIndent) return { text: newLf, adjusted: false };
|
|
164
|
+
const lines = newLf.split("\n");
|
|
165
|
+
if (toIndent.startsWith(fromIndent)) {
|
|
166
|
+
const extra = toIndent.slice(fromIndent.length);
|
|
167
|
+
return {
|
|
168
|
+
text: lines.map((l) => l.trim() === "" ? l : extra + l).join("\n"),
|
|
169
|
+
adjusted: true
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (fromIndent.startsWith(toIndent)) {
|
|
173
|
+
const remove = fromIndent.slice(toIndent.length);
|
|
174
|
+
return {
|
|
175
|
+
text: lines.map((l) => l.startsWith(remove) ? l.slice(remove.length) : l).join("\n"),
|
|
176
|
+
adjusted: true
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return { text: newLf, adjusted: false };
|
|
180
|
+
}
|
|
181
|
+
function nearestMatchHint(fileLf, oldLf) {
|
|
182
|
+
const fileLines = fileLf.split("\n");
|
|
183
|
+
const needleLines = oldLf.split("\n");
|
|
184
|
+
if (needleLines.length > fileLines.length) return void 0;
|
|
185
|
+
const needleTrimmed = needleLines.map((l) => l.trim());
|
|
186
|
+
let bestScore = 0;
|
|
187
|
+
let bestStart = -1;
|
|
188
|
+
for (let i = 0; i + needleLines.length <= fileLines.length; i++) {
|
|
189
|
+
let sum = 0;
|
|
190
|
+
for (let j = 0; j < needleLines.length; j++) {
|
|
191
|
+
sum += prefixSimilarity(needleTrimmed[j], fileLines[i + j].trim());
|
|
192
|
+
}
|
|
193
|
+
const score = sum / needleLines.length;
|
|
194
|
+
if (score > bestScore) {
|
|
195
|
+
bestScore = score;
|
|
196
|
+
bestStart = i;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (bestStart === -1 || bestScore < 0.5) return void 0;
|
|
200
|
+
const snippet = fileLines.slice(bestStart, bestStart + Math.min(3, needleLines.length)).map((l) => l.length > 120 ? `${l.slice(0, 120)}\u2026` : l).join("\n");
|
|
201
|
+
return { line: bestStart + 1, snippet };
|
|
202
|
+
}
|
|
203
|
+
function prefixSimilarity(a, b) {
|
|
204
|
+
if (a === b) return 1;
|
|
205
|
+
const max = Math.max(a.length, b.length);
|
|
206
|
+
if (max === 0) return 1;
|
|
207
|
+
let p = 0;
|
|
208
|
+
const lim = Math.min(a.length, b.length);
|
|
209
|
+
while (p < lim && a.charCodeAt(p) === b.charCodeAt(p)) p++;
|
|
210
|
+
return p / max;
|
|
211
|
+
}
|
|
212
|
+
var TS_LIKE = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
213
|
+
var MAX_CHECK_CHARS = 15e5;
|
|
214
|
+
var MAX_ERRORS = 5;
|
|
215
|
+
var tsLoad = null;
|
|
216
|
+
function loadTypescript() {
|
|
217
|
+
tsLoad ??= import('typescript').then(
|
|
218
|
+
(m) => m.default ?? m,
|
|
219
|
+
() => null
|
|
220
|
+
);
|
|
221
|
+
return tsLoad;
|
|
222
|
+
}
|
|
223
|
+
async function checkSyntax(filePath, content, previousContent) {
|
|
224
|
+
const errors = await parseErrors(filePath, content);
|
|
225
|
+
if (errors === void 0) return void 0;
|
|
226
|
+
if (errors.length === 0) return { errors, preExisting: false };
|
|
227
|
+
let preExisting = false;
|
|
228
|
+
if (previousContent !== void 0) {
|
|
229
|
+
const prevErrors = await parseErrors(filePath, previousContent);
|
|
230
|
+
preExisting = prevErrors !== void 0 && prevErrors.length > 0;
|
|
231
|
+
}
|
|
232
|
+
return { errors, preExisting };
|
|
233
|
+
}
|
|
234
|
+
function isJsoncFile(filePath) {
|
|
235
|
+
const base = path2.basename(filePath).toLowerCase();
|
|
236
|
+
if (base.endsWith(".jsonc")) return true;
|
|
237
|
+
if (/^(tsconfig|jsconfig)([.-].*)?\.json$/.test(base)) return true;
|
|
238
|
+
const dir = path2.basename(path2.dirname(filePath)).toLowerCase();
|
|
239
|
+
return dir === ".vscode";
|
|
240
|
+
}
|
|
241
|
+
async function parseErrors(filePath, content) {
|
|
242
|
+
if (content.length > MAX_CHECK_CHARS) return void 0;
|
|
243
|
+
const ext = path2.extname(filePath).toLowerCase();
|
|
244
|
+
if (ext === ".json" || ext === ".jsonc") {
|
|
245
|
+
try {
|
|
246
|
+
JSON.parse(content);
|
|
247
|
+
return [];
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (isJsoncFile(filePath)) {
|
|
250
|
+
const ts2 = await loadTypescript();
|
|
251
|
+
if (ts2) {
|
|
252
|
+
const jsonc = ts2.parseConfigFileTextToJson(filePath, content);
|
|
253
|
+
if (!jsonc.error) return [];
|
|
254
|
+
return [formatDiag(ts2, jsonc.error, content)];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return [`JSON parse error: ${err.message}`];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!TS_LIKE.has(ext)) return void 0;
|
|
261
|
+
const ts = await loadTypescript();
|
|
262
|
+
if (!ts) return void 0;
|
|
263
|
+
const scriptKind = ext === ".tsx" ? ts.ScriptKind.TSX : ext === ".ts" || ext === ".mts" || ext === ".cts" ? ts.ScriptKind.TS : (
|
|
264
|
+
// Plain JS may legitimately contain JSX; the JSX grammar is a
|
|
265
|
+
// superset for untyped code, so parsing .js/.jsx as JSX avoids
|
|
266
|
+
// false positives on React files.
|
|
267
|
+
ts.ScriptKind.JSX
|
|
268
|
+
);
|
|
269
|
+
const sourceFile = ts.createSourceFile(
|
|
270
|
+
path2.basename(filePath),
|
|
271
|
+
content,
|
|
272
|
+
ts.ScriptTarget.Latest,
|
|
273
|
+
/* setParentNodes */
|
|
274
|
+
false,
|
|
275
|
+
scriptKind
|
|
276
|
+
);
|
|
277
|
+
const diags = sourceFile.parseDiagnostics ?? [];
|
|
278
|
+
return diags.slice(0, MAX_ERRORS).map((d) => formatDiag(ts, d, content, sourceFile));
|
|
279
|
+
}
|
|
280
|
+
function formatDiag(ts, diag, content, sourceFile) {
|
|
281
|
+
const message = ts.flattenDiagnosticMessageText(diag.messageText, " ");
|
|
282
|
+
if (diag.start === void 0) return message;
|
|
283
|
+
let line;
|
|
284
|
+
if (sourceFile) {
|
|
285
|
+
line = sourceFile.getLineAndCharacterOfPosition(diag.start).line + 1;
|
|
286
|
+
} else {
|
|
287
|
+
line = 1;
|
|
288
|
+
for (let i = 0; i < diag.start && i < content.length; i++) {
|
|
289
|
+
if (content.charCodeAt(i) === 10) line++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return `line ${line}: ${message}`;
|
|
293
|
+
}
|
|
294
|
+
function sha256hex(content) {
|
|
295
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
296
|
+
}
|
|
7
297
|
function resolvePath(input, ctx) {
|
|
8
|
-
return
|
|
298
|
+
return path2.isAbsolute(input) ? path2.normalize(input) : path2.resolve(ctx.workingDir ?? ctx.cwd, input);
|
|
9
299
|
}
|
|
10
300
|
function allowedRoots(ctx) {
|
|
11
|
-
return [
|
|
301
|
+
return [path2.resolve(ctx.projectRoot), path2.resolve(Core.wstackGlobalRoot())];
|
|
12
302
|
}
|
|
13
303
|
function isInsideAny(target, roots) {
|
|
14
304
|
return roots.some((root) => {
|
|
15
|
-
const rel =
|
|
16
|
-
return rel === "" || !rel.startsWith("..") && !
|
|
305
|
+
const rel = path2.relative(root, target);
|
|
306
|
+
return rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel);
|
|
17
307
|
});
|
|
18
308
|
}
|
|
19
309
|
function ensureInsideRoot(absPath, ctx) {
|
|
20
|
-
const target =
|
|
310
|
+
const target = path2.resolve(absPath);
|
|
21
311
|
if (ctx.allowOutsideProjectRoot) return target;
|
|
22
312
|
if (isInsideAny(target, allowedRoots(ctx))) return target;
|
|
23
|
-
throw new Error(`Path "${absPath}" is outside project root "${
|
|
313
|
+
throw new Error(`Path "${absPath}" is outside project root "${path2.resolve(ctx.projectRoot)}"`);
|
|
24
314
|
}
|
|
25
315
|
function safeResolve(input, ctx) {
|
|
26
316
|
return ensureInsideRoot(resolvePath(input, ctx), ctx);
|
|
@@ -28,7 +318,7 @@ function safeResolve(input, ctx) {
|
|
|
28
318
|
async function assertRealInsideRoot(absPath, ctx) {
|
|
29
319
|
if (ctx.allowOutsideProjectRoot) return;
|
|
30
320
|
const realRoots = await Promise.all(
|
|
31
|
-
allowedRoots(ctx).map((r) => fs.realpath(r).catch(() =>
|
|
321
|
+
allowedRoots(ctx).map((r) => fs.realpath(r).catch(() => path2.resolve(r)))
|
|
32
322
|
);
|
|
33
323
|
let probe = absPath;
|
|
34
324
|
for (; ; ) {
|
|
@@ -37,7 +327,7 @@ async function assertRealInsideRoot(absPath, ctx) {
|
|
|
37
327
|
real = await fs.realpath(probe);
|
|
38
328
|
} catch (err) {
|
|
39
329
|
if (err.code === "ENOENT") {
|
|
40
|
-
const parent =
|
|
330
|
+
const parent = path2.dirname(probe);
|
|
41
331
|
if (parent === probe) return;
|
|
42
332
|
probe = parent;
|
|
43
333
|
continue;
|
|
@@ -61,7 +351,11 @@ var editTool = {
|
|
|
61
351
|
name: "edit",
|
|
62
352
|
category: "Filesystem",
|
|
63
353
|
description: "Perform a precise, surgical text replacement in a file. This is the preferred tool for modifying existing code. It works best after a prior `read`, but can auto-read the current file when the replacement is still unambiguous. Fails safely if the `old_string` appears more than once unless `replace_all` is set.",
|
|
64
|
-
usageHint: "RECOMMENDED WORKFLOW:\n1. Prefer calling `read` on the target file first when planning an edit.\n2. Use a sufficiently unique `old_string` (include surrounding lines/context if needed).\n3. If the string appears multiple times and you want to change all of them, set `replace_all: true`.\n4. `new_string` must be the exact replacement text.\n\nIf no prior read is recorded, the tool auto-reads the current file and only applies the edit after the same ambiguity checks pass.",
|
|
354
|
+
usageHint: "RECOMMENDED WORKFLOW:\n1. Prefer calling `read` on the target file first when planning an edit.\n2. Use a sufficiently unique `old_string` (include surrounding lines/context if needed).\n3. If the string appears multiple times and you want to change all of them, set `replace_all: true`.\n4. `new_string` must be the exact replacement text.\n\nIf no prior read is recorded, the tool auto-reads the current file and only applies the edit after the same ambiguity checks pass.\nIf `old_string` differs from the file only in whitespace (trailing spaces, indentation), a lower-confidence fallback match is applied and reported in `matched_by`/`note` \u2014 always verify the diff when this happens.\nAfter the edit, TS/JS/JSON files are syntax-checked; if `syntax_errors` is present in the output, fix those errors immediately with a follow-up edit.",
|
|
355
|
+
selection: {
|
|
356
|
+
doNotUseWhen: "creating a new file, replacing the whole file, or applying an existing unified diff.",
|
|
357
|
+
useInstead: ["write", "patch"]
|
|
358
|
+
},
|
|
65
359
|
permission: "confirm",
|
|
66
360
|
mutating: true,
|
|
67
361
|
capabilities: ["fs.write"],
|
|
@@ -77,7 +371,7 @@ var editTool = {
|
|
|
77
371
|
},
|
|
78
372
|
required: ["path", "old_string", "new_string"]
|
|
79
373
|
},
|
|
80
|
-
async execute(input, ctx) {
|
|
374
|
+
async execute(input, ctx, opts) {
|
|
81
375
|
if (!input?.path) {
|
|
82
376
|
throw new ToolValidationError({ message: "edit: path is required", field: "path" });
|
|
83
377
|
}
|
|
@@ -120,13 +414,25 @@ var editTool = {
|
|
|
120
414
|
const original = await fs.readFile(absPath, "utf8");
|
|
121
415
|
const updated = await fs.stat(absPath);
|
|
122
416
|
const mtimeTolerance = process.platform === "win32" ? 2e3 : 1;
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
417
|
+
const originalHash = sha256hex(original);
|
|
418
|
+
const lastReadHash = ctx.lastReadHash?.(absPath);
|
|
419
|
+
if (lastReadHash !== void 0) {
|
|
420
|
+
if (lastReadHash !== originalHash) {
|
|
421
|
+
throw new ToolValidationError({
|
|
422
|
+
message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
|
|
423
|
+
field: "path",
|
|
424
|
+
context: { reason: "external_modification" }
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
const lastReadMtime = ctx.lastReadMtime(absPath);
|
|
429
|
+
if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
|
|
430
|
+
throw new ToolValidationError({
|
|
431
|
+
message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
|
|
432
|
+
field: "path",
|
|
433
|
+
context: { reason: "external_modification" }
|
|
434
|
+
});
|
|
435
|
+
}
|
|
130
436
|
}
|
|
131
437
|
if (autoRead && updated.mtimeMs > stat2.mtimeMs + mtimeTolerance) {
|
|
132
438
|
throw new ToolValidationError({
|
|
@@ -141,42 +447,78 @@ var editTool = {
|
|
|
141
447
|
const oldLf = normalizeToLf(input.old_string);
|
|
142
448
|
const newLf = normalizeToLf(input.new_string);
|
|
143
449
|
if (oldLf === newLf) {
|
|
144
|
-
if (autoRead) ctx.recordRead(absPath, updated.mtimeMs);
|
|
450
|
+
if (autoRead) ctx.recordRead(absPath, updated.mtimeMs, "user", originalHash);
|
|
145
451
|
return {
|
|
146
452
|
path: absPath,
|
|
147
453
|
replacements: 0,
|
|
148
454
|
diff: "(no-op: old and new are identical)",
|
|
455
|
+
matched_by: "exact",
|
|
149
456
|
note: autoReadNote
|
|
150
457
|
};
|
|
151
458
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
while (idx !== -1) {
|
|
156
|
-
matches.push(idx);
|
|
157
|
-
count++;
|
|
158
|
-
idx = fileLf.indexOf(oldLf, idx + 1);
|
|
159
|
-
}
|
|
160
|
-
if (count === 0) {
|
|
161
|
-
const hint = findSimilarity(fileLf, oldLf);
|
|
459
|
+
const ladder = findLadderMatches(fileLf, oldLf);
|
|
460
|
+
if (!ladder) {
|
|
461
|
+
const hint = nearestMatchHint(fileLf, oldLf);
|
|
162
462
|
throw new ToolValidationError({
|
|
163
|
-
message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint}
|
|
463
|
+
message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint.line}:
|
|
464
|
+
${hint.snippet}
|
|
465
|
+
Compare this against your old_string and retry with the file's actual text.` : ""}`,
|
|
164
466
|
field: "old_string"
|
|
165
467
|
});
|
|
166
468
|
}
|
|
469
|
+
const { tier, matches } = ladder;
|
|
470
|
+
const count = matches.length;
|
|
471
|
+
if (ladder.ambiguous) {
|
|
472
|
+
const lines = matches.map((m) => m.startLine);
|
|
473
|
+
throw new ToolValidationError({
|
|
474
|
+
message: `edit: old_string only matched fuzzily and ${count} candidate blocks scored too close to distinguish (lines: ${lines.join(", ")}) in "${input.path}". Re-read the file and use the exact text of the intended block.`,
|
|
475
|
+
field: "old_string",
|
|
476
|
+
context: { occurrences: count, matchTier: tier }
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
if (input.replace_all && tier !== "exact" && tier !== "trailing-whitespace") {
|
|
480
|
+
throw new ToolValidationError({
|
|
481
|
+
message: `edit: old_string only matched via ${TIER_LABEL[tier]} in "${input.path}", but replace_all requires an exact (or trailing-whitespace) match. Re-read the file and use its exact text.`,
|
|
482
|
+
field: "old_string",
|
|
483
|
+
context: { matchTier: tier }
|
|
484
|
+
});
|
|
485
|
+
}
|
|
167
486
|
if (count > 1 && !input.replace_all) {
|
|
168
|
-
const lines =
|
|
487
|
+
const lines = matches.map((m) => m.startLine);
|
|
169
488
|
throw new ToolValidationError({
|
|
170
|
-
message: `edit: old_string matched ${count} times in "${input.path}" (lines: ${lines.join(", ")}). Add more context to make it unique, or set replace_all: true.`,
|
|
489
|
+
message: `edit: old_string matched ${count} times in "${input.path}" (lines: ${lines.join(", ")})${tier === "exact" ? "" : ` via ${TIER_LABEL[tier]}`}. Add more context to make it unique, or set replace_all: true.`,
|
|
171
490
|
field: "old_string",
|
|
172
|
-
context: { occurrences: count }
|
|
491
|
+
context: { occurrences: count, matchTier: tier }
|
|
173
492
|
});
|
|
174
493
|
}
|
|
175
|
-
|
|
494
|
+
let newFileLf;
|
|
495
|
+
let tierNote;
|
|
496
|
+
if (tier === "exact") {
|
|
497
|
+
newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
|
|
498
|
+
} else {
|
|
499
|
+
let replacement = newLf;
|
|
500
|
+
let indentSuffix = "";
|
|
501
|
+
if (tier === "whitespace-normalized" || tier === "fuzzy") {
|
|
502
|
+
const first = matches[0];
|
|
503
|
+
const matchedText = fileLf.slice(first.start, first.end);
|
|
504
|
+
const adjusted = adjustIndent(newLf, firstLineIndent(oldLf), firstLineIndent(matchedText));
|
|
505
|
+
replacement = adjusted.text;
|
|
506
|
+
if (adjusted.adjusted) indentSuffix = "; replacement re-indented to match the file";
|
|
507
|
+
}
|
|
508
|
+
newFileLf = fileLf;
|
|
509
|
+
const applied = input.replace_all ? matches : matches.slice(0, 1);
|
|
510
|
+
for (let i = applied.length - 1; i >= 0; i--) {
|
|
511
|
+
const m = applied[i];
|
|
512
|
+
newFileLf = newFileLf.slice(0, m.start) + replacement + newFileLf.slice(m.end);
|
|
513
|
+
}
|
|
514
|
+
const scoreSuffix = ladder.score !== void 0 ? `, similarity ${(ladder.score * 100).toFixed(1)}%` : "";
|
|
515
|
+
tierNote = `old_string did not match exactly; applied via ${TIER_LABEL[tier]} at line ${matches[0].startLine} (confidence: ${TIER_CONFIDENCE[tier]}${scoreSuffix}${indentSuffix}). Review the diff to confirm the intended target was edited.`;
|
|
516
|
+
}
|
|
176
517
|
const newFile = toStyle(newFileLf, style);
|
|
518
|
+
opts?.signal?.throwIfAborted();
|
|
177
519
|
await atomicWrite(absPath, newFile, { mode: updated.mode & 511 });
|
|
178
520
|
const written = await fs.stat(absPath);
|
|
179
|
-
ctx.recordRead(absPath, written.mtimeMs, "write");
|
|
521
|
+
ctx.recordRead(absPath, written.mtimeMs, "write", sha256hex(newFile));
|
|
180
522
|
ctx.session.recordFileChange({
|
|
181
523
|
path: absPath,
|
|
182
524
|
action: "modified",
|
|
@@ -187,38 +529,22 @@ var editTool = {
|
|
|
187
529
|
fromFile: input.path,
|
|
188
530
|
toFile: input.path
|
|
189
531
|
});
|
|
532
|
+
const syntax = await checkSyntax(absPath, newFile, original).catch(() => void 0);
|
|
533
|
+
let syntaxNote;
|
|
534
|
+
if (syntax && syntax.errors.length > 0) {
|
|
535
|
+
syntaxNote = syntax.preExisting ? `Syntax check: the file still has parse errors (they pre-date this edit) \u2014 see syntax_errors.` : `Syntax check: this edit introduced ${syntax.errors.length} parse error(s) \u2014 fix them now, see syntax_errors.`;
|
|
536
|
+
}
|
|
537
|
+
const notes = [autoReadNote, tierNote, syntaxNote].filter(Boolean);
|
|
190
538
|
return {
|
|
191
539
|
path: absPath,
|
|
192
540
|
replacements: input.replace_all ? count : 1,
|
|
193
541
|
diff,
|
|
194
|
-
|
|
542
|
+
matched_by: tier,
|
|
543
|
+
syntax_errors: syntax && syntax.errors.length > 0 ? syntax.errors : void 0,
|
|
544
|
+
note: notes.length > 0 ? notes.join("\n") : void 0
|
|
195
545
|
};
|
|
196
546
|
}
|
|
197
547
|
};
|
|
198
|
-
function lineNumbersFor(text, indices) {
|
|
199
|
-
const out = [];
|
|
200
|
-
let pos = 0;
|
|
201
|
-
let line = 1;
|
|
202
|
-
for (const target of indices) {
|
|
203
|
-
while (pos < target) {
|
|
204
|
-
if (text.charCodeAt(pos) === 10) line++;
|
|
205
|
-
pos++;
|
|
206
|
-
}
|
|
207
|
-
out.push(line);
|
|
208
|
-
}
|
|
209
|
-
return out;
|
|
210
|
-
}
|
|
211
|
-
function findSimilarity(haystack, needle) {
|
|
212
|
-
if (needle.length < 20) return void 0;
|
|
213
|
-
const probe = needle.slice(0, Math.min(40, needle.length));
|
|
214
|
-
const idx = haystack.indexOf(probe);
|
|
215
|
-
if (idx === -1) return void 0;
|
|
216
|
-
let line = 1;
|
|
217
|
-
for (let i = 0; i < idx; i++) {
|
|
218
|
-
if (haystack.charCodeAt(i) === 10) line++;
|
|
219
|
-
}
|
|
220
|
-
return line;
|
|
221
|
-
}
|
|
222
548
|
|
|
223
549
|
export { editTool };
|
|
224
550
|
//# sourceMappingURL=edit.js.map
|