opencode-hashline 1.2.0 → 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 +48 -0
- package/README.md +48 -0
- 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
package/README.en.md
CHANGED
|
@@ -104,6 +104,49 @@ if (!result.valid) {
|
|
|
104
104
|
|
|
105
105
|
Hash verification uses the length of the provided hash reference (not the current file size), so a reference like `2:f1` remains valid even if the file has grown.
|
|
106
106
|
|
|
107
|
+
### 🔒 File Revision (`fileRev`)
|
|
108
|
+
|
|
109
|
+
In addition to per-line hashes, hashline computes a whole-file hash (FNV-1a, 8 hex chars). It's prepended as the first annotation line:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
#HL REV:72c4946c
|
|
113
|
+
#HL 1:a3f|function hello() {
|
|
114
|
+
#HL 2:f1c| return "world";
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Pass `fileRev` to `hashline_edit` when editing — if the file changed since it was read, the edit is rejected with `FILE_REV_MISMATCH`.
|
|
118
|
+
|
|
119
|
+
### 🔄 Safe Reapply
|
|
120
|
+
|
|
121
|
+
If a line moved (e.g., due to insertions above), `safeReapply` finds it by content hash:
|
|
122
|
+
|
|
123
|
+
- **1 candidate** — edit applies at the new position
|
|
124
|
+
- **>1 candidates** — `AMBIGUOUS_REAPPLY` error (ambiguous)
|
|
125
|
+
- **0 candidates** — `HASH_MISMATCH` error
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
const result = applyHashEdit(
|
|
129
|
+
{ operation: "replace", startRef: "1:a3f", replacement: "new" },
|
|
130
|
+
content,
|
|
131
|
+
undefined,
|
|
132
|
+
true, // safeReapply
|
|
133
|
+
);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 🏷️ Structured Errors
|
|
137
|
+
|
|
138
|
+
All hashline errors are instances of `HashlineError` with error codes, diagnostics, and hints:
|
|
139
|
+
|
|
140
|
+
| Code | Description |
|
|
141
|
+
|------|-------------|
|
|
142
|
+
| `HASH_MISMATCH` | Line content changed since last read |
|
|
143
|
+
| `FILE_REV_MISMATCH` | File was modified since last read |
|
|
144
|
+
| `AMBIGUOUS_REAPPLY` | Multiple candidates found during safe reapply |
|
|
145
|
+
| `TARGET_OUT_OF_RANGE` | Line number exceeds file length |
|
|
146
|
+
| `INVALID_REF` | Malformed hash reference |
|
|
147
|
+
| `INVALID_RANGE` | Start line is after end line |
|
|
148
|
+
| `MISSING_REPLACEMENT` | Replace/insert operation without content |
|
|
149
|
+
|
|
107
150
|
### 🔍 Indentation-Sensitive Hashing
|
|
108
151
|
|
|
109
152
|
Hash computation uses `trimEnd()` (not `trim()`), so changes to leading whitespace (indentation) are detected as content changes, while trailing whitespace is ignored.
|
|
@@ -155,6 +198,8 @@ const isExcluded = hl.shouldExclude("node_modules/foo.js"); // true
|
|
|
155
198
|
| `hashLength` | `number \| undefined` | `undefined` (adaptive) | Force specific hash length |
|
|
156
199
|
| `cacheSize` | `number` | `100` | Max files in LRU cache |
|
|
157
200
|
| `prefix` | `string \| false` | `"#HL "` | Line prefix (`false` to disable) |
|
|
201
|
+
| `fileRev` | `boolean` | `true` | Include file revision hash (`#HL REV:...`) in annotations |
|
|
202
|
+
| `safeReapply` | `boolean` | `false` | Auto-relocate moved lines by content hash |
|
|
158
203
|
|
|
159
204
|
Default exclude patterns cover: lock files, `node_modules`, minified files, binary files (images, fonts, archives, etc.).
|
|
160
205
|
|
|
@@ -442,9 +487,12 @@ The idea behind hashline is inspired by concepts from **oh-my-pi** by [can1357](
|
|
|
442
487
|
|
|
443
488
|
Hashline solves this by assigning each line a short, deterministic hash tag (e.g. `2:f1c`), making line addressing **exact and unambiguous**. The model can reference any line or range precisely, eliminating off-by-one errors and duplicate-line confusion.
|
|
444
489
|
|
|
490
|
+
The advanced features — **file revision** (`fileRev`), **safe reapply**, and **structured errors** — are inspired by the hash-based editing implementation in **AssistAgents** by [OzeroHAX](https://github.com/OzeroHAX/AssistAgents), which independently applied a similar approach for OpenCode with additional integrity checks and error diagnostics.
|
|
491
|
+
|
|
445
492
|
**References:**
|
|
446
493
|
- [oh-my-pi by can1357](https://github.com/can1357/oh-my-pi) — AI coding agent toolkit: coding agent CLI, unified LLM API, TUI libraries
|
|
447
494
|
- [The Harness Problem](https://blog.can.ac/2026/02/12/the-harness-problem/) — blog post describing the problem in detail
|
|
495
|
+
- [AssistAgents by OzeroHAX](https://github.com/OzeroHAX/AssistAgents) — hash-based editing for OpenCode with file revision, safe reapply, and structured conflicts
|
|
448
496
|
- [Описание подхода на Хабре](https://habr.com/ru/companies/bothub/news/995986/) — overview of the approach in Russian
|
|
449
497
|
|
|
450
498
|
---
|
package/README.md
CHANGED
|
@@ -104,6 +104,49 @@ if (!result.valid) {
|
|
|
104
104
|
|
|
105
105
|
Верификация хешей использует длину предоставленной хеш-ссылки (а не текущий размер файла), поэтому ссылка вроде `2:f1` остаётся валидной даже если файл вырос.
|
|
106
106
|
|
|
107
|
+
### 🔒 Ревизия файла (`fileRev`)
|
|
108
|
+
|
|
109
|
+
Помимо построчных хешей, hashline вычисляет хеш всего файла (FNV-1a, 8 hex-символов). Он добавляется первой строкой аннотации:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
#HL REV:72c4946c
|
|
113
|
+
#HL 1:a3f|function hello() {
|
|
114
|
+
#HL 2:f1c| return "world";
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
При редактировании передайте `fileRev` в `hashline_edit` — если файл изменился с момента чтения, правка будет отклонена с ошибкой `FILE_REV_MISMATCH`.
|
|
118
|
+
|
|
119
|
+
### 🔄 Safe Reapply
|
|
120
|
+
|
|
121
|
+
Если строка переместилась (например, из-за вставки строк выше), `safeReapply` находит её по хешу контента:
|
|
122
|
+
|
|
123
|
+
- **1 кандидат** — правка применяется к новой позиции
|
|
124
|
+
- **>1 кандидатов** — ошибка `AMBIGUOUS_REAPPLY` (неоднозначность)
|
|
125
|
+
- **0 кандидатов** — ошибка `HASH_MISMATCH`
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
const result = applyHashEdit(
|
|
129
|
+
{ operation: "replace", startRef: "1:a3f", replacement: "new" },
|
|
130
|
+
content,
|
|
131
|
+
undefined,
|
|
132
|
+
true, // safeReapply
|
|
133
|
+
);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 🏷️ Structured Errors
|
|
137
|
+
|
|
138
|
+
Все ошибки hashline — экземпляры `HashlineError` с кодом, диагностикой и подсказками:
|
|
139
|
+
|
|
140
|
+
| Код | Описание |
|
|
141
|
+
|-----|----------|
|
|
142
|
+
| `HASH_MISMATCH` | Содержимое строки изменилось |
|
|
143
|
+
| `FILE_REV_MISMATCH` | Файл модифицирован с момента чтения |
|
|
144
|
+
| `AMBIGUOUS_REAPPLY` | Несколько кандидатов при safe reapply |
|
|
145
|
+
| `TARGET_OUT_OF_RANGE` | Номер строки за пределами файла |
|
|
146
|
+
| `INVALID_REF` | Некорректная хеш-ссылка |
|
|
147
|
+
| `INVALID_RANGE` | Начало диапазона после конца |
|
|
148
|
+
| `MISSING_REPLACEMENT` | Операция replace/insert без содержимого |
|
|
149
|
+
|
|
107
150
|
### 🔍 Чувствительность к отступам
|
|
108
151
|
|
|
109
152
|
Вычисление хеша использует `trimEnd()` (а не `trim()`), поэтому изменения ведущих пробелов (отступов) обнаруживаются как изменения содержимого, а завершающие пробелы игнорируются.
|
|
@@ -155,6 +198,8 @@ const isExcluded = hl.shouldExclude("node_modules/foo.js"); // true
|
|
|
155
198
|
| `hashLength` | `number \| undefined` | `undefined` (адаптивно) | Принудительная длина хеша |
|
|
156
199
|
| `cacheSize` | `number` | `100` | Макс. файлов в LRU-кеше |
|
|
157
200
|
| `prefix` | `string \| false` | `"#HL "` | Префикс строки (`false` для отключения) |
|
|
201
|
+
| `fileRev` | `boolean` | `true` | Включать ревизию файла (`#HL REV:...`) в аннотации |
|
|
202
|
+
| `safeReapply` | `boolean` | `false` | Автоматический поиск перемещённых строк по хешу |
|
|
158
203
|
|
|
159
204
|
Паттерны исключения по умолчанию: lock-файлы, `node_modules`, минифицированные файлы, бинарные файлы (изображения, шрифты, архивы и т.д.).
|
|
160
205
|
|
|
@@ -417,9 +462,12 @@ npm run typecheck
|
|
|
417
462
|
|
|
418
463
|
Hashline решает эту проблему, присваивая каждой строке короткий детерминированный хеш-тег (например, `2:f1c`), что делает адресацию строк **точной и однозначной**. Модель может ссылаться на любую строку или диапазон без ошибок смещения и путаницы с дубликатами.
|
|
419
464
|
|
|
465
|
+
Продвинутые фичи — **ревизия файла** (`fileRev`), **safe reapply** и **structured errors** — вдохновлены реализацией hash-based editing в проекте **AssistAgents** от [OzeroHAX](https://github.com/OzeroHAX/AssistAgents), который независимо применил аналогичный подход для OpenCode с дополнительными механизмами проверки целостности и диагностики ошибок.
|
|
466
|
+
|
|
420
467
|
**Ссылки:**
|
|
421
468
|
- [oh-my-pi от can1357](https://github.com/can1357/oh-my-pi) — AI-тулкит для разработки: coding agent CLI, unified LLM API, TUI-библиотеки
|
|
422
469
|
- [The Harness Problem](https://blog.can.ac/2026/02/12/the-harness-problem/) — блог-пост с подробным описанием проблемы
|
|
470
|
+
- [AssistAgents от OzeroHAX](https://github.com/OzeroHAX/AssistAgents) — hash-based editing для OpenCode с file revision, safe reapply и structured conflicts
|
|
423
471
|
- [Статья на Хабре](https://habr.com/ru/companies/bothub/news/995986/) — описание подхода на русском языке
|
|
424
472
|
|
|
425
473
|
---
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
resolveConfig,
|
|
5
5
|
shouldExclude,
|
|
6
6
|
stripHashes
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-DOR4YDIS.js";
|
|
8
8
|
|
|
9
9
|
// src/hooks.ts
|
|
10
10
|
import { appendFileSync } from "fs";
|
|
@@ -94,7 +94,7 @@ function createFileReadAfterHook(cache, config) {
|
|
|
94
94
|
return;
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
-
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
|
|
97
|
+
const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, resolved.fileRev);
|
|
98
98
|
output.output = annotated;
|
|
99
99
|
debug("annotated", typeof filePath === "string" ? filePath : input.tool, "lines:", content.split("\n").length);
|
|
100
100
|
if (cache && typeof filePath === "string") {
|
|
@@ -206,10 +206,32 @@ function createSystemPromptHook(config) {
|
|
|
206
206
|
'- Hash references include both the line number AND the content hash, so `2:f1c` means "line 2 with hash f1c".',
|
|
207
207
|
"- If you see a mismatch, do NOT proceed with the edit \u2014 re-read the file to get fresh references.",
|
|
208
208
|
"",
|
|
209
|
+
"### File revision (`#HL REV:<hash>`):",
|
|
210
|
+
"- When files are read, the first line may contain a file revision header: `" + prefix + "REV:<8-char-hex>`.",
|
|
211
|
+
"- 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.",
|
|
212
|
+
"- If the file was modified between read and edit, the revision check fails with `FILE_REV_MISMATCH` \u2014 re-read the file.",
|
|
213
|
+
"",
|
|
214
|
+
"### Safe reapply (`safeReapply`):",
|
|
215
|
+
"- Pass `safeReapply: true` to `hashline_edit` to enable automatic line relocation.",
|
|
216
|
+
"- If a line moved (e.g., due to insertions above), safe reapply finds it by content hash.",
|
|
217
|
+
"- If exactly one match is found, the edit proceeds at the new location.",
|
|
218
|
+
"- If multiple matches exist, the edit fails with `AMBIGUOUS_REAPPLY` \u2014 re-read the file.",
|
|
219
|
+
"",
|
|
220
|
+
"### Structured error codes:",
|
|
221
|
+
"- `HASH_MISMATCH` \u2014 line content changed since last read",
|
|
222
|
+
"- `FILE_REV_MISMATCH` \u2014 file was modified since last read",
|
|
223
|
+
"- `AMBIGUOUS_REAPPLY` \u2014 multiple candidate lines found during safe reapply",
|
|
224
|
+
"- `TARGET_OUT_OF_RANGE` \u2014 line number exceeds file length",
|
|
225
|
+
"- `INVALID_REF` \u2014 malformed hash reference",
|
|
226
|
+
"- `INVALID_RANGE` \u2014 start line is after end line",
|
|
227
|
+
"- `MISSING_REPLACEMENT` \u2014 replace/insert operation without replacement content",
|
|
228
|
+
"",
|
|
209
229
|
"### Best practices:",
|
|
210
230
|
"- Use hash references for all edit operations to ensure precision.",
|
|
211
231
|
"- When making multiple edits, work from bottom to top to avoid line number shifts.",
|
|
212
|
-
"- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines."
|
|
232
|
+
"- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines.",
|
|
233
|
+
"- Use `fileRev` to guard against stale edits on critical files.",
|
|
234
|
+
"- Use `safeReapply: true` when editing files that may have shifted due to earlier edits."
|
|
213
235
|
].join("\n")
|
|
214
236
|
);
|
|
215
237
|
};
|
|
@@ -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,
|