opencode-hashline 1.2.0 → 1.3.0
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 +48 -0
- package/README.md +48 -0
- package/dist/{chunk-VPCMHCTB.js → chunk-7KUPGN4M.js} +25 -3
- package/dist/{chunk-I6RACR3D.js → chunk-DOR4YDIS.js} +242 -43
- 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/{hashline-5PFAXY3H.js → hashline-MGDEWZ77.js} +11 -1
- package/dist/opencode-hashline.cjs +285 -50
- package/dist/opencode-hashline.d.cts +2 -2
- package/dist/opencode-hashline.d.ts +2 -2
- package/dist/opencode-hashline.js +22 -7
- package/dist/utils.cjs +271 -45
- 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
package/dist/utils.cjs
CHANGED
|
@@ -34,13 +34,17 @@ __export(utils_exports, {
|
|
|
34
34
|
DEFAULT_EXCLUDE_PATTERNS: () => DEFAULT_EXCLUDE_PATTERNS,
|
|
35
35
|
DEFAULT_PREFIX: () => DEFAULT_PREFIX,
|
|
36
36
|
HashlineCache: () => HashlineCache,
|
|
37
|
+
HashlineError: () => HashlineError,
|
|
37
38
|
applyHashEdit: () => applyHashEdit,
|
|
38
39
|
buildHashMap: () => buildHashMap,
|
|
40
|
+
computeFileRev: () => computeFileRev,
|
|
39
41
|
computeLineHash: () => computeLineHash,
|
|
40
42
|
createFileEditBeforeHook: () => createFileEditBeforeHook,
|
|
41
43
|
createFileReadAfterHook: () => createFileReadAfterHook,
|
|
42
44
|
createHashline: () => createHashline,
|
|
43
45
|
createSystemPromptHook: () => createSystemPromptHook,
|
|
46
|
+
extractFileRev: () => extractFileRev,
|
|
47
|
+
findCandidateLines: () => findCandidateLines,
|
|
44
48
|
formatFileWithHashes: () => formatFileWithHashes,
|
|
45
49
|
getAdaptiveHashLength: () => getAdaptiveHashLength,
|
|
46
50
|
getByteLength: () => getByteLength,
|
|
@@ -53,6 +57,7 @@ __export(utils_exports, {
|
|
|
53
57
|
resolveRange: () => resolveRange,
|
|
54
58
|
shouldExclude: () => shouldExclude,
|
|
55
59
|
stripHashes: () => stripHashes,
|
|
60
|
+
verifyFileRev: () => verifyFileRev,
|
|
56
61
|
verifyHash: () => verifyHash
|
|
57
62
|
});
|
|
58
63
|
module.exports = __toCommonJS(utils_exports);
|
|
@@ -115,7 +120,9 @@ var DEFAULT_CONFIG = {
|
|
|
115
120
|
// 0 = adaptive
|
|
116
121
|
cacheSize: 100,
|
|
117
122
|
prefix: DEFAULT_PREFIX,
|
|
118
|
-
debug: false
|
|
123
|
+
debug: false,
|
|
124
|
+
fileRev: true,
|
|
125
|
+
safeReapply: false
|
|
119
126
|
};
|
|
120
127
|
function resolveConfig(config, pluginConfig) {
|
|
121
128
|
const merged = {
|
|
@@ -131,9 +138,55 @@ function resolveConfig(config, pluginConfig) {
|
|
|
131
138
|
hashLength: merged.hashLength ?? DEFAULT_CONFIG.hashLength,
|
|
132
139
|
cacheSize: merged.cacheSize ?? DEFAULT_CONFIG.cacheSize,
|
|
133
140
|
prefix: merged.prefix !== void 0 ? merged.prefix : DEFAULT_CONFIG.prefix,
|
|
134
|
-
debug: merged.debug ?? DEFAULT_CONFIG.debug
|
|
141
|
+
debug: merged.debug ?? DEFAULT_CONFIG.debug,
|
|
142
|
+
fileRev: merged.fileRev ?? DEFAULT_CONFIG.fileRev,
|
|
143
|
+
safeReapply: merged.safeReapply ?? DEFAULT_CONFIG.safeReapply
|
|
135
144
|
};
|
|
136
145
|
}
|
|
146
|
+
var HashlineError = class extends Error {
|
|
147
|
+
code;
|
|
148
|
+
expected;
|
|
149
|
+
actual;
|
|
150
|
+
candidates;
|
|
151
|
+
hint;
|
|
152
|
+
lineNumber;
|
|
153
|
+
filePath;
|
|
154
|
+
constructor(opts) {
|
|
155
|
+
super(opts.message);
|
|
156
|
+
this.name = "HashlineError";
|
|
157
|
+
this.code = opts.code;
|
|
158
|
+
this.expected = opts.expected;
|
|
159
|
+
this.actual = opts.actual;
|
|
160
|
+
this.candidates = opts.candidates;
|
|
161
|
+
this.hint = opts.hint;
|
|
162
|
+
this.lineNumber = opts.lineNumber;
|
|
163
|
+
this.filePath = opts.filePath;
|
|
164
|
+
}
|
|
165
|
+
toDiagnostic() {
|
|
166
|
+
const parts = [`[${this.code}] ${this.message}`];
|
|
167
|
+
if (this.filePath) {
|
|
168
|
+
parts.push(` File: ${this.filePath}`);
|
|
169
|
+
}
|
|
170
|
+
if (this.lineNumber !== void 0) {
|
|
171
|
+
parts.push(` Line: ${this.lineNumber}`);
|
|
172
|
+
}
|
|
173
|
+
if (this.expected !== void 0 && this.actual !== void 0) {
|
|
174
|
+
parts.push(` Expected hash: ${this.expected}`);
|
|
175
|
+
parts.push(` Actual hash: ${this.actual}`);
|
|
176
|
+
}
|
|
177
|
+
if (this.candidates && this.candidates.length > 0) {
|
|
178
|
+
parts.push(` Candidates (${this.candidates.length}):`);
|
|
179
|
+
for (const c of this.candidates) {
|
|
180
|
+
const preview = c.content.length > 60 ? c.content.slice(0, 60) + "..." : c.content;
|
|
181
|
+
parts.push(` - line ${c.lineNumber}: ${preview}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (this.hint) {
|
|
185
|
+
parts.push(` Hint: ${this.hint}`);
|
|
186
|
+
}
|
|
187
|
+
return parts.join("\n");
|
|
188
|
+
}
|
|
189
|
+
};
|
|
137
190
|
function fnv1aHash(str) {
|
|
138
191
|
let hash = 2166136261;
|
|
139
192
|
for (let i = 0; i < str.length; i++) {
|
|
@@ -163,7 +216,49 @@ function computeLineHash(idx, line, hashLen = 3) {
|
|
|
163
216
|
const hash = raw % modulus;
|
|
164
217
|
return hash.toString(16).padStart(hashLen, "0");
|
|
165
218
|
}
|
|
166
|
-
function
|
|
219
|
+
function computeFileRev(content) {
|
|
220
|
+
const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
|
|
221
|
+
const hash = fnv1aHash(normalized);
|
|
222
|
+
return hash.toString(16).padStart(8, "0");
|
|
223
|
+
}
|
|
224
|
+
function extractFileRev(annotatedContent, prefix) {
|
|
225
|
+
const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
|
|
226
|
+
const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
227
|
+
const pattern = new RegExp(`^${escapedPrefix}REV:([0-9a-f]{8})$`);
|
|
228
|
+
const firstLine = annotatedContent.split("\n")[0];
|
|
229
|
+
const match = firstLine.match(pattern);
|
|
230
|
+
return match ? match[1] : null;
|
|
231
|
+
}
|
|
232
|
+
function verifyFileRev(expectedRev, currentContent) {
|
|
233
|
+
const actualRev = computeFileRev(currentContent);
|
|
234
|
+
if (actualRev !== expectedRev) {
|
|
235
|
+
throw new HashlineError({
|
|
236
|
+
code: "FILE_REV_MISMATCH",
|
|
237
|
+
message: `File revision mismatch: expected "${expectedRev}", got "${actualRev}". The file has changed since it was last read.`,
|
|
238
|
+
expected: expectedRev,
|
|
239
|
+
actual: actualRev,
|
|
240
|
+
hint: "Re-read the file to get fresh hash references and a new file revision."
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
|
|
245
|
+
const effectiveLen = hashLen && hashLen >= 2 ? hashLen : expectedHash.length;
|
|
246
|
+
const originalIdx = originalLineNumber - 1;
|
|
247
|
+
const candidates = [];
|
|
248
|
+
for (let i = 0; i < lines.length; i++) {
|
|
249
|
+
if (i === originalIdx) continue;
|
|
250
|
+
const candidateHash = computeLineHash(originalIdx, lines[i], effectiveLen);
|
|
251
|
+
if (candidateHash === expectedHash) {
|
|
252
|
+
candidates.push({
|
|
253
|
+
lineNumber: i + 1,
|
|
254
|
+
// 1-based
|
|
255
|
+
content: lines[i]
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return candidates;
|
|
260
|
+
}
|
|
261
|
+
function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
|
|
167
262
|
const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
|
|
168
263
|
const lines = normalized.split("\n");
|
|
169
264
|
const effectiveLen = hashLen && hashLen >= 3 ? hashLen : getAdaptiveHashLength(lines.length);
|
|
@@ -187,9 +282,14 @@ function formatFileWithHashes(content, hashLen, prefix) {
|
|
|
187
282
|
hashes[idx] = hash;
|
|
188
283
|
}
|
|
189
284
|
}
|
|
190
|
-
|
|
285
|
+
const annotatedLines = lines.map((line, idx) => {
|
|
191
286
|
return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
|
|
192
|
-
})
|
|
287
|
+
});
|
|
288
|
+
if (includeFileRev) {
|
|
289
|
+
const rev = computeFileRev(content);
|
|
290
|
+
annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
|
|
291
|
+
}
|
|
292
|
+
return annotatedLines.join("\n");
|
|
193
293
|
}
|
|
194
294
|
var stripRegexCache = /* @__PURE__ */ new Map();
|
|
195
295
|
function stripHashes(content, prefix) {
|
|
@@ -200,9 +300,10 @@ function stripHashes(content, prefix) {
|
|
|
200
300
|
hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
|
|
201
301
|
stripRegexCache.set(escapedPrefix, hashLinePattern);
|
|
202
302
|
}
|
|
303
|
+
const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
|
|
203
304
|
const lineEnding = detectLineEnding(content);
|
|
204
305
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
205
|
-
const result = normalized.split("\n").map((line) => {
|
|
306
|
+
const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
|
|
206
307
|
const match = line.match(hashLinePattern);
|
|
207
308
|
if (match) {
|
|
208
309
|
const patchMarker = match[1] || "";
|
|
@@ -216,7 +317,10 @@ function parseHashRef(ref) {
|
|
|
216
317
|
const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
|
|
217
318
|
if (!match) {
|
|
218
319
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
219
|
-
throw new
|
|
320
|
+
throw new HashlineError({
|
|
321
|
+
code: "INVALID_REF",
|
|
322
|
+
message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
|
|
323
|
+
});
|
|
220
324
|
}
|
|
221
325
|
return {
|
|
222
326
|
line: parseInt(match[1], 10),
|
|
@@ -234,9 +338,10 @@ function normalizeHashRef(ref) {
|
|
|
234
338
|
return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
|
|
235
339
|
}
|
|
236
340
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
237
|
-
throw new
|
|
238
|
-
|
|
239
|
-
|
|
341
|
+
throw new HashlineError({
|
|
342
|
+
code: "INVALID_REF",
|
|
343
|
+
message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
|
|
344
|
+
});
|
|
240
345
|
}
|
|
241
346
|
function buildHashMap(content, hashLen) {
|
|
242
347
|
const lines = content.split("\n");
|
|
@@ -249,50 +354,97 @@ function buildHashMap(content, hashLen) {
|
|
|
249
354
|
}
|
|
250
355
|
return map;
|
|
251
356
|
}
|
|
252
|
-
function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
|
|
357
|
+
function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
|
|
253
358
|
const contentLines = lines ?? currentContent.split("\n");
|
|
254
359
|
const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
|
|
255
360
|
if (lineNumber < 1 || lineNumber > contentLines.length) {
|
|
256
361
|
return {
|
|
257
362
|
valid: false,
|
|
363
|
+
code: "TARGET_OUT_OF_RANGE",
|
|
258
364
|
message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
|
|
259
365
|
};
|
|
260
366
|
}
|
|
261
367
|
const idx = lineNumber - 1;
|
|
262
368
|
const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
|
|
263
369
|
if (actualHash !== hash) {
|
|
370
|
+
const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
|
|
371
|
+
if (safeReapply && candidates.length === 1) {
|
|
372
|
+
return {
|
|
373
|
+
valid: true,
|
|
374
|
+
relocatedLine: candidates[0].lineNumber,
|
|
375
|
+
candidates
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (safeReapply && candidates.length > 1) {
|
|
379
|
+
return {
|
|
380
|
+
valid: false,
|
|
381
|
+
code: "AMBIGUOUS_REAPPLY",
|
|
382
|
+
expected: hash,
|
|
383
|
+
actual: actualHash,
|
|
384
|
+
candidates,
|
|
385
|
+
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
|
|
386
|
+
};
|
|
387
|
+
}
|
|
264
388
|
return {
|
|
265
389
|
valid: false,
|
|
390
|
+
code: "HASH_MISMATCH",
|
|
266
391
|
expected: hash,
|
|
267
392
|
actual: actualHash,
|
|
393
|
+
candidates,
|
|
268
394
|
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
|
|
269
395
|
};
|
|
270
396
|
}
|
|
271
397
|
return { valid: true };
|
|
272
398
|
}
|
|
273
|
-
function resolveRange(startRef, endRef, content, hashLen) {
|
|
399
|
+
function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
|
|
274
400
|
const start = parseHashRef(startRef);
|
|
275
401
|
const end = parseHashRef(endRef);
|
|
276
402
|
if (start.line > end.line) {
|
|
277
|
-
throw new
|
|
278
|
-
|
|
279
|
-
|
|
403
|
+
throw new HashlineError({
|
|
404
|
+
code: "INVALID_RANGE",
|
|
405
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
406
|
+
});
|
|
280
407
|
}
|
|
281
408
|
const lineEnding = detectLineEnding(content);
|
|
282
409
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
283
410
|
const lines = normalized.split("\n");
|
|
284
|
-
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
|
|
411
|
+
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
|
|
285
412
|
if (!startVerify.valid) {
|
|
286
|
-
throw new
|
|
413
|
+
throw new HashlineError({
|
|
414
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
415
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
416
|
+
expected: startVerify.expected,
|
|
417
|
+
actual: startVerify.actual,
|
|
418
|
+
candidates: startVerify.candidates,
|
|
419
|
+
lineNumber: start.line,
|
|
420
|
+
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."
|
|
421
|
+
});
|
|
287
422
|
}
|
|
288
|
-
const
|
|
423
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
424
|
+
const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
|
|
289
425
|
if (!endVerify.valid) {
|
|
290
|
-
throw new
|
|
426
|
+
throw new HashlineError({
|
|
427
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
428
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
429
|
+
expected: endVerify.expected,
|
|
430
|
+
actual: endVerify.actual,
|
|
431
|
+
candidates: endVerify.candidates,
|
|
432
|
+
lineNumber: end.line,
|
|
433
|
+
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."
|
|
434
|
+
});
|
|
291
435
|
}
|
|
292
|
-
const
|
|
436
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
437
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
438
|
+
throw new HashlineError({
|
|
439
|
+
code: "INVALID_RANGE",
|
|
440
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
441
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
|
|
293
445
|
return {
|
|
294
|
-
startLine:
|
|
295
|
-
endLine:
|
|
446
|
+
startLine: effectiveStartLine,
|
|
447
|
+
endLine: effectiveEndLine,
|
|
296
448
|
lines: rangeLines,
|
|
297
449
|
content: rangeLines.join(lineEnding)
|
|
298
450
|
};
|
|
@@ -308,22 +460,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
|
|
|
308
460
|
const result = [...before, ...replacementLines, ...after].join("\n");
|
|
309
461
|
return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
|
|
310
462
|
}
|
|
311
|
-
function applyHashEdit(input, content, hashLen) {
|
|
463
|
+
function applyHashEdit(input, content, hashLen, safeReapply) {
|
|
312
464
|
const lineEnding = detectLineEnding(content);
|
|
313
465
|
const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
466
|
+
if (input.fileRev) {
|
|
467
|
+
verifyFileRev(input.fileRev, workContent);
|
|
468
|
+
}
|
|
314
469
|
const normalizedStart = normalizeHashRef(input.startRef);
|
|
315
470
|
const start = parseHashRef(normalizedStart);
|
|
316
471
|
const lines = workContent.split("\n");
|
|
317
|
-
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
|
|
472
|
+
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
|
|
318
473
|
if (!startVerify.valid) {
|
|
319
|
-
throw new
|
|
474
|
+
throw new HashlineError({
|
|
475
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
476
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
477
|
+
expected: startVerify.expected,
|
|
478
|
+
actual: startVerify.actual,
|
|
479
|
+
candidates: startVerify.candidates,
|
|
480
|
+
lineNumber: start.line,
|
|
481
|
+
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."
|
|
482
|
+
});
|
|
320
483
|
}
|
|
484
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
321
485
|
if (input.operation === "insert_before" || input.operation === "insert_after") {
|
|
322
486
|
if (input.replacement === void 0) {
|
|
323
|
-
throw new
|
|
487
|
+
throw new HashlineError({
|
|
488
|
+
code: "MISSING_REPLACEMENT",
|
|
489
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
490
|
+
});
|
|
324
491
|
}
|
|
325
492
|
const insertionLines = input.replacement.split("\n");
|
|
326
|
-
const insertIndex = input.operation === "insert_before" ?
|
|
493
|
+
const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
|
|
327
494
|
const next2 = [
|
|
328
495
|
...lines.slice(0, insertIndex),
|
|
329
496
|
...insertionLines,
|
|
@@ -331,34 +498,54 @@ function applyHashEdit(input, content, hashLen) {
|
|
|
331
498
|
].join("\n");
|
|
332
499
|
return {
|
|
333
500
|
operation: input.operation,
|
|
334
|
-
startLine:
|
|
335
|
-
endLine:
|
|
501
|
+
startLine: effectiveStartLine,
|
|
502
|
+
endLine: effectiveStartLine,
|
|
336
503
|
content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
|
|
337
504
|
};
|
|
338
505
|
}
|
|
339
506
|
const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
|
|
340
507
|
const end = parseHashRef(normalizedEnd);
|
|
341
508
|
if (start.line > end.line) {
|
|
342
|
-
throw new
|
|
343
|
-
|
|
344
|
-
|
|
509
|
+
throw new HashlineError({
|
|
510
|
+
code: "INVALID_RANGE",
|
|
511
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
512
|
+
});
|
|
345
513
|
}
|
|
346
|
-
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
|
|
514
|
+
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
|
|
347
515
|
if (!endVerify.valid) {
|
|
348
|
-
throw new
|
|
516
|
+
throw new HashlineError({
|
|
517
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
518
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
519
|
+
expected: endVerify.expected,
|
|
520
|
+
actual: endVerify.actual,
|
|
521
|
+
candidates: endVerify.candidates,
|
|
522
|
+
lineNumber: end.line,
|
|
523
|
+
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."
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
527
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
528
|
+
throw new HashlineError({
|
|
529
|
+
code: "INVALID_RANGE",
|
|
530
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
531
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
532
|
+
});
|
|
349
533
|
}
|
|
350
534
|
const replacement = input.operation === "delete" ? "" : input.replacement;
|
|
351
535
|
if (replacement === void 0) {
|
|
352
|
-
throw new
|
|
536
|
+
throw new HashlineError({
|
|
537
|
+
code: "MISSING_REPLACEMENT",
|
|
538
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
539
|
+
});
|
|
353
540
|
}
|
|
354
|
-
const before = lines.slice(0,
|
|
355
|
-
const after = lines.slice(
|
|
541
|
+
const before = lines.slice(0, effectiveStartLine - 1);
|
|
542
|
+
const after = lines.slice(effectiveEndLine);
|
|
356
543
|
const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
|
|
357
544
|
const next = [...before, ...replacementLines, ...after].join("\n");
|
|
358
545
|
return {
|
|
359
546
|
operation: input.operation,
|
|
360
|
-
startLine:
|
|
361
|
-
endLine:
|
|
547
|
+
startLine: effectiveStartLine,
|
|
548
|
+
endLine: effectiveEndLine,
|
|
362
549
|
content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
|
|
363
550
|
};
|
|
364
551
|
}
|
|
@@ -455,7 +642,7 @@ function createHashline(config) {
|
|
|
455
642
|
const cached = cache.get(filePath, content);
|
|
456
643
|
if (cached) return cached;
|
|
457
644
|
}
|
|
458
|
-
const result = formatFileWithHashes(content, hl, pfx);
|
|
645
|
+
const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
|
|
459
646
|
if (filePath) {
|
|
460
647
|
cache.set(filePath, content, result);
|
|
461
648
|
}
|
|
@@ -471,16 +658,16 @@ function createHashline(config) {
|
|
|
471
658
|
return buildHashMap(content, hl);
|
|
472
659
|
},
|
|
473
660
|
verifyHash(lineNumber, hash, currentContent) {
|
|
474
|
-
return verifyHash(lineNumber, hash, currentContent, hl);
|
|
661
|
+
return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
|
|
475
662
|
},
|
|
476
663
|
resolveRange(startRef, endRef, content) {
|
|
477
|
-
return resolveRange(startRef, endRef, content, hl);
|
|
664
|
+
return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
|
|
478
665
|
},
|
|
479
666
|
replaceRange(startRef, endRef, content, replacement) {
|
|
480
667
|
return replaceRange(startRef, endRef, content, replacement, hl);
|
|
481
668
|
},
|
|
482
669
|
applyHashEdit(input, content) {
|
|
483
|
-
return applyHashEdit(input, content, hl);
|
|
670
|
+
return applyHashEdit(input, content, hl, resolved.safeReapply);
|
|
484
671
|
},
|
|
485
672
|
normalizeHashRef(ref) {
|
|
486
673
|
return normalizeHashRef(ref);
|
|
@@ -490,6 +677,18 @@ function createHashline(config) {
|
|
|
490
677
|
},
|
|
491
678
|
shouldExclude(filePath) {
|
|
492
679
|
return shouldExclude(filePath, resolved.exclude);
|
|
680
|
+
},
|
|
681
|
+
computeFileRev(content) {
|
|
682
|
+
return computeFileRev(content);
|
|
683
|
+
},
|
|
684
|
+
verifyFileRev(expectedRev, currentContent) {
|
|
685
|
+
return verifyFileRev(expectedRev, currentContent);
|
|
686
|
+
},
|
|
687
|
+
extractFileRev(annotatedContent) {
|
|
688
|
+
return extractFileRev(annotatedContent, pfx);
|
|
689
|
+
},
|
|
690
|
+
findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
|
|
691
|
+
return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
|
|
493
692
|
}
|
|
494
693
|
};
|
|
495
694
|
}
|
|
@@ -579,7 +778,7 @@ function createFileReadAfterHook(cache, config) {
|
|
|
579
778
|
return;
|
|
580
779
|
}
|
|
581
780
|
}
|
|
582
|
-
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
|
|
781
|
+
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, resolved.fileRev);
|
|
583
782
|
output.output = annotated;
|
|
584
783
|
debug("annotated", typeof filePath === "string" ? filePath : input.tool, "lines:", content.split("\n").length);
|
|
585
784
|
if (cache && typeof filePath === "string") {
|
|
@@ -691,10 +890,32 @@ function createSystemPromptHook(config) {
|
|
|
691
890
|
'- Hash references include both the line number AND the content hash, so `2:f1c` means "line 2 with hash f1c".',
|
|
692
891
|
"- If you see a mismatch, do NOT proceed with the edit \u2014 re-read the file to get fresh references.",
|
|
693
892
|
"",
|
|
893
|
+
"### File revision (`#HL REV:<hash>`):",
|
|
894
|
+
"- When files are read, the first line may contain a file revision header: `" + prefix + "REV:<8-char-hex>`.",
|
|
895
|
+
"- 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.",
|
|
896
|
+
"- If the file was modified between read and edit, the revision check fails with `FILE_REV_MISMATCH` \u2014 re-read the file.",
|
|
897
|
+
"",
|
|
898
|
+
"### Safe reapply (`safeReapply`):",
|
|
899
|
+
"- Pass `safeReapply: true` to `hashline_edit` to enable automatic line relocation.",
|
|
900
|
+
"- If a line moved (e.g., due to insertions above), safe reapply finds it by content hash.",
|
|
901
|
+
"- If exactly one match is found, the edit proceeds at the new location.",
|
|
902
|
+
"- If multiple matches exist, the edit fails with `AMBIGUOUS_REAPPLY` \u2014 re-read the file.",
|
|
903
|
+
"",
|
|
904
|
+
"### Structured error codes:",
|
|
905
|
+
"- `HASH_MISMATCH` \u2014 line content changed since last read",
|
|
906
|
+
"- `FILE_REV_MISMATCH` \u2014 file was modified since last read",
|
|
907
|
+
"- `AMBIGUOUS_REAPPLY` \u2014 multiple candidate lines found during safe reapply",
|
|
908
|
+
"- `TARGET_OUT_OF_RANGE` \u2014 line number exceeds file length",
|
|
909
|
+
"- `INVALID_REF` \u2014 malformed hash reference",
|
|
910
|
+
"- `INVALID_RANGE` \u2014 start line is after end line",
|
|
911
|
+
"- `MISSING_REPLACEMENT` \u2014 replace/insert operation without replacement content",
|
|
912
|
+
"",
|
|
694
913
|
"### Best practices:",
|
|
695
914
|
"- Use hash references for all edit operations to ensure precision.",
|
|
696
915
|
"- When making multiple edits, work from bottom to top to avoid line number shifts.",
|
|
697
|
-
"- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines."
|
|
916
|
+
"- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines.",
|
|
917
|
+
"- Use `fileRev` to guard against stale edits on critical files.",
|
|
918
|
+
"- Use `safeReapply: true` when editing files that may have shifted due to earlier edits."
|
|
698
919
|
].join("\n")
|
|
699
920
|
);
|
|
700
921
|
};
|
|
@@ -705,13 +926,17 @@ function createSystemPromptHook(config) {
|
|
|
705
926
|
DEFAULT_EXCLUDE_PATTERNS,
|
|
706
927
|
DEFAULT_PREFIX,
|
|
707
928
|
HashlineCache,
|
|
929
|
+
HashlineError,
|
|
708
930
|
applyHashEdit,
|
|
709
931
|
buildHashMap,
|
|
932
|
+
computeFileRev,
|
|
710
933
|
computeLineHash,
|
|
711
934
|
createFileEditBeforeHook,
|
|
712
935
|
createFileReadAfterHook,
|
|
713
936
|
createHashline,
|
|
714
937
|
createSystemPromptHook,
|
|
938
|
+
extractFileRev,
|
|
939
|
+
findCandidateLines,
|
|
715
940
|
formatFileWithHashes,
|
|
716
941
|
getAdaptiveHashLength,
|
|
717
942
|
getByteLength,
|
|
@@ -724,5 +949,6 @@ function createSystemPromptHook(config) {
|
|
|
724
949
|
resolveRange,
|
|
725
950
|
shouldExclude,
|
|
726
951
|
stripHashes,
|
|
952
|
+
verifyFileRev,
|
|
727
953
|
verifyHash
|
|
728
954
|
});
|
package/dist/utils.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { H as HashlineConfig,
|
|
2
|
-
export { D as DEFAULT_CONFIG,
|
|
1
|
+
import { H as HashlineConfig, f as HashlineCache } from './hashline-A7k2yn3G.cjs';
|
|
2
|
+
export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-A7k2yn3G.cjs';
|
|
3
3
|
import { Hooks } from '@opencode-ai/plugin';
|
|
4
4
|
|
|
5
5
|
/**
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { H as HashlineConfig,
|
|
2
|
-
export { D as DEFAULT_CONFIG,
|
|
1
|
+
import { H as HashlineConfig, f as HashlineCache } from './hashline-A7k2yn3G.js';
|
|
2
|
+
export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-A7k2yn3G.js';
|
|
3
3
|
import { Hooks } from '@opencode-ai/plugin';
|
|
4
4
|
|
|
5
5
|
/**
|
package/dist/utils.js
CHANGED
|
@@ -3,16 +3,20 @@ import {
|
|
|
3
3
|
createFileReadAfterHook,
|
|
4
4
|
createSystemPromptHook,
|
|
5
5
|
isFileReadTool
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-7KUPGN4M.js";
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_CONFIG,
|
|
9
9
|
DEFAULT_EXCLUDE_PATTERNS,
|
|
10
10
|
DEFAULT_PREFIX,
|
|
11
11
|
HashlineCache,
|
|
12
|
+
HashlineError,
|
|
12
13
|
applyHashEdit,
|
|
13
14
|
buildHashMap,
|
|
15
|
+
computeFileRev,
|
|
14
16
|
computeLineHash,
|
|
15
17
|
createHashline,
|
|
18
|
+
extractFileRev,
|
|
19
|
+
findCandidateLines,
|
|
16
20
|
formatFileWithHashes,
|
|
17
21
|
getAdaptiveHashLength,
|
|
18
22
|
getByteLength,
|
|
@@ -24,20 +28,25 @@ import {
|
|
|
24
28
|
resolveRange,
|
|
25
29
|
shouldExclude,
|
|
26
30
|
stripHashes,
|
|
31
|
+
verifyFileRev,
|
|
27
32
|
verifyHash
|
|
28
|
-
} from "./chunk-
|
|
33
|
+
} from "./chunk-DOR4YDIS.js";
|
|
29
34
|
export {
|
|
30
35
|
DEFAULT_CONFIG,
|
|
31
36
|
DEFAULT_EXCLUDE_PATTERNS,
|
|
32
37
|
DEFAULT_PREFIX,
|
|
33
38
|
HashlineCache,
|
|
39
|
+
HashlineError,
|
|
34
40
|
applyHashEdit,
|
|
35
41
|
buildHashMap,
|
|
42
|
+
computeFileRev,
|
|
36
43
|
computeLineHash,
|
|
37
44
|
createFileEditBeforeHook,
|
|
38
45
|
createFileReadAfterHook,
|
|
39
46
|
createHashline,
|
|
40
47
|
createSystemPromptHook,
|
|
48
|
+
extractFileRev,
|
|
49
|
+
findCandidateLines,
|
|
41
50
|
formatFileWithHashes,
|
|
42
51
|
getAdaptiveHashLength,
|
|
43
52
|
getByteLength,
|
|
@@ -50,5 +59,6 @@ export {
|
|
|
50
59
|
resolveRange,
|
|
51
60
|
shouldExclude,
|
|
52
61
|
stripHashes,
|
|
62
|
+
verifyFileRev,
|
|
53
63
|
verifyHash
|
|
54
64
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-hashline",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Hashline plugin for OpenCode — content-addressable line hashing for precise AI code editing",
|
|
5
5
|
"main": "dist/opencode-hashline.cjs",
|
|
6
6
|
"module": "dist/opencode-hashline.js",
|