opencode-hashline 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +55 -6
- package/README.md +55 -6
- package/README.ru.md +7 -6
- package/dist/{chunk-I6RACR3D.js → chunk-GKXY5ZBM.js} +266 -57
- package/dist/{chunk-VPCMHCTB.js → chunk-VSVVWPET.js} +25 -3
- package/dist/{hashline-5PFAXY3H.js → hashline-37RYBX5A.js} +11 -1
- package/dist/{hashline-yhMw1Abs.d.ts → hashline-A7k2yn3G.d.cts} +70 -5
- package/dist/{hashline-yhMw1Abs.d.cts → hashline-A7k2yn3G.d.ts} +70 -5
- package/dist/opencode-hashline.cjs +342 -81
- package/dist/opencode-hashline.d.cts +2 -2
- package/dist/opencode-hashline.d.ts +2 -2
- package/dist/opencode-hashline.js +56 -25
- package/dist/utils.cjs +295 -59
- package/dist/utils.d.cts +2 -2
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +12 -2
- package/package.json +1 -1
|
@@ -56,7 +56,9 @@ var DEFAULT_CONFIG = {
|
|
|
56
56
|
// 0 = adaptive
|
|
57
57
|
cacheSize: 100,
|
|
58
58
|
prefix: DEFAULT_PREFIX,
|
|
59
|
-
debug: false
|
|
59
|
+
debug: false,
|
|
60
|
+
fileRev: true,
|
|
61
|
+
safeReapply: false
|
|
60
62
|
};
|
|
61
63
|
function resolveConfig(config, pluginConfig) {
|
|
62
64
|
const merged = {
|
|
@@ -72,9 +74,55 @@ function resolveConfig(config, pluginConfig) {
|
|
|
72
74
|
hashLength: merged.hashLength ?? DEFAULT_CONFIG.hashLength,
|
|
73
75
|
cacheSize: merged.cacheSize ?? DEFAULT_CONFIG.cacheSize,
|
|
74
76
|
prefix: merged.prefix !== void 0 ? merged.prefix : DEFAULT_CONFIG.prefix,
|
|
75
|
-
debug: merged.debug ?? DEFAULT_CONFIG.debug
|
|
77
|
+
debug: merged.debug ?? DEFAULT_CONFIG.debug,
|
|
78
|
+
fileRev: merged.fileRev ?? DEFAULT_CONFIG.fileRev,
|
|
79
|
+
safeReapply: merged.safeReapply ?? DEFAULT_CONFIG.safeReapply
|
|
76
80
|
};
|
|
77
81
|
}
|
|
82
|
+
var HashlineError = class extends Error {
|
|
83
|
+
code;
|
|
84
|
+
expected;
|
|
85
|
+
actual;
|
|
86
|
+
candidates;
|
|
87
|
+
hint;
|
|
88
|
+
lineNumber;
|
|
89
|
+
filePath;
|
|
90
|
+
constructor(opts) {
|
|
91
|
+
super(opts.message);
|
|
92
|
+
this.name = "HashlineError";
|
|
93
|
+
this.code = opts.code;
|
|
94
|
+
this.expected = opts.expected;
|
|
95
|
+
this.actual = opts.actual;
|
|
96
|
+
this.candidates = opts.candidates;
|
|
97
|
+
this.hint = opts.hint;
|
|
98
|
+
this.lineNumber = opts.lineNumber;
|
|
99
|
+
this.filePath = opts.filePath;
|
|
100
|
+
}
|
|
101
|
+
toDiagnostic() {
|
|
102
|
+
const parts = [`[${this.code}] ${this.message}`];
|
|
103
|
+
if (this.filePath) {
|
|
104
|
+
parts.push(` File: ${this.filePath}`);
|
|
105
|
+
}
|
|
106
|
+
if (this.lineNumber !== void 0) {
|
|
107
|
+
parts.push(` Line: ${this.lineNumber}`);
|
|
108
|
+
}
|
|
109
|
+
if (this.expected !== void 0 && this.actual !== void 0) {
|
|
110
|
+
parts.push(` Expected hash: ${this.expected}`);
|
|
111
|
+
parts.push(` Actual hash: ${this.actual}`);
|
|
112
|
+
}
|
|
113
|
+
if (this.candidates && this.candidates.length > 0) {
|
|
114
|
+
parts.push(` Candidates (${this.candidates.length}):`);
|
|
115
|
+
for (const c of this.candidates) {
|
|
116
|
+
const preview = c.content.length > 60 ? c.content.slice(0, 60) + "..." : c.content;
|
|
117
|
+
parts.push(` - line ${c.lineNumber}: ${preview}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (this.hint) {
|
|
121
|
+
parts.push(` Hint: ${this.hint}`);
|
|
122
|
+
}
|
|
123
|
+
return parts.join("\n");
|
|
124
|
+
}
|
|
125
|
+
};
|
|
78
126
|
function fnv1aHash(str) {
|
|
79
127
|
let hash = 2166136261;
|
|
80
128
|
for (let i = 0; i < str.length; i++) {
|
|
@@ -104,33 +152,90 @@ function computeLineHash(idx, line, hashLen = 3) {
|
|
|
104
152
|
const hash = raw % modulus;
|
|
105
153
|
return hash.toString(16).padStart(hashLen, "0");
|
|
106
154
|
}
|
|
107
|
-
function
|
|
155
|
+
function computeFileRev(content) {
|
|
156
|
+
const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
|
|
157
|
+
const hash = fnv1aHash(normalized);
|
|
158
|
+
return hash.toString(16).padStart(8, "0");
|
|
159
|
+
}
|
|
160
|
+
function extractFileRev(annotatedContent, prefix) {
|
|
161
|
+
const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
|
|
162
|
+
const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
163
|
+
const pattern = new RegExp(`^${escapedPrefix}REV:([0-9a-f]{8})$`);
|
|
164
|
+
const firstLine = annotatedContent.split("\n")[0];
|
|
165
|
+
const match = firstLine.match(pattern);
|
|
166
|
+
return match ? match[1] : null;
|
|
167
|
+
}
|
|
168
|
+
function verifyFileRev(expectedRev, currentContent) {
|
|
169
|
+
const actualRev = computeFileRev(currentContent);
|
|
170
|
+
if (actualRev !== expectedRev) {
|
|
171
|
+
throw new HashlineError({
|
|
172
|
+
code: "FILE_REV_MISMATCH",
|
|
173
|
+
message: `File revision mismatch: expected "${expectedRev}", got "${actualRev}". The file has changed since it was last read.`,
|
|
174
|
+
expected: expectedRev,
|
|
175
|
+
actual: actualRev,
|
|
176
|
+
hint: "Re-read the file to get fresh hash references and a new file revision."
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
|
|
181
|
+
const effectiveLen = hashLen && hashLen >= 2 ? hashLen : expectedHash.length;
|
|
182
|
+
const originalIdx = originalLineNumber - 1;
|
|
183
|
+
const candidates = [];
|
|
184
|
+
for (let i = 0; i < lines.length; i++) {
|
|
185
|
+
if (i === originalIdx) continue;
|
|
186
|
+
const candidateHash = computeLineHash(originalIdx, lines[i], effectiveLen);
|
|
187
|
+
if (candidateHash === expectedHash) {
|
|
188
|
+
candidates.push({
|
|
189
|
+
lineNumber: i + 1,
|
|
190
|
+
// 1-based
|
|
191
|
+
content: lines[i]
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return candidates;
|
|
196
|
+
}
|
|
197
|
+
function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
|
|
108
198
|
const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
|
|
109
199
|
const lines = normalized.split("\n");
|
|
110
200
|
const effectiveLen = hashLen && hashLen >= 3 ? hashLen : getAdaptiveHashLength(lines.length);
|
|
111
201
|
const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
|
|
202
|
+
const hashLens = new Array(lines.length).fill(effectiveLen);
|
|
112
203
|
const hashes = new Array(lines.length);
|
|
113
|
-
const seen = /* @__PURE__ */ new Map();
|
|
114
|
-
const upgraded = /* @__PURE__ */ new Set();
|
|
115
204
|
for (let idx = 0; idx < lines.length; idx++) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
205
|
+
hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
|
|
206
|
+
}
|
|
207
|
+
let hasCollisions = true;
|
|
208
|
+
while (hasCollisions) {
|
|
209
|
+
hasCollisions = false;
|
|
210
|
+
const seen = /* @__PURE__ */ new Map();
|
|
211
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
212
|
+
const h = hashes[idx];
|
|
213
|
+
const group = seen.get(h);
|
|
214
|
+
if (group) {
|
|
215
|
+
group.push(idx);
|
|
216
|
+
} else {
|
|
217
|
+
seen.set(h, [idx]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const [, group] of seen) {
|
|
221
|
+
if (group.length < 2) continue;
|
|
222
|
+
for (const idx of group) {
|
|
223
|
+
const newLen = Math.min(hashLens[idx] + 1, 8);
|
|
224
|
+
if (newLen === hashLens[idx]) continue;
|
|
225
|
+
hashLens[idx] = newLen;
|
|
226
|
+
hashes[idx] = computeLineHash(idx, lines[idx], newLen);
|
|
227
|
+
hasCollisions = true;
|
|
123
228
|
}
|
|
124
|
-
hashes[idx] = computeLineHash(idx, lines[idx], longerLen);
|
|
125
|
-
upgraded.add(idx);
|
|
126
|
-
} else {
|
|
127
|
-
seen.set(hash, idx);
|
|
128
|
-
hashes[idx] = hash;
|
|
129
229
|
}
|
|
130
230
|
}
|
|
131
|
-
|
|
231
|
+
const annotatedLines = lines.map((line, idx) => {
|
|
132
232
|
return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
|
|
133
|
-
})
|
|
233
|
+
});
|
|
234
|
+
if (includeFileRev) {
|
|
235
|
+
const rev = computeFileRev(content);
|
|
236
|
+
annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
|
|
237
|
+
}
|
|
238
|
+
return annotatedLines.join("\n");
|
|
134
239
|
}
|
|
135
240
|
var stripRegexCache = /* @__PURE__ */ new Map();
|
|
136
241
|
function stripHashes(content, prefix) {
|
|
@@ -141,9 +246,10 @@ function stripHashes(content, prefix) {
|
|
|
141
246
|
hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
|
|
142
247
|
stripRegexCache.set(escapedPrefix, hashLinePattern);
|
|
143
248
|
}
|
|
249
|
+
const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
|
|
144
250
|
const lineEnding = detectLineEnding(content);
|
|
145
251
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
146
|
-
const result = normalized.split("\n").map((line) => {
|
|
252
|
+
const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
|
|
147
253
|
const match = line.match(hashLinePattern);
|
|
148
254
|
if (match) {
|
|
149
255
|
const patchMarker = match[1] || "";
|
|
@@ -157,7 +263,10 @@ function parseHashRef(ref) {
|
|
|
157
263
|
const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
|
|
158
264
|
if (!match) {
|
|
159
265
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
160
|
-
throw new
|
|
266
|
+
throw new HashlineError({
|
|
267
|
+
code: "INVALID_REF",
|
|
268
|
+
message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
|
|
269
|
+
});
|
|
161
270
|
}
|
|
162
271
|
return {
|
|
163
272
|
line: parseInt(match[1], 10),
|
|
@@ -175,9 +284,10 @@ function normalizeHashRef(ref) {
|
|
|
175
284
|
return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
|
|
176
285
|
}
|
|
177
286
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
178
|
-
throw new
|
|
179
|
-
|
|
180
|
-
|
|
287
|
+
throw new HashlineError({
|
|
288
|
+
code: "INVALID_REF",
|
|
289
|
+
message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
|
|
290
|
+
});
|
|
181
291
|
}
|
|
182
292
|
function buildHashMap(content, hashLen) {
|
|
183
293
|
const lines = content.split("\n");
|
|
@@ -190,50 +300,97 @@ function buildHashMap(content, hashLen) {
|
|
|
190
300
|
}
|
|
191
301
|
return map;
|
|
192
302
|
}
|
|
193
|
-
function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
|
|
303
|
+
function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
|
|
194
304
|
const contentLines = lines ?? currentContent.split("\n");
|
|
195
305
|
const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
|
|
196
306
|
if (lineNumber < 1 || lineNumber > contentLines.length) {
|
|
197
307
|
return {
|
|
198
308
|
valid: false,
|
|
309
|
+
code: "TARGET_OUT_OF_RANGE",
|
|
199
310
|
message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
|
|
200
311
|
};
|
|
201
312
|
}
|
|
202
313
|
const idx = lineNumber - 1;
|
|
203
314
|
const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
|
|
204
315
|
if (actualHash !== hash) {
|
|
316
|
+
const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
|
|
317
|
+
if (safeReapply && candidates.length === 1) {
|
|
318
|
+
return {
|
|
319
|
+
valid: true,
|
|
320
|
+
relocatedLine: candidates[0].lineNumber,
|
|
321
|
+
candidates
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (safeReapply && candidates.length > 1) {
|
|
325
|
+
return {
|
|
326
|
+
valid: false,
|
|
327
|
+
code: "AMBIGUOUS_REAPPLY",
|
|
328
|
+
expected: hash,
|
|
329
|
+
actual: actualHash,
|
|
330
|
+
candidates,
|
|
331
|
+
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
|
|
332
|
+
};
|
|
333
|
+
}
|
|
205
334
|
return {
|
|
206
335
|
valid: false,
|
|
336
|
+
code: "HASH_MISMATCH",
|
|
207
337
|
expected: hash,
|
|
208
338
|
actual: actualHash,
|
|
339
|
+
candidates,
|
|
209
340
|
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
|
|
210
341
|
};
|
|
211
342
|
}
|
|
212
343
|
return { valid: true };
|
|
213
344
|
}
|
|
214
|
-
function resolveRange(startRef, endRef, content, hashLen) {
|
|
345
|
+
function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
|
|
215
346
|
const start = parseHashRef(startRef);
|
|
216
347
|
const end = parseHashRef(endRef);
|
|
217
348
|
if (start.line > end.line) {
|
|
218
|
-
throw new
|
|
219
|
-
|
|
220
|
-
|
|
349
|
+
throw new HashlineError({
|
|
350
|
+
code: "INVALID_RANGE",
|
|
351
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
352
|
+
});
|
|
221
353
|
}
|
|
222
354
|
const lineEnding = detectLineEnding(content);
|
|
223
355
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
224
356
|
const lines = normalized.split("\n");
|
|
225
|
-
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
|
|
357
|
+
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
|
|
226
358
|
if (!startVerify.valid) {
|
|
227
|
-
throw new
|
|
359
|
+
throw new HashlineError({
|
|
360
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
361
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
362
|
+
expected: startVerify.expected,
|
|
363
|
+
actual: startVerify.actual,
|
|
364
|
+
candidates: startVerify.candidates,
|
|
365
|
+
lineNumber: start.line,
|
|
366
|
+
hint: startVerify.candidates && startVerify.candidates.length > 0 ? `Content may have moved. Candidates: ${startVerify.candidates.map((c) => `line ${c.lineNumber}`).join(", ")}` : "Re-read the file to get fresh hash references."
|
|
367
|
+
});
|
|
228
368
|
}
|
|
229
|
-
const
|
|
369
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
370
|
+
const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
|
|
230
371
|
if (!endVerify.valid) {
|
|
231
|
-
throw new
|
|
372
|
+
throw new HashlineError({
|
|
373
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
374
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
375
|
+
expected: endVerify.expected,
|
|
376
|
+
actual: endVerify.actual,
|
|
377
|
+
candidates: endVerify.candidates,
|
|
378
|
+
lineNumber: end.line,
|
|
379
|
+
hint: endVerify.candidates && endVerify.candidates.length > 0 ? `Content may have moved. Candidates: ${endVerify.candidates.map((c) => `line ${c.lineNumber}`).join(", ")}` : "Re-read the file to get fresh hash references."
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
383
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
384
|
+
throw new HashlineError({
|
|
385
|
+
code: "INVALID_RANGE",
|
|
386
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
387
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
388
|
+
});
|
|
232
389
|
}
|
|
233
|
-
const rangeLines = lines.slice(
|
|
390
|
+
const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
|
|
234
391
|
return {
|
|
235
|
-
startLine:
|
|
236
|
-
endLine:
|
|
392
|
+
startLine: effectiveStartLine,
|
|
393
|
+
endLine: effectiveEndLine,
|
|
237
394
|
lines: rangeLines,
|
|
238
395
|
content: rangeLines.join(lineEnding)
|
|
239
396
|
};
|
|
@@ -249,22 +406,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
|
|
|
249
406
|
const result = [...before, ...replacementLines, ...after].join("\n");
|
|
250
407
|
return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
|
|
251
408
|
}
|
|
252
|
-
function applyHashEdit(input, content, hashLen) {
|
|
409
|
+
function applyHashEdit(input, content, hashLen, safeReapply) {
|
|
253
410
|
const lineEnding = detectLineEnding(content);
|
|
254
411
|
const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
412
|
+
if (input.fileRev) {
|
|
413
|
+
verifyFileRev(input.fileRev, workContent);
|
|
414
|
+
}
|
|
255
415
|
const normalizedStart = normalizeHashRef(input.startRef);
|
|
256
416
|
const start = parseHashRef(normalizedStart);
|
|
257
417
|
const lines = workContent.split("\n");
|
|
258
|
-
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
|
|
418
|
+
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
|
|
259
419
|
if (!startVerify.valid) {
|
|
260
|
-
throw new
|
|
420
|
+
throw new HashlineError({
|
|
421
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
422
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
423
|
+
expected: startVerify.expected,
|
|
424
|
+
actual: startVerify.actual,
|
|
425
|
+
candidates: startVerify.candidates,
|
|
426
|
+
lineNumber: start.line,
|
|
427
|
+
hint: startVerify.candidates && startVerify.candidates.length > 0 ? `Content may have moved. Candidates: ${startVerify.candidates.map((c) => `line ${c.lineNumber}`).join(", ")}` : "Re-read the file to get fresh hash references."
|
|
428
|
+
});
|
|
261
429
|
}
|
|
430
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
262
431
|
if (input.operation === "insert_before" || input.operation === "insert_after") {
|
|
263
432
|
if (input.replacement === void 0) {
|
|
264
|
-
throw new
|
|
433
|
+
throw new HashlineError({
|
|
434
|
+
code: "MISSING_REPLACEMENT",
|
|
435
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
436
|
+
});
|
|
265
437
|
}
|
|
266
438
|
const insertionLines = input.replacement.split("\n");
|
|
267
|
-
const insertIndex = input.operation === "insert_before" ?
|
|
439
|
+
const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
|
|
268
440
|
const next2 = [
|
|
269
441
|
...lines.slice(0, insertIndex),
|
|
270
442
|
...insertionLines,
|
|
@@ -272,34 +444,54 @@ function applyHashEdit(input, content, hashLen) {
|
|
|
272
444
|
].join("\n");
|
|
273
445
|
return {
|
|
274
446
|
operation: input.operation,
|
|
275
|
-
startLine:
|
|
276
|
-
endLine:
|
|
447
|
+
startLine: effectiveStartLine,
|
|
448
|
+
endLine: effectiveStartLine,
|
|
277
449
|
content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
|
|
278
450
|
};
|
|
279
451
|
}
|
|
280
452
|
const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
|
|
281
453
|
const end = parseHashRef(normalizedEnd);
|
|
282
454
|
if (start.line > end.line) {
|
|
283
|
-
throw new
|
|
284
|
-
|
|
285
|
-
|
|
455
|
+
throw new HashlineError({
|
|
456
|
+
code: "INVALID_RANGE",
|
|
457
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
458
|
+
});
|
|
286
459
|
}
|
|
287
|
-
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
|
|
460
|
+
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
|
|
288
461
|
if (!endVerify.valid) {
|
|
289
|
-
throw new
|
|
462
|
+
throw new HashlineError({
|
|
463
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
464
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
465
|
+
expected: endVerify.expected,
|
|
466
|
+
actual: endVerify.actual,
|
|
467
|
+
candidates: endVerify.candidates,
|
|
468
|
+
lineNumber: end.line,
|
|
469
|
+
hint: endVerify.candidates && endVerify.candidates.length > 0 ? `Content may have moved. Candidates: ${endVerify.candidates.map((c) => `line ${c.lineNumber}`).join(", ")}` : "Re-read the file to get fresh hash references."
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
473
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
474
|
+
throw new HashlineError({
|
|
475
|
+
code: "INVALID_RANGE",
|
|
476
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
477
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
478
|
+
});
|
|
290
479
|
}
|
|
291
480
|
const replacement = input.operation === "delete" ? "" : input.replacement;
|
|
292
481
|
if (replacement === void 0) {
|
|
293
|
-
throw new
|
|
482
|
+
throw new HashlineError({
|
|
483
|
+
code: "MISSING_REPLACEMENT",
|
|
484
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
485
|
+
});
|
|
294
486
|
}
|
|
295
|
-
const before = lines.slice(0,
|
|
296
|
-
const after = lines.slice(
|
|
487
|
+
const before = lines.slice(0, effectiveStartLine - 1);
|
|
488
|
+
const after = lines.slice(effectiveEndLine);
|
|
297
489
|
const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
|
|
298
490
|
const next = [...before, ...replacementLines, ...after].join("\n");
|
|
299
491
|
return {
|
|
300
492
|
operation: input.operation,
|
|
301
|
-
startLine:
|
|
302
|
-
endLine:
|
|
493
|
+
startLine: effectiveStartLine,
|
|
494
|
+
endLine: effectiveEndLine,
|
|
303
495
|
content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
|
|
304
496
|
};
|
|
305
497
|
}
|
|
@@ -396,7 +588,7 @@ function createHashline(config) {
|
|
|
396
588
|
const cached = cache.get(filePath, content);
|
|
397
589
|
if (cached) return cached;
|
|
398
590
|
}
|
|
399
|
-
const result = formatFileWithHashes(content, hl, pfx);
|
|
591
|
+
const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
|
|
400
592
|
if (filePath) {
|
|
401
593
|
cache.set(filePath, content, result);
|
|
402
594
|
}
|
|
@@ -412,16 +604,16 @@ function createHashline(config) {
|
|
|
412
604
|
return buildHashMap(content, hl);
|
|
413
605
|
},
|
|
414
606
|
verifyHash(lineNumber, hash, currentContent) {
|
|
415
|
-
return verifyHash(lineNumber, hash, currentContent, hl);
|
|
607
|
+
return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
|
|
416
608
|
},
|
|
417
609
|
resolveRange(startRef, endRef, content) {
|
|
418
|
-
return resolveRange(startRef, endRef, content, hl);
|
|
610
|
+
return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
|
|
419
611
|
},
|
|
420
612
|
replaceRange(startRef, endRef, content, replacement) {
|
|
421
613
|
return replaceRange(startRef, endRef, content, replacement, hl);
|
|
422
614
|
},
|
|
423
615
|
applyHashEdit(input, content) {
|
|
424
|
-
return applyHashEdit(input, content, hl);
|
|
616
|
+
return applyHashEdit(input, content, hl, resolved.safeReapply);
|
|
425
617
|
},
|
|
426
618
|
normalizeHashRef(ref) {
|
|
427
619
|
return normalizeHashRef(ref);
|
|
@@ -431,6 +623,18 @@ function createHashline(config) {
|
|
|
431
623
|
},
|
|
432
624
|
shouldExclude(filePath) {
|
|
433
625
|
return shouldExclude(filePath, resolved.exclude);
|
|
626
|
+
},
|
|
627
|
+
computeFileRev(content) {
|
|
628
|
+
return computeFileRev(content);
|
|
629
|
+
},
|
|
630
|
+
verifyFileRev(expectedRev, currentContent) {
|
|
631
|
+
return verifyFileRev(expectedRev, currentContent);
|
|
632
|
+
},
|
|
633
|
+
extractFileRev(annotatedContent) {
|
|
634
|
+
return extractFileRev(annotatedContent, pfx);
|
|
635
|
+
},
|
|
636
|
+
findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
|
|
637
|
+
return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
|
|
434
638
|
}
|
|
435
639
|
};
|
|
436
640
|
}
|
|
@@ -440,8 +644,13 @@ export {
|
|
|
440
644
|
DEFAULT_PREFIX,
|
|
441
645
|
DEFAULT_CONFIG,
|
|
442
646
|
resolveConfig,
|
|
647
|
+
HashlineError,
|
|
443
648
|
getAdaptiveHashLength,
|
|
444
649
|
computeLineHash,
|
|
650
|
+
computeFileRev,
|
|
651
|
+
extractFileRev,
|
|
652
|
+
verifyFileRev,
|
|
653
|
+
findCandidateLines,
|
|
445
654
|
formatFileWithHashes,
|
|
446
655
|
stripHashes,
|
|
447
656
|
parseHashRef,
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
resolveConfig,
|
|
5
5
|
shouldExclude,
|
|
6
6
|
stripHashes
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-GKXY5ZBM.js";
|
|
8
8
|
|
|
9
9
|
// src/hooks.ts
|
|
10
10
|
import { appendFileSync } from "fs";
|
|
@@ -94,7 +94,7 @@ function createFileReadAfterHook(cache, config) {
|
|
|
94
94
|
return;
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
-
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
|
|
97
|
+
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, resolved.fileRev);
|
|
98
98
|
output.output = annotated;
|
|
99
99
|
debug("annotated", typeof filePath === "string" ? filePath : input.tool, "lines:", content.split("\n").length);
|
|
100
100
|
if (cache && typeof filePath === "string") {
|
|
@@ -206,10 +206,32 @@ function createSystemPromptHook(config) {
|
|
|
206
206
|
'- Hash references include both the line number AND the content hash, so `2:f1c` means "line 2 with hash f1c".',
|
|
207
207
|
"- If you see a mismatch, do NOT proceed with the edit \u2014 re-read the file to get fresh references.",
|
|
208
208
|
"",
|
|
209
|
+
"### File revision (`#HL REV:<hash>`):",
|
|
210
|
+
"- When files are read, the first line may contain a file revision header: `" + prefix + "REV:<8-char-hex>`.",
|
|
211
|
+
"- This is a hash of the entire file content. Pass it as the `fileRev` parameter to `hashline_edit` to verify the file hasn't changed.",
|
|
212
|
+
"- If the file was modified between read and edit, the revision check fails with `FILE_REV_MISMATCH` \u2014 re-read the file.",
|
|
213
|
+
"",
|
|
214
|
+
"### Safe reapply (`safeReapply`):",
|
|
215
|
+
"- Pass `safeReapply: true` to `hashline_edit` to enable automatic line relocation.",
|
|
216
|
+
"- If a line moved (e.g., due to insertions above), safe reapply finds it by content hash.",
|
|
217
|
+
"- If exactly one match is found, the edit proceeds at the new location.",
|
|
218
|
+
"- If multiple matches exist, the edit fails with `AMBIGUOUS_REAPPLY` \u2014 re-read the file.",
|
|
219
|
+
"",
|
|
220
|
+
"### Structured error codes:",
|
|
221
|
+
"- `HASH_MISMATCH` \u2014 line content changed since last read",
|
|
222
|
+
"- `FILE_REV_MISMATCH` \u2014 file was modified since last read",
|
|
223
|
+
"- `AMBIGUOUS_REAPPLY` \u2014 multiple candidate lines found during safe reapply",
|
|
224
|
+
"- `TARGET_OUT_OF_RANGE` \u2014 line number exceeds file length",
|
|
225
|
+
"- `INVALID_REF` \u2014 malformed hash reference",
|
|
226
|
+
"- `INVALID_RANGE` \u2014 start line is after end line",
|
|
227
|
+
"- `MISSING_REPLACEMENT` \u2014 replace/insert operation without replacement content",
|
|
228
|
+
"",
|
|
209
229
|
"### Best practices:",
|
|
210
230
|
"- Use hash references for all edit operations to ensure precision.",
|
|
211
231
|
"- When making multiple edits, work from bottom to top to avoid line number shifts.",
|
|
212
|
-
"- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines."
|
|
232
|
+
"- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines.",
|
|
233
|
+
"- Use `fileRev` to guard against stale edits on critical files.",
|
|
234
|
+
"- Use `safeReapply: true` when editing files that may have shifted due to earlier edits."
|
|
213
235
|
].join("\n")
|
|
214
236
|
);
|
|
215
237
|
};
|
|
@@ -3,11 +3,15 @@ import {
|
|
|
3
3
|
DEFAULT_EXCLUDE_PATTERNS,
|
|
4
4
|
DEFAULT_PREFIX,
|
|
5
5
|
HashlineCache,
|
|
6
|
+
HashlineError,
|
|
6
7
|
applyHashEdit,
|
|
7
8
|
buildHashMap,
|
|
9
|
+
computeFileRev,
|
|
8
10
|
computeLineHash,
|
|
9
11
|
createHashline,
|
|
10
12
|
detectLineEnding,
|
|
13
|
+
extractFileRev,
|
|
14
|
+
findCandidateLines,
|
|
11
15
|
formatFileWithHashes,
|
|
12
16
|
getAdaptiveHashLength,
|
|
13
17
|
getByteLength,
|
|
@@ -19,18 +23,23 @@ import {
|
|
|
19
23
|
resolveRange,
|
|
20
24
|
shouldExclude,
|
|
21
25
|
stripHashes,
|
|
26
|
+
verifyFileRev,
|
|
22
27
|
verifyHash
|
|
23
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-GKXY5ZBM.js";
|
|
24
29
|
export {
|
|
25
30
|
DEFAULT_CONFIG,
|
|
26
31
|
DEFAULT_EXCLUDE_PATTERNS,
|
|
27
32
|
DEFAULT_PREFIX,
|
|
28
33
|
HashlineCache,
|
|
34
|
+
HashlineError,
|
|
29
35
|
applyHashEdit,
|
|
30
36
|
buildHashMap,
|
|
37
|
+
computeFileRev,
|
|
31
38
|
computeLineHash,
|
|
32
39
|
createHashline,
|
|
33
40
|
detectLineEnding,
|
|
41
|
+
extractFileRev,
|
|
42
|
+
findCandidateLines,
|
|
34
43
|
formatFileWithHashes,
|
|
35
44
|
getAdaptiveHashLength,
|
|
36
45
|
getByteLength,
|
|
@@ -42,5 +51,6 @@ export {
|
|
|
42
51
|
resolveRange,
|
|
43
52
|
shouldExclude,
|
|
44
53
|
stripHashes,
|
|
54
|
+
verifyFileRev,
|
|
45
55
|
verifyHash
|
|
46
56
|
};
|