@levnikolaevich/hex-line-mcp 1.3.1 → 1.3.3

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/lib/edit.mjs CHANGED
@@ -2,28 +2,25 @@
2
2
  * Hash-verified file editing with diff output.
3
3
  *
4
4
  * Supports:
5
- * - Range-based: range "ab.12-cd.15" + checksum
6
- * - Anchor-based: set_line, replace_lines, insert_after
7
- * - Text-based: replace { old_text, new_text, all }
5
+ * - set_line / replace_lines / insert_after / replace_between
8
6
  * - dry_run preview, noop detection, diff output
7
+ * - optional revision-aware conservative auto-rebase
9
8
  */
10
9
 
11
- import { writeFileSync } from "node:fs";
10
+ import { statSync, writeFileSync } from "node:fs";
12
11
  import { diffLines } from "diff";
13
- import { fnv1a, lineTag, rangeChecksum, parseChecksum, parseRef } from "./hash.mjs";
14
- import { validatePath } from "./security.mjs";
15
- import { getGraphDB, blastRadius, getRelativePath } from "./graph-enrich.mjs";
16
- import { readText } from "./format.mjs";
17
-
18
- // Unicode characters visually similar to ASCII hyphen-minus (U+002D)
19
- const CONFUSABLE_HYPHENS = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
20
-
21
- /**
22
- * Normalize confusable unicode hyphens to ASCII hyphen-minus.
23
- */
24
- function normalizeConfusables(text) {
25
- return text.replace(CONFUSABLE_HYPHENS, "-");
26
- }
12
+ import { fnv1a, lineTag, parseChecksum, parseRef, rangeChecksum } from "@levnikolaevich/hex-common/text-protocol/hash";
13
+ import { validatePath, normalizePath } from "./security.mjs";
14
+ import { getGraphDB, callImpact, getRelativePath } from "./graph-enrich.mjs";
15
+ import {
16
+ buildRangeChecksum,
17
+ computeChangedRanges,
18
+ describeChangedRanges,
19
+ getSnapshotByRevision,
20
+ overlapsChangedRanges,
21
+ readSnapshot,
22
+ rememberSnapshot,
23
+ } from "./revisions.mjs";
27
24
 
28
25
  /**
29
26
  * Restore indentation from original lines onto replacement lines.
@@ -35,18 +32,14 @@ function restoreIndent(origLines, newLines) {
35
32
  const newIndent = newLines[0].match(/^\s*/)[0];
36
33
  if (origIndent === newIndent) return newLines;
37
34
  return newLines.map(line => {
38
- if (!line.trim()) return line; // skip empty lines
35
+ if (!line.trim()) return line;
39
36
  if (line.startsWith(newIndent)) return origIndent + line.slice(newIndent.length);
40
37
  return line;
41
38
  });
42
39
  }
43
40
 
44
41
  /**
45
- * Build hash-annotated snippet around a position for error messages.
46
- * @param {string[]} lines - file lines
47
- * @param {number} centerIdx - 0-based center index
48
- * @param {number} radius - lines before/after center (default 5)
49
- * @returns {{ start: number, end: number, text: string }}
42
+ * Build hash-annotated snippet around a position for error or conflict messages.
50
43
  */
51
44
  function buildErrorSnippet(lines, centerIdx, radius = 5) {
52
45
  const start = Math.max(0, centerIdx - radius);
@@ -59,24 +52,6 @@ function buildErrorSnippet(lines, centerIdx, radius = 5) {
59
52
  return { start: start + 1, end, text };
60
53
  }
61
54
 
62
- /**
63
- * Build a hash index of all lines, keeping only unique tags.
64
- * 2-char tags have collisions — duplicates are excluded to avoid wrong relocations.
65
- * @param {string[]} lines
66
- * @returns {Map<string, number>} tag → line index (0-based)
67
- */
68
- function buildHashIndex(lines) {
69
- const hashIndex = new Map();
70
- const duplicates = new Set();
71
- for (let i = 0; i < lines.length; i++) {
72
- const tag = lineTag(fnv1a(lines[i]));
73
- if (duplicates.has(tag)) continue;
74
- if (hashIndex.has(tag)) { hashIndex.delete(tag); duplicates.add(tag); continue; }
75
- hashIndex.set(tag, i);
76
- }
77
- return hashIndex;
78
- }
79
-
80
55
  /**
81
56
  * Find line by tag.lineNum reference with fuzzy matching (+-5 lines).
82
57
  * Falls back to global hash relocation via hashIndex before throwing.
@@ -96,7 +71,6 @@ function findLine(lines, lineNum, expectedTag, hashIndex) {
96
71
  const actual = lineTag(fnv1a(lines[idx]));
97
72
  if (actual === expectedTag) return idx;
98
73
 
99
- // Fuzzy: search +-5
100
74
  for (let d = 1; d <= 5; d++) {
101
75
  for (const off of [d, -d]) {
102
76
  const c = idx + off;
@@ -104,7 +78,6 @@ function findLine(lines, lineNum, expectedTag, hashIndex) {
104
78
  }
105
79
  }
106
80
 
107
- // Whitespace-tolerant
108
81
  const stripped = lines[idx].replace(/\s+/g, "");
109
82
  if (stripped.length > 0) {
110
83
  for (let j = Math.max(0, idx - 5); j <= Math.min(lines.length - 1, idx + 5); j++) {
@@ -112,14 +85,14 @@ function findLine(lines, lineNum, expectedTag, hashIndex) {
112
85
  }
113
86
  }
114
87
 
115
- // Confusable normalization: try matching after normalizing unicode hyphens
116
- const normalizedExpected = normalizeConfusables(expectedTag);
88
+ const CONFUSABLE_RE = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
89
+ const norm = t => t.replace(CONFUSABLE_RE, "-");
90
+ const normalizedExpected = norm(expectedTag);
117
91
  for (let i = Math.max(0, idx - 10); i <= Math.min(lines.length - 1, idx + 10); i++) {
118
- const normalizedActual = normalizeConfusables(lineTag(fnv1a(normalizeConfusables(lines[i]))));
92
+ const normalizedActual = norm(lineTag(fnv1a(norm(lines[i]))));
119
93
  if (normalizedActual === normalizedExpected) return i;
120
94
  }
121
95
 
122
- // Global hash relocation: search entire file via pre-built unique-tag index
123
96
  if (hashIndex) {
124
97
  const relocated = hashIndex.get(expectedTag);
125
98
  if (relocated !== undefined) return relocated;
@@ -133,7 +106,6 @@ function findLine(lines, lineNum, expectedTag, hashIndex) {
133
106
  );
134
107
  }
135
108
 
136
-
137
109
  /**
138
110
  * Context diff via `diff` package (Myers O(ND) algorithm).
139
111
  * Returns compact hunks with ±ctx context lines, or null if no changes.
@@ -163,13 +135,13 @@ export function simpleDiff(oldLines, newLines, ctx = 3) {
163
135
  let start = 0, end = lines.length;
164
136
  if (!lastChange) start = Math.max(0, end - ctx);
165
137
  if (!next && end - start > ctx) end = start + ctx;
166
- if (start > 0) { out.push(`...`); oldNum += start; newNum += start; }
138
+ if (start > 0) { out.push("..."); oldNum += start; newNum += start; }
167
139
  for (let k = start; k < end; k++) {
168
140
  out.push(` ${oldNum}| ${lines[k]}`);
169
141
  oldNum++; newNum++;
170
142
  }
171
143
  if (end < lines.length) {
172
- out.push(`...`);
144
+ out.push("...");
173
145
  oldNum += lines.length - end;
174
146
  newNum += lines.length - end;
175
147
  }
@@ -183,147 +155,80 @@ export function simpleDiff(oldLines, newLines, ctx = 3) {
183
155
  return out.length ? out.join("\n") : null;
184
156
  }
185
157
 
186
- /**
187
- * Find the longest common substring between two strings.
188
- * Returns { pos, len } — position in `haystack` and length of match.
189
- */
190
- function longestCommonSubstring(haystack, needle) {
191
- if (!haystack || !needle) return { pos: 0, len: 0 };
192
- const h = haystack, n = needle;
193
- let bestLen = 0, bestPos = 0;
194
- // Sliding window: for each start in needle, check match lengths in haystack
195
- // Use suffix approach limited to first 200 chars of needle for performance
196
- const sample = n.slice(0, 200);
197
- for (let i = 0; i < h.length && bestLen < sample.length; i++) {
198
- let len = 0;
199
- for (let j = 0; j < sample.length && i + len < h.length; j++) {
200
- if (h[i + len] === sample[j]) { len++; } else { if (len > bestLen) { bestLen = len; bestPos = i; } len = 0; }
201
- }
202
- if (len > bestLen) { bestLen = len; bestPos = i; }
203
- }
204
- return { pos: bestPos, len: bestLen };
158
+ function verifyChecksumAgainstSnapshot(snapshot, rc) {
159
+ const { start, end, hex } = parseChecksum(rc);
160
+ const actual = buildRangeChecksum(snapshot, start, end);
161
+ if (!actual) return { ok: false, actual: null, start, end };
162
+ return { ok: actual.split(":")[1] === hex, actual, start, end };
205
163
  }
206
164
 
207
- /**
208
- * Build a snippet of ~10 lines around a character position in normalized content.
209
- */
210
- function buildSnippet(norm, charPos) {
211
- const lines = norm.split("\n");
212
- // Find which line the charPos falls on
213
- let cumulative = 0;
214
- let targetLine = 0;
215
- for (let i = 0; i < lines.length; i++) {
216
- cumulative += lines[i].length + 1; // +1 for \n
217
- if (cumulative > charPos) { targetLine = i; break; }
218
- }
219
- const half = 5;
220
- const start = Math.max(0, targetLine - half);
221
- const end = Math.min(lines.length, start + 10);
222
- const snippetLines = [];
223
- for (let i = start; i < end; i++) {
224
- const tag = lineTag(fnv1a(lines[i]));
225
- snippetLines.push(`${tag}.${i + 1}\t${lines[i]}`);
226
- }
227
- return { start: start + 1, end, text: snippetLines.join("\n") };
165
+ function buildConflictMessage({
166
+ filePath,
167
+ reason,
168
+ revision,
169
+ fileChecksum,
170
+ lines,
171
+ centerIdx,
172
+ changedRanges,
173
+ retryChecksum,
174
+ details,
175
+ }) {
176
+ const safeCenter = Math.max(0, Math.min(lines.length - 1, centerIdx));
177
+ const snip = buildErrorSnippet(lines, safeCenter);
178
+ let msg =
179
+ `status: CONFLICT\n` +
180
+ `reason: ${reason}\n` +
181
+ `revision: ${revision}\n` +
182
+ `file: ${fileChecksum}`;
183
+ if (changedRanges) msg += `\nchanged_ranges: ${describeChangedRanges(changedRanges)}`;
184
+ if (retryChecksum) msg += `\nretry_checksum: ${retryChecksum}`;
185
+ msg += `\n\n${details}\n\nCurrent content (lines ${snip.start}-${snip.end}):\n${snip.text}`;
186
+ msg += `\n\nTip: Retry from the fresh local snippet above.`;
187
+ if (filePath) msg += `\npath: ${filePath}`;
188
+ return msg;
228
189
  }
229
190
 
230
- /**
231
- * Fuzzy text replacement.
232
- */
233
- function textReplace(content, oldText, newText, all) {
234
- const norm = content.replace(/\r\n/g, "\n");
235
- const normOld = oldText.replace(/\r\n/g, "\n");
236
- const normNew = newText.replace(/\r\n/g, "\n");
237
-
238
- if (!all) {
239
- // Uniqueness check: count occurrences
240
- const parts = norm.split(normOld);
241
- const count = parts.length - 1;
242
- if (count === 0) {
243
- // Fall through to TEXT_NOT_FOUND below
244
- } else if (count === 1) {
245
- // Unique match — safe to replace single occurrence
246
- return parts.join(normNew);
247
- } else {
248
- throw new Error(
249
- `AMBIGUOUS_MATCH: "${normOld.slice(0, 80)}" found ${count} times. ` +
250
- `Use all:true to replace all, or use set_line/replace_lines for a specific occurrence.`
251
- );
252
- }
191
+ function targetRangeForReplaceBetween(startIdx, endIdx, boundaryMode) {
192
+ if (boundaryMode === "exclusive") {
193
+ return { start: startIdx + 2, end: Math.max(startIdx + 1, endIdx) };
253
194
  }
254
- let idx = norm.indexOf(normOld);
255
- let confusableMatch = false;
256
- if (idx === -1) {
257
- // Confusable normalization: try matching after normalizing unicode hyphens
258
- const normContent = normalizeConfusables(norm);
259
- const normSearch = normalizeConfusables(normOld);
260
- const confIdx = normContent.indexOf(normSearch);
261
- if (confIdx !== -1) {
262
- idx = confIdx;
263
- confusableMatch = true;
264
- } else {
265
- const { pos, len } = longestCommonSubstring(norm, normOld);
266
- const anchor = len > 3 ? pos : Math.floor(norm.length / 2);
267
- const snip = buildSnippet(norm, anchor);
268
- throw new Error(
269
- `TEXT_NOT_FOUND: "${normOld.slice(0, 100)}..." not found.\n\n` +
270
- `Nearest content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
271
- `Tip: Use hashes above for anchor-based edit, or adjust old_text.`
272
- );
273
- }
274
- }
275
-
276
- // Determine the match length in original content (same as normOld.length for both paths)
277
- const matchLen = normOld.length;
278
-
279
- if (confusableMatch) {
280
- // Replace all via normalized matching
281
- const normContent = normalizeConfusables(norm);
282
- const normSearch = normalizeConfusables(normOld);
283
- let result = "";
284
- let pos = 0;
285
- let searchIdx = normContent.indexOf(normSearch, pos);
286
- while (searchIdx !== -1) {
287
- result += norm.slice(pos, searchIdx) + normNew;
288
- pos = searchIdx + matchLen;
289
- searchIdx = normContent.indexOf(normSearch, pos);
290
- }
291
- result += norm.slice(pos);
292
- return result;
293
- }
294
- return norm.split(normOld).join(normNew);
195
+ return { start: startIdx + 1, end: endIdx + 1 };
295
196
  }
296
197
 
297
-
298
198
  /**
299
199
  * Apply edits to a file.
300
200
  *
301
201
  * @param {string} filePath
302
202
  * @param {Array} edits - parsed edit objects
303
- * @param {object} opts - { dryRun }
203
+ * @param {object} opts - { dryRun, restoreIndent, baseRevision, conflictPolicy }
304
204
  * @returns {string} result message with diff
305
205
  */
306
206
  export function editFile(filePath, edits, opts = {}) {
207
+ filePath = normalizePath(filePath);
307
208
  const real = validatePath(filePath);
308
- const original = readText(real);
309
- const lines = original.split("\n");
310
- const origLines = [...lines];
209
+ const currentSnapshot = readSnapshot(real);
210
+ const baseSnapshot = opts.baseRevision ? getSnapshotByRevision(opts.baseRevision) : null;
211
+ const hasBaseSnapshot = !!(baseSnapshot && baseSnapshot.path === real);
212
+ const staleRevision = !!opts.baseRevision && opts.baseRevision !== currentSnapshot.revision;
213
+ const changedRanges = staleRevision && hasBaseSnapshot
214
+ ? computeChangedRanges(baseSnapshot.lines, currentSnapshot.lines)
215
+ : [];
216
+ const conflictPolicy = opts.conflictPolicy || "conservative";
217
+
218
+ const original = currentSnapshot.content;
219
+ const lines = [...currentSnapshot.lines];
220
+ const origLines = [...currentSnapshot.lines];
311
221
  const hadTrailingNewline = original.endsWith("\n");
222
+ const hashIndex = currentSnapshot.uniqueTagIndex;
223
+ let autoRebased = false;
312
224
 
313
- // Build hash index once for global relocation in findLine
314
- const hashIndex = buildHashIndex(lines);
315
-
316
- // Separate anchor edits from text-replace edits
317
225
  const anchored = [];
318
- const texts = [];
319
-
320
226
  for (const e of edits) {
321
- if (e.set_line || e.replace_lines || e.insert_after) anchored.push(e);
322
- else if (e.replace) texts.push(e);
227
+ if (e.set_line || e.replace_lines || e.insert_after || e.replace_between) anchored.push(e);
228
+ else if (e.replace) throw new Error("REPLACE_REMOVED: replace is no longer supported in edit_file. Use set_line/replace_lines for single edits, bulk_replace tool for rename/refactor.");
323
229
  else throw new Error(`BAD_INPUT: unknown edit type: ${JSON.stringify(e)}`);
324
230
  }
325
231
 
326
- // Overlap validation: reject duplicate/overlapping edit targets
327
232
  const editTargets = [];
328
233
  for (const e of anchored) {
329
234
  if (e.set_line) {
@@ -336,12 +241,16 @@ export function editFile(filePath, edits, opts = {}) {
336
241
  } else if (e.insert_after) {
337
242
  const line = parseRef(e.insert_after.anchor).line;
338
243
  editTargets.push({ start: line, end: line, insert: true });
244
+ } else if (e.replace_between) {
245
+ const s = parseRef(e.replace_between.start_anchor).line;
246
+ const en = parseRef(e.replace_between.end_anchor).line;
247
+ editTargets.push({ start: s, end: en });
339
248
  }
340
249
  }
341
250
  for (let i = 0; i < editTargets.length; i++) {
342
251
  for (let j = i + 1; j < editTargets.length; j++) {
343
252
  const a = editTargets[i], b = editTargets[j];
344
- if (a.insert || b.insert) continue; // insert_after doesn't overlap
253
+ if (a.insert || b.insert) continue;
345
254
  if (a.start <= b.end && b.start <= a.end) {
346
255
  throw new Error(
347
256
  `OVERLAPPING_EDITS: lines ${a.start}-${a.end} and ${b.start}-${b.end} overlap. ` +
@@ -351,20 +260,72 @@ export function editFile(filePath, edits, opts = {}) {
351
260
  }
352
261
  }
353
262
 
354
- // Sort anchor edits bottom-to-top
355
263
  const sorted = anchored.map((e) => {
356
264
  let sortKey;
357
265
  if (e.set_line) sortKey = parseRef(e.set_line.anchor).line;
358
266
  else if (e.replace_lines) sortKey = parseRef(e.replace_lines.start_anchor).line;
359
267
  else if (e.insert_after) sortKey = parseRef(e.insert_after.anchor).line;
268
+ else if (e.replace_between) sortKey = parseRef(e.replace_between.start_anchor).line;
360
269
  return { ...e, _k: sortKey };
361
270
  }).sort((a, b) => b._k - a._k);
362
271
 
363
- // Apply anchor edits
272
+ const conflictIfNeeded = (reason, centerIdx, retryChecksum, details) => {
273
+ if (conflictPolicy !== "conservative") {
274
+ throw new Error(details);
275
+ }
276
+ return buildConflictMessage({
277
+ filePath,
278
+ reason,
279
+ revision: currentSnapshot.revision,
280
+ fileChecksum: currentSnapshot.fileChecksum,
281
+ lines,
282
+ centerIdx,
283
+ changedRanges: staleRevision && hasBaseSnapshot ? changedRanges : null,
284
+ retryChecksum,
285
+ details,
286
+ });
287
+ };
288
+
289
+ const locateOrConflict = (ref, reason = "stale_anchor") => {
290
+ try {
291
+ return findLine(lines, ref.line, ref.tag, hashIndex);
292
+ } catch (e) {
293
+ if (conflictPolicy !== "conservative" || !staleRevision) throw e;
294
+ const centerIdx = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
295
+ return conflictIfNeeded(reason, centerIdx, null, e.message);
296
+ }
297
+ };
298
+
299
+ const ensureRevisionContext = (actualStart, actualEnd, centerIdx) => {
300
+ if (!staleRevision || conflictPolicy !== "conservative") return null;
301
+ if (!hasBaseSnapshot) {
302
+ return conflictIfNeeded(
303
+ "base_revision_evicted",
304
+ centerIdx,
305
+ null,
306
+ `Base revision ${opts.baseRevision} is not available in the local revision cache.`
307
+ );
308
+ }
309
+ if (overlapsChangedRanges(changedRanges, actualStart, actualEnd)) {
310
+ return conflictIfNeeded(
311
+ "overlap",
312
+ centerIdx,
313
+ null,
314
+ `Changes since ${opts.baseRevision} overlap edit range ${actualStart}-${actualEnd}.`
315
+ );
316
+ }
317
+ autoRebased = true;
318
+ return null;
319
+ };
320
+
364
321
  for (const e of sorted) {
365
322
  if (e.set_line) {
366
323
  const { tag, line } = parseRef(e.set_line.anchor);
367
- const idx = findLine(lines, line, tag, hashIndex);
324
+ const idx = locateOrConflict({ tag, line });
325
+ if (typeof idx === "string") return idx;
326
+ const conflict = ensureRevisionContext(idx + 1, idx + 1, idx);
327
+ if (conflict) return conflict;
328
+
368
329
  const txt = e.set_line.new_text;
369
330
  if (!txt && txt !== 0) {
370
331
  lines.splice(idx, 1);
@@ -374,81 +335,120 @@ export function editFile(filePath, edits, opts = {}) {
374
335
  const newLines = opts.restoreIndent ? restoreIndent(origLine, raw) : raw;
375
336
  lines.splice(idx, 1, ...newLines);
376
337
  }
377
- } else if (e.replace_lines) {
378
- const s = parseRef(e.replace_lines.start_anchor);
379
- const en = parseRef(e.replace_lines.end_anchor);
380
- const si = findLine(lines, s.line, s.tag, hashIndex);
381
- const ei = findLine(lines, en.line, en.tag, hashIndex);
338
+ continue;
339
+ }
382
340
 
383
- // Range checksum verification (mandatory)
384
- const rc = e.replace_lines.range_checksum;
385
- if (!rc) throw new Error("range_checksum required for replace_lines. Read the range first via read_file, then pass its checksum.");
341
+ if (e.insert_after) {
342
+ const { tag, line } = parseRef(e.insert_after.anchor);
343
+ const idx = locateOrConflict({ tag, line });
344
+ if (typeof idx === "string") return idx;
345
+ const conflict = ensureRevisionContext(idx + 1, idx + 1, idx);
346
+ if (conflict) return conflict;
386
347
 
387
- // Checksum's range is authoritative (from read_file), not anchor range
388
- const { start: csStart, end: csEnd, hex: csHex } = parseChecksum(rc);
348
+ let insertLines = e.insert_after.text.split("\n");
349
+ if (opts.restoreIndent) insertLines = restoreIndent([lines[idx]], insertLines);
350
+ lines.splice(idx + 1, 0, ...insertLines);
351
+ continue;
352
+ }
389
353
 
390
- // Coverage check: checksum range must contain ACTUAL edit range (after relocation)
354
+ if (e.replace_lines) {
355
+ const s = parseRef(e.replace_lines.start_anchor);
356
+ const en = parseRef(e.replace_lines.end_anchor);
357
+ const si = locateOrConflict(s);
358
+ if (typeof si === "string") return si;
359
+ const ei = locateOrConflict(en);
360
+ if (typeof ei === "string") return ei;
391
361
  const actualStart = si + 1;
392
362
  const actualEnd = ei + 1;
393
- if (csStart > actualStart || csEnd < actualEnd) {
394
- const snip = buildErrorSnippet(origLines, actualStart - 1);
395
- throw new Error(
396
- `CHECKSUM_RANGE_GAP: range ${csStart}-${csEnd} does not cover edit range ${actualStart}-${actualEnd}.\n\n` +
397
- `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
398
- `Tip: Use updated hashes above for retry.`
399
- );
400
- }
363
+ const rc = e.replace_lines.range_checksum;
364
+ if (!rc) throw new Error("range_checksum required for replace_lines. Read the range first via read_file, then pass its checksum.");
401
365
 
402
- // Verify freshness over checksum's own range using origLines snapshot
403
- const csStartIdx = csStart - 1;
404
- const csEndIdx = csEnd - 1;
405
- if (csStartIdx < 0 || csEndIdx >= origLines.length) {
406
- const snip = buildErrorSnippet(origLines, origLines.length - 1);
407
- throw new Error(
408
- `CHECKSUM_OUT_OF_BOUNDS: range ${csStart}-${csEnd} exceeds file length ${origLines.length}.\n\n` +
409
- `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
410
- `Tip: Use updated hashes above for retry.`
411
- );
412
- }
413
- const lineHashes = [];
414
- for (let i = csStartIdx; i <= csEndIdx; i++) lineHashes.push(fnv1a(origLines[i]));
415
- const actual = rangeChecksum(lineHashes, csStart, csEnd);
416
- const actualHex = actual.split(":")[1];
417
- if (csHex !== actualHex) {
418
- const snip = buildErrorSnippet(origLines, csStartIdx);
419
- throw new Error(
420
- `CHECKSUM_MISMATCH: expected ${rc}, got ${actual}. File changed \u2014 re-read lines ${csStart}-${csEnd}.\n\n` +
421
- `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
422
- `Retry with fresh checksum ${actual}, or use set_line with hashes above.`
423
- );
366
+ if (staleRevision && conflictPolicy === "conservative") {
367
+ const conflict = ensureRevisionContext(actualStart, actualEnd, si);
368
+ if (conflict) return conflict;
369
+ const baseCheck = hasBaseSnapshot ? verifyChecksumAgainstSnapshot(baseSnapshot, rc) : null;
370
+ if (!baseCheck?.ok) {
371
+ return conflictIfNeeded(
372
+ "stale_checksum",
373
+ si,
374
+ baseCheck?.actual || null,
375
+ baseCheck?.actual
376
+ ? `Provided checksum ${rc} does not match base revision ${opts.baseRevision}.`
377
+ : `Checksum range from ${rc} is outside the available base revision.`
378
+ );
379
+ }
380
+ } else {
381
+ const { start: csStart, end: csEnd, hex: csHex } = parseChecksum(rc);
382
+ if (csStart > actualStart || csEnd < actualEnd) {
383
+ const snip = buildErrorSnippet(origLines, actualStart - 1);
384
+ throw new Error(
385
+ `CHECKSUM_RANGE_GAP: range ${csStart}-${csEnd} does not cover edit range ${actualStart}-${actualEnd}.\n\n` +
386
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
387
+ `Tip: Use updated hashes above for retry.`
388
+ );
389
+ }
390
+ const actual = buildRangeChecksum(currentSnapshot, csStart, csEnd);
391
+ const actualHex = actual?.split(":")[1];
392
+ if (!actual || csHex !== actualHex) {
393
+ const details =
394
+ `CHECKSUM_MISMATCH: expected ${rc}, got ${actual}. File changed — re-read lines ${csStart}-${csEnd}.`;
395
+ if (conflictPolicy === "conservative") {
396
+ return conflictIfNeeded("stale_checksum", csStart - 1, actual, details);
397
+ }
398
+ const snip = buildErrorSnippet(origLines, csStart - 1);
399
+ throw new Error(
400
+ `${details}\n\n` +
401
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
402
+ `Retry with fresh checksum ${actual}, or use set_line with hashes above.`
403
+ );
404
+ }
424
405
  }
425
406
 
426
407
  const txt = e.replace_lines.new_text;
427
408
  if (!txt && txt !== 0) {
428
409
  lines.splice(si, ei - si + 1);
429
410
  } else {
430
- const origLines = lines.slice(si, ei + 1);
411
+ const origRange = lines.slice(si, ei + 1);
431
412
  let newLines = String(txt).split("\n");
432
- if (opts.restoreIndent) newLines = restoreIndent(origLines, newLines);
413
+ if (opts.restoreIndent) newLines = restoreIndent(origRange, newLines);
433
414
  lines.splice(si, ei - si + 1, ...newLines);
434
415
  }
435
- } else if (e.insert_after) {
436
- const { tag, line } = parseRef(e.insert_after.anchor);
437
- const idx = findLine(lines, line, tag, hashIndex);
438
- let insertLines = e.insert_after.text.split("\n");
439
- if (opts.restoreIndent) insertLines = restoreIndent([lines[idx]], insertLines);
440
- lines.splice(idx + 1, 0, ...insertLines);
416
+ continue;
417
+ }
418
+
419
+ if (e.replace_between) {
420
+ const boundaryMode = e.replace_between.boundary_mode || "inclusive";
421
+ if (boundaryMode !== "inclusive" && boundaryMode !== "exclusive") {
422
+ throw new Error(`BAD_INPUT: replace_between boundary_mode must be inclusive or exclusive, got ${boundaryMode}`);
423
+ }
424
+ const s = parseRef(e.replace_between.start_anchor);
425
+ const en = parseRef(e.replace_between.end_anchor);
426
+ const si = locateOrConflict(s);
427
+ if (typeof si === "string") return si;
428
+ const ei = locateOrConflict(en);
429
+ if (typeof ei === "string") return ei;
430
+ if (si > ei) {
431
+ throw new Error(`BAD_INPUT: replace_between start anchor resolves after end anchor (${si + 1} > ${ei + 1})`);
432
+ }
433
+
434
+ const targetRange = targetRangeForReplaceBetween(si, ei, boundaryMode);
435
+ const conflict = ensureRevisionContext(targetRange.start, targetRange.end, si);
436
+ if (conflict) return conflict;
437
+
438
+ const txt = e.replace_between.new_text;
439
+ let newLines = String(txt ?? "").split("\n");
440
+ const sliceStart = boundaryMode === "exclusive" ? si + 1 : si;
441
+ const removeCount = boundaryMode === "exclusive" ? Math.max(0, ei - si - 1) : (ei - si + 1);
442
+ const origRange = lines.slice(sliceStart, sliceStart + removeCount);
443
+ if (opts.restoreIndent && origRange.length > 0) newLines = restoreIndent(origRange, newLines);
444
+ if (txt === "" || txt === null) newLines = [];
445
+ lines.splice(sliceStart, removeCount, ...newLines);
441
446
  }
442
447
  }
443
448
 
444
- // Apply text replacements
445
449
  let content = lines.join("\n");
446
450
  if (hadTrailingNewline && !content.endsWith("\n")) content += "\n";
447
451
  if (!hadTrailingNewline && content.endsWith("\n")) content = content.slice(0, -1);
448
- for (const e of texts) {
449
- if (!e.replace.old_text) throw new Error("replace.old_text required");
450
- content = textReplace(content, e.replace.old_text, e.replace.new_text || "", e.replace.all || false);
451
- }
452
452
 
453
453
  if (original === content) {
454
454
  throw new Error("NOOP_EDIT: File already contains the desired content. No changes needed.");
@@ -460,65 +460,75 @@ export function editFile(filePath, edits, opts = {}) {
460
460
  }
461
461
 
462
462
  if (opts.dryRun) {
463
- let msg = `Dry run: ${filePath} would change (${content.split("\n").length} lines)`;
463
+ let msg = `status: ${autoRebased ? "AUTO_REBASED" : "OK"}\nrevision: ${currentSnapshot.revision}\nfile: ${currentSnapshot.fileChecksum}\nDry run: ${filePath} would change (${content.split("\n").length} lines)`;
464
+ if (staleRevision && hasBaseSnapshot) msg += `\nchanged_ranges: ${describeChangedRanges(changedRanges)}`;
464
465
  if (diff) msg += `\n\nDiff:\n\`\`\`diff\n${diff}\n\`\`\``;
465
466
  return msg;
466
467
  }
467
468
 
468
469
  writeFileSync(real, content, "utf-8");
469
- let msg = `Updated ${filePath} (${content.split("\n").length} lines)`;
470
+ const nextStat = statSync(real);
471
+ const nextSnapshot = rememberSnapshot(real, content, { mtimeMs: nextStat.mtimeMs, size: nextStat.size });
472
+ let msg =
473
+ `status: ${autoRebased ? "AUTO_REBASED" : "OK"}\n` +
474
+ `revision: ${nextSnapshot.revision}\n` +
475
+ `file: ${nextSnapshot.fileChecksum}`;
476
+ if (autoRebased && staleRevision && hasBaseSnapshot) {
477
+ msg += `\nchanged_ranges: ${describeChangedRanges(changedRanges)}`;
478
+ }
479
+ msg += `\nUpdated ${filePath} (${content.split("\n").length} lines)`;
470
480
  if (diff) msg += `\n\nDiff:\n\`\`\`diff\n${diff}\n\`\`\``;
471
481
 
472
- // Blast radius warning (optional — silent if no graph DB)
473
482
  try {
474
483
  const db = getGraphDB(real);
475
484
  const relFile = db ? getRelativePath(real) : null;
476
- if (db && relFile) {
477
- // Find changed line range from diff
478
- const diffLines = diff.split("\n");
485
+ if (db && relFile && diff) {
486
+ const diffLinesOut = diff.split("\n");
479
487
  let minLine = Infinity, maxLine = 0;
480
- for (const dl of diffLines) {
488
+ for (const dl of diffLinesOut) {
481
489
  const m = dl.match(/^[+-](\d+)\|/);
482
- if (m) { const n = +m[1]; if (n < minLine) minLine = n; if (n > maxLine) maxLine = n; }
490
+ if (m) {
491
+ const n = +m[1];
492
+ if (n < minLine) minLine = n;
493
+ if (n > maxLine) maxLine = n;
494
+ }
483
495
  }
484
496
  if (minLine <= maxLine) {
485
- const affected = blastRadius(db, relFile, minLine, maxLine);
497
+ const affected = callImpact(db, relFile, minLine, maxLine);
486
498
  if (affected.length > 0) {
487
499
  const list = affected.map(a => `${a.name} (${a.file}:${a.line})`).join(", ");
488
- msg += `\n\n\u26A0 Blast radius: ${affected.length} dependents in other files\n ${list}`;
500
+ msg += `\n\n Call impact: ${affected.length} callers in other files\n ${list}`;
489
501
  }
490
502
  }
491
503
  }
492
504
  } catch { /* silent */ }
493
505
 
494
- // Post-edit context: hash-annotated lines around changed region + checksums
495
- const newLines = content.split("\n");
506
+ const newLinesAll = content.split("\n");
496
507
  if (diff) {
497
508
  const diffArr = diff.split("\n");
498
509
  let minLine = Infinity, maxLine = 0;
499
510
  for (const dl of diffArr) {
500
511
  const m = dl.match(/^[+-](\d+)\|/);
501
- if (m) { const n = +m[1]; if (n < minLine) minLine = n; if (n > maxLine) maxLine = n; }
512
+ if (m) {
513
+ const n = +m[1];
514
+ if (n < minLine) minLine = n;
515
+ if (n > maxLine) maxLine = n;
516
+ }
502
517
  }
503
518
  if (minLine <= maxLine) {
504
519
  const ctxStart = Math.max(0, minLine - 6);
505
- const ctxEnd = Math.min(newLines.length, maxLine + 5);
520
+ const ctxEnd = Math.min(newLinesAll.length, maxLine + 5);
506
521
  const ctxLines = [];
507
522
  const ctxHashes = [];
508
523
  for (let i = ctxStart; i < ctxEnd; i++) {
509
- const h = fnv1a(newLines[i]);
524
+ const h = fnv1a(newLinesAll[i]);
510
525
  ctxHashes.push(h);
511
- ctxLines.push(`${lineTag(h)}.${i + 1}\t${newLines[i]}`);
526
+ ctxLines.push(`${lineTag(h)}.${i + 1}\t${newLinesAll[i]}`);
512
527
  }
513
528
  const ctxCs = rangeChecksum(ctxHashes, ctxStart + 1, ctxEnd);
514
529
  msg += `\n\nPost-edit (lines ${ctxStart + 1}-${ctxEnd}):\n${ctxLines.join("\n")}\nchecksum: ${ctxCs}`;
515
530
  }
516
531
  }
517
- // File-level checksum
518
- const fileHashes = [];
519
- for (let i = 0; i < newLines.length; i++) fileHashes.push(fnv1a(newLines[i]));
520
- const fileCs = rangeChecksum(fileHashes, 1, newLines.length);
521
- msg += `\nfile: ${fileCs}`;
522
532
 
523
533
  return msg;
524
534
  }