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
|
@@ -56,7 +56,9 @@ var DEFAULT_CONFIG = {
|
|
|
56
56
|
// 0 = adaptive
|
|
57
57
|
cacheSize: 100,
|
|
58
58
|
prefix: DEFAULT_PREFIX,
|
|
59
|
-
debug: false
|
|
59
|
+
debug: false,
|
|
60
|
+
fileRev: true,
|
|
61
|
+
safeReapply: false
|
|
60
62
|
};
|
|
61
63
|
function resolveConfig(config, pluginConfig) {
|
|
62
64
|
const merged = {
|
|
@@ -72,9 +74,55 @@ function resolveConfig(config, pluginConfig) {
|
|
|
72
74
|
hashLength: merged.hashLength ?? DEFAULT_CONFIG.hashLength,
|
|
73
75
|
cacheSize: merged.cacheSize ?? DEFAULT_CONFIG.cacheSize,
|
|
74
76
|
prefix: merged.prefix !== void 0 ? merged.prefix : DEFAULT_CONFIG.prefix,
|
|
75
|
-
debug: merged.debug ?? DEFAULT_CONFIG.debug
|
|
77
|
+
debug: merged.debug ?? DEFAULT_CONFIG.debug,
|
|
78
|
+
fileRev: merged.fileRev ?? DEFAULT_CONFIG.fileRev,
|
|
79
|
+
safeReapply: merged.safeReapply ?? DEFAULT_CONFIG.safeReapply
|
|
76
80
|
};
|
|
77
81
|
}
|
|
82
|
+
var HashlineError = class extends Error {
|
|
83
|
+
code;
|
|
84
|
+
expected;
|
|
85
|
+
actual;
|
|
86
|
+
candidates;
|
|
87
|
+
hint;
|
|
88
|
+
lineNumber;
|
|
89
|
+
filePath;
|
|
90
|
+
constructor(opts) {
|
|
91
|
+
super(opts.message);
|
|
92
|
+
this.name = "HashlineError";
|
|
93
|
+
this.code = opts.code;
|
|
94
|
+
this.expected = opts.expected;
|
|
95
|
+
this.actual = opts.actual;
|
|
96
|
+
this.candidates = opts.candidates;
|
|
97
|
+
this.hint = opts.hint;
|
|
98
|
+
this.lineNumber = opts.lineNumber;
|
|
99
|
+
this.filePath = opts.filePath;
|
|
100
|
+
}
|
|
101
|
+
toDiagnostic() {
|
|
102
|
+
const parts = [`[${this.code}] ${this.message}`];
|
|
103
|
+
if (this.filePath) {
|
|
104
|
+
parts.push(` File: ${this.filePath}`);
|
|
105
|
+
}
|
|
106
|
+
if (this.lineNumber !== void 0) {
|
|
107
|
+
parts.push(` Line: ${this.lineNumber}`);
|
|
108
|
+
}
|
|
109
|
+
if (this.expected !== void 0 && this.actual !== void 0) {
|
|
110
|
+
parts.push(` Expected hash: ${this.expected}`);
|
|
111
|
+
parts.push(` Actual hash: ${this.actual}`);
|
|
112
|
+
}
|
|
113
|
+
if (this.candidates && this.candidates.length > 0) {
|
|
114
|
+
parts.push(` Candidates (${this.candidates.length}):`);
|
|
115
|
+
for (const c of this.candidates) {
|
|
116
|
+
const preview = c.content.length > 60 ? c.content.slice(0, 60) + "..." : c.content;
|
|
117
|
+
parts.push(` - line ${c.lineNumber}: ${preview}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (this.hint) {
|
|
121
|
+
parts.push(` Hint: ${this.hint}`);
|
|
122
|
+
}
|
|
123
|
+
return parts.join("\n");
|
|
124
|
+
}
|
|
125
|
+
};
|
|
78
126
|
function fnv1aHash(str) {
|
|
79
127
|
let hash = 2166136261;
|
|
80
128
|
for (let i = 0; i < str.length; i++) {
|
|
@@ -104,7 +152,49 @@ function computeLineHash(idx, line, hashLen = 3) {
|
|
|
104
152
|
const hash = raw % modulus;
|
|
105
153
|
return hash.toString(16).padStart(hashLen, "0");
|
|
106
154
|
}
|
|
107
|
-
function
|
|
155
|
+
function computeFileRev(content) {
|
|
156
|
+
const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
|
|
157
|
+
const hash = fnv1aHash(normalized);
|
|
158
|
+
return hash.toString(16).padStart(8, "0");
|
|
159
|
+
}
|
|
160
|
+
function extractFileRev(annotatedContent, prefix) {
|
|
161
|
+
const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
|
|
162
|
+
const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
163
|
+
const pattern = new RegExp(`^${escapedPrefix}REV:([0-9a-f]{8})$`);
|
|
164
|
+
const firstLine = annotatedContent.split("\n")[0];
|
|
165
|
+
const match = firstLine.match(pattern);
|
|
166
|
+
return match ? match[1] : null;
|
|
167
|
+
}
|
|
168
|
+
function verifyFileRev(expectedRev, currentContent) {
|
|
169
|
+
const actualRev = computeFileRev(currentContent);
|
|
170
|
+
if (actualRev !== expectedRev) {
|
|
171
|
+
throw new HashlineError({
|
|
172
|
+
code: "FILE_REV_MISMATCH",
|
|
173
|
+
message: `File revision mismatch: expected "${expectedRev}", got "${actualRev}". The file has changed since it was last read.`,
|
|
174
|
+
expected: expectedRev,
|
|
175
|
+
actual: actualRev,
|
|
176
|
+
hint: "Re-read the file to get fresh hash references and a new file revision."
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
|
|
181
|
+
const effectiveLen = hashLen && hashLen >= 2 ? hashLen : expectedHash.length;
|
|
182
|
+
const originalIdx = originalLineNumber - 1;
|
|
183
|
+
const candidates = [];
|
|
184
|
+
for (let i = 0; i < lines.length; i++) {
|
|
185
|
+
if (i === originalIdx) continue;
|
|
186
|
+
const candidateHash = computeLineHash(originalIdx, lines[i], effectiveLen);
|
|
187
|
+
if (candidateHash === expectedHash) {
|
|
188
|
+
candidates.push({
|
|
189
|
+
lineNumber: i + 1,
|
|
190
|
+
// 1-based
|
|
191
|
+
content: lines[i]
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return candidates;
|
|
196
|
+
}
|
|
197
|
+
function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
|
|
108
198
|
const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
|
|
109
199
|
const lines = normalized.split("\n");
|
|
110
200
|
const effectiveLen = hashLen && hashLen >= 3 ? hashLen : getAdaptiveHashLength(lines.length);
|
|
@@ -128,9 +218,14 @@ function formatFileWithHashes(content, hashLen, prefix) {
|
|
|
128
218
|
hashes[idx] = hash;
|
|
129
219
|
}
|
|
130
220
|
}
|
|
131
|
-
|
|
221
|
+
const annotatedLines = lines.map((line, idx) => {
|
|
132
222
|
return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
|
|
133
|
-
})
|
|
223
|
+
});
|
|
224
|
+
if (includeFileRev) {
|
|
225
|
+
const rev = computeFileRev(content);
|
|
226
|
+
annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
|
|
227
|
+
}
|
|
228
|
+
return annotatedLines.join("\n");
|
|
134
229
|
}
|
|
135
230
|
var stripRegexCache = /* @__PURE__ */ new Map();
|
|
136
231
|
function stripHashes(content, prefix) {
|
|
@@ -141,9 +236,10 @@ function stripHashes(content, prefix) {
|
|
|
141
236
|
hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
|
|
142
237
|
stripRegexCache.set(escapedPrefix, hashLinePattern);
|
|
143
238
|
}
|
|
239
|
+
const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
|
|
144
240
|
const lineEnding = detectLineEnding(content);
|
|
145
241
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
146
|
-
const result = normalized.split("\n").map((line) => {
|
|
242
|
+
const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
|
|
147
243
|
const match = line.match(hashLinePattern);
|
|
148
244
|
if (match) {
|
|
149
245
|
const patchMarker = match[1] || "";
|
|
@@ -157,7 +253,10 @@ function parseHashRef(ref) {
|
|
|
157
253
|
const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
|
|
158
254
|
if (!match) {
|
|
159
255
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
160
|
-
throw new
|
|
256
|
+
throw new HashlineError({
|
|
257
|
+
code: "INVALID_REF",
|
|
258
|
+
message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
|
|
259
|
+
});
|
|
161
260
|
}
|
|
162
261
|
return {
|
|
163
262
|
line: parseInt(match[1], 10),
|
|
@@ -175,9 +274,10 @@ function normalizeHashRef(ref) {
|
|
|
175
274
|
return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
|
|
176
275
|
}
|
|
177
276
|
const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
|
|
178
|
-
throw new
|
|
179
|
-
|
|
180
|
-
|
|
277
|
+
throw new HashlineError({
|
|
278
|
+
code: "INVALID_REF",
|
|
279
|
+
message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
|
|
280
|
+
});
|
|
181
281
|
}
|
|
182
282
|
function buildHashMap(content, hashLen) {
|
|
183
283
|
const lines = content.split("\n");
|
|
@@ -190,50 +290,97 @@ function buildHashMap(content, hashLen) {
|
|
|
190
290
|
}
|
|
191
291
|
return map;
|
|
192
292
|
}
|
|
193
|
-
function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
|
|
293
|
+
function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
|
|
194
294
|
const contentLines = lines ?? currentContent.split("\n");
|
|
195
295
|
const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
|
|
196
296
|
if (lineNumber < 1 || lineNumber > contentLines.length) {
|
|
197
297
|
return {
|
|
198
298
|
valid: false,
|
|
299
|
+
code: "TARGET_OUT_OF_RANGE",
|
|
199
300
|
message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
|
|
200
301
|
};
|
|
201
302
|
}
|
|
202
303
|
const idx = lineNumber - 1;
|
|
203
304
|
const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
|
|
204
305
|
if (actualHash !== hash) {
|
|
306
|
+
const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
|
|
307
|
+
if (safeReapply && candidates.length === 1) {
|
|
308
|
+
return {
|
|
309
|
+
valid: true,
|
|
310
|
+
relocatedLine: candidates[0].lineNumber,
|
|
311
|
+
candidates
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (safeReapply && candidates.length > 1) {
|
|
315
|
+
return {
|
|
316
|
+
valid: false,
|
|
317
|
+
code: "AMBIGUOUS_REAPPLY",
|
|
318
|
+
expected: hash,
|
|
319
|
+
actual: actualHash,
|
|
320
|
+
candidates,
|
|
321
|
+
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
|
|
322
|
+
};
|
|
323
|
+
}
|
|
205
324
|
return {
|
|
206
325
|
valid: false,
|
|
326
|
+
code: "HASH_MISMATCH",
|
|
207
327
|
expected: hash,
|
|
208
328
|
actual: actualHash,
|
|
329
|
+
candidates,
|
|
209
330
|
message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
|
|
210
331
|
};
|
|
211
332
|
}
|
|
212
333
|
return { valid: true };
|
|
213
334
|
}
|
|
214
|
-
function resolveRange(startRef, endRef, content, hashLen) {
|
|
335
|
+
function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
|
|
215
336
|
const start = parseHashRef(startRef);
|
|
216
337
|
const end = parseHashRef(endRef);
|
|
217
338
|
if (start.line > end.line) {
|
|
218
|
-
throw new
|
|
219
|
-
|
|
220
|
-
|
|
339
|
+
throw new HashlineError({
|
|
340
|
+
code: "INVALID_RANGE",
|
|
341
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
342
|
+
});
|
|
221
343
|
}
|
|
222
344
|
const lineEnding = detectLineEnding(content);
|
|
223
345
|
const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
224
346
|
const lines = normalized.split("\n");
|
|
225
|
-
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
|
|
347
|
+
const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
|
|
226
348
|
if (!startVerify.valid) {
|
|
227
|
-
throw new
|
|
349
|
+
throw new HashlineError({
|
|
350
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
351
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
352
|
+
expected: startVerify.expected,
|
|
353
|
+
actual: startVerify.actual,
|
|
354
|
+
candidates: startVerify.candidates,
|
|
355
|
+
lineNumber: start.line,
|
|
356
|
+
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."
|
|
357
|
+
});
|
|
228
358
|
}
|
|
229
|
-
const
|
|
359
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
360
|
+
const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
|
|
230
361
|
if (!endVerify.valid) {
|
|
231
|
-
throw new
|
|
362
|
+
throw new HashlineError({
|
|
363
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
364
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
365
|
+
expected: endVerify.expected,
|
|
366
|
+
actual: endVerify.actual,
|
|
367
|
+
candidates: endVerify.candidates,
|
|
368
|
+
lineNumber: end.line,
|
|
369
|
+
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."
|
|
370
|
+
});
|
|
232
371
|
}
|
|
233
|
-
const
|
|
372
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
373
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
374
|
+
throw new HashlineError({
|
|
375
|
+
code: "INVALID_RANGE",
|
|
376
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
377
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
|
|
234
381
|
return {
|
|
235
|
-
startLine:
|
|
236
|
-
endLine:
|
|
382
|
+
startLine: effectiveStartLine,
|
|
383
|
+
endLine: effectiveEndLine,
|
|
237
384
|
lines: rangeLines,
|
|
238
385
|
content: rangeLines.join(lineEnding)
|
|
239
386
|
};
|
|
@@ -249,22 +396,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
|
|
|
249
396
|
const result = [...before, ...replacementLines, ...after].join("\n");
|
|
250
397
|
return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
|
|
251
398
|
}
|
|
252
|
-
function applyHashEdit(input, content, hashLen) {
|
|
399
|
+
function applyHashEdit(input, content, hashLen, safeReapply) {
|
|
253
400
|
const lineEnding = detectLineEnding(content);
|
|
254
401
|
const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
|
|
402
|
+
if (input.fileRev) {
|
|
403
|
+
verifyFileRev(input.fileRev, workContent);
|
|
404
|
+
}
|
|
255
405
|
const normalizedStart = normalizeHashRef(input.startRef);
|
|
256
406
|
const start = parseHashRef(normalizedStart);
|
|
257
407
|
const lines = workContent.split("\n");
|
|
258
|
-
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
|
|
408
|
+
const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
|
|
259
409
|
if (!startVerify.valid) {
|
|
260
|
-
throw new
|
|
410
|
+
throw new HashlineError({
|
|
411
|
+
code: startVerify.code ?? "HASH_MISMATCH",
|
|
412
|
+
message: `Start reference invalid: ${startVerify.message}`,
|
|
413
|
+
expected: startVerify.expected,
|
|
414
|
+
actual: startVerify.actual,
|
|
415
|
+
candidates: startVerify.candidates,
|
|
416
|
+
lineNumber: start.line,
|
|
417
|
+
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."
|
|
418
|
+
});
|
|
261
419
|
}
|
|
420
|
+
const effectiveStartLine = startVerify.relocatedLine ?? start.line;
|
|
262
421
|
if (input.operation === "insert_before" || input.operation === "insert_after") {
|
|
263
422
|
if (input.replacement === void 0) {
|
|
264
|
-
throw new
|
|
423
|
+
throw new HashlineError({
|
|
424
|
+
code: "MISSING_REPLACEMENT",
|
|
425
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
426
|
+
});
|
|
265
427
|
}
|
|
266
428
|
const insertionLines = input.replacement.split("\n");
|
|
267
|
-
const insertIndex = input.operation === "insert_before" ?
|
|
429
|
+
const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
|
|
268
430
|
const next2 = [
|
|
269
431
|
...lines.slice(0, insertIndex),
|
|
270
432
|
...insertionLines,
|
|
@@ -272,34 +434,54 @@ function applyHashEdit(input, content, hashLen) {
|
|
|
272
434
|
].join("\n");
|
|
273
435
|
return {
|
|
274
436
|
operation: input.operation,
|
|
275
|
-
startLine:
|
|
276
|
-
endLine:
|
|
437
|
+
startLine: effectiveStartLine,
|
|
438
|
+
endLine: effectiveStartLine,
|
|
277
439
|
content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
|
|
278
440
|
};
|
|
279
441
|
}
|
|
280
442
|
const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
|
|
281
443
|
const end = parseHashRef(normalizedEnd);
|
|
282
444
|
if (start.line > end.line) {
|
|
283
|
-
throw new
|
|
284
|
-
|
|
285
|
-
|
|
445
|
+
throw new HashlineError({
|
|
446
|
+
code: "INVALID_RANGE",
|
|
447
|
+
message: `Invalid range: start line ${start.line} is after end line ${end.line}`
|
|
448
|
+
});
|
|
286
449
|
}
|
|
287
|
-
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
|
|
450
|
+
const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
|
|
288
451
|
if (!endVerify.valid) {
|
|
289
|
-
throw new
|
|
452
|
+
throw new HashlineError({
|
|
453
|
+
code: endVerify.code ?? "HASH_MISMATCH",
|
|
454
|
+
message: `End reference invalid: ${endVerify.message}`,
|
|
455
|
+
expected: endVerify.expected,
|
|
456
|
+
actual: endVerify.actual,
|
|
457
|
+
candidates: endVerify.candidates,
|
|
458
|
+
lineNumber: end.line,
|
|
459
|
+
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."
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const effectiveEndLine = endVerify.relocatedLine ?? end.line;
|
|
463
|
+
if (effectiveStartLine > effectiveEndLine) {
|
|
464
|
+
throw new HashlineError({
|
|
465
|
+
code: "INVALID_RANGE",
|
|
466
|
+
message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
|
|
467
|
+
hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
|
|
468
|
+
});
|
|
290
469
|
}
|
|
291
470
|
const replacement = input.operation === "delete" ? "" : input.replacement;
|
|
292
471
|
if (replacement === void 0) {
|
|
293
|
-
throw new
|
|
472
|
+
throw new HashlineError({
|
|
473
|
+
code: "MISSING_REPLACEMENT",
|
|
474
|
+
message: `Operation "${input.operation}" requires "replacement" content`
|
|
475
|
+
});
|
|
294
476
|
}
|
|
295
|
-
const before = lines.slice(0,
|
|
296
|
-
const after = lines.slice(
|
|
477
|
+
const before = lines.slice(0, effectiveStartLine - 1);
|
|
478
|
+
const after = lines.slice(effectiveEndLine);
|
|
297
479
|
const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
|
|
298
480
|
const next = [...before, ...replacementLines, ...after].join("\n");
|
|
299
481
|
return {
|
|
300
482
|
operation: input.operation,
|
|
301
|
-
startLine:
|
|
302
|
-
endLine:
|
|
483
|
+
startLine: effectiveStartLine,
|
|
484
|
+
endLine: effectiveEndLine,
|
|
303
485
|
content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
|
|
304
486
|
};
|
|
305
487
|
}
|
|
@@ -396,7 +578,7 @@ function createHashline(config) {
|
|
|
396
578
|
const cached = cache.get(filePath, content);
|
|
397
579
|
if (cached) return cached;
|
|
398
580
|
}
|
|
399
|
-
const result = formatFileWithHashes(content, hl, pfx);
|
|
581
|
+
const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
|
|
400
582
|
if (filePath) {
|
|
401
583
|
cache.set(filePath, content, result);
|
|
402
584
|
}
|
|
@@ -412,16 +594,16 @@ function createHashline(config) {
|
|
|
412
594
|
return buildHashMap(content, hl);
|
|
413
595
|
},
|
|
414
596
|
verifyHash(lineNumber, hash, currentContent) {
|
|
415
|
-
return verifyHash(lineNumber, hash, currentContent, hl);
|
|
597
|
+
return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
|
|
416
598
|
},
|
|
417
599
|
resolveRange(startRef, endRef, content) {
|
|
418
|
-
return resolveRange(startRef, endRef, content, hl);
|
|
600
|
+
return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
|
|
419
601
|
},
|
|
420
602
|
replaceRange(startRef, endRef, content, replacement) {
|
|
421
603
|
return replaceRange(startRef, endRef, content, replacement, hl);
|
|
422
604
|
},
|
|
423
605
|
applyHashEdit(input, content) {
|
|
424
|
-
return applyHashEdit(input, content, hl);
|
|
606
|
+
return applyHashEdit(input, content, hl, resolved.safeReapply);
|
|
425
607
|
},
|
|
426
608
|
normalizeHashRef(ref) {
|
|
427
609
|
return normalizeHashRef(ref);
|
|
@@ -431,6 +613,18 @@ function createHashline(config) {
|
|
|
431
613
|
},
|
|
432
614
|
shouldExclude(filePath) {
|
|
433
615
|
return shouldExclude(filePath, resolved.exclude);
|
|
616
|
+
},
|
|
617
|
+
computeFileRev(content) {
|
|
618
|
+
return computeFileRev(content);
|
|
619
|
+
},
|
|
620
|
+
verifyFileRev(expectedRev, currentContent) {
|
|
621
|
+
return verifyFileRev(expectedRev, currentContent);
|
|
622
|
+
},
|
|
623
|
+
extractFileRev(annotatedContent) {
|
|
624
|
+
return extractFileRev(annotatedContent, pfx);
|
|
625
|
+
},
|
|
626
|
+
findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
|
|
627
|
+
return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
|
|
434
628
|
}
|
|
435
629
|
};
|
|
436
630
|
}
|
|
@@ -440,8 +634,13 @@ export {
|
|
|
440
634
|
DEFAULT_PREFIX,
|
|
441
635
|
DEFAULT_CONFIG,
|
|
442
636
|
resolveConfig,
|
|
637
|
+
HashlineError,
|
|
443
638
|
getAdaptiveHashLength,
|
|
444
639
|
computeLineHash,
|
|
640
|
+
computeFileRev,
|
|
641
|
+
extractFileRev,
|
|
642
|
+
verifyFileRev,
|
|
643
|
+
findCandidateLines,
|
|
445
644
|
formatFileWithHashes,
|
|
446
645
|
stripHashes,
|
|
447
646
|
parseHashRef,
|
|
@@ -31,6 +31,10 @@ interface HashlineConfig {
|
|
|
31
31
|
prefix?: string | false;
|
|
32
32
|
/** Enable debug logging to ~/.config/opencode/hashline-debug.log (default: false) */
|
|
33
33
|
debug?: boolean;
|
|
34
|
+
/** Include file revision hash in annotations (default: true) */
|
|
35
|
+
fileRev?: boolean;
|
|
36
|
+
/** Enable safe reapply — relocate lines by hash when they move (default: false) */
|
|
37
|
+
safeReapply?: boolean;
|
|
34
38
|
}
|
|
35
39
|
/** Default exclude patterns */
|
|
36
40
|
declare const DEFAULT_EXCLUDE_PATTERNS: string[];
|
|
@@ -45,6 +49,31 @@ declare const DEFAULT_CONFIG: Required<HashlineConfig>;
|
|
|
45
49
|
* @param pluginConfig - optional config from plugin context (e.g. opencode.json)
|
|
46
50
|
*/
|
|
47
51
|
declare function resolveConfig(config?: HashlineConfig, pluginConfig?: HashlineConfig): Required<HashlineConfig>;
|
|
52
|
+
type HashlineErrorCode = "HASH_MISMATCH" | "FILE_REV_MISMATCH" | "AMBIGUOUS_REAPPLY" | "TARGET_OUT_OF_RANGE" | "INVALID_REF" | "INVALID_RANGE" | "MISSING_REPLACEMENT";
|
|
53
|
+
interface CandidateLine {
|
|
54
|
+
lineNumber: number;
|
|
55
|
+
content: string;
|
|
56
|
+
}
|
|
57
|
+
declare class HashlineError extends Error {
|
|
58
|
+
readonly code: HashlineErrorCode;
|
|
59
|
+
readonly expected?: string;
|
|
60
|
+
readonly actual?: string;
|
|
61
|
+
readonly candidates?: CandidateLine[];
|
|
62
|
+
readonly hint?: string;
|
|
63
|
+
readonly lineNumber?: number;
|
|
64
|
+
readonly filePath?: string;
|
|
65
|
+
constructor(opts: {
|
|
66
|
+
code: HashlineErrorCode;
|
|
67
|
+
message: string;
|
|
68
|
+
expected?: string;
|
|
69
|
+
actual?: string;
|
|
70
|
+
candidates?: CandidateLine[];
|
|
71
|
+
hint?: string;
|
|
72
|
+
lineNumber?: number;
|
|
73
|
+
filePath?: string;
|
|
74
|
+
});
|
|
75
|
+
toDiagnostic(): string;
|
|
76
|
+
}
|
|
48
77
|
/**
|
|
49
78
|
* Determine the appropriate hash length based on the number of lines.
|
|
50
79
|
*
|
|
@@ -65,6 +94,34 @@ declare function getAdaptiveHashLength(lineCount: number): number;
|
|
|
65
94
|
* @returns lowercase hex string of the specified length
|
|
66
95
|
*/
|
|
67
96
|
declare function computeLineHash(idx: number, line: string, hashLen?: number): string;
|
|
97
|
+
/**
|
|
98
|
+
* Compute a file-level revision hash from the entire content.
|
|
99
|
+
* Uses FNV-1a on CRLF-normalized content, returns 8-char hex (full 32 bits).
|
|
100
|
+
*/
|
|
101
|
+
declare function computeFileRev(content: string): string;
|
|
102
|
+
/**
|
|
103
|
+
* Extract the file revision from annotated content.
|
|
104
|
+
* Looks for a line matching `<prefix>REV:<8-hex>` at the start of the content.
|
|
105
|
+
*
|
|
106
|
+
* @param annotatedContent - content with hashline annotations
|
|
107
|
+
* @param prefix - prefix string (default "#HL "), or false for legacy format
|
|
108
|
+
* @returns the revision hash string, or null if not found
|
|
109
|
+
*/
|
|
110
|
+
declare function extractFileRev(annotatedContent: string, prefix?: string | false): string | null;
|
|
111
|
+
/**
|
|
112
|
+
* Verify that the file revision matches the current content.
|
|
113
|
+
* Throws HashlineError with code FILE_REV_MISMATCH if it doesn't match.
|
|
114
|
+
*/
|
|
115
|
+
declare function verifyFileRev(expectedRev: string, currentContent: string): void;
|
|
116
|
+
/**
|
|
117
|
+
* Find candidate lines that match the expected hash for a given original line index.
|
|
118
|
+
* Used for safe reapply: if a line moved, find where it went.
|
|
119
|
+
*
|
|
120
|
+
* Since computeLineHash uses `${idx}:${trimmed}`, we check each line in the file
|
|
121
|
+
* computing its hash as if it were at the original index — a match means the content
|
|
122
|
+
* is the same as what was originally at that position.
|
|
123
|
+
*/
|
|
124
|
+
declare function findCandidateLines(originalLineNumber: number, expectedHash: string, lines: string[], hashLen?: number): CandidateLine[];
|
|
68
125
|
/**
|
|
69
126
|
* Format file content with hashline annotations.
|
|
70
127
|
*
|
|
@@ -78,7 +135,7 @@ declare function computeLineHash(idx: number, line: string, hashLen?: number): s
|
|
|
78
135
|
* @param prefix - prefix string (default "#HL "), or false to disable
|
|
79
136
|
* @returns annotated content with hash prefixes
|
|
80
137
|
*/
|
|
81
|
-
declare function formatFileWithHashes(content: string, hashLen?: number, prefix?: string | false): string;
|
|
138
|
+
declare function formatFileWithHashes(content: string, hashLen?: number, prefix?: string | false, includeFileRev?: boolean): string;
|
|
82
139
|
/**
|
|
83
140
|
* Strip hashline prefixes to recover original file content.
|
|
84
141
|
*
|
|
@@ -122,6 +179,7 @@ interface HashEditInput {
|
|
|
122
179
|
startRef: string;
|
|
123
180
|
endRef?: string;
|
|
124
181
|
replacement?: string;
|
|
182
|
+
fileRev?: string;
|
|
125
183
|
}
|
|
126
184
|
/**
|
|
127
185
|
* Result of applying a hash-aware edit.
|
|
@@ -151,6 +209,9 @@ interface VerifyHashResult {
|
|
|
151
209
|
expected?: string;
|
|
152
210
|
actual?: string;
|
|
153
211
|
message?: string;
|
|
212
|
+
code?: HashlineErrorCode;
|
|
213
|
+
candidates?: CandidateLine[];
|
|
214
|
+
relocatedLine?: number;
|
|
154
215
|
}
|
|
155
216
|
/**
|
|
156
217
|
* Verify that a line's hash matches the current content.
|
|
@@ -169,7 +230,7 @@ interface VerifyHashResult {
|
|
|
169
230
|
* @param lines - optional pre-split lines array to avoid re-splitting
|
|
170
231
|
* @returns verification result
|
|
171
232
|
*/
|
|
172
|
-
declare function verifyHash(lineNumber: number, hash: string, currentContent: string, hashLen?: number, lines?: string[]): VerifyHashResult;
|
|
233
|
+
declare function verifyHash(lineNumber: number, hash: string, currentContent: string, hashLen?: number, lines?: string[], safeReapply?: boolean): VerifyHashResult;
|
|
173
234
|
/**
|
|
174
235
|
* Result of a range resolution.
|
|
175
236
|
*/
|
|
@@ -189,7 +250,7 @@ interface ResolvedRange {
|
|
|
189
250
|
* @param hashLen - override hash length (0 or undefined = use hash.length from ref)
|
|
190
251
|
* @returns resolved range with line numbers and content
|
|
191
252
|
*/
|
|
192
|
-
declare function resolveRange(startRef: string, endRef: string, content: string, hashLen?: number): ResolvedRange;
|
|
253
|
+
declare function resolveRange(startRef: string, endRef: string, content: string, hashLen?: number, safeReapply?: boolean): ResolvedRange;
|
|
193
254
|
/**
|
|
194
255
|
* Replace a range of lines identified by hash references with new content.
|
|
195
256
|
* Splits content once and reuses the lines array.
|
|
@@ -208,7 +269,7 @@ declare function replaceRange(startRef: string, endRef: string, content: string,
|
|
|
208
269
|
* Unlike search/replace tools, this resolves references by line+hash and
|
|
209
270
|
* verifies them before editing, so exact old-string matching is not required.
|
|
210
271
|
*/
|
|
211
|
-
declare function applyHashEdit(input: HashEditInput, content: string, hashLen?: number): HashEditResult;
|
|
272
|
+
declare function applyHashEdit(input: HashEditInput, content: string, hashLen?: number, safeReapply?: boolean): HashEditResult;
|
|
212
273
|
/**
|
|
213
274
|
* Simple LRU cache for annotated file content.
|
|
214
275
|
*/
|
|
@@ -268,6 +329,10 @@ interface HashlineInstance {
|
|
|
268
329
|
hash: string;
|
|
269
330
|
};
|
|
270
331
|
shouldExclude: (filePath: string) => boolean;
|
|
332
|
+
computeFileRev: (content: string) => string;
|
|
333
|
+
verifyFileRev: (expectedRev: string, currentContent: string) => void;
|
|
334
|
+
extractFileRev: (annotatedContent: string) => string | null;
|
|
335
|
+
findCandidateLines: (originalLineNumber: number, expectedHash: string, lines: string[], hashLen?: number) => CandidateLine[];
|
|
271
336
|
}
|
|
272
337
|
/**
|
|
273
338
|
* Create a Hashline instance with custom configuration.
|
|
@@ -277,4 +342,4 @@ interface HashlineInstance {
|
|
|
277
342
|
*/
|
|
278
343
|
declare function createHashline(config?: HashlineConfig): HashlineInstance;
|
|
279
344
|
|
|
280
|
-
export { DEFAULT_CONFIG as D, type HashlineConfig as H, type ResolvedRange as R, type VerifyHashResult as V, type HashEditInput as a, type HashEditOperation as b, type HashEditResult as c, type
|
|
345
|
+
export { stripHashes as A, verifyFileRev as B, type CandidateLine as C, DEFAULT_CONFIG as D, verifyHash as E, type HashlineConfig as H, type ResolvedRange as R, type VerifyHashResult as V, type HashEditInput as a, type HashEditOperation as b, type HashEditResult as c, type HashlineErrorCode as d, type HashlineInstance as e, HashlineCache as f, DEFAULT_EXCLUDE_PATTERNS as g, DEFAULT_PREFIX as h, HashlineError as i, applyHashEdit as j, buildHashMap as k, computeFileRev as l, computeLineHash as m, createHashline as n, extractFileRev as o, findCandidateLines as p, formatFileWithHashes as q, getAdaptiveHashLength as r, getByteLength as s, matchesGlob as t, normalizeHashRef as u, parseHashRef as v, replaceRange as w, resolveConfig as x, resolveRange as y, shouldExclude as z };
|