@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/README.md +120 -47
- package/benchmark/atomic.mjs +502 -0
- package/benchmark/graph.mjs +80 -0
- package/benchmark/index.mjs +144 -0
- package/benchmark/workflows.mjs +350 -0
- package/hook.mjs +48 -15
- package/lib/benchmark-helpers.mjs +1 -1
- package/lib/changes.mjs +2 -1
- package/lib/coerce.mjs +1 -42
- package/lib/edit.mjs +258 -248
- package/lib/graph-enrich.mjs +76 -58
- package/lib/hash.mjs +1 -109
- package/lib/info.mjs +1 -1
- package/lib/normalize.mjs +1 -106
- package/lib/outline.mjs +32 -87
- package/lib/read.mjs +8 -5
- package/lib/revisions.mjs +238 -0
- package/lib/search.mjs +6 -7
- package/lib/security.mjs +4 -4
- package/lib/setup.mjs +7 -20
- package/lib/update-check.mjs +1 -56
- package/lib/verify.mjs +32 -16
- package/output-style.md +21 -6
- package/package.json +18 -6
- package/server.mjs +35 -43
- package/benchmark.mjs +0 -1106
package/lib/edit.mjs
CHANGED
|
@@ -2,28 +2,25 @@
|
|
|
2
2
|
* Hash-verified file editing with diff output.
|
|
3
3
|
*
|
|
4
4
|
* Supports:
|
|
5
|
-
* -
|
|
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,
|
|
14
|
-
import { validatePath } from "./security.mjs";
|
|
15
|
-
import { getGraphDB,
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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;
|
|
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
|
-
|
|
116
|
-
const
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
309
|
-
const
|
|
310
|
-
const
|
|
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)
|
|
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;
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
411
|
+
const origRange = lines.slice(si, ei + 1);
|
|
431
412
|
let newLines = String(txt).split("\n");
|
|
432
|
-
if (opts.restoreIndent) newLines = restoreIndent(
|
|
413
|
+
if (opts.restoreIndent) newLines = restoreIndent(origRange, newLines);
|
|
433
414
|
lines.splice(si, ei - si + 1, ...newLines);
|
|
434
415
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 = `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
488
|
+
for (const dl of diffLinesOut) {
|
|
481
489
|
const m = dl.match(/^[+-](\d+)\|/);
|
|
482
|
-
if (m) {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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) {
|
|
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(
|
|
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(
|
|
524
|
+
const h = fnv1a(newLinesAll[i]);
|
|
510
525
|
ctxHashes.push(h);
|
|
511
|
-
ctxLines.push(`${lineTag(h)}.${i + 1}\t${
|
|
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
|
}
|