opencode-hashline 1.1.3 → 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 +67 -11
- package/README.md +67 -11
- 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
|
@@ -37,11 +37,15 @@ __export(hashline_exports, {
|
|
|
37
37
|
DEFAULT_EXCLUDE_PATTERNS: () => DEFAULT_EXCLUDE_PATTERNS,
|
|
38
38
|
DEFAULT_PREFIX: () => DEFAULT_PREFIX,
|
|
39
39
|
HashlineCache: () => HashlineCache,
|
|
40
|
+
HashlineError: () => HashlineError,
|
|
40
41
|
applyHashEdit: () => applyHashEdit,
|
|
41
42
|
buildHashMap: () => buildHashMap,
|
|
43
|
+
computeFileRev: () => computeFileRev,
|
|
42
44
|
computeLineHash: () => computeLineHash,
|
|
43
45
|
createHashline: () => createHashline,
|
|
44
46
|
detectLineEnding: () => detectLineEnding,
|
|
47
|
+
extractFileRev: () => extractFileRev,
|
|
48
|
+
findCandidateLines: () => findCandidateLines,
|
|
45
49
|
formatFileWithHashes: () => formatFileWithHashes,
|
|
46
50
|
getAdaptiveHashLength: () => getAdaptiveHashLength,
|
|
47
51
|
getByteLength: () => getByteLength,
|
|
@@ -53,6 +57,7 @@ __export(hashline_exports, {
|
|
|
53
57
|
resolveRange: () => resolveRange,
|
|
54
58
|
shouldExclude: () => shouldExclude,
|
|
55
59
|
stripHashes: () => stripHashes,
|
|
60
|
+
verifyFileRev: () => verifyFileRev,
|
|
56
61
|
verifyHash: () => verifyHash
|
|
57
62
|
});
|
|
58
63
|
function resolveConfig(config, pluginConfig) {
|
|
@@ -69,7 +74,9 @@ function resolveConfig(config, pluginConfig) {
|
|
|
69
74
|
hashLength: merged.hashLength ?? DEFAULT_CONFIG.hashLength,
|
|
70
75
|
cacheSize: merged.cacheSize ?? DEFAULT_CONFIG.cacheSize,
|
|
71
76
|
prefix: merged.prefix !== void 0 ? merged.prefix : DEFAULT_CONFIG.prefix,
|
|
72
|
-
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
|
|
73
80
|
};
|
|
74
81
|
}
|
|
75
82
|
function fnv1aHash(str) {
|
|
@@ -100,7 +107,49 @@ function computeLineHash(idx, line, hashLen = 3) {
|
|
|
100
107
|
const hash = raw % modulus;
|
|
101
108
|
return hash.toString(16).padStart(hashLen, "0");
|
|
102
109
|
}
|
|
103
|
-
function
|
|
110
|
+
function computeFileRev(content) {
|
|
111
|
+
const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
|
|
112
|
+
const hash = fnv1aHash(normalized);
|
|
113
|
+
return hash.toString(16).padStart(8, "0");
|
|
114
|
+
}
|
|
115
|
+
function extractFileRev(annotatedContent, prefix) {
|
|
116
|
+
const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
|
|
117
|
+
const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
118
|
+
const pattern = new RegExp(`^${escapedPrefix}REV:([0-9a-f]{8})$`);
|
|
119
|
+
const firstLine = annotatedContent.split("\n")[0];
|
|
120
|
+
const match = firstLine.match(pattern);
|
|
121
|
+
return match ? match[1] : null;
|
|
122
|
+
}
|
|
123
|
+
function verifyFileRev(expectedRev, currentContent) {
|
|
124
|
+
const actualRev = computeFileRev(currentContent);
|
|
125
|
+
if (actualRev !== expectedRev) {
|
|
126
|
+
throw new HashlineError({
|
|
127
|
+
code: "FILE_REV_MISMATCH",
|
|
128
|
+
message: `File revision mismatch: expected "${expectedRev}", got "${actualRev}". The file has changed since it was last read.`,
|
|
129
|
+
expected: expectedRev,
|
|
130
|
+
actual: actualRev,
|
|
131
|
+
hint: "Re-read the file to get fresh hash references and a new file revision."
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
|
|
136
|
+
const effectiveLen = hashLen && hashLen >= 2 ? hashLen : expectedHash.length;
|
|
137
|
+
const originalIdx = originalLineNumber - 1;
|
|
138
|
+
const candidates = [];
|
|
139
|
+
for (let i = 0; i < lines.length; i++) {
|
|
140
|
+
if (i === originalIdx) continue;
|
|
141
|
+
const candidateHash = computeLineHash(originalIdx, lines[i], effectiveLen);
|
|
142
|
+
if (candidateHash === expectedHash) {
|
|
143
|
+
candidates.push({
|
|
144
|
+
lineNumber: i + 1,
|
|
145
|
+
// 1-based
|
|
146
|
+
content: lines[i]
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return candidates;
|
|
151
|
+
}
|
|
152
|
+
function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
|
|
104
153
|
const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
|
|
105
154
|
const lines = normalized.split("\n");
|
|
106
155
|
const effectiveLen = hashLen && hashLen >= 3 ? hashLen : getAdaptiveHashLength(lines.length);
|
|
@@ -124,9 +173,14 @@ function formatFileWithHashes(content, hashLen, prefix) {
|
|
|
124
173
|
hashes[idx] = hash;
|
|
125
174
|
}
|
|
126
175
|
}
|
|
127
|
-
|
|
176
|
+
const annotatedLines = lines.map((line, idx) => {
|
|
128
177
|
return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
|
|
129
|
-
})
|
|
178
|
+
});
|
|
179
|
+
if (includeFileRev) {
|
|
180
|
+
const rev = computeFileRev(content);
|
|
181
|
+
annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
|
|
182
|
+
}
|
|
183
|
+
return annotatedLines.join("\n");
|
|
130
184
|
}
|
|
131
185
|
function stripHashes(content, prefix) {
|
|
132
186
|
const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
|
|
@@ -136,9 +190,10 @@ function stripHashes(content, prefix) {
|
|
|
136
190
|
hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
|
|
137
191
|
stripRegexCache.set(escapedPrefix, hashLinePattern);
|
|
138
192
|
}
|
|
193
|
+
const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
|
|
139
194
|
const lineEnding = detectLineEnding(content);
|
|
140
195
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
141
|
-
const result = normalized.split("\n").map((line) => {
|
|
196
|
+
const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
|
|
142
197
|
const match = line.match(hashLinePattern);
|
|
143
198
|
if (match) {
|
|
144
199
|
const patchMarker = match[1] || "";
|
|
@@ -152,7 +207,10 @@ function parseHashRef(ref) {
|
|
|
152
207
|
const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
|
|
153
208
|
if (!match) {
|
|
154
209
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
155
|
-
throw new
|
|
210
|
+
throw new HashlineError({
|
|
211
|
+
code: "INVALID_REF",
|
|
212
|
+
message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
|
|
213
|
+
});
|
|
156
214
|
}
|
|
157
215
|
return {
|
|
158
216
|
line: parseInt(match[1], 10),
|
|
@@ -170,9 +228,10 @@ function normalizeHashRef(ref) {
|
|
|
170
228
|
return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
|
|
171
229
|
}
|
|
172
230
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
173
|
-
throw new
|
|
174
|
-
|
|
175
|
-
|
|
231
|
+
throw new HashlineError({
|
|
232
|
+
code: "INVALID_REF",
|
|
233
|
+
message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
|
|
234
|
+
});
|
|
176
235
|
}
|
|
177
236
|
function buildHashMap(content, hashLen) {
|
|
178
237
|
const lines = content.split("\n");
|
|
@@ -185,50 +244,97 @@ function buildHashMap(content, hashLen) {
|
|
|
185
244
|
}
|
|
186
245
|
return map;
|
|
187
246
|
}
|
|
188
|
-
function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
|
|
247
|
+
function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
|
|
189
248
|
const contentLines = lines ?? currentContent.split("\n");
|
|
190
249
|
const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
|
|
191
250
|
if (lineNumber < 1 || lineNumber > contentLines.length) {
|
|
192
251
|
return {
|
|
193
252
|
valid: false,
|
|
253
|
+
code: "TARGET_OUT_OF_RANGE",
|
|
194
254
|
message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
|
|
195
255
|
};
|
|
196
256
|
}
|
|
197
257
|
const idx = lineNumber - 1;
|
|
198
258
|
const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
|
|
199
259
|
if (actualHash !== hash) {
|
|
260
|
+
const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
|
|
261
|
+
if (safeReapply && candidates.length === 1) {
|
|
262
|
+
return {
|
|
263
|
+
valid: true,
|
|
264
|
+
relocatedLine: candidates[0].lineNumber,
|
|
265
|
+
candidates
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (safeReapply && candidates.length > 1) {
|
|
269
|
+
return {
|
|
270
|
+
valid: false,
|
|
271
|
+
code: "AMBIGUOUS_REAPPLY",
|
|
272
|
+
expected: hash,
|
|
273
|
+
actual: actualHash,
|
|
274
|
+
candidates,
|
|
275
|
+
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
|
|
276
|
+
};
|
|
277
|
+
}
|
|
200
278
|
return {
|
|
201
279
|
valid: false,
|
|
280
|
+
code: "HASH_MISMATCH",
|
|
202
281
|
expected: hash,
|
|
203
282
|
actual: actualHash,
|
|
283
|
+
candidates,
|
|
204
284
|
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
|
|
205
285
|
};
|
|
206
286
|
}
|
|
207
287
|
return { valid: true };
|
|
208
288
|
}
|
|
209
|
-
function resolveRange(startRef, endRef, content, hashLen) {
|
|
289
|
+
function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
|
|
210
290
|
const start = parseHashRef(startRef);
|
|
211
291
|
const end = parseHashRef(endRef);
|
|
212
292
|
if (start.line > end.line) {
|
|
213
|
-
throw new
|
|
214
|
-
|
|
215
|
-
|
|
293
|
+
throw new HashlineError({
|
|
294
|
+
code: "INVALID_RANGE",
|
|
295
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
296
|
+
});
|
|
216
297
|
}
|
|
217
298
|
const lineEnding = detectLineEnding(content);
|
|
218
299
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
219
300
|
const lines = normalized.split("\n");
|
|
220
|
-
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
|
|
301
|
+
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
|
|
221
302
|
if (!startVerify.valid) {
|
|
222
|
-
throw new
|
|
303
|
+
throw new HashlineError({
|
|
304
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
305
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
306
|
+
expected: startVerify.expected,
|
|
307
|
+
actual: startVerify.actual,
|
|
308
|
+
candidates: startVerify.candidates,
|
|
309
|
+
lineNumber: start.line,
|
|
310
|
+
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."
|
|
311
|
+
});
|
|
223
312
|
}
|
|
224
|
-
const
|
|
313
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
314
|
+
const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
|
|
225
315
|
if (!endVerify.valid) {
|
|
226
|
-
throw new
|
|
316
|
+
throw new HashlineError({
|
|
317
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
318
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
319
|
+
expected: endVerify.expected,
|
|
320
|
+
actual: endVerify.actual,
|
|
321
|
+
candidates: endVerify.candidates,
|
|
322
|
+
lineNumber: end.line,
|
|
323
|
+
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."
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
327
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
328
|
+
throw new HashlineError({
|
|
329
|
+
code: "INVALID_RANGE",
|
|
330
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
331
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
332
|
+
});
|
|
227
333
|
}
|
|
228
|
-
const rangeLines = lines.slice(
|
|
334
|
+
const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
|
|
229
335
|
return {
|
|
230
|
-
startLine:
|
|
231
|
-
endLine:
|
|
336
|
+
startLine: effectiveStartLine,
|
|
337
|
+
endLine: effectiveEndLine,
|
|
232
338
|
lines: rangeLines,
|
|
233
339
|
content: rangeLines.join(lineEnding)
|
|
234
340
|
};
|
|
@@ -244,22 +350,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
|
|
|
244
350
|
const result = [...before, ...replacementLines, ...after].join("\n");
|
|
245
351
|
return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
|
|
246
352
|
}
|
|
247
|
-
function applyHashEdit(input, content, hashLen) {
|
|
353
|
+
function applyHashEdit(input, content, hashLen, safeReapply) {
|
|
248
354
|
const lineEnding = detectLineEnding(content);
|
|
249
355
|
const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
356
|
+
if (input.fileRev) {
|
|
357
|
+
verifyFileRev(input.fileRev, workContent);
|
|
358
|
+
}
|
|
250
359
|
const normalizedStart = normalizeHashRef(input.startRef);
|
|
251
360
|
const start = parseHashRef(normalizedStart);
|
|
252
361
|
const lines = workContent.split("\n");
|
|
253
|
-
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
|
|
362
|
+
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
|
|
254
363
|
if (!startVerify.valid) {
|
|
255
|
-
throw new
|
|
364
|
+
throw new HashlineError({
|
|
365
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
366
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
367
|
+
expected: startVerify.expected,
|
|
368
|
+
actual: startVerify.actual,
|
|
369
|
+
candidates: startVerify.candidates,
|
|
370
|
+
lineNumber: start.line,
|
|
371
|
+
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."
|
|
372
|
+
});
|
|
256
373
|
}
|
|
374
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
257
375
|
if (input.operation === "insert_before" || input.operation === "insert_after") {
|
|
258
376
|
if (input.replacement === void 0) {
|
|
259
|
-
throw new
|
|
377
|
+
throw new HashlineError({
|
|
378
|
+
code: "MISSING_REPLACEMENT",
|
|
379
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
380
|
+
});
|
|
260
381
|
}
|
|
261
382
|
const insertionLines = input.replacement.split("\n");
|
|
262
|
-
const insertIndex = input.operation === "insert_before" ?
|
|
383
|
+
const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
|
|
263
384
|
const next2 = [
|
|
264
385
|
...lines.slice(0, insertIndex),
|
|
265
386
|
...insertionLines,
|
|
@@ -267,34 +388,54 @@ function applyHashEdit(input, content, hashLen) {
|
|
|
267
388
|
].join("\n");
|
|
268
389
|
return {
|
|
269
390
|
operation: input.operation,
|
|
270
|
-
startLine:
|
|
271
|
-
endLine:
|
|
391
|
+
startLine: effectiveStartLine,
|
|
392
|
+
endLine: effectiveStartLine,
|
|
272
393
|
content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
|
|
273
394
|
};
|
|
274
395
|
}
|
|
275
396
|
const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
|
|
276
397
|
const end = parseHashRef(normalizedEnd);
|
|
277
398
|
if (start.line > end.line) {
|
|
278
|
-
throw new
|
|
279
|
-
|
|
280
|
-
|
|
399
|
+
throw new HashlineError({
|
|
400
|
+
code: "INVALID_RANGE",
|
|
401
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
402
|
+
});
|
|
281
403
|
}
|
|
282
|
-
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
|
|
404
|
+
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
|
|
283
405
|
if (!endVerify.valid) {
|
|
284
|
-
throw new
|
|
406
|
+
throw new HashlineError({
|
|
407
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
408
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
409
|
+
expected: endVerify.expected,
|
|
410
|
+
actual: endVerify.actual,
|
|
411
|
+
candidates: endVerify.candidates,
|
|
412
|
+
lineNumber: end.line,
|
|
413
|
+
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."
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
417
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
418
|
+
throw new HashlineError({
|
|
419
|
+
code: "INVALID_RANGE",
|
|
420
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
421
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
422
|
+
});
|
|
285
423
|
}
|
|
286
424
|
const replacement = input.operation === "delete" ? "" : input.replacement;
|
|
287
425
|
if (replacement === void 0) {
|
|
288
|
-
throw new
|
|
426
|
+
throw new HashlineError({
|
|
427
|
+
code: "MISSING_REPLACEMENT",
|
|
428
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
429
|
+
});
|
|
289
430
|
}
|
|
290
|
-
const before = lines.slice(0,
|
|
291
|
-
const after = lines.slice(
|
|
431
|
+
const before = lines.slice(0, effectiveStartLine - 1);
|
|
432
|
+
const after = lines.slice(effectiveEndLine);
|
|
292
433
|
const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
|
|
293
434
|
const next = [...before, ...replacementLines, ...after].join("\n");
|
|
294
435
|
return {
|
|
295
436
|
operation: input.operation,
|
|
296
|
-
startLine:
|
|
297
|
-
endLine:
|
|
437
|
+
startLine: effectiveStartLine,
|
|
438
|
+
endLine: effectiveEndLine,
|
|
298
439
|
content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
|
|
299
440
|
};
|
|
300
441
|
}
|
|
@@ -330,7 +471,7 @@ function createHashline(config) {
|
|
|
330
471
|
const cached = cache.get(filePath, content);
|
|
331
472
|
if (cached) return cached;
|
|
332
473
|
}
|
|
333
|
-
const result = formatFileWithHashes(content, hl, pfx);
|
|
474
|
+
const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
|
|
334
475
|
if (filePath) {
|
|
335
476
|
cache.set(filePath, content, result);
|
|
336
477
|
}
|
|
@@ -346,16 +487,16 @@ function createHashline(config) {
|
|
|
346
487
|
return buildHashMap(content, hl);
|
|
347
488
|
},
|
|
348
489
|
verifyHash(lineNumber, hash, currentContent) {
|
|
349
|
-
return verifyHash(lineNumber, hash, currentContent, hl);
|
|
490
|
+
return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
|
|
350
491
|
},
|
|
351
492
|
resolveRange(startRef, endRef, content) {
|
|
352
|
-
return resolveRange(startRef, endRef, content, hl);
|
|
493
|
+
return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
|
|
353
494
|
},
|
|
354
495
|
replaceRange(startRef, endRef, content, replacement) {
|
|
355
496
|
return replaceRange(startRef, endRef, content, replacement, hl);
|
|
356
497
|
},
|
|
357
498
|
applyHashEdit(input, content) {
|
|
358
|
-
return applyHashEdit(input, content, hl);
|
|
499
|
+
return applyHashEdit(input, content, hl, resolved.safeReapply);
|
|
359
500
|
},
|
|
360
501
|
normalizeHashRef(ref) {
|
|
361
502
|
return normalizeHashRef(ref);
|
|
@@ -365,10 +506,22 @@ function createHashline(config) {
|
|
|
365
506
|
},
|
|
366
507
|
shouldExclude(filePath) {
|
|
367
508
|
return shouldExclude(filePath, resolved.exclude);
|
|
509
|
+
},
|
|
510
|
+
computeFileRev(content) {
|
|
511
|
+
return computeFileRev(content);
|
|
512
|
+
},
|
|
513
|
+
verifyFileRev(expectedRev, currentContent) {
|
|
514
|
+
return verifyFileRev(expectedRev, currentContent);
|
|
515
|
+
},
|
|
516
|
+
extractFileRev(annotatedContent) {
|
|
517
|
+
return extractFileRev(annotatedContent, pfx);
|
|
518
|
+
},
|
|
519
|
+
findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
|
|
520
|
+
return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
|
|
368
521
|
}
|
|
369
522
|
};
|
|
370
523
|
}
|
|
371
|
-
var import_picomatch, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_PREFIX, DEFAULT_CONFIG, modulusCache, stripRegexCache, HashlineCache, globMatcherCache, textEncoder;
|
|
524
|
+
var import_picomatch, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_PREFIX, DEFAULT_CONFIG, HashlineError, modulusCache, stripRegexCache, HashlineCache, globMatcherCache, textEncoder;
|
|
372
525
|
var init_hashline = __esm({
|
|
373
526
|
"src/hashline.ts"() {
|
|
374
527
|
"use strict";
|
|
@@ -429,7 +582,53 @@ var init_hashline = __esm({
|
|
|
429
582
|
// 0 = adaptive
|
|
430
583
|
cacheSize: 100,
|
|
431
584
|
prefix: DEFAULT_PREFIX,
|
|
432
|
-
debug: false
|
|
585
|
+
debug: false,
|
|
586
|
+
fileRev: true,
|
|
587
|
+
safeReapply: false
|
|
588
|
+
};
|
|
589
|
+
HashlineError = class extends Error {
|
|
590
|
+
code;
|
|
591
|
+
expected;
|
|
592
|
+
actual;
|
|
593
|
+
candidates;
|
|
594
|
+
hint;
|
|
595
|
+
lineNumber;
|
|
596
|
+
filePath;
|
|
597
|
+
constructor(opts) {
|
|
598
|
+
super(opts.message);
|
|
599
|
+
this.name = "HashlineError";
|
|
600
|
+
this.code = opts.code;
|
|
601
|
+
this.expected = opts.expected;
|
|
602
|
+
this.actual = opts.actual;
|
|
603
|
+
this.candidates = opts.candidates;
|
|
604
|
+
this.hint = opts.hint;
|
|
605
|
+
this.lineNumber = opts.lineNumber;
|
|
606
|
+
this.filePath = opts.filePath;
|
|
607
|
+
}
|
|
608
|
+
toDiagnostic() {
|
|
609
|
+
const parts = [`[${this.code}] ${this.message}`];
|
|
610
|
+
if (this.filePath) {
|
|
611
|
+
parts.push(` File: ${this.filePath}`);
|
|
612
|
+
}
|
|
613
|
+
if (this.lineNumber !== void 0) {
|
|
614
|
+
parts.push(` Line: ${this.lineNumber}`);
|
|
615
|
+
}
|
|
616
|
+
if (this.expected !== void 0 && this.actual !== void 0) {
|
|
617
|
+
parts.push(` Expected hash: ${this.expected}`);
|
|
618
|
+
parts.push(` Actual hash: ${this.actual}`);
|
|
619
|
+
}
|
|
620
|
+
if (this.candidates && this.candidates.length > 0) {
|
|
621
|
+
parts.push(` Candidates (${this.candidates.length}):`);
|
|
622
|
+
for (const c of this.candidates) {
|
|
623
|
+
const preview = c.content.length > 60 ? c.content.slice(0, 60) + "..." : c.content;
|
|
624
|
+
parts.push(` - line ${c.lineNumber}: ${preview}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (this.hint) {
|
|
628
|
+
parts.push(` Hint: ${this.hint}`);
|
|
629
|
+
}
|
|
630
|
+
return parts.join("\n");
|
|
631
|
+
}
|
|
433
632
|
};
|
|
434
633
|
modulusCache = /* @__PURE__ */ new Map();
|
|
435
634
|
stripRegexCache = /* @__PURE__ */ new Map();
|
|
@@ -599,7 +798,7 @@ function createFileReadAfterHook(cache, config) {
|
|
|
599
798
|
return;
|
|
600
799
|
}
|
|
601
800
|
}
|
|
602
|
-
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
|
|
801
|
+
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, resolved.fileRev);
|
|
603
802
|
output.output = annotated;
|
|
604
803
|
debug("annotated", typeof filePath === "string" ? filePath : input.tool, "lines:", content.split("\n").length);
|
|
605
804
|
if (cache && typeof filePath === "string") {
|
|
@@ -711,10 +910,32 @@ function createSystemPromptHook(config) {
|
|
|
711
910
|
'- Hash references include both the line number AND the content hash, so `2:f1c` means "line 2 with hash f1c".',
|
|
712
911
|
"- If you see a mismatch, do NOT proceed with the edit \u2014 re-read the file to get fresh references.",
|
|
713
912
|
"",
|
|
913
|
+
"### File revision (`#HL REV:<hash>`):",
|
|
914
|
+
"- When files are read, the first line may contain a file revision header: `" + prefix + "REV:<8-char-hex>`.",
|
|
915
|
+
"- 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.",
|
|
916
|
+
"- If the file was modified between read and edit, the revision check fails with `FILE_REV_MISMATCH` \u2014 re-read the file.",
|
|
917
|
+
"",
|
|
918
|
+
"### Safe reapply (`safeReapply`):",
|
|
919
|
+
"- Pass `safeReapply: true` to `hashline_edit` to enable automatic line relocation.",
|
|
920
|
+
"- If a line moved (e.g., due to insertions above), safe reapply finds it by content hash.",
|
|
921
|
+
"- If exactly one match is found, the edit proceeds at the new location.",
|
|
922
|
+
"- If multiple matches exist, the edit fails with `AMBIGUOUS_REAPPLY` \u2014 re-read the file.",
|
|
923
|
+
"",
|
|
924
|
+
"### Structured error codes:",
|
|
925
|
+
"- `HASH_MISMATCH` \u2014 line content changed since last read",
|
|
926
|
+
"- `FILE_REV_MISMATCH` \u2014 file was modified since last read",
|
|
927
|
+
"- `AMBIGUOUS_REAPPLY` \u2014 multiple candidate lines found during safe reapply",
|
|
928
|
+
"- `TARGET_OUT_OF_RANGE` \u2014 line number exceeds file length",
|
|
929
|
+
"- `INVALID_REF` \u2014 malformed hash reference",
|
|
930
|
+
"- `INVALID_RANGE` \u2014 start line is after end line",
|
|
931
|
+
"- `MISSING_REPLACEMENT` \u2014 replace/insert operation without replacement content",
|
|
932
|
+
"",
|
|
714
933
|
"### Best practices:",
|
|
715
934
|
"- Use hash references for all edit operations to ensure precision.",
|
|
716
935
|
"- When making multiple edits, work from bottom to top to avoid line number shifts.",
|
|
717
|
-
"- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines."
|
|
936
|
+
"- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines.",
|
|
937
|
+
"- Use `fileRev` to guard against stale edits on critical files.",
|
|
938
|
+
"- Use `safeReapply: true` when editing files that may have shifted due to earlier edits."
|
|
718
939
|
].join("\n")
|
|
719
940
|
);
|
|
720
941
|
};
|
|
@@ -736,10 +957,12 @@ function createHashlineEditTool(config, cache) {
|
|
|
736
957
|
operation: import_zod.z.enum(["replace", "delete", "insert_before", "insert_after"]).describe("Edit operation"),
|
|
737
958
|
startRef: import_zod.z.string().describe('Start hash reference, e.g. "5:a3f" or "#HL 5:a3f|const x = 1;"'),
|
|
738
959
|
endRef: import_zod.z.string().optional().describe("End hash reference for range operations. Defaults to startRef when omitted."),
|
|
739
|
-
replacement: import_zod.z.string().max(1e7).optional().describe("Replacement/inserted content. Required for replace/insert operations.")
|
|
960
|
+
replacement: import_zod.z.string().max(1e7).optional().describe("Replacement/inserted content. Required for replace/insert operations."),
|
|
961
|
+
fileRev: import_zod.z.string().optional().describe("File revision hash (8-char hex from #HL REV:<hash>). When provided, verifies the file hasn't changed before editing."),
|
|
962
|
+
safeReapply: import_zod.z.boolean().optional().describe("Enable safe reapply: if a line moved, attempt to find it by content hash. Fails on ambiguous matches.")
|
|
740
963
|
},
|
|
741
964
|
async execute(args, context) {
|
|
742
|
-
const { path, operation, startRef, endRef, replacement } = args;
|
|
965
|
+
const { path, operation, startRef, endRef, replacement, fileRev, safeReapply } = args;
|
|
743
966
|
const absPath = (0, import_path2.isAbsolute)(path) ? path : (0, import_path2.resolve)(context.directory, path);
|
|
744
967
|
const realDirectory = (0, import_fs2.realpathSync)((0, import_path2.resolve)(context.directory));
|
|
745
968
|
const realWorktree = (0, import_fs2.realpathSync)((0, import_path2.resolve)(context.worktree));
|
|
@@ -793,15 +1016,21 @@ function createHashlineEditTool(config, cache) {
|
|
|
793
1016
|
operation,
|
|
794
1017
|
startRef,
|
|
795
1018
|
endRef,
|
|
796
|
-
replacement
|
|
1019
|
+
replacement,
|
|
1020
|
+
fileRev
|
|
797
1021
|
},
|
|
798
1022
|
current,
|
|
799
|
-
config.hashLength || void 0
|
|
1023
|
+
config.hashLength || void 0,
|
|
1024
|
+
safeReapply ?? config.safeReapply
|
|
800
1025
|
);
|
|
801
1026
|
nextContent = result.content;
|
|
802
1027
|
startLine = result.startLine;
|
|
803
1028
|
endLine = result.endLine;
|
|
804
1029
|
} catch (error) {
|
|
1030
|
+
if (error instanceof HashlineError) {
|
|
1031
|
+
throw new Error(`Hashline edit failed for "${displayPath}":
|
|
1032
|
+
${error.toDiagnostic()}`);
|
|
1033
|
+
}
|
|
805
1034
|
const reason = error instanceof Error ? error.message : String(error);
|
|
806
1035
|
throw new Error(`Hashline edit failed for "${displayPath}": ${reason}`);
|
|
807
1036
|
}
|
|
@@ -866,6 +1095,12 @@ function sanitizeConfig(raw) {
|
|
|
866
1095
|
if (typeof r.debug === "boolean") {
|
|
867
1096
|
result.debug = r.debug;
|
|
868
1097
|
}
|
|
1098
|
+
if (typeof r.fileRev === "boolean") {
|
|
1099
|
+
result.fileRev = r.fileRev;
|
|
1100
|
+
}
|
|
1101
|
+
if (typeof r.safeReapply === "boolean") {
|
|
1102
|
+
result.safeReapply = r.safeReapply;
|
|
1103
|
+
}
|
|
869
1104
|
return result;
|
|
870
1105
|
}
|
|
871
1106
|
function loadConfigFile(filePath) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Plugin } from '@opencode-ai/plugin';
|
|
2
|
-
import { H as HashlineConfig } from './hashline-
|
|
3
|
-
export { a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-
|
|
2
|
+
import { H as HashlineConfig } from './hashline-A7k2yn3G.cjs';
|
|
3
|
+
export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-A7k2yn3G.cjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* opencode-hashline — Hashline plugin for OpenCode
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Plugin } from '@opencode-ai/plugin';
|
|
2
|
-
import { H as HashlineConfig } from './hashline-
|
|
3
|
-
export { a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-
|
|
2
|
+
import { H as HashlineConfig } from './hashline-A7k2yn3G.js';
|
|
3
|
+
export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-A7k2yn3G.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* opencode-hashline — Hashline plugin for OpenCode
|
|
@@ -3,13 +3,14 @@ import {
|
|
|
3
3
|
createFileReadAfterHook,
|
|
4
4
|
createSystemPromptHook,
|
|
5
5
|
setDebug
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-7KUPGN4M.js";
|
|
7
7
|
import {
|
|
8
8
|
HashlineCache,
|
|
9
|
+
HashlineError,
|
|
9
10
|
applyHashEdit,
|
|
10
11
|
getByteLength,
|
|
11
12
|
resolveConfig
|
|
12
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-DOR4YDIS.js";
|
|
13
14
|
|
|
14
15
|
// src/index.ts
|
|
15
16
|
import { readFileSync as readFileSync2, realpathSync as realpathSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
@@ -29,10 +30,12 @@ function createHashlineEditTool(config, cache) {
|
|
|
29
30
|
operation: z.enum(["replace", "delete", "insert_before", "insert_after"]).describe("Edit operation"),
|
|
30
31
|
startRef: z.string().describe('Start hash reference, e.g. "5:a3f" or "#HL 5:a3f|const x = 1;"'),
|
|
31
32
|
endRef: z.string().optional().describe("End hash reference for range operations. Defaults to startRef when omitted."),
|
|
32
|
-
replacement: z.string().max(1e7).optional().describe("Replacement/inserted content. Required for replace/insert operations.")
|
|
33
|
+
replacement: z.string().max(1e7).optional().describe("Replacement/inserted content. Required for replace/insert operations."),
|
|
34
|
+
fileRev: z.string().optional().describe("File revision hash (8-char hex from #HL REV:<hash>). When provided, verifies the file hasn't changed before editing."),
|
|
35
|
+
safeReapply: z.boolean().optional().describe("Enable safe reapply: if a line moved, attempt to find it by content hash. Fails on ambiguous matches.")
|
|
33
36
|
},
|
|
34
37
|
async execute(args, context) {
|
|
35
|
-
const { path, operation, startRef, endRef, replacement } = args;
|
|
38
|
+
const { path, operation, startRef, endRef, replacement, fileRev, safeReapply } = args;
|
|
36
39
|
const absPath = isAbsolute(path) ? path : resolve(context.directory, path);
|
|
37
40
|
const realDirectory = realpathSync(resolve(context.directory));
|
|
38
41
|
const realWorktree = realpathSync(resolve(context.worktree));
|
|
@@ -86,15 +89,21 @@ function createHashlineEditTool(config, cache) {
|
|
|
86
89
|
operation,
|
|
87
90
|
startRef,
|
|
88
91
|
endRef,
|
|
89
|
-
replacement
|
|
92
|
+
replacement,
|
|
93
|
+
fileRev
|
|
90
94
|
},
|
|
91
95
|
current,
|
|
92
|
-
config.hashLength || void 0
|
|
96
|
+
config.hashLength || void 0,
|
|
97
|
+
safeReapply ?? config.safeReapply
|
|
93
98
|
);
|
|
94
99
|
nextContent = result.content;
|
|
95
100
|
startLine = result.startLine;
|
|
96
101
|
endLine = result.endLine;
|
|
97
102
|
} catch (error) {
|
|
103
|
+
if (error instanceof HashlineError) {
|
|
104
|
+
throw new Error(`Hashline edit failed for "${displayPath}":
|
|
105
|
+
${error.toDiagnostic()}`);
|
|
106
|
+
}
|
|
98
107
|
const reason = error instanceof Error ? error.message : String(error);
|
|
99
108
|
throw new Error(`Hashline edit failed for "${displayPath}": ${reason}`);
|
|
100
109
|
}
|
|
@@ -159,6 +168,12 @@ function sanitizeConfig(raw) {
|
|
|
159
168
|
if (typeof r.debug === "boolean") {
|
|
160
169
|
result.debug = r.debug;
|
|
161
170
|
}
|
|
171
|
+
if (typeof r.fileRev === "boolean") {
|
|
172
|
+
result.fileRev = r.fileRev;
|
|
173
|
+
}
|
|
174
|
+
if (typeof r.safeReapply === "boolean") {
|
|
175
|
+
result.safeReapply = r.safeReapply;
|
|
176
|
+
}
|
|
162
177
|
return result;
|
|
163
178
|
}
|
|
164
179
|
function loadConfigFile(filePath) {
|
|
@@ -222,7 +237,7 @@ function createHashlinePlugin(userConfig) {
|
|
|
222
237
|
const out = output;
|
|
223
238
|
const hashLen = config.hashLength || 0;
|
|
224
239
|
const prefix = config.prefix;
|
|
225
|
-
const { formatFileWithHashes, shouldExclude, getByteLength: getByteLength2 } = await import("./hashline-
|
|
240
|
+
const { formatFileWithHashes, shouldExclude, getByteLength: getByteLength2 } = await import("./hashline-MGDEWZ77.js");
|
|
226
241
|
for (const p of out.parts ?? []) {
|
|
227
242
|
if (p.type !== "file") continue;
|
|
228
243
|
if (!p.url || !p.mime?.startsWith("text/")) continue;
|