@svilupp/hash-edit 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jan Siml
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # hash-edit
2
+
3
+ Minimal AI-agent file read/edit/write tools with line-hash anchors for TypeScript.
4
+
5
+ ## Install
6
+
7
+ Bun:
8
+
9
+ ```bash
10
+ bun add @svilupp/hash-edit
11
+ ```
12
+
13
+ Node.js:
14
+
15
+ ```bash
16
+ npm install @svilupp/hash-edit
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```typescript
22
+ import { HashEditHarness } from "@svilupp/hash-edit";
23
+
24
+ const h = new HashEditHarness("/path/to/project");
25
+
26
+ // 1. Read — returns rendered lines with hashes and a file version
27
+ const result = await h.read("src/app.ts");
28
+ // result.version === "4a3f..."
29
+ // result.lines === ["1:ab|import fs from 'fs'", "2:cd|", "3:ef|export function main() {}"]
30
+
31
+ // 2. Edit — anchored ops, verified against expected_version
32
+ await h.edit(
33
+ "src/app.ts",
34
+ [
35
+ { op: "replace", line: 3, hash: "ef", lines: ["export function main() {", " return 0;", "}"] },
36
+ ],
37
+ { expected_version: result.version },
38
+ );
39
+
40
+ // 3. Write — safely overwrite an existing file (version required) or create a new one
41
+ const current = await h.read("src/app.ts");
42
+ await h.write("src/app.ts", "// new content\n", { expected_version: current.version });
43
+ ```
44
+
45
+ ## Windowed reads
46
+
47
+ For large files, read only the lines you need. This can save 55–95% of tokens sent to the model:
48
+
49
+ ```typescript
50
+ // 1 000-line file — read only lines 400–450
51
+ const window = await h.read("src/big.ts", { start_line: 400, end_line: 450 });
52
+ // window.lines.length === 51
53
+ // window.total_lines === 1000 ← tells the model the full file size
54
+ // window.version ← same digest as a full-file read; valid for edit()
55
+ ```
56
+
57
+ The returned `version` is a full-file blake2s digest, so it can be passed directly to `edit()` without re-reading the whole file.
58
+
59
+ ## Error classes
60
+
61
+ | Error | When |
62
+ |-------|------|
63
+ | `VersionConflictError` | File changed since last read |
64
+ | `AnchorMismatchError` | Hash doesn't match current line; includes updated rendered context |
65
+ | `InvalidOperationError` | Bad op payload (e.g. missing `hash`, no-op replace, out-of-range line) |
66
+ | `PathEscapeError` | Path escapes the configured root directory |
67
+ | `MixedNewlineError` | File uses more than one newline style |
68
+ | `FileEncodingError` | File cannot be decoded with the configured encoding (UTF-8 by default) |
69
+
70
+ ## Conditional exports
71
+
72
+ Bun users get direct TypeScript source via the `bun` conditional export — no build step needed. Node.js consumers get pre-built ESM (`dist/index.js`) and CJS (`dist/index.cjs`) bundles.
73
+
74
+ ## See also
75
+
76
+ [Root README](https://github.com/jansiml/hash-edit#readme) — design rationale, Python package, shared fixture corpus, and benchmark results.
package/dist/index.cjs ADDED
@@ -0,0 +1,443 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ AnchorMismatchError: () => AnchorMismatchError,
24
+ FileEncodingError: () => FileEncodingError,
25
+ HashEditError: () => HashEditError,
26
+ HashEditHarness: () => HashEditHarness,
27
+ InvalidOperationError: () => InvalidOperationError,
28
+ MixedNewlineError: () => MixedNewlineError,
29
+ PathEscapeError: () => PathEscapeError,
30
+ VersionConflictError: () => VersionConflictError,
31
+ computeLineHash: () => computeLineHash,
32
+ renderLine: () => renderLine,
33
+ renderLines: () => renderLines,
34
+ stripRenderPrefixes: () => stripRenderPrefixes
35
+ });
36
+ module.exports = __toCommonJS(index_exports);
37
+ var import_node_crypto = require("crypto");
38
+ var import_node_fs = require("fs");
39
+ var import_promises = require("fs/promises");
40
+ var import_node_path = require("path");
41
+ var DEFAULT_ENCODING = "utf-8";
42
+ var DEFAULT_HASH_LENGTH = 2;
43
+ var UTF8_BOM = Buffer.from([239, 187, 191]);
44
+ var PREFIX_RE = /^\s*\d+:[0-9a-f]{2,}\|/;
45
+ var HashEditError = class extends Error {
46
+ };
47
+ var PathEscapeError = class extends HashEditError {
48
+ };
49
+ var MixedNewlineError = class extends HashEditError {
50
+ };
51
+ var FileEncodingError = class extends HashEditError {
52
+ };
53
+ var InvalidOperationError = class extends HashEditError {
54
+ };
55
+ var VersionConflictError = class extends HashEditError {
56
+ path;
57
+ expected;
58
+ actual;
59
+ constructor(path, expected, actual) {
60
+ const message = expected === null ? `${path} already exists. read() first and pass expected_version to overwrite safely.` : `Version conflict for ${path}: expected ${expected}, actual ${actual}. Re-read the file first.`;
61
+ super(message);
62
+ this.path = path;
63
+ this.expected = expected;
64
+ this.actual = actual;
65
+ }
66
+ };
67
+ var AnchorMismatchError = class extends HashEditError {
68
+ mismatches;
69
+ constructor(message, mismatches) {
70
+ super(message);
71
+ this.mismatches = mismatches;
72
+ }
73
+ };
74
+ function newlineSeparator(style) {
75
+ if (style === "lf") return "\n";
76
+ if (style === "crlf") return "\r\n";
77
+ if (style === "cr") return "\r";
78
+ return "\n";
79
+ }
80
+ function detectNewlineStyle(text) {
81
+ const matches = text.match(/\r\n|\n|\r/g) ?? [];
82
+ const unique = new Set(matches);
83
+ if (unique.size > 1) {
84
+ throw new MixedNewlineError(`Mixed newline styles detected: ${Array.from(unique).join(", ")}`);
85
+ }
86
+ if (matches.length === 0) {
87
+ return { newline: "none", has_final_newline: false, lines: text === "" ? [] : [text] };
88
+ }
89
+ const separator = matches[0] ?? "\n";
90
+ const newline = separator === "\n" ? "lf" : separator === "\r\n" ? "crlf" : "cr";
91
+ const has_final_newline = text.endsWith(separator);
92
+ const split = text.split(separator);
93
+ const lines = has_final_newline ? split.slice(0, -1) : split;
94
+ return { newline, has_final_newline, lines };
95
+ }
96
+ function joinText(lines, newline, hasFinalNewline) {
97
+ if (lines.length === 0) return "";
98
+ const separator = newlineSeparator(newline);
99
+ let text = lines.join(separator);
100
+ if (hasFinalNewline) text += separator;
101
+ return text;
102
+ }
103
+ function versionForBytes(data) {
104
+ return (0, import_node_crypto.createHash)("blake2s256").update(data).digest("hex").slice(0, 32);
105
+ }
106
+ async function pathExists(path) {
107
+ try {
108
+ await (0, import_promises.stat)(path);
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+ function computeLineHash(lineNumber, text, hashLength = DEFAULT_HASH_LENGTH) {
115
+ if (hashLength < 2) throw new Error("hashLength must be >= 2");
116
+ const normalized = text.replace(/\r/g, "").replace(/[ \t]+$/u, "");
117
+ const material = /[\p{L}\p{N}]/u.test(normalized) ? normalized : `${lineNumber}\0${normalized}`;
118
+ return (0, import_node_crypto.createHash)("blake2s256").update(material, DEFAULT_ENCODING).digest("hex").slice(0, hashLength);
119
+ }
120
+ function renderLine(lineNumber, text, hashLength = DEFAULT_HASH_LENGTH) {
121
+ return `${lineNumber}:${computeLineHash(lineNumber, text, hashLength)}|${text}`;
122
+ }
123
+ function renderLines(lines, startLine = 1, hashLength = DEFAULT_HASH_LENGTH) {
124
+ return lines.map((line, index) => renderLine(startLine + index, line, hashLength));
125
+ }
126
+ function stripRenderPrefixes(lines) {
127
+ const nonEmpty = lines.filter((line) => line !== "");
128
+ if (nonEmpty.length > 0 && nonEmpty.every((line) => PREFIX_RE.test(line))) {
129
+ return lines.map((line) => line.replace(PREFIX_RE, ""));
130
+ }
131
+ return [...lines];
132
+ }
133
+ function mismatchMessage(path, lines, hashLength, mismatches) {
134
+ const displayLines = /* @__PURE__ */ new Set();
135
+ for (const mismatch of mismatches) {
136
+ for (let line = Math.max(1, mismatch.line - 2); line <= Math.min(lines.length, mismatch.line + 2); line += 1) {
137
+ displayLines.add(line);
138
+ }
139
+ }
140
+ const rendered = [
141
+ `Anchors are stale in ${path}. Re-read the file and use the updated rendered lines below.`
142
+ ];
143
+ if (displayLines.size === 0) return rendered.join("\n");
144
+ rendered.push("");
145
+ const mismatchSet = new Set(mismatches.map((mismatch) => mismatch.line));
146
+ let previous = -1;
147
+ for (const lineNumber of [...displayLines].sort((left, right) => left - right)) {
148
+ if (previous !== -1 && lineNumber > previous + 1) {
149
+ rendered.push(" ...");
150
+ }
151
+ const prefix = mismatchSet.has(lineNumber) ? ">>> " : " ";
152
+ rendered.push(prefix + renderLine(lineNumber, lines[lineNumber - 1] ?? "", hashLength));
153
+ previous = lineNumber;
154
+ }
155
+ return rendered.join("\n");
156
+ }
157
+ var HashEditHarness = class {
158
+ #root;
159
+ #resolvedRoot;
160
+ #encoding;
161
+ #hashLength;
162
+ constructor(root = ".", options = {}) {
163
+ this.#root = (0, import_node_path.resolve)(root);
164
+ this.#resolvedRoot = resolveExistingPath(this.#root);
165
+ this.#encoding = options.default_encoding ?? DEFAULT_ENCODING;
166
+ this.#hashLength = options.hash_length ?? DEFAULT_HASH_LENGTH;
167
+ }
168
+ resolvePath(path) {
169
+ const absolute = (0, import_node_path.resolve)(this.#root, path);
170
+ const resolved = resolveExistingPath(absolute);
171
+ const rel = (0, import_node_path.relative)(this.#resolvedRoot, resolved);
172
+ if (rel.startsWith("..") || (0, import_node_path.isAbsolute)(rel)) {
173
+ throw new PathEscapeError(`${path} escapes root ${this.#root}`);
174
+ }
175
+ return resolved;
176
+ }
177
+ async read(path, options = {}) {
178
+ const resolved = this.resolvePath(path);
179
+ const snapshot = await this.readSnapshot(resolved);
180
+ if (snapshot.lines.length === 0) {
181
+ return {
182
+ path: snapshot.path,
183
+ version: snapshot.version,
184
+ encoding: snapshot.encoding,
185
+ bom: snapshot.bom,
186
+ newline: snapshot.newline,
187
+ has_final_newline: snapshot.has_final_newline,
188
+ hash_length: this.#hashLength,
189
+ total_lines: 0,
190
+ start_line: 0,
191
+ end_line: 0,
192
+ lines: []
193
+ };
194
+ }
195
+ const startLine = options.start_line ?? 1;
196
+ const endLine = options.end_line ?? snapshot.lines.length;
197
+ if (startLine < 1 || endLine < startLine || endLine > snapshot.lines.length) {
198
+ throw new InvalidOperationError(
199
+ `Invalid read window for ${snapshot.path}: start_line=${startLine}, end_line=${endLine}, total_lines=${snapshot.lines.length}`
200
+ );
201
+ }
202
+ return {
203
+ path: snapshot.path,
204
+ version: snapshot.version,
205
+ encoding: snapshot.encoding,
206
+ bom: snapshot.bom,
207
+ newline: snapshot.newline,
208
+ has_final_newline: snapshot.has_final_newline,
209
+ hash_length: this.#hashLength,
210
+ total_lines: snapshot.lines.length,
211
+ start_line: startLine,
212
+ end_line: endLine,
213
+ lines: renderLines(snapshot.lines.slice(startLine - 1, endLine), startLine, this.#hashLength)
214
+ };
215
+ }
216
+ async edit(path, ops, options) {
217
+ const resolved = this.resolvePath(path);
218
+ const snapshot = await this.readSnapshot(resolved);
219
+ if (options.expected_version !== snapshot.version) {
220
+ throw new VersionConflictError(snapshot.path, options.expected_version, snapshot.version);
221
+ }
222
+ const operations = Array.isArray(ops) ? [...ops] : [ops];
223
+ if (operations.length === 0) {
224
+ throw new InvalidOperationError("edit() requires at least one operation");
225
+ }
226
+ const lines = [...snapshot.lines];
227
+ const mismatches = [];
228
+ const validateAnchor = (line, expectedHash) => {
229
+ if (line < 1 || line > lines.length) {
230
+ throw new InvalidOperationError(
231
+ `Line ${line} is out of range for ${snapshot.path} (total_lines=${lines.length})`
232
+ );
233
+ }
234
+ const actual = computeLineHash(line, lines[line - 1] ?? "", this.#hashLength);
235
+ if (actual !== expectedHash) {
236
+ mismatches.push({ line, expected: expectedHash, actual });
237
+ }
238
+ };
239
+ for (const operation of operations) {
240
+ validateAnchor(operation.line, operation.hash);
241
+ if ("end_line" in operation && operation.end_line !== void 0) {
242
+ if (!operation.end_hash) {
243
+ throw new InvalidOperationError("Range edits require end_hash");
244
+ }
245
+ validateAnchor(operation.end_line, operation.end_hash);
246
+ if (operation.end_line < operation.line) {
247
+ throw new InvalidOperationError("end_line must be >= line");
248
+ }
249
+ }
250
+ }
251
+ if (mismatches.length > 0) {
252
+ throw new AnchorMismatchError(
253
+ mismatchMessage(snapshot.path, lines, this.#hashLength, mismatches),
254
+ mismatches
255
+ );
256
+ }
257
+ const sortKey = (operation) => {
258
+ const lineNumber = "end_line" in operation && operation.end_line !== void 0 ? operation.end_line : operation.line;
259
+ const precedence = { replace: 0, delete: 0, insert_after: 1, insert_before: 2 }[operation.op];
260
+ return [-lineNumber, precedence];
261
+ };
262
+ let firstChangedLine = null;
263
+ for (const operation of [...operations].sort((left, right) => {
264
+ const [leftLine, leftPrec] = sortKey(left);
265
+ const [rightLine, rightPrec] = sortKey(right);
266
+ return leftLine - rightLine || leftPrec - rightPrec;
267
+ })) {
268
+ if (operation.op === "replace") {
269
+ const replacement = stripRenderPrefixes([...operation.lines]);
270
+ const endLine = operation.end_line ?? operation.line;
271
+ const current = lines.slice(operation.line - 1, endLine);
272
+ if (current.length === replacement.length && current.every((line, index) => line === replacement[index])) {
273
+ throw new InvalidOperationError(
274
+ `No changes made to ${snapshot.path}. Replacement for ${operation.line}:${operation.hash} is identical.`
275
+ );
276
+ }
277
+ lines.splice(operation.line - 1, endLine - operation.line + 1, ...replacement);
278
+ firstChangedLine = firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line);
279
+ } else if (operation.op === "delete") {
280
+ const endLine = operation.end_line ?? operation.line;
281
+ lines.splice(operation.line - 1, endLine - operation.line + 1);
282
+ firstChangedLine = firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line);
283
+ } else if (operation.op === "insert_before") {
284
+ const inserted = stripRenderPrefixes([...operation.lines]);
285
+ const payload = inserted.length === 0 ? [""] : inserted;
286
+ lines.splice(operation.line - 1, 0, ...payload);
287
+ firstChangedLine = firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line);
288
+ } else {
289
+ const inserted = stripRenderPrefixes([...operation.lines]);
290
+ const payload = inserted.length === 0 ? [""] : inserted;
291
+ lines.splice(operation.line, 0, ...payload);
292
+ const insertionLine = operation.line + 1;
293
+ firstChangedLine = firstChangedLine === null ? insertionLine : Math.min(firstChangedLine, insertionLine);
294
+ }
295
+ }
296
+ const versionAfter = await this.writeSnapshot(resolved, {
297
+ lines,
298
+ bom: snapshot.bom,
299
+ newline: snapshot.newline,
300
+ has_final_newline: snapshot.has_final_newline
301
+ });
302
+ return {
303
+ path: snapshot.path,
304
+ version_before: snapshot.version,
305
+ version_after: versionAfter,
306
+ applied: operations.length,
307
+ first_changed_line: firstChangedLine,
308
+ encoding: snapshot.encoding,
309
+ bom: snapshot.bom,
310
+ newline: snapshot.newline,
311
+ has_final_newline: snapshot.has_final_newline,
312
+ total_lines: lines.length
313
+ };
314
+ }
315
+ async write(path, text, options = {}) {
316
+ const resolved = this.resolvePath(path);
317
+ const created = !await pathExists(resolved);
318
+ const input = detectNewlineStyle(text);
319
+ let bom = false;
320
+ let newline = input.newline;
321
+ let versionAfter;
322
+ if (created) {
323
+ versionAfter = await this.writeSnapshot(resolved, {
324
+ lines: input.lines,
325
+ bom: false,
326
+ newline: input.newline,
327
+ has_final_newline: input.has_final_newline
328
+ });
329
+ } else {
330
+ const snapshot = await this.readSnapshot(resolved);
331
+ if ((options.expected_version ?? null) !== snapshot.version) {
332
+ throw new VersionConflictError(
333
+ snapshot.path,
334
+ options.expected_version ?? null,
335
+ snapshot.version
336
+ );
337
+ }
338
+ bom = snapshot.bom;
339
+ newline = snapshot.newline === "none" ? input.newline : snapshot.newline;
340
+ versionAfter = await this.writeSnapshot(resolved, {
341
+ lines: input.lines,
342
+ bom,
343
+ newline,
344
+ has_final_newline: input.has_final_newline
345
+ });
346
+ }
347
+ return {
348
+ path: (0, import_node_path.relative)(this.#root, resolved),
349
+ version_after: versionAfter,
350
+ created,
351
+ encoding: this.#encoding,
352
+ bom,
353
+ newline,
354
+ has_final_newline: input.has_final_newline,
355
+ total_lines: input.lines.length
356
+ };
357
+ }
358
+ async readSnapshot(path) {
359
+ const raw = Buffer.from(await (0, import_promises.readFile)(path));
360
+ const version = versionForBytes(raw);
361
+ const bom = raw.subarray(0, UTF8_BOM.length).equals(UTF8_BOM);
362
+ const payload = bom ? raw.subarray(UTF8_BOM.length) : raw;
363
+ let text;
364
+ try {
365
+ text = new TextDecoder(this.#encoding, { fatal: true }).decode(payload);
366
+ } catch (error) {
367
+ throw new FileEncodingError(`${(0, import_node_path.relative)(this.#root, path)} is not valid ${this.#encoding}`, {
368
+ cause: error
369
+ });
370
+ }
371
+ const parsed = detectNewlineStyle(text);
372
+ return {
373
+ path: (0, import_node_path.relative)(this.#root, path),
374
+ version,
375
+ encoding: this.#encoding,
376
+ bom,
377
+ newline: parsed.newline,
378
+ has_final_newline: parsed.has_final_newline,
379
+ lines: parsed.lines
380
+ };
381
+ }
382
+ async writeSnapshot(path, options) {
383
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)(path), { recursive: true });
384
+ const text = joinText(options.lines, options.newline, options.has_final_newline);
385
+ let payload = Buffer.from(text, this.#encoding);
386
+ if (options.bom) {
387
+ payload = Buffer.concat([UTF8_BOM, payload]);
388
+ }
389
+ const tempPath = `${path}.${(0, import_node_crypto.randomUUID)()}.tmp`;
390
+ let mode;
391
+ try {
392
+ mode = (await (0, import_promises.stat)(path)).mode;
393
+ } catch {
394
+ }
395
+ try {
396
+ await (0, import_promises.writeFile)(tempPath, payload);
397
+ if (mode !== void 0) {
398
+ await (0, import_promises.chmod)(tempPath, mode);
399
+ }
400
+ await (0, import_promises.rename)(tempPath, path);
401
+ } finally {
402
+ await (0, import_promises.rm)(tempPath, { force: true });
403
+ }
404
+ return versionForBytes(payload);
405
+ }
406
+ };
407
+ function resolveExistingPath(path) {
408
+ const missingSegments = [];
409
+ let current = path;
410
+ while (true) {
411
+ try {
412
+ const resolved = import_node_fs.realpathSync.native(current);
413
+ const suffix = [...missingSegments].reverse();
414
+ return missingSegments.length === 0 ? resolved : (0, import_node_path.join)(resolved, ...suffix);
415
+ } catch (error) {
416
+ const candidate = error;
417
+ if (candidate.code !== "ENOENT") {
418
+ throw candidate;
419
+ }
420
+ const parent = (0, import_node_path.dirname)(current);
421
+ if (parent === current) {
422
+ return (0, import_node_path.join)(current, ...[...missingSegments].reverse());
423
+ }
424
+ missingSegments.push((0, import_node_path.basename)(current));
425
+ current = parent;
426
+ }
427
+ }
428
+ }
429
+ // Annotate the CommonJS export names for ESM import in node:
430
+ 0 && (module.exports = {
431
+ AnchorMismatchError,
432
+ FileEncodingError,
433
+ HashEditError,
434
+ HashEditHarness,
435
+ InvalidOperationError,
436
+ MixedNewlineError,
437
+ PathEscapeError,
438
+ VersionConflictError,
439
+ computeLineHash,
440
+ renderLine,
441
+ renderLines,
442
+ stripRenderPrefixes
443
+ });
@@ -0,0 +1,118 @@
1
+ type NewlineStyle = "lf" | "crlf" | "cr" | "none";
2
+ type ReplaceOp = {
3
+ op: "replace";
4
+ line: number;
5
+ hash: string;
6
+ lines: string[];
7
+ end_line?: number;
8
+ end_hash?: string;
9
+ };
10
+ type InsertBeforeOp = {
11
+ op: "insert_before";
12
+ line: number;
13
+ hash: string;
14
+ lines: string[];
15
+ };
16
+ type InsertAfterOp = {
17
+ op: "insert_after";
18
+ line: number;
19
+ hash: string;
20
+ lines: string[];
21
+ };
22
+ type DeleteOp = {
23
+ op: "delete";
24
+ line: number;
25
+ hash: string;
26
+ end_line?: number;
27
+ end_hash?: string;
28
+ };
29
+ type EditOp = ReplaceOp | InsertBeforeOp | InsertAfterOp | DeleteOp;
30
+ type ReadResult = {
31
+ path: string;
32
+ version: string;
33
+ encoding: string;
34
+ bom: boolean;
35
+ newline: NewlineStyle;
36
+ has_final_newline: boolean;
37
+ hash_length: number;
38
+ total_lines: number;
39
+ start_line: number;
40
+ end_line: number;
41
+ lines: string[];
42
+ };
43
+ type EditResult = {
44
+ path: string;
45
+ version_before: string;
46
+ version_after: string;
47
+ applied: number;
48
+ first_changed_line: number | null;
49
+ encoding: string;
50
+ bom: boolean;
51
+ newline: NewlineStyle;
52
+ has_final_newline: boolean;
53
+ total_lines: number;
54
+ };
55
+ type WriteResult = {
56
+ path: string;
57
+ version_after: string;
58
+ created: boolean;
59
+ encoding: string;
60
+ bom: boolean;
61
+ newline: NewlineStyle;
62
+ has_final_newline: boolean;
63
+ total_lines: number;
64
+ };
65
+ declare class HashEditError extends Error {
66
+ }
67
+ declare class PathEscapeError extends HashEditError {
68
+ }
69
+ declare class MixedNewlineError extends HashEditError {
70
+ }
71
+ declare class FileEncodingError extends HashEditError {
72
+ }
73
+ declare class InvalidOperationError extends HashEditError {
74
+ }
75
+ declare class VersionConflictError extends HashEditError {
76
+ path: string;
77
+ expected: string | null;
78
+ actual: string;
79
+ constructor(path: string, expected: string | null, actual: string);
80
+ }
81
+ declare class AnchorMismatchError extends HashEditError {
82
+ mismatches: Array<{
83
+ line: number;
84
+ expected: string;
85
+ actual: string;
86
+ }>;
87
+ constructor(message: string, mismatches: Array<{
88
+ line: number;
89
+ expected: string;
90
+ actual: string;
91
+ }>);
92
+ }
93
+ declare function computeLineHash(lineNumber: number, text: string, hashLength?: number): string;
94
+ declare function renderLine(lineNumber: number, text: string, hashLength?: number): string;
95
+ declare function renderLines(lines: string[], startLine?: number, hashLength?: number): string[];
96
+ declare function stripRenderPrefixes(lines: string[]): string[];
97
+ declare class HashEditHarness {
98
+ #private;
99
+ constructor(root?: string, options?: {
100
+ default_encoding?: string;
101
+ hash_length?: number;
102
+ });
103
+ resolvePath(path: string): string;
104
+ read(path: string, options?: {
105
+ start_line?: number;
106
+ end_line?: number;
107
+ }): Promise<ReadResult>;
108
+ edit(path: string, ops: EditOp | EditOp[], options: {
109
+ expected_version: string;
110
+ }): Promise<EditResult>;
111
+ write(path: string, text: string, options?: {
112
+ expected_version?: string;
113
+ }): Promise<WriteResult>;
114
+ private readSnapshot;
115
+ private writeSnapshot;
116
+ }
117
+
118
+ export { AnchorMismatchError, type DeleteOp, type EditOp, type EditResult, FileEncodingError, HashEditError, HashEditHarness, type InsertAfterOp, type InsertBeforeOp, InvalidOperationError, MixedNewlineError, type NewlineStyle, PathEscapeError, type ReadResult, type ReplaceOp, VersionConflictError, type WriteResult, computeLineHash, renderLine, renderLines, stripRenderPrefixes };