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