@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.
Files changed (56) hide show
  1. package/dist/audit.js.map +1 -1
  2. package/dist/bash.js +4 -0
  3. package/dist/bash.js.map +1 -1
  4. package/dist/builtin.js +685 -253
  5. package/dist/builtin.js.map +1 -1
  6. package/dist/codebase-index/index.js +2 -2
  7. package/dist/codebase-index/index.js.map +1 -1
  8. package/dist/diff.js.map +1 -1
  9. package/dist/document.js.map +1 -1
  10. package/dist/edit.d.ts +43 -0
  11. package/dist/edit.js +386 -60
  12. package/dist/edit.js.map +1 -1
  13. package/dist/{exec-Ca3fnpUh.d.ts → exec-2OKoT6tk.d.ts} +1 -1
  14. package/dist/exec.d.ts +1 -1
  15. package/dist/exec.js +13 -14
  16. package/dist/exec.js.map +1 -1
  17. package/dist/fetch.js.map +1 -1
  18. package/dist/format.js.map +1 -1
  19. package/dist/git.js.map +1 -1
  20. package/dist/glob.js +10 -1
  21. package/dist/glob.js.map +1 -1
  22. package/dist/grep.js +4 -0
  23. package/dist/grep.js.map +1 -1
  24. package/dist/index.d.ts +13 -2
  25. package/dist/index.js +730 -268
  26. package/dist/index.js.map +1 -1
  27. package/dist/install.js.map +1 -1
  28. package/dist/json.js.map +1 -1
  29. package/dist/lint.js.map +1 -1
  30. package/dist/logs.js.map +1 -1
  31. package/dist/next-steps.d.ts +66 -0
  32. package/dist/next-steps.js +116 -0
  33. package/dist/next-steps.js.map +1 -0
  34. package/dist/outdated.js.map +1 -1
  35. package/dist/pack.js +685 -253
  36. package/dist/pack.js.map +1 -1
  37. package/dist/patch.js +43 -0
  38. package/dist/patch.js.map +1 -1
  39. package/dist/read.js +13 -4
  40. package/dist/read.js.map +1 -1
  41. package/dist/replace.js +14 -0
  42. package/dist/replace.js.map +1 -1
  43. package/dist/scaffold.js.map +1 -1
  44. package/dist/test.js.map +1 -1
  45. package/dist/tool-diff.d.ts +88 -0
  46. package/dist/tool-diff.js +229 -0
  47. package/dist/tool-diff.js.map +1 -0
  48. package/dist/tool-summary.d.ts +24 -0
  49. package/dist/tool-summary.js +208 -0
  50. package/dist/tool-summary.js.map +1 -0
  51. package/dist/tree.js.map +1 -1
  52. package/dist/typecheck.js.map +1 -1
  53. package/dist/write.d.ts +6 -0
  54. package/dist/write.js +118 -19
  55. package/dist/write.js.map +1 -1
  56. 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 path from 'node:path';
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 path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);
298
+ return path2.isAbsolute(input) ? path2.normalize(input) : path2.resolve(ctx.workingDir ?? ctx.cwd, input);
9
299
  }
10
300
  function allowedRoots(ctx) {
11
- return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];
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 = path.relative(root, target);
16
- return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
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 = path.resolve(absPath);
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 "${path.resolve(ctx.projectRoot)}"`);
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(() => path.resolve(r)))
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 = path.dirname(probe);
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 lastReadMtime = ctx.lastReadMtime(absPath);
124
- if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
125
- throw new ToolValidationError({
126
- message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
127
- field: "path",
128
- context: { reason: "external_modification" }
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
- let count = 0;
153
- let idx = fileLf.indexOf(oldLf);
154
- const matches = [];
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 = lineNumbersFor(fileLf, matches);
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
- const newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
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
- note: autoReadNote
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