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
|
@@ -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,33 +107,90 @@ 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);
|
|
107
156
|
const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
|
|
157
|
+
const hashLens = new Array(lines.length).fill(effectiveLen);
|
|
108
158
|
const hashes = new Array(lines.length);
|
|
109
|
-
const seen = /* @__PURE__ */ new Map();
|
|
110
|
-
const upgraded = /* @__PURE__ */ new Set();
|
|
111
159
|
for (let idx = 0; idx < lines.length; idx++) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
160
|
+
hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
|
|
161
|
+
}
|
|
162
|
+
let hasCollisions = true;
|
|
163
|
+
while (hasCollisions) {
|
|
164
|
+
hasCollisions = false;
|
|
165
|
+
const seen = /* @__PURE__ */ new Map();
|
|
166
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
167
|
+
const h = hashes[idx];
|
|
168
|
+
const group = seen.get(h);
|
|
169
|
+
if (group) {
|
|
170
|
+
group.push(idx);
|
|
171
|
+
} else {
|
|
172
|
+
seen.set(h, [idx]);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const [, group] of seen) {
|
|
176
|
+
if (group.length < 2) continue;
|
|
177
|
+
for (const idx of group) {
|
|
178
|
+
const newLen = Math.min(hashLens[idx] + 1, 8);
|
|
179
|
+
if (newLen === hashLens[idx]) continue;
|
|
180
|
+
hashLens[idx] = newLen;
|
|
181
|
+
hashes[idx] = computeLineHash(idx, lines[idx], newLen);
|
|
182
|
+
hasCollisions = true;
|
|
119
183
|
}
|
|
120
|
-
hashes[idx] = computeLineHash(idx, lines[idx], longerLen);
|
|
121
|
-
upgraded.add(idx);
|
|
122
|
-
} else {
|
|
123
|
-
seen.set(hash, idx);
|
|
124
|
-
hashes[idx] = hash;
|
|
125
184
|
}
|
|
126
185
|
}
|
|
127
|
-
|
|
186
|
+
const annotatedLines = lines.map((line, idx) => {
|
|
128
187
|
return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
|
|
129
|
-
})
|
|
188
|
+
});
|
|
189
|
+
if (includeFileRev) {
|
|
190
|
+
const rev = computeFileRev(content);
|
|
191
|
+
annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
|
|
192
|
+
}
|
|
193
|
+
return annotatedLines.join("\n");
|
|
130
194
|
}
|
|
131
195
|
function stripHashes(content, prefix) {
|
|
132
196
|
const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
|
|
@@ -136,9 +200,10 @@ function stripHashes(content, prefix) {
|
|
|
136
200
|
hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
|
|
137
201
|
stripRegexCache.set(escapedPrefix, hashLinePattern);
|
|
138
202
|
}
|
|
203
|
+
const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
|
|
139
204
|
const lineEnding = detectLineEnding(content);
|
|
140
205
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
141
|
-
const result = normalized.split("\n").map((line) => {
|
|
206
|
+
const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
|
|
142
207
|
const match = line.match(hashLinePattern);
|
|
143
208
|
if (match) {
|
|
144
209
|
const patchMarker = match[1] || "";
|
|
@@ -152,7 +217,10 @@ function parseHashRef(ref) {
|
|
|
152
217
|
const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
|
|
153
218
|
if (!match) {
|
|
154
219
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
155
|
-
throw new
|
|
220
|
+
throw new HashlineError({
|
|
221
|
+
code: "INVALID_REF",
|
|
222
|
+
message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
|
|
223
|
+
});
|
|
156
224
|
}
|
|
157
225
|
return {
|
|
158
226
|
line: parseInt(match[1], 10),
|
|
@@ -170,9 +238,10 @@ function normalizeHashRef(ref) {
|
|
|
170
238
|
return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
|
|
171
239
|
}
|
|
172
240
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
173
|
-
throw new
|
|
174
|
-
|
|
175
|
-
|
|
241
|
+
throw new HashlineError({
|
|
242
|
+
code: "INVALID_REF",
|
|
243
|
+
message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
|
|
244
|
+
});
|
|
176
245
|
}
|
|
177
246
|
function buildHashMap(content, hashLen) {
|
|
178
247
|
const lines = content.split("\n");
|
|
@@ -185,50 +254,97 @@ function buildHashMap(content, hashLen) {
|
|
|
185
254
|
}
|
|
186
255
|
return map;
|
|
187
256
|
}
|
|
188
|
-
function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
|
|
257
|
+
function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
|
|
189
258
|
const contentLines = lines ?? currentContent.split("\n");
|
|
190
259
|
const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
|
|
191
260
|
if (lineNumber < 1 || lineNumber > contentLines.length) {
|
|
192
261
|
return {
|
|
193
262
|
valid: false,
|
|
263
|
+
code: "TARGET_OUT_OF_RANGE",
|
|
194
264
|
message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
|
|
195
265
|
};
|
|
196
266
|
}
|
|
197
267
|
const idx = lineNumber - 1;
|
|
198
268
|
const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
|
|
199
269
|
if (actualHash !== hash) {
|
|
270
|
+
const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
|
|
271
|
+
if (safeReapply && candidates.length === 1) {
|
|
272
|
+
return {
|
|
273
|
+
valid: true,
|
|
274
|
+
relocatedLine: candidates[0].lineNumber,
|
|
275
|
+
candidates
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (safeReapply && candidates.length > 1) {
|
|
279
|
+
return {
|
|
280
|
+
valid: false,
|
|
281
|
+
code: "AMBIGUOUS_REAPPLY",
|
|
282
|
+
expected: hash,
|
|
283
|
+
actual: actualHash,
|
|
284
|
+
candidates,
|
|
285
|
+
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
|
|
286
|
+
};
|
|
287
|
+
}
|
|
200
288
|
return {
|
|
201
289
|
valid: false,
|
|
290
|
+
code: "HASH_MISMATCH",
|
|
202
291
|
expected: hash,
|
|
203
292
|
actual: actualHash,
|
|
293
|
+
candidates,
|
|
204
294
|
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
|
|
205
295
|
};
|
|
206
296
|
}
|
|
207
297
|
return { valid: true };
|
|
208
298
|
}
|
|
209
|
-
function resolveRange(startRef, endRef, content, hashLen) {
|
|
299
|
+
function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
|
|
210
300
|
const start = parseHashRef(startRef);
|
|
211
301
|
const end = parseHashRef(endRef);
|
|
212
302
|
if (start.line > end.line) {
|
|
213
|
-
throw new
|
|
214
|
-
|
|
215
|
-
|
|
303
|
+
throw new HashlineError({
|
|
304
|
+
code: "INVALID_RANGE",
|
|
305
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
306
|
+
});
|
|
216
307
|
}
|
|
217
308
|
const lineEnding = detectLineEnding(content);
|
|
218
309
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
219
310
|
const lines = normalized.split("\n");
|
|
220
|
-
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
|
|
311
|
+
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
|
|
221
312
|
if (!startVerify.valid) {
|
|
222
|
-
throw new
|
|
313
|
+
throw new HashlineError({
|
|
314
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
315
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
316
|
+
expected: startVerify.expected,
|
|
317
|
+
actual: startVerify.actual,
|
|
318
|
+
candidates: startVerify.candidates,
|
|
319
|
+
lineNumber: start.line,
|
|
320
|
+
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."
|
|
321
|
+
});
|
|
223
322
|
}
|
|
224
|
-
const
|
|
323
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
324
|
+
const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
|
|
225
325
|
if (!endVerify.valid) {
|
|
226
|
-
throw new
|
|
326
|
+
throw new HashlineError({
|
|
327
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
328
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
329
|
+
expected: endVerify.expected,
|
|
330
|
+
actual: endVerify.actual,
|
|
331
|
+
candidates: endVerify.candidates,
|
|
332
|
+
lineNumber: end.line,
|
|
333
|
+
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."
|
|
334
|
+
});
|
|
227
335
|
}
|
|
228
|
-
const
|
|
336
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
337
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
338
|
+
throw new HashlineError({
|
|
339
|
+
code: "INVALID_RANGE",
|
|
340
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
341
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
|
|
229
345
|
return {
|
|
230
|
-
startLine:
|
|
231
|
-
endLine:
|
|
346
|
+
startLine: effectiveStartLine,
|
|
347
|
+
endLine: effectiveEndLine,
|
|
232
348
|
lines: rangeLines,
|
|
233
349
|
content: rangeLines.join(lineEnding)
|
|
234
350
|
};
|
|
@@ -244,22 +360,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
|
|
|
244
360
|
const result = [...before, ...replacementLines, ...after].join("\n");
|
|
245
361
|
return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
|
|
246
362
|
}
|
|
247
|
-
function applyHashEdit(input, content, hashLen) {
|
|
363
|
+
function applyHashEdit(input, content, hashLen, safeReapply) {
|
|
248
364
|
const lineEnding = detectLineEnding(content);
|
|
249
365
|
const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
366
|
+
if (input.fileRev) {
|
|
367
|
+
verifyFileRev(input.fileRev, workContent);
|
|
368
|
+
}
|
|
250
369
|
const normalizedStart = normalizeHashRef(input.startRef);
|
|
251
370
|
const start = parseHashRef(normalizedStart);
|
|
252
371
|
const lines = workContent.split("\n");
|
|
253
|
-
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
|
|
372
|
+
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
|
|
254
373
|
if (!startVerify.valid) {
|
|
255
|
-
throw new
|
|
374
|
+
throw new HashlineError({
|
|
375
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
376
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
377
|
+
expected: startVerify.expected,
|
|
378
|
+
actual: startVerify.actual,
|
|
379
|
+
candidates: startVerify.candidates,
|
|
380
|
+
lineNumber: start.line,
|
|
381
|
+
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."
|
|
382
|
+
});
|
|
256
383
|
}
|
|
384
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
257
385
|
if (input.operation === "insert_before" || input.operation === "insert_after") {
|
|
258
386
|
if (input.replacement === void 0) {
|
|
259
|
-
throw new
|
|
387
|
+
throw new HashlineError({
|
|
388
|
+
code: "MISSING_REPLACEMENT",
|
|
389
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
390
|
+
});
|
|
260
391
|
}
|
|
261
392
|
const insertionLines = input.replacement.split("\n");
|
|
262
|
-
const insertIndex = input.operation === "insert_before" ?
|
|
393
|
+
const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
|
|
263
394
|
const next2 = [
|
|
264
395
|
...lines.slice(0, insertIndex),
|
|
265
396
|
...insertionLines,
|
|
@@ -267,34 +398,54 @@ function applyHashEdit(input, content, hashLen) {
|
|
|
267
398
|
].join("\n");
|
|
268
399
|
return {
|
|
269
400
|
operation: input.operation,
|
|
270
|
-
startLine:
|
|
271
|
-
endLine:
|
|
401
|
+
startLine: effectiveStartLine,
|
|
402
|
+
endLine: effectiveStartLine,
|
|
272
403
|
content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
|
|
273
404
|
};
|
|
274
405
|
}
|
|
275
406
|
const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
|
|
276
407
|
const end = parseHashRef(normalizedEnd);
|
|
277
408
|
if (start.line > end.line) {
|
|
278
|
-
throw new
|
|
279
|
-
|
|
280
|
-
|
|
409
|
+
throw new HashlineError({
|
|
410
|
+
code: "INVALID_RANGE",
|
|
411
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
412
|
+
});
|
|
281
413
|
}
|
|
282
|
-
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
|
|
414
|
+
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
|
|
283
415
|
if (!endVerify.valid) {
|
|
284
|
-
throw new
|
|
416
|
+
throw new HashlineError({
|
|
417
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
418
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
419
|
+
expected: endVerify.expected,
|
|
420
|
+
actual: endVerify.actual,
|
|
421
|
+
candidates: endVerify.candidates,
|
|
422
|
+
lineNumber: end.line,
|
|
423
|
+
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."
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
427
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
428
|
+
throw new HashlineError({
|
|
429
|
+
code: "INVALID_RANGE",
|
|
430
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
431
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
432
|
+
});
|
|
285
433
|
}
|
|
286
434
|
const replacement = input.operation === "delete" ? "" : input.replacement;
|
|
287
435
|
if (replacement === void 0) {
|
|
288
|
-
throw new
|
|
436
|
+
throw new HashlineError({
|
|
437
|
+
code: "MISSING_REPLACEMENT",
|
|
438
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
439
|
+
});
|
|
289
440
|
}
|
|
290
|
-
const before = lines.slice(0,
|
|
291
|
-
const after = lines.slice(
|
|
441
|
+
const before = lines.slice(0, effectiveStartLine - 1);
|
|
442
|
+
const after = lines.slice(effectiveEndLine);
|
|
292
443
|
const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
|
|
293
444
|
const next = [...before, ...replacementLines, ...after].join("\n");
|
|
294
445
|
return {
|
|
295
446
|
operation: input.operation,
|
|
296
|
-
startLine:
|
|
297
|
-
endLine:
|
|
447
|
+
startLine: effectiveStartLine,
|
|
448
|
+
endLine: effectiveEndLine,
|
|
298
449
|
content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
|
|
299
450
|
};
|
|
300
451
|
}
|
|
@@ -330,7 +481,7 @@ function createHashline(config) {
|
|
|
330
481
|
const cached = cache.get(filePath, content);
|
|
331
482
|
if (cached) return cached;
|
|
332
483
|
}
|
|
333
|
-
const result = formatFileWithHashes(content, hl, pfx);
|
|
484
|
+
const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
|
|
334
485
|
if (filePath) {
|
|
335
486
|
cache.set(filePath, content, result);
|
|
336
487
|
}
|
|
@@ -346,16 +497,16 @@ function createHashline(config) {
|
|
|
346
497
|
return buildHashMap(content, hl);
|
|
347
498
|
},
|
|
348
499
|
verifyHash(lineNumber, hash, currentContent) {
|
|
349
|
-
return verifyHash(lineNumber, hash, currentContent, hl);
|
|
500
|
+
return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
|
|
350
501
|
},
|
|
351
502
|
resolveRange(startRef, endRef, content) {
|
|
352
|
-
return resolveRange(startRef, endRef, content, hl);
|
|
503
|
+
return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
|
|
353
504
|
},
|
|
354
505
|
replaceRange(startRef, endRef, content, replacement) {
|
|
355
506
|
return replaceRange(startRef, endRef, content, replacement, hl);
|
|
356
507
|
},
|
|
357
508
|
applyHashEdit(input, content) {
|
|
358
|
-
return applyHashEdit(input, content, hl);
|
|
509
|
+
return applyHashEdit(input, content, hl, resolved.safeReapply);
|
|
359
510
|
},
|
|
360
511
|
normalizeHashRef(ref) {
|
|
361
512
|
return normalizeHashRef(ref);
|
|
@@ -365,10 +516,22 @@ function createHashline(config) {
|
|
|
365
516
|
},
|
|
366
517
|
shouldExclude(filePath) {
|
|
367
518
|
return shouldExclude(filePath, resolved.exclude);
|
|
519
|
+
},
|
|
520
|
+
computeFileRev(content) {
|
|
521
|
+
return computeFileRev(content);
|
|
522
|
+
},
|
|
523
|
+
verifyFileRev(expectedRev, currentContent) {
|
|
524
|
+
return verifyFileRev(expectedRev, currentContent);
|
|
525
|
+
},
|
|
526
|
+
extractFileRev(annotatedContent) {
|
|
527
|
+
return extractFileRev(annotatedContent, pfx);
|
|
528
|
+
},
|
|
529
|
+
findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
|
|
530
|
+
return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
|
|
368
531
|
}
|
|
369
532
|
};
|
|
370
533
|
}
|
|
371
|
-
var import_picomatch, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_PREFIX, DEFAULT_CONFIG, modulusCache, stripRegexCache, HashlineCache, globMatcherCache, textEncoder;
|
|
534
|
+
var import_picomatch, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_PREFIX, DEFAULT_CONFIG, HashlineError, modulusCache, stripRegexCache, HashlineCache, globMatcherCache, textEncoder;
|
|
372
535
|
var init_hashline = __esm({
|
|
373
536
|
"src/hashline.ts"() {
|
|
374
537
|
"use strict";
|
|
@@ -429,7 +592,53 @@ var init_hashline = __esm({
|
|
|
429
592
|
// 0 = adaptive
|
|
430
593
|
cacheSize: 100,
|
|
431
594
|
prefix: DEFAULT_PREFIX,
|
|
432
|
-
debug: false
|
|
595
|
+
debug: false,
|
|
596
|
+
fileRev: true,
|
|
597
|
+
safeReapply: false
|
|
598
|
+
};
|
|
599
|
+
HashlineError = class extends Error {
|
|
600
|
+
code;
|
|
601
|
+
expected;
|
|
602
|
+
actual;
|
|
603
|
+
candidates;
|
|
604
|
+
hint;
|
|
605
|
+
lineNumber;
|
|
606
|
+
filePath;
|
|
607
|
+
constructor(opts) {
|
|
608
|
+
super(opts.message);
|
|
609
|
+
this.name = "HashlineError";
|
|
610
|
+
this.code = opts.code;
|
|
611
|
+
this.expected = opts.expected;
|
|
612
|
+
this.actual = opts.actual;
|
|
613
|
+
this.candidates = opts.candidates;
|
|
614
|
+
this.hint = opts.hint;
|
|
615
|
+
this.lineNumber = opts.lineNumber;
|
|
616
|
+
this.filePath = opts.filePath;
|
|
617
|
+
}
|
|
618
|
+
toDiagnostic() {
|
|
619
|
+
const parts = [`[${this.code}] ${this.message}`];
|
|
620
|
+
if (this.filePath) {
|
|
621
|
+
parts.push(` File: ${this.filePath}`);
|
|
622
|
+
}
|
|
623
|
+
if (this.lineNumber !== void 0) {
|
|
624
|
+
parts.push(` Line: ${this.lineNumber}`);
|
|
625
|
+
}
|
|
626
|
+
if (this.expected !== void 0 && this.actual !== void 0) {
|
|
627
|
+
parts.push(` Expected hash: ${this.expected}`);
|
|
628
|
+
parts.push(` Actual hash: ${this.actual}`);
|
|
629
|
+
}
|
|
630
|
+
if (this.candidates && this.candidates.length > 0) {
|
|
631
|
+
parts.push(` Candidates (${this.candidates.length}):`);
|
|
632
|
+
for (const c of this.candidates) {
|
|
633
|
+
const preview = c.content.length > 60 ? c.content.slice(0, 60) + "..." : c.content;
|
|
634
|
+
parts.push(` - line ${c.lineNumber}: ${preview}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (this.hint) {
|
|
638
|
+
parts.push(` Hint: ${this.hint}`);
|
|
639
|
+
}
|
|
640
|
+
return parts.join("\n");
|
|
641
|
+
}
|
|
433
642
|
};
|
|
434
643
|
modulusCache = /* @__PURE__ */ new Map();
|
|
435
644
|
stripRegexCache = /* @__PURE__ */ new Map();
|
|
@@ -508,6 +717,7 @@ module.exports = __toCommonJS(src_exports);
|
|
|
508
717
|
var import_fs3 = require("fs");
|
|
509
718
|
var import_path3 = require("path");
|
|
510
719
|
var import_os2 = require("os");
|
|
720
|
+
var import_crypto = require("crypto");
|
|
511
721
|
var import_url = require("url");
|
|
512
722
|
|
|
513
723
|
// src/hooks.ts
|
|
@@ -599,7 +809,7 @@ function createFileReadAfterHook(cache, config) {
|
|
|
599
809
|
return;
|
|
600
810
|
}
|
|
601
811
|
}
|
|
602
|
-
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
|
|
812
|
+
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, resolved.fileRev);
|
|
603
813
|
output.output = annotated;
|
|
604
814
|
debug("annotated", typeof filePath === "string" ? filePath : input.tool, "lines:", content.split("\n").length);
|
|
605
815
|
if (cache && typeof filePath === "string") {
|
|
@@ -711,10 +921,32 @@ function createSystemPromptHook(config) {
|
|
|
711
921
|
'- Hash references include both the line number AND the content hash, so `2:f1c` means "line 2 with hash f1c".',
|
|
712
922
|
"- If you see a mismatch, do NOT proceed with the edit \u2014 re-read the file to get fresh references.",
|
|
713
923
|
"",
|
|
924
|
+
"### File revision (`#HL REV:<hash>`):",
|
|
925
|
+
"- When files are read, the first line may contain a file revision header: `" + prefix + "REV:<8-char-hex>`.",
|
|
926
|
+
"- 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.",
|
|
927
|
+
"- If the file was modified between read and edit, the revision check fails with `FILE_REV_MISMATCH` \u2014 re-read the file.",
|
|
928
|
+
"",
|
|
929
|
+
"### Safe reapply (`safeReapply`):",
|
|
930
|
+
"- Pass `safeReapply: true` to `hashline_edit` to enable automatic line relocation.",
|
|
931
|
+
"- If a line moved (e.g., due to insertions above), safe reapply finds it by content hash.",
|
|
932
|
+
"- If exactly one match is found, the edit proceeds at the new location.",
|
|
933
|
+
"- If multiple matches exist, the edit fails with `AMBIGUOUS_REAPPLY` \u2014 re-read the file.",
|
|
934
|
+
"",
|
|
935
|
+
"### Structured error codes:",
|
|
936
|
+
"- `HASH_MISMATCH` \u2014 line content changed since last read",
|
|
937
|
+
"- `FILE_REV_MISMATCH` \u2014 file was modified since last read",
|
|
938
|
+
"- `AMBIGUOUS_REAPPLY` \u2014 multiple candidate lines found during safe reapply",
|
|
939
|
+
"- `TARGET_OUT_OF_RANGE` \u2014 line number exceeds file length",
|
|
940
|
+
"- `INVALID_REF` \u2014 malformed hash reference",
|
|
941
|
+
"- `INVALID_RANGE` \u2014 start line is after end line",
|
|
942
|
+
"- `MISSING_REPLACEMENT` \u2014 replace/insert operation without replacement content",
|
|
943
|
+
"",
|
|
714
944
|
"### Best practices:",
|
|
715
945
|
"- Use hash references for all edit operations to ensure precision.",
|
|
716
946
|
"- 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."
|
|
947
|
+
"- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines.",
|
|
948
|
+
"- Use `fileRev` to guard against stale edits on critical files.",
|
|
949
|
+
"- Use `safeReapply: true` when editing files that may have shifted due to earlier edits."
|
|
718
950
|
].join("\n")
|
|
719
951
|
);
|
|
720
952
|
};
|
|
@@ -736,10 +968,12 @@ function createHashlineEditTool(config, cache) {
|
|
|
736
968
|
operation: import_zod.z.enum(["replace", "delete", "insert_before", "insert_after"]).describe("Edit operation"),
|
|
737
969
|
startRef: import_zod.z.string().describe('Start hash reference, e.g. "5:a3f" or "#HL 5:a3f|const x = 1;"'),
|
|
738
970
|
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.")
|
|
971
|
+
replacement: import_zod.z.string().max(1e7).optional().describe("Replacement/inserted content. Required for replace/insert operations."),
|
|
972
|
+
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."),
|
|
973
|
+
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
974
|
},
|
|
741
975
|
async execute(args, context) {
|
|
742
|
-
const { path, operation, startRef, endRef, replacement } = args;
|
|
976
|
+
const { path, operation, startRef, endRef, replacement, fileRev, safeReapply } = args;
|
|
743
977
|
const absPath = (0, import_path2.isAbsolute)(path) ? path : (0, import_path2.resolve)(context.directory, path);
|
|
744
978
|
const realDirectory = (0, import_fs2.realpathSync)((0, import_path2.resolve)(context.directory));
|
|
745
979
|
const realWorktree = (0, import_fs2.realpathSync)((0, import_path2.resolve)(context.worktree));
|
|
@@ -793,15 +1027,21 @@ function createHashlineEditTool(config, cache) {
|
|
|
793
1027
|
operation,
|
|
794
1028
|
startRef,
|
|
795
1029
|
endRef,
|
|
796
|
-
replacement
|
|
1030
|
+
replacement,
|
|
1031
|
+
fileRev
|
|
797
1032
|
},
|
|
798
1033
|
current,
|
|
799
|
-
config.hashLength || void 0
|
|
1034
|
+
config.hashLength || void 0,
|
|
1035
|
+
safeReapply ?? config.safeReapply
|
|
800
1036
|
);
|
|
801
1037
|
nextContent = result.content;
|
|
802
1038
|
startLine = result.startLine;
|
|
803
1039
|
endLine = result.endLine;
|
|
804
1040
|
} catch (error) {
|
|
1041
|
+
if (error instanceof HashlineError) {
|
|
1042
|
+
throw new Error(`Hashline edit failed for "${displayPath}":
|
|
1043
|
+
${error.toDiagnostic()}`);
|
|
1044
|
+
}
|
|
805
1045
|
const reason = error instanceof Error ? error.message : String(error);
|
|
806
1046
|
throw new Error(`Hashline edit failed for "${displayPath}": ${reason}`);
|
|
807
1047
|
}
|
|
@@ -838,6 +1078,33 @@ function createHashlineEditTool(config, cache) {
|
|
|
838
1078
|
|
|
839
1079
|
// src/index.ts
|
|
840
1080
|
var CONFIG_FILENAME = "opencode-hashline.json";
|
|
1081
|
+
var tempDirs = /* @__PURE__ */ new Set();
|
|
1082
|
+
var exitListenerRegistered = false;
|
|
1083
|
+
function registerTempDir(dir) {
|
|
1084
|
+
tempDirs.add(dir);
|
|
1085
|
+
if (!exitListenerRegistered) {
|
|
1086
|
+
exitListenerRegistered = true;
|
|
1087
|
+
process.on("exit", () => {
|
|
1088
|
+
for (const d of tempDirs) {
|
|
1089
|
+
try {
|
|
1090
|
+
(0, import_fs3.rmSync)(d, { recursive: true, force: true });
|
|
1091
|
+
} catch {
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
function writeTempFile(tempDir, content) {
|
|
1098
|
+
const name = `hl-${(0, import_crypto.randomBytes)(16).toString("hex")}.txt`;
|
|
1099
|
+
const tmpPath = (0, import_path3.join)(tempDir, name);
|
|
1100
|
+
const fd = (0, import_fs3.openSync)(tmpPath, import_fs3.constants.O_WRONLY | import_fs3.constants.O_CREAT | import_fs3.constants.O_EXCL, 384);
|
|
1101
|
+
try {
|
|
1102
|
+
(0, import_fs3.writeFileSync)(fd, content, "utf-8");
|
|
1103
|
+
} finally {
|
|
1104
|
+
(0, import_fs3.closeSync)(fd);
|
|
1105
|
+
}
|
|
1106
|
+
return tmpPath;
|
|
1107
|
+
}
|
|
841
1108
|
function sanitizeConfig(raw) {
|
|
842
1109
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
|
843
1110
|
const r = raw;
|
|
@@ -866,6 +1133,12 @@ function sanitizeConfig(raw) {
|
|
|
866
1133
|
if (typeof r.debug === "boolean") {
|
|
867
1134
|
result.debug = r.debug;
|
|
868
1135
|
}
|
|
1136
|
+
if (typeof r.fileRev === "boolean") {
|
|
1137
|
+
result.fileRev = r.fileRev;
|
|
1138
|
+
}
|
|
1139
|
+
if (typeof r.safeReapply === "boolean") {
|
|
1140
|
+
result.safeReapply = r.safeReapply;
|
|
1141
|
+
}
|
|
869
1142
|
return result;
|
|
870
1143
|
}
|
|
871
1144
|
function loadConfigFile(filePath) {
|
|
@@ -906,17 +1179,8 @@ function createHashlinePlugin(userConfig) {
|
|
|
906
1179
|
} catch {
|
|
907
1180
|
}
|
|
908
1181
|
}
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
for (const f of tempFiles) {
|
|
912
|
-
try {
|
|
913
|
-
(0, import_fs3.unlinkSync)(f);
|
|
914
|
-
} catch {
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
tempFiles.clear();
|
|
918
|
-
};
|
|
919
|
-
process.on("exit", cleanupTempFiles);
|
|
1182
|
+
const instanceTmpDir = (0, import_fs3.mkdtempSync)((0, import_path3.join)((0, import_os2.tmpdir)(), "hashline-"));
|
|
1183
|
+
registerTempDir(instanceTmpDir);
|
|
920
1184
|
return {
|
|
921
1185
|
tool: {
|
|
922
1186
|
hashline_edit: createHashlineEditTool(config, cache)
|
|
@@ -959,9 +1223,7 @@ function createHashlinePlugin(userConfig) {
|
|
|
959
1223
|
if (config.maxFileSize > 0 && getByteLength2(content) > config.maxFileSize) continue;
|
|
960
1224
|
const cached = cache.get(filePath, content);
|
|
961
1225
|
if (cached) {
|
|
962
|
-
const tmpPath2 = (
|
|
963
|
-
(0, import_fs3.writeFileSync)(tmpPath2, cached, "utf-8");
|
|
964
|
-
tempFiles.add(tmpPath2);
|
|
1226
|
+
const tmpPath2 = writeTempFile(instanceTmpDir, cached);
|
|
965
1227
|
p.url = `file://${tmpPath2}`;
|
|
966
1228
|
if (config.debug) {
|
|
967
1229
|
try {
|
|
@@ -972,10 +1234,9 @@ function createHashlinePlugin(userConfig) {
|
|
|
972
1234
|
}
|
|
973
1235
|
continue;
|
|
974
1236
|
}
|
|
975
|
-
const annotated = formatFileWithHashes2(content, hashLen || void 0, prefix);
|
|
1237
|
+
const annotated = formatFileWithHashes2(content, hashLen || void 0, prefix, config.fileRev);
|
|
976
1238
|
cache.set(filePath, content, annotated);
|
|
977
|
-
const tmpPath = (
|
|
978
|
-
(0, import_fs3.writeFileSync)(tmpPath, annotated, "utf-8");
|
|
1239
|
+
const tmpPath = writeTempFile(instanceTmpDir, annotated);
|
|
979
1240
|
p.url = `file://${tmpPath}`;
|
|
980
1241
|
if (config.debug) {
|
|
981
1242
|
try {
|