@levnikolaevich/hex-line-mcp 1.3.3 → 1.3.4
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/hook.mjs +428 -0
- package/dist/server.mjs +6615 -0
- package/package.json +6 -8
- package/benchmark/atomic.mjs +0 -502
- package/benchmark/graph.mjs +0 -80
- package/benchmark/index.mjs +0 -144
- package/benchmark/workflows.mjs +0 -350
- package/hook.mjs +0 -466
- package/lib/benchmark-helpers.mjs +0 -541
- package/lib/bulk-replace.mjs +0 -65
- package/lib/changes.mjs +0 -176
- package/lib/coerce.mjs +0 -1
- package/lib/edit.mjs +0 -534
- package/lib/format.mjs +0 -138
- package/lib/graph-enrich.mjs +0 -226
- package/lib/hash.mjs +0 -1
- package/lib/info.mjs +0 -91
- package/lib/normalize.mjs +0 -1
- package/lib/outline.mjs +0 -145
- package/lib/read.mjs +0 -138
- package/lib/revisions.mjs +0 -238
- package/lib/search.mjs +0 -268
- package/lib/security.mjs +0 -112
- package/lib/setup.mjs +0 -275
- package/lib/tree.mjs +0 -236
- package/lib/update-check.mjs +0 -1
- package/lib/verify.mjs +0 -70
- package/server.mjs +0 -375
package/lib/edit.mjs
DELETED
|
@@ -1,534 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hash-verified file editing with diff output.
|
|
3
|
-
*
|
|
4
|
-
* Supports:
|
|
5
|
-
* - set_line / replace_lines / insert_after / replace_between
|
|
6
|
-
* - dry_run preview, noop detection, diff output
|
|
7
|
-
* - optional revision-aware conservative auto-rebase
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { statSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { diffLines } from "diff";
|
|
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";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Restore indentation from original lines onto replacement lines.
|
|
27
|
-
* Preserves relative indentation structure while matching the anchor's indent level.
|
|
28
|
-
*/
|
|
29
|
-
function restoreIndent(origLines, newLines) {
|
|
30
|
-
if (!origLines.length || !newLines.length) return newLines;
|
|
31
|
-
const origIndent = origLines[0].match(/^\s*/)[0];
|
|
32
|
-
const newIndent = newLines[0].match(/^\s*/)[0];
|
|
33
|
-
if (origIndent === newIndent) return newLines;
|
|
34
|
-
return newLines.map(line => {
|
|
35
|
-
if (!line.trim()) return line;
|
|
36
|
-
if (line.startsWith(newIndent)) return origIndent + line.slice(newIndent.length);
|
|
37
|
-
return line;
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Build hash-annotated snippet around a position for error or conflict messages.
|
|
43
|
-
*/
|
|
44
|
-
function buildErrorSnippet(lines, centerIdx, radius = 5) {
|
|
45
|
-
const start = Math.max(0, centerIdx - radius);
|
|
46
|
-
const end = Math.min(lines.length, centerIdx + radius + 1);
|
|
47
|
-
const text = lines.slice(start, end).map((line, i) => {
|
|
48
|
-
const num = start + i + 1;
|
|
49
|
-
const tag = lineTag(fnv1a(line));
|
|
50
|
-
return `${tag}.${num}\t${line}`;
|
|
51
|
-
}).join("\n");
|
|
52
|
-
return { start: start + 1, end, text };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Find line by tag.lineNum reference with fuzzy matching (+-5 lines).
|
|
57
|
-
* Falls back to global hash relocation via hashIndex before throwing.
|
|
58
|
-
*/
|
|
59
|
-
function findLine(lines, lineNum, expectedTag, hashIndex) {
|
|
60
|
-
const idx = lineNum - 1;
|
|
61
|
-
if (idx < 0 || idx >= lines.length) {
|
|
62
|
-
const center = idx >= lines.length ? lines.length - 1 : 0;
|
|
63
|
-
const snip = buildErrorSnippet(lines, center);
|
|
64
|
-
throw new Error(
|
|
65
|
-
`Line ${lineNum} out of range (1-${lines.length}).\n\n` +
|
|
66
|
-
`Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
|
|
67
|
-
`Tip: Use updated hashes above for retry.`
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const actual = lineTag(fnv1a(lines[idx]));
|
|
72
|
-
if (actual === expectedTag) return idx;
|
|
73
|
-
|
|
74
|
-
for (let d = 1; d <= 5; d++) {
|
|
75
|
-
for (const off of [d, -d]) {
|
|
76
|
-
const c = idx + off;
|
|
77
|
-
if (c >= 0 && c < lines.length && lineTag(fnv1a(lines[c])) === expectedTag) return c;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const stripped = lines[idx].replace(/\s+/g, "");
|
|
82
|
-
if (stripped.length > 0) {
|
|
83
|
-
for (let j = Math.max(0, idx - 5); j <= Math.min(lines.length - 1, idx + 5); j++) {
|
|
84
|
-
if (lines[j].replace(/\s+/g, "") === stripped && lineTag(fnv1a(lines[j])) === expectedTag) return j;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
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);
|
|
91
|
-
for (let i = Math.max(0, idx - 10); i <= Math.min(lines.length - 1, idx + 10); i++) {
|
|
92
|
-
const normalizedActual = norm(lineTag(fnv1a(norm(lines[i]))));
|
|
93
|
-
if (normalizedActual === normalizedExpected) return i;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (hashIndex) {
|
|
97
|
-
const relocated = hashIndex.get(expectedTag);
|
|
98
|
-
if (relocated !== undefined) return relocated;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const snip = buildErrorSnippet(lines, idx);
|
|
102
|
-
throw new Error(
|
|
103
|
-
`HASH_MISMATCH: line ${lineNum} expected ${expectedTag}, got ${actual}.\n\n` +
|
|
104
|
-
`Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
|
|
105
|
-
`Tip: Use updated hashes above for retry.`
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Context diff via `diff` package (Myers O(ND) algorithm).
|
|
111
|
-
* Returns compact hunks with ±ctx context lines, or null if no changes.
|
|
112
|
-
*/
|
|
113
|
-
export function simpleDiff(oldLines, newLines, ctx = 3) {
|
|
114
|
-
const oldText = oldLines.join("\n") + "\n";
|
|
115
|
-
const newText = newLines.join("\n") + "\n";
|
|
116
|
-
const parts = diffLines(oldText, newText);
|
|
117
|
-
|
|
118
|
-
const out = [];
|
|
119
|
-
let oldNum = 1, newNum = 1;
|
|
120
|
-
let lastChange = false;
|
|
121
|
-
|
|
122
|
-
for (let i = 0; i < parts.length; i++) {
|
|
123
|
-
const part = parts[i];
|
|
124
|
-
const lines = part.value.replace(/\n$/, "").split("\n");
|
|
125
|
-
|
|
126
|
-
if (part.added || part.removed) {
|
|
127
|
-
for (const line of lines) {
|
|
128
|
-
if (part.removed) { out.push(`-${oldNum}| ${line}`); oldNum++; }
|
|
129
|
-
else { out.push(`+${newNum}| ${line}`); newNum++; }
|
|
130
|
-
}
|
|
131
|
-
lastChange = true;
|
|
132
|
-
} else {
|
|
133
|
-
const next = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
|
134
|
-
if (lastChange || next) {
|
|
135
|
-
let start = 0, end = lines.length;
|
|
136
|
-
if (!lastChange) start = Math.max(0, end - ctx);
|
|
137
|
-
if (!next && end - start > ctx) end = start + ctx;
|
|
138
|
-
if (start > 0) { out.push("..."); oldNum += start; newNum += start; }
|
|
139
|
-
for (let k = start; k < end; k++) {
|
|
140
|
-
out.push(` ${oldNum}| ${lines[k]}`);
|
|
141
|
-
oldNum++; newNum++;
|
|
142
|
-
}
|
|
143
|
-
if (end < lines.length) {
|
|
144
|
-
out.push("...");
|
|
145
|
-
oldNum += lines.length - end;
|
|
146
|
-
newNum += lines.length - end;
|
|
147
|
-
}
|
|
148
|
-
} else {
|
|
149
|
-
oldNum += lines.length;
|
|
150
|
-
newNum += lines.length;
|
|
151
|
-
}
|
|
152
|
-
lastChange = false;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return out.length ? out.join("\n") : null;
|
|
156
|
-
}
|
|
157
|
-
|
|
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 };
|
|
163
|
-
}
|
|
164
|
-
|
|
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;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function targetRangeForReplaceBetween(startIdx, endIdx, boundaryMode) {
|
|
192
|
-
if (boundaryMode === "exclusive") {
|
|
193
|
-
return { start: startIdx + 2, end: Math.max(startIdx + 1, endIdx) };
|
|
194
|
-
}
|
|
195
|
-
return { start: startIdx + 1, end: endIdx + 1 };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Apply edits to a file.
|
|
200
|
-
*
|
|
201
|
-
* @param {string} filePath
|
|
202
|
-
* @param {Array} edits - parsed edit objects
|
|
203
|
-
* @param {object} opts - { dryRun, restoreIndent, baseRevision, conflictPolicy }
|
|
204
|
-
* @returns {string} result message with diff
|
|
205
|
-
*/
|
|
206
|
-
export function editFile(filePath, edits, opts = {}) {
|
|
207
|
-
filePath = normalizePath(filePath);
|
|
208
|
-
const real = validatePath(filePath);
|
|
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];
|
|
221
|
-
const hadTrailingNewline = original.endsWith("\n");
|
|
222
|
-
const hashIndex = currentSnapshot.uniqueTagIndex;
|
|
223
|
-
let autoRebased = false;
|
|
224
|
-
|
|
225
|
-
const anchored = [];
|
|
226
|
-
for (const e of edits) {
|
|
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.");
|
|
229
|
-
else throw new Error(`BAD_INPUT: unknown edit type: ${JSON.stringify(e)}`);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const editTargets = [];
|
|
233
|
-
for (const e of anchored) {
|
|
234
|
-
if (e.set_line) {
|
|
235
|
-
const line = parseRef(e.set_line.anchor).line;
|
|
236
|
-
editTargets.push({ start: line, end: line });
|
|
237
|
-
} else if (e.replace_lines) {
|
|
238
|
-
const s = parseRef(e.replace_lines.start_anchor).line;
|
|
239
|
-
const en = parseRef(e.replace_lines.end_anchor).line;
|
|
240
|
-
editTargets.push({ start: s, end: en });
|
|
241
|
-
} else if (e.insert_after) {
|
|
242
|
-
const line = parseRef(e.insert_after.anchor).line;
|
|
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 });
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
for (let i = 0; i < editTargets.length; i++) {
|
|
251
|
-
for (let j = i + 1; j < editTargets.length; j++) {
|
|
252
|
-
const a = editTargets[i], b = editTargets[j];
|
|
253
|
-
if (a.insert || b.insert) continue;
|
|
254
|
-
if (a.start <= b.end && b.start <= a.end) {
|
|
255
|
-
throw new Error(
|
|
256
|
-
`OVERLAPPING_EDITS: lines ${a.start}-${a.end} and ${b.start}-${b.end} overlap. ` +
|
|
257
|
-
`Split into separate edit_file calls.`
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const sorted = anchored.map((e) => {
|
|
264
|
-
let sortKey;
|
|
265
|
-
if (e.set_line) sortKey = parseRef(e.set_line.anchor).line;
|
|
266
|
-
else if (e.replace_lines) sortKey = parseRef(e.replace_lines.start_anchor).line;
|
|
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;
|
|
269
|
-
return { ...e, _k: sortKey };
|
|
270
|
-
}).sort((a, b) => b._k - a._k);
|
|
271
|
-
|
|
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
|
-
|
|
321
|
-
for (const e of sorted) {
|
|
322
|
-
if (e.set_line) {
|
|
323
|
-
const { tag, line } = parseRef(e.set_line.anchor);
|
|
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
|
-
|
|
329
|
-
const txt = e.set_line.new_text;
|
|
330
|
-
if (!txt && txt !== 0) {
|
|
331
|
-
lines.splice(idx, 1);
|
|
332
|
-
} else {
|
|
333
|
-
const origLine = [lines[idx]];
|
|
334
|
-
const raw = String(txt).split("\n");
|
|
335
|
-
const newLines = opts.restoreIndent ? restoreIndent(origLine, raw) : raw;
|
|
336
|
-
lines.splice(idx, 1, ...newLines);
|
|
337
|
-
}
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
|
|
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;
|
|
347
|
-
|
|
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
|
-
}
|
|
353
|
-
|
|
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;
|
|
361
|
-
const actualStart = si + 1;
|
|
362
|
-
const actualEnd = ei + 1;
|
|
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.");
|
|
365
|
-
|
|
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
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const txt = e.replace_lines.new_text;
|
|
408
|
-
if (!txt && txt !== 0) {
|
|
409
|
-
lines.splice(si, ei - si + 1);
|
|
410
|
-
} else {
|
|
411
|
-
const origRange = lines.slice(si, ei + 1);
|
|
412
|
-
let newLines = String(txt).split("\n");
|
|
413
|
-
if (opts.restoreIndent) newLines = restoreIndent(origRange, newLines);
|
|
414
|
-
lines.splice(si, ei - si + 1, ...newLines);
|
|
415
|
-
}
|
|
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);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
let content = lines.join("\n");
|
|
450
|
-
if (hadTrailingNewline && !content.endsWith("\n")) content += "\n";
|
|
451
|
-
if (!hadTrailingNewline && content.endsWith("\n")) content = content.slice(0, -1);
|
|
452
|
-
|
|
453
|
-
if (original === content) {
|
|
454
|
-
throw new Error("NOOP_EDIT: File already contains the desired content. No changes needed.");
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
let diff = simpleDiff(origLines, content.split("\n"));
|
|
458
|
-
if (diff && diff.length > 80000) {
|
|
459
|
-
diff = diff.slice(0, 80000) + `\n... (diff truncated, ${diff.length} chars total)`;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
if (opts.dryRun) {
|
|
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)}`;
|
|
465
|
-
if (diff) msg += `\n\nDiff:\n\`\`\`diff\n${diff}\n\`\`\``;
|
|
466
|
-
return msg;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
writeFileSync(real, content, "utf-8");
|
|
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)`;
|
|
480
|
-
if (diff) msg += `\n\nDiff:\n\`\`\`diff\n${diff}\n\`\`\``;
|
|
481
|
-
|
|
482
|
-
try {
|
|
483
|
-
const db = getGraphDB(real);
|
|
484
|
-
const relFile = db ? getRelativePath(real) : null;
|
|
485
|
-
if (db && relFile && diff) {
|
|
486
|
-
const diffLinesOut = diff.split("\n");
|
|
487
|
-
let minLine = Infinity, maxLine = 0;
|
|
488
|
-
for (const dl of diffLinesOut) {
|
|
489
|
-
const m = dl.match(/^[+-](\d+)\|/);
|
|
490
|
-
if (m) {
|
|
491
|
-
const n = +m[1];
|
|
492
|
-
if (n < minLine) minLine = n;
|
|
493
|
-
if (n > maxLine) maxLine = n;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
if (minLine <= maxLine) {
|
|
497
|
-
const affected = callImpact(db, relFile, minLine, maxLine);
|
|
498
|
-
if (affected.length > 0) {
|
|
499
|
-
const list = affected.map(a => `${a.name} (${a.file}:${a.line})`).join(", ");
|
|
500
|
-
msg += `\n\n⚠ Call impact: ${affected.length} callers in other files\n ${list}`;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
} catch { /* silent */ }
|
|
505
|
-
|
|
506
|
-
const newLinesAll = content.split("\n");
|
|
507
|
-
if (diff) {
|
|
508
|
-
const diffArr = diff.split("\n");
|
|
509
|
-
let minLine = Infinity, maxLine = 0;
|
|
510
|
-
for (const dl of diffArr) {
|
|
511
|
-
const m = dl.match(/^[+-](\d+)\|/);
|
|
512
|
-
if (m) {
|
|
513
|
-
const n = +m[1];
|
|
514
|
-
if (n < minLine) minLine = n;
|
|
515
|
-
if (n > maxLine) maxLine = n;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
if (minLine <= maxLine) {
|
|
519
|
-
const ctxStart = Math.max(0, minLine - 6);
|
|
520
|
-
const ctxEnd = Math.min(newLinesAll.length, maxLine + 5);
|
|
521
|
-
const ctxLines = [];
|
|
522
|
-
const ctxHashes = [];
|
|
523
|
-
for (let i = ctxStart; i < ctxEnd; i++) {
|
|
524
|
-
const h = fnv1a(newLinesAll[i]);
|
|
525
|
-
ctxHashes.push(h);
|
|
526
|
-
ctxLines.push(`${lineTag(h)}.${i + 1}\t${newLinesAll[i]}`);
|
|
527
|
-
}
|
|
528
|
-
const ctxCs = rangeChecksum(ctxHashes, ctxStart + 1, ctxEnd);
|
|
529
|
-
msg += `\n\nPost-edit (lines ${ctxStart + 1}-${ctxEnd}):\n${ctxLines.join("\n")}\nchecksum: ${ctxCs}`;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
return msg;
|
|
534
|
-
}
|
package/lib/format.mjs
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared format helpers for hex-line-mcp.
|
|
3
|
-
* Single source of truth for formatSize, relativeTime, countFileLines, listDirectory, readText, MAX_OUTPUT_CHARS.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Format bytes as human-readable size string.
|
|
11
|
-
* @param {number} bytes
|
|
12
|
-
* @returns {string}
|
|
13
|
-
*/
|
|
14
|
-
export function formatSize(bytes) {
|
|
15
|
-
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
16
|
-
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
17
|
-
return `${bytes}B`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Format a Date as relative time string.
|
|
22
|
-
* @param {Date} date
|
|
23
|
-
* @param {boolean} compact - true: "5m ago", false: "5 min ago"
|
|
24
|
-
* @returns {string}
|
|
25
|
-
*/
|
|
26
|
-
export function relativeTime(date, compact = false) {
|
|
27
|
-
const sec = Math.round((Date.now() - date.getTime()) / 1000);
|
|
28
|
-
if (compact) {
|
|
29
|
-
if (sec < 60) return "now";
|
|
30
|
-
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
31
|
-
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
|
32
|
-
if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
|
|
33
|
-
if (sec < 2592000) return `${Math.floor(sec / 604800)}w ago`;
|
|
34
|
-
return `${Math.floor(sec / 2592000)}mo ago`;
|
|
35
|
-
}
|
|
36
|
-
if (sec < 60) return "just now";
|
|
37
|
-
const min = Math.floor(sec / 60);
|
|
38
|
-
if (min < 60) return `${min} min ago`;
|
|
39
|
-
const hrs = Math.floor(min / 60);
|
|
40
|
-
if (hrs < 24) return `${hrs} hour${hrs === 1 ? "" : "s"} ago`;
|
|
41
|
-
const days = Math.floor(hrs / 24);
|
|
42
|
-
if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
43
|
-
const months = Math.floor(days / 30);
|
|
44
|
-
if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
|
|
45
|
-
const years = Math.floor(months / 12);
|
|
46
|
-
return `${years} year${years === 1 ? "" : "s"} ago`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Count lines in a text file. Returns null for binary or oversized files.
|
|
51
|
-
* Buffer-based single-pass with built-in binary detection.
|
|
52
|
-
* @param {string} filePath
|
|
53
|
-
* @param {number} size - file size in bytes
|
|
54
|
-
* @param {number} maxSize - skip if larger (default 1MB)
|
|
55
|
-
* @returns {number|null}
|
|
56
|
-
*/
|
|
57
|
-
export function countFileLines(filePath, size, maxSize = 1_000_000) {
|
|
58
|
-
if (size === 0 || size > maxSize) return null;
|
|
59
|
-
try {
|
|
60
|
-
const buf = readFileSync(filePath);
|
|
61
|
-
const checkLen = Math.min(buf.length, 8192);
|
|
62
|
-
for (let i = 0; i < checkLen; i++) if (buf[i] === 0) return null; // binary
|
|
63
|
-
let count = 1;
|
|
64
|
-
for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0A) count++;
|
|
65
|
-
return count;
|
|
66
|
-
} catch { return null; }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Flat single-level directory listing with optional metadata.
|
|
71
|
-
* Sorted: directories first, then files, alphabetical.
|
|
72
|
-
* @param {string} dirPath - absolute directory path
|
|
73
|
-
* @param {object} opts
|
|
74
|
-
* @param {number} opts.limit - max entries (0 = all, default 0)
|
|
75
|
-
* @param {boolean} opts.metadata - show size/lines/time per entry (default false)
|
|
76
|
-
* @param {boolean} opts.compact - compact time format (default false)
|
|
77
|
-
* @param {string} opts.indent - prefix per line (default " ")
|
|
78
|
-
* @returns {{ text: string, total: number }}
|
|
79
|
-
*/
|
|
80
|
-
export function listDirectory(dirPath, opts = {}) {
|
|
81
|
-
const { limit = 0, metadata = false, compact = false, indent = " " } = opts;
|
|
82
|
-
|
|
83
|
-
let entries;
|
|
84
|
-
try {
|
|
85
|
-
entries = readdirSync(dirPath, { withFileTypes: true });
|
|
86
|
-
} catch {
|
|
87
|
-
return { text: "", total: 0 };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Sort: directories first, then files, alphabetical
|
|
91
|
-
entries.sort((a, b) => {
|
|
92
|
-
const aDir = a.isDirectory() ? 0 : 1;
|
|
93
|
-
const bDir = b.isDirectory() ? 0 : 1;
|
|
94
|
-
if (aDir !== bDir) return aDir - bDir;
|
|
95
|
-
return a.name.localeCompare(b.name);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
const total = entries.length;
|
|
99
|
-
const visible = limit > 0 ? entries.slice(0, limit) : entries;
|
|
100
|
-
|
|
101
|
-
const lines = visible.map(entry => {
|
|
102
|
-
const isDir = entry.isDirectory();
|
|
103
|
-
if (!metadata) {
|
|
104
|
-
return `${indent}${isDir ? "d" : "f"} ${entry.name}`;
|
|
105
|
-
}
|
|
106
|
-
if (isDir) {
|
|
107
|
-
return `${indent}${entry.name}/`;
|
|
108
|
-
}
|
|
109
|
-
// File with metadata
|
|
110
|
-
const full = join(dirPath, entry.name);
|
|
111
|
-
const parts = [];
|
|
112
|
-
try {
|
|
113
|
-
const st = statSync(full);
|
|
114
|
-
const lineCount = countFileLines(full, st.size);
|
|
115
|
-
if (lineCount !== null) parts.push(`${lineCount}L`);
|
|
116
|
-
parts.push(formatSize(st.size));
|
|
117
|
-
if (st.mtime) parts.push(relativeTime(st.mtime, compact));
|
|
118
|
-
} catch {
|
|
119
|
-
parts.push("?");
|
|
120
|
-
}
|
|
121
|
-
return `${indent}${entry.name} (${parts.join(", ")})`;
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
return { text: lines.join("\n"), total };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
/** Max output characters for read_file and bulk_replace. */
|
|
129
|
-
export const MAX_OUTPUT_CHARS = 80000;
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Read a text file with CRLF normalization.
|
|
133
|
-
* @param {string} filePath
|
|
134
|
-
* @returns {string}
|
|
135
|
-
*/
|
|
136
|
-
export function readText(filePath) {
|
|
137
|
-
return readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
|
|
138
|
-
}
|