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.
@@ -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 formatFileWithHashes(content, hashLen, prefix) {
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
- return lines.map((line, idx) => {
221
+ const annotatedLines = lines.map((line, idx) => {
132
222
  return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
133
- }).join("\n");
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 Error(`Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`);
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 Error(
179
- `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
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 Error(
219
- `Invalid range: start line ${start.line} is after end line ${end.line}`
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 Error(`Start reference invalid: ${startVerify.message}`);
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 endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines);
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 Error(`End reference invalid: ${endVerify.message}`);
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 rangeLines = lines.slice(start.line - 1, end.line);
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: start.line,
236
- endLine: end.line,
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 Error(`Start reference invalid: ${startVerify.message}`);
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 Error(`Operation "${input.operation}" requires "replacement" content`);
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" ? start.line - 1 : start.line;
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: start.line,
276
- endLine: start.line,
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 Error(
284
- `Invalid range: start line ${start.line} is after end line ${end.line}`
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 Error(`End reference invalid: ${endVerify.message}`);
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 Error(`Operation "${input.operation}" requires "replacement" content`);
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, start.line - 1);
296
- const after = lines.slice(end.line);
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: start.line,
302
- endLine: end.line,
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 HashlineInstance as d, HashlineCache as e, DEFAULT_EXCLUDE_PATTERNS as f, DEFAULT_PREFIX as g, applyHashEdit as h, buildHashMap as i, computeLineHash as j, createHashline as k, formatFileWithHashes as l, getAdaptiveHashLength as m, getByteLength as n, matchesGlob as o, normalizeHashRef as p, parseHashRef as q, replaceRange as r, resolveConfig as s, resolveRange as t, shouldExclude as u, stripHashes as v, verifyHash as w };
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 };