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
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,33 +216,90 @@ 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);
|
|
170
265
|
const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
|
|
266
|
+
const hashLens = new Array(lines.length).fill(effectiveLen);
|
|
171
267
|
const hashes = new Array(lines.length);
|
|
172
|
-
const seen = /* @__PURE__ */ new Map();
|
|
173
|
-
const upgraded = /* @__PURE__ */ new Set();
|
|
174
268
|
for (let idx = 0; idx < lines.length; idx++) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
269
|
+
hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
|
|
270
|
+
}
|
|
271
|
+
let hasCollisions = true;
|
|
272
|
+
while (hasCollisions) {
|
|
273
|
+
hasCollisions = false;
|
|
274
|
+
const seen = /* @__PURE__ */ new Map();
|
|
275
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
276
|
+
const h = hashes[idx];
|
|
277
|
+
const group = seen.get(h);
|
|
278
|
+
if (group) {
|
|
279
|
+
group.push(idx);
|
|
280
|
+
} else {
|
|
281
|
+
seen.set(h, [idx]);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const [, group] of seen) {
|
|
285
|
+
if (group.length < 2) continue;
|
|
286
|
+
for (const idx of group) {
|
|
287
|
+
const newLen = Math.min(hashLens[idx] + 1, 8);
|
|
288
|
+
if (newLen === hashLens[idx]) continue;
|
|
289
|
+
hashLens[idx] = newLen;
|
|
290
|
+
hashes[idx] = computeLineHash(idx, lines[idx], newLen);
|
|
291
|
+
hasCollisions = true;
|
|
182
292
|
}
|
|
183
|
-
hashes[idx] = computeLineHash(idx, lines[idx], longerLen);
|
|
184
|
-
upgraded.add(idx);
|
|
185
|
-
} else {
|
|
186
|
-
seen.set(hash, idx);
|
|
187
|
-
hashes[idx] = hash;
|
|
188
293
|
}
|
|
189
294
|
}
|
|
190
|
-
|
|
295
|
+
const annotatedLines = lines.map((line, idx) => {
|
|
191
296
|
return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
|
|
192
|
-
})
|
|
297
|
+
});
|
|
298
|
+
if (includeFileRev) {
|
|
299
|
+
const rev = computeFileRev(content);
|
|
300
|
+
annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
|
|
301
|
+
}
|
|
302
|
+
return annotatedLines.join("\n");
|
|
193
303
|
}
|
|
194
304
|
var stripRegexCache = /* @__PURE__ */ new Map();
|
|
195
305
|
function stripHashes(content, prefix) {
|
|
@@ -200,9 +310,10 @@ function stripHashes(content, prefix) {
|
|
|
200
310
|
hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
|
|
201
311
|
stripRegexCache.set(escapedPrefix, hashLinePattern);
|
|
202
312
|
}
|
|
313
|
+
const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
|
|
203
314
|
const lineEnding = detectLineEnding(content);
|
|
204
315
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
205
|
-
const result = normalized.split("\n").map((line) => {
|
|
316
|
+
const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
|
|
206
317
|
const match = line.match(hashLinePattern);
|
|
207
318
|
if (match) {
|
|
208
319
|
const patchMarker = match[1] || "";
|
|
@@ -216,7 +327,10 @@ function parseHashRef(ref) {
|
|
|
216
327
|
const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
|
|
217
328
|
if (!match) {
|
|
218
329
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
219
|
-
throw new
|
|
330
|
+
throw new HashlineError({
|
|
331
|
+
code: "INVALID_REF",
|
|
332
|
+
message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
|
|
333
|
+
});
|
|
220
334
|
}
|
|
221
335
|
return {
|
|
222
336
|
line: parseInt(match[1], 10),
|
|
@@ -234,9 +348,10 @@ function normalizeHashRef(ref) {
|
|
|
234
348
|
return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
|
|
235
349
|
}
|
|
236
350
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
237
|
-
throw new
|
|
238
|
-
|
|
239
|
-
|
|
351
|
+
throw new HashlineError({
|
|
352
|
+
code: "INVALID_REF",
|
|
353
|
+
message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
|
|
354
|
+
});
|
|
240
355
|
}
|
|
241
356
|
function buildHashMap(content, hashLen) {
|
|
242
357
|
const lines = content.split("\n");
|
|
@@ -249,50 +364,97 @@ function buildHashMap(content, hashLen) {
|
|
|
249
364
|
}
|
|
250
365
|
return map;
|
|
251
366
|
}
|
|
252
|
-
function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
|
|
367
|
+
function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
|
|
253
368
|
const contentLines = lines ?? currentContent.split("\n");
|
|
254
369
|
const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
|
|
255
370
|
if (lineNumber < 1 || lineNumber > contentLines.length) {
|
|
256
371
|
return {
|
|
257
372
|
valid: false,
|
|
373
|
+
code: "TARGET_OUT_OF_RANGE",
|
|
258
374
|
message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
|
|
259
375
|
};
|
|
260
376
|
}
|
|
261
377
|
const idx = lineNumber - 1;
|
|
262
378
|
const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
|
|
263
379
|
if (actualHash !== hash) {
|
|
380
|
+
const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
|
|
381
|
+
if (safeReapply && candidates.length === 1) {
|
|
382
|
+
return {
|
|
383
|
+
valid: true,
|
|
384
|
+
relocatedLine: candidates[0].lineNumber,
|
|
385
|
+
candidates
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (safeReapply && candidates.length > 1) {
|
|
389
|
+
return {
|
|
390
|
+
valid: false,
|
|
391
|
+
code: "AMBIGUOUS_REAPPLY",
|
|
392
|
+
expected: hash,
|
|
393
|
+
actual: actualHash,
|
|
394
|
+
candidates,
|
|
395
|
+
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
|
|
396
|
+
};
|
|
397
|
+
}
|
|
264
398
|
return {
|
|
265
399
|
valid: false,
|
|
400
|
+
code: "HASH_MISMATCH",
|
|
266
401
|
expected: hash,
|
|
267
402
|
actual: actualHash,
|
|
403
|
+
candidates,
|
|
268
404
|
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
|
|
269
405
|
};
|
|
270
406
|
}
|
|
271
407
|
return { valid: true };
|
|
272
408
|
}
|
|
273
|
-
function resolveRange(startRef, endRef, content, hashLen) {
|
|
409
|
+
function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
|
|
274
410
|
const start = parseHashRef(startRef);
|
|
275
411
|
const end = parseHashRef(endRef);
|
|
276
412
|
if (start.line > end.line) {
|
|
277
|
-
throw new
|
|
278
|
-
|
|
279
|
-
|
|
413
|
+
throw new HashlineError({
|
|
414
|
+
code: "INVALID_RANGE",
|
|
415
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
416
|
+
});
|
|
280
417
|
}
|
|
281
418
|
const lineEnding = detectLineEnding(content);
|
|
282
419
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
283
420
|
const lines = normalized.split("\n");
|
|
284
|
-
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
|
|
421
|
+
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
|
|
285
422
|
if (!startVerify.valid) {
|
|
286
|
-
throw new
|
|
423
|
+
throw new HashlineError({
|
|
424
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
425
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
426
|
+
expected: startVerify.expected,
|
|
427
|
+
actual: startVerify.actual,
|
|
428
|
+
candidates: startVerify.candidates,
|
|
429
|
+
lineNumber: start.line,
|
|
430
|
+
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."
|
|
431
|
+
});
|
|
287
432
|
}
|
|
288
|
-
const
|
|
433
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
434
|
+
const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
|
|
289
435
|
if (!endVerify.valid) {
|
|
290
|
-
throw new
|
|
436
|
+
throw new HashlineError({
|
|
437
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
438
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
439
|
+
expected: endVerify.expected,
|
|
440
|
+
actual: endVerify.actual,
|
|
441
|
+
candidates: endVerify.candidates,
|
|
442
|
+
lineNumber: end.line,
|
|
443
|
+
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."
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
447
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
448
|
+
throw new HashlineError({
|
|
449
|
+
code: "INVALID_RANGE",
|
|
450
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
451
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
452
|
+
});
|
|
291
453
|
}
|
|
292
|
-
const rangeLines = lines.slice(
|
|
454
|
+
const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
|
|
293
455
|
return {
|
|
294
|
-
startLine:
|
|
295
|
-
endLine:
|
|
456
|
+
startLine: effectiveStartLine,
|
|
457
|
+
endLine: effectiveEndLine,
|
|
296
458
|
lines: rangeLines,
|
|
297
459
|
content: rangeLines.join(lineEnding)
|
|
298
460
|
};
|
|
@@ -308,22 +470,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
|
|
|
308
470
|
const result = [...before, ...replacementLines, ...after].join("\n");
|
|
309
471
|
return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
|
|
310
472
|
}
|
|
311
|
-
function applyHashEdit(input, content, hashLen) {
|
|
473
|
+
function applyHashEdit(input, content, hashLen, safeReapply) {
|
|
312
474
|
const lineEnding = detectLineEnding(content);
|
|
313
475
|
const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
476
|
+
if (input.fileRev) {
|
|
477
|
+
verifyFileRev(input.fileRev, workContent);
|
|
478
|
+
}
|
|
314
479
|
const normalizedStart = normalizeHashRef(input.startRef);
|
|
315
480
|
const start = parseHashRef(normalizedStart);
|
|
316
481
|
const lines = workContent.split("\n");
|
|
317
|
-
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
|
|
482
|
+
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
|
|
318
483
|
if (!startVerify.valid) {
|
|
319
|
-
throw new
|
|
484
|
+
throw new HashlineError({
|
|
485
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
486
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
487
|
+
expected: startVerify.expected,
|
|
488
|
+
actual: startVerify.actual,
|
|
489
|
+
candidates: startVerify.candidates,
|
|
490
|
+
lineNumber: start.line,
|
|
491
|
+
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."
|
|
492
|
+
});
|
|
320
493
|
}
|
|
494
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
321
495
|
if (input.operation === "insert_before" || input.operation === "insert_after") {
|
|
322
496
|
if (input.replacement === void 0) {
|
|
323
|
-
throw new
|
|
497
|
+
throw new HashlineError({
|
|
498
|
+
code: "MISSING_REPLACEMENT",
|
|
499
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
500
|
+
});
|
|
324
501
|
}
|
|
325
502
|
const insertionLines = input.replacement.split("\n");
|
|
326
|
-
const insertIndex = input.operation === "insert_before" ?
|
|
503
|
+
const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
|
|
327
504
|
const next2 = [
|
|
328
505
|
...lines.slice(0, insertIndex),
|
|
329
506
|
...insertionLines,
|
|
@@ -331,34 +508,54 @@ function applyHashEdit(input, content, hashLen) {
|
|
|
331
508
|
].join("\n");
|
|
332
509
|
return {
|
|
333
510
|
operation: input.operation,
|
|
334
|
-
startLine:
|
|
335
|
-
endLine:
|
|
511
|
+
startLine: effectiveStartLine,
|
|
512
|
+
endLine: effectiveStartLine,
|
|
336
513
|
content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
|
|
337
514
|
};
|
|
338
515
|
}
|
|
339
516
|
const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
|
|
340
517
|
const end = parseHashRef(normalizedEnd);
|
|
341
518
|
if (start.line > end.line) {
|
|
342
|
-
throw new
|
|
343
|
-
|
|
344
|
-
|
|
519
|
+
throw new HashlineError({
|
|
520
|
+
code: "INVALID_RANGE",
|
|
521
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
522
|
+
});
|
|
345
523
|
}
|
|
346
|
-
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
|
|
524
|
+
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
|
|
347
525
|
if (!endVerify.valid) {
|
|
348
|
-
throw new
|
|
526
|
+
throw new HashlineError({
|
|
527
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
528
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
529
|
+
expected: endVerify.expected,
|
|
530
|
+
actual: endVerify.actual,
|
|
531
|
+
candidates: endVerify.candidates,
|
|
532
|
+
lineNumber: end.line,
|
|
533
|
+
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."
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
537
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
538
|
+
throw new HashlineError({
|
|
539
|
+
code: "INVALID_RANGE",
|
|
540
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
541
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
542
|
+
});
|
|
349
543
|
}
|
|
350
544
|
const replacement = input.operation === "delete" ? "" : input.replacement;
|
|
351
545
|
if (replacement === void 0) {
|
|
352
|
-
throw new
|
|
546
|
+
throw new HashlineError({
|
|
547
|
+
code: "MISSING_REPLACEMENT",
|
|
548
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
549
|
+
});
|
|
353
550
|
}
|
|
354
|
-
const before = lines.slice(0,
|
|
355
|
-
const after = lines.slice(
|
|
551
|
+
const before = lines.slice(0, effectiveStartLine - 1);
|
|
552
|
+
const after = lines.slice(effectiveEndLine);
|
|
356
553
|
const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
|
|
357
554
|
const next = [...before, ...replacementLines, ...after].join("\n");
|
|
358
555
|
return {
|
|
359
556
|
operation: input.operation,
|
|
360
|
-
startLine:
|
|
361
|
-
endLine:
|
|
557
|
+
startLine: effectiveStartLine,
|
|
558
|
+
endLine: effectiveEndLine,
|
|
362
559
|
content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
|
|
363
560
|
};
|
|
364
561
|
}
|
|
@@ -455,7 +652,7 @@ function createHashline(config) {
|
|
|
455
652
|
const cached = cache.get(filePath, content);
|
|
456
653
|
if (cached) return cached;
|
|
457
654
|
}
|
|
458
|
-
const result = formatFileWithHashes(content, hl, pfx);
|
|
655
|
+
const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
|
|
459
656
|
if (filePath) {
|
|
460
657
|
cache.set(filePath, content, result);
|
|
461
658
|
}
|
|
@@ -471,16 +668,16 @@ function createHashline(config) {
|
|
|
471
668
|
return buildHashMap(content, hl);
|
|
472
669
|
},
|
|
473
670
|
verifyHash(lineNumber, hash, currentContent) {
|
|
474
|
-
return verifyHash(lineNumber, hash, currentContent, hl);
|
|
671
|
+
return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
|
|
475
672
|
},
|
|
476
673
|
resolveRange(startRef, endRef, content) {
|
|
477
|
-
return resolveRange(startRef, endRef, content, hl);
|
|
674
|
+
return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
|
|
478
675
|
},
|
|
479
676
|
replaceRange(startRef, endRef, content, replacement) {
|
|
480
677
|
return replaceRange(startRef, endRef, content, replacement, hl);
|
|
481
678
|
},
|
|
482
679
|
applyHashEdit(input, content) {
|
|
483
|
-
return applyHashEdit(input, content, hl);
|
|
680
|
+
return applyHashEdit(input, content, hl, resolved.safeReapply);
|
|
484
681
|
},
|
|
485
682
|
normalizeHashRef(ref) {
|
|
486
683
|
return normalizeHashRef(ref);
|
|
@@ -490,6 +687,18 @@ function createHashline(config) {
|
|
|
490
687
|
},
|
|
491
688
|
shouldExclude(filePath) {
|
|
492
689
|
return shouldExclude(filePath, resolved.exclude);
|
|
690
|
+
},
|
|
691
|
+
computeFileRev(content) {
|
|
692
|
+
return computeFileRev(content);
|
|
693
|
+
},
|
|
694
|
+
verifyFileRev(expectedRev, currentContent) {
|
|
695
|
+
return verifyFileRev(expectedRev, currentContent);
|
|
696
|
+
},
|
|
697
|
+
extractFileRev(annotatedContent) {
|
|
698
|
+
return extractFileRev(annotatedContent, pfx);
|
|
699
|
+
},
|
|
700
|
+
findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
|
|
701
|
+
return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
|
|
493
702
|
}
|
|
494
703
|
};
|
|
495
704
|
}
|
|
@@ -579,7 +788,7 @@ function createFileReadAfterHook(cache, config) {
|
|
|
579
788
|
return;
|
|
580
789
|
}
|
|
581
790
|
}
|
|
582
|
-
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
|
|
791
|
+
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, resolved.fileRev);
|
|
583
792
|
output.output = annotated;
|
|
584
793
|
debug("annotated", typeof filePath === "string" ? filePath : input.tool, "lines:", content.split("\n").length);
|
|
585
794
|
if (cache && typeof filePath === "string") {
|
|
@@ -691,10 +900,32 @@ function createSystemPromptHook(config) {
|
|
|
691
900
|
'- Hash references include both the line number AND the content hash, so `2:f1c` means "line 2 with hash f1c".',
|
|
692
901
|
"- If you see a mismatch, do NOT proceed with the edit \u2014 re-read the file to get fresh references.",
|
|
693
902
|
"",
|
|
903
|
+
"### File revision (`#HL REV:<hash>`):",
|
|
904
|
+
"- When files are read, the first line may contain a file revision header: `" + prefix + "REV:<8-char-hex>`.",
|
|
905
|
+
"- 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.",
|
|
906
|
+
"- If the file was modified between read and edit, the revision check fails with `FILE_REV_MISMATCH` \u2014 re-read the file.",
|
|
907
|
+
"",
|
|
908
|
+
"### Safe reapply (`safeReapply`):",
|
|
909
|
+
"- Pass `safeReapply: true` to `hashline_edit` to enable automatic line relocation.",
|
|
910
|
+
"- If a line moved (e.g., due to insertions above), safe reapply finds it by content hash.",
|
|
911
|
+
"- If exactly one match is found, the edit proceeds at the new location.",
|
|
912
|
+
"- If multiple matches exist, the edit fails with `AMBIGUOUS_REAPPLY` \u2014 re-read the file.",
|
|
913
|
+
"",
|
|
914
|
+
"### Structured error codes:",
|
|
915
|
+
"- `HASH_MISMATCH` \u2014 line content changed since last read",
|
|
916
|
+
"- `FILE_REV_MISMATCH` \u2014 file was modified since last read",
|
|
917
|
+
"- `AMBIGUOUS_REAPPLY` \u2014 multiple candidate lines found during safe reapply",
|
|
918
|
+
"- `TARGET_OUT_OF_RANGE` \u2014 line number exceeds file length",
|
|
919
|
+
"- `INVALID_REF` \u2014 malformed hash reference",
|
|
920
|
+
"- `INVALID_RANGE` \u2014 start line is after end line",
|
|
921
|
+
"- `MISSING_REPLACEMENT` \u2014 replace/insert operation without replacement content",
|
|
922
|
+
"",
|
|
694
923
|
"### Best practices:",
|
|
695
924
|
"- Use hash references for all edit operations to ensure precision.",
|
|
696
925
|
"- 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."
|
|
926
|
+
"- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines.",
|
|
927
|
+
"- Use `fileRev` to guard against stale edits on critical files.",
|
|
928
|
+
"- Use `safeReapply: true` when editing files that may have shifted due to earlier edits."
|
|
698
929
|
].join("\n")
|
|
699
930
|
);
|
|
700
931
|
};
|
|
@@ -705,13 +936,17 @@ function createSystemPromptHook(config) {
|
|
|
705
936
|
DEFAULT_EXCLUDE_PATTERNS,
|
|
706
937
|
DEFAULT_PREFIX,
|
|
707
938
|
HashlineCache,
|
|
939
|
+
HashlineError,
|
|
708
940
|
applyHashEdit,
|
|
709
941
|
buildHashMap,
|
|
942
|
+
computeFileRev,
|
|
710
943
|
computeLineHash,
|
|
711
944
|
createFileEditBeforeHook,
|
|
712
945
|
createFileReadAfterHook,
|
|
713
946
|
createHashline,
|
|
714
947
|
createSystemPromptHook,
|
|
948
|
+
extractFileRev,
|
|
949
|
+
findCandidateLines,
|
|
715
950
|
formatFileWithHashes,
|
|
716
951
|
getAdaptiveHashLength,
|
|
717
952
|
getByteLength,
|
|
@@ -724,5 +959,6 @@ function createSystemPromptHook(config) {
|
|
|
724
959
|
resolveRange,
|
|
725
960
|
shouldExclude,
|
|
726
961
|
stripHashes,
|
|
962
|
+
verifyFileRev,
|
|
727
963
|
verifyHash
|
|
728
964
|
});
|
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-VSVVWPET.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-GKXY5ZBM.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.1",
|
|
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",
|