@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 +21 -0
- package/README.md +76 -0
- package/dist/index.cjs +443 -0
- package/dist/index.d.cts +118 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +407 -0
- package/package.json +37 -0
- package/src/index.ts +564 -0
package/dist/index.d.ts
ADDED
|
@@ -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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
|
+
import { realpathSync } from "fs";
|
|
4
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join as joinPath, relative, resolve } from "path";
|
|
6
|
+
var DEFAULT_ENCODING = "utf-8";
|
|
7
|
+
var DEFAULT_HASH_LENGTH = 2;
|
|
8
|
+
var UTF8_BOM = Buffer.from([239, 187, 191]);
|
|
9
|
+
var PREFIX_RE = /^\s*\d+:[0-9a-f]{2,}\|/;
|
|
10
|
+
var HashEditError = class extends Error {
|
|
11
|
+
};
|
|
12
|
+
var PathEscapeError = class extends HashEditError {
|
|
13
|
+
};
|
|
14
|
+
var MixedNewlineError = class extends HashEditError {
|
|
15
|
+
};
|
|
16
|
+
var FileEncodingError = class extends HashEditError {
|
|
17
|
+
};
|
|
18
|
+
var InvalidOperationError = class extends HashEditError {
|
|
19
|
+
};
|
|
20
|
+
var VersionConflictError = class extends HashEditError {
|
|
21
|
+
path;
|
|
22
|
+
expected;
|
|
23
|
+
actual;
|
|
24
|
+
constructor(path, expected, actual) {
|
|
25
|
+
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.`;
|
|
26
|
+
super(message);
|
|
27
|
+
this.path = path;
|
|
28
|
+
this.expected = expected;
|
|
29
|
+
this.actual = actual;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var AnchorMismatchError = class extends HashEditError {
|
|
33
|
+
mismatches;
|
|
34
|
+
constructor(message, mismatches) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.mismatches = mismatches;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
function newlineSeparator(style) {
|
|
40
|
+
if (style === "lf") return "\n";
|
|
41
|
+
if (style === "crlf") return "\r\n";
|
|
42
|
+
if (style === "cr") return "\r";
|
|
43
|
+
return "\n";
|
|
44
|
+
}
|
|
45
|
+
function detectNewlineStyle(text) {
|
|
46
|
+
const matches = text.match(/\r\n|\n|\r/g) ?? [];
|
|
47
|
+
const unique = new Set(matches);
|
|
48
|
+
if (unique.size > 1) {
|
|
49
|
+
throw new MixedNewlineError(`Mixed newline styles detected: ${Array.from(unique).join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
if (matches.length === 0) {
|
|
52
|
+
return { newline: "none", has_final_newline: false, lines: text === "" ? [] : [text] };
|
|
53
|
+
}
|
|
54
|
+
const separator = matches[0] ?? "\n";
|
|
55
|
+
const newline = separator === "\n" ? "lf" : separator === "\r\n" ? "crlf" : "cr";
|
|
56
|
+
const has_final_newline = text.endsWith(separator);
|
|
57
|
+
const split = text.split(separator);
|
|
58
|
+
const lines = has_final_newline ? split.slice(0, -1) : split;
|
|
59
|
+
return { newline, has_final_newline, lines };
|
|
60
|
+
}
|
|
61
|
+
function joinText(lines, newline, hasFinalNewline) {
|
|
62
|
+
if (lines.length === 0) return "";
|
|
63
|
+
const separator = newlineSeparator(newline);
|
|
64
|
+
let text = lines.join(separator);
|
|
65
|
+
if (hasFinalNewline) text += separator;
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
function versionForBytes(data) {
|
|
69
|
+
return createHash("blake2s256").update(data).digest("hex").slice(0, 32);
|
|
70
|
+
}
|
|
71
|
+
async function pathExists(path) {
|
|
72
|
+
try {
|
|
73
|
+
await stat(path);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function computeLineHash(lineNumber, text, hashLength = DEFAULT_HASH_LENGTH) {
|
|
80
|
+
if (hashLength < 2) throw new Error("hashLength must be >= 2");
|
|
81
|
+
const normalized = text.replace(/\r/g, "").replace(/[ \t]+$/u, "");
|
|
82
|
+
const material = /[\p{L}\p{N}]/u.test(normalized) ? normalized : `${lineNumber}\0${normalized}`;
|
|
83
|
+
return createHash("blake2s256").update(material, DEFAULT_ENCODING).digest("hex").slice(0, hashLength);
|
|
84
|
+
}
|
|
85
|
+
function renderLine(lineNumber, text, hashLength = DEFAULT_HASH_LENGTH) {
|
|
86
|
+
return `${lineNumber}:${computeLineHash(lineNumber, text, hashLength)}|${text}`;
|
|
87
|
+
}
|
|
88
|
+
function renderLines(lines, startLine = 1, hashLength = DEFAULT_HASH_LENGTH) {
|
|
89
|
+
return lines.map((line, index) => renderLine(startLine + index, line, hashLength));
|
|
90
|
+
}
|
|
91
|
+
function stripRenderPrefixes(lines) {
|
|
92
|
+
const nonEmpty = lines.filter((line) => line !== "");
|
|
93
|
+
if (nonEmpty.length > 0 && nonEmpty.every((line) => PREFIX_RE.test(line))) {
|
|
94
|
+
return lines.map((line) => line.replace(PREFIX_RE, ""));
|
|
95
|
+
}
|
|
96
|
+
return [...lines];
|
|
97
|
+
}
|
|
98
|
+
function mismatchMessage(path, lines, hashLength, mismatches) {
|
|
99
|
+
const displayLines = /* @__PURE__ */ new Set();
|
|
100
|
+
for (const mismatch of mismatches) {
|
|
101
|
+
for (let line = Math.max(1, mismatch.line - 2); line <= Math.min(lines.length, mismatch.line + 2); line += 1) {
|
|
102
|
+
displayLines.add(line);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const rendered = [
|
|
106
|
+
`Anchors are stale in ${path}. Re-read the file and use the updated rendered lines below.`
|
|
107
|
+
];
|
|
108
|
+
if (displayLines.size === 0) return rendered.join("\n");
|
|
109
|
+
rendered.push("");
|
|
110
|
+
const mismatchSet = new Set(mismatches.map((mismatch) => mismatch.line));
|
|
111
|
+
let previous = -1;
|
|
112
|
+
for (const lineNumber of [...displayLines].sort((left, right) => left - right)) {
|
|
113
|
+
if (previous !== -1 && lineNumber > previous + 1) {
|
|
114
|
+
rendered.push(" ...");
|
|
115
|
+
}
|
|
116
|
+
const prefix = mismatchSet.has(lineNumber) ? ">>> " : " ";
|
|
117
|
+
rendered.push(prefix + renderLine(lineNumber, lines[lineNumber - 1] ?? "", hashLength));
|
|
118
|
+
previous = lineNumber;
|
|
119
|
+
}
|
|
120
|
+
return rendered.join("\n");
|
|
121
|
+
}
|
|
122
|
+
var HashEditHarness = class {
|
|
123
|
+
#root;
|
|
124
|
+
#resolvedRoot;
|
|
125
|
+
#encoding;
|
|
126
|
+
#hashLength;
|
|
127
|
+
constructor(root = ".", options = {}) {
|
|
128
|
+
this.#root = resolve(root);
|
|
129
|
+
this.#resolvedRoot = resolveExistingPath(this.#root);
|
|
130
|
+
this.#encoding = options.default_encoding ?? DEFAULT_ENCODING;
|
|
131
|
+
this.#hashLength = options.hash_length ?? DEFAULT_HASH_LENGTH;
|
|
132
|
+
}
|
|
133
|
+
resolvePath(path) {
|
|
134
|
+
const absolute = resolve(this.#root, path);
|
|
135
|
+
const resolved = resolveExistingPath(absolute);
|
|
136
|
+
const rel = relative(this.#resolvedRoot, resolved);
|
|
137
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
138
|
+
throw new PathEscapeError(`${path} escapes root ${this.#root}`);
|
|
139
|
+
}
|
|
140
|
+
return resolved;
|
|
141
|
+
}
|
|
142
|
+
async read(path, options = {}) {
|
|
143
|
+
const resolved = this.resolvePath(path);
|
|
144
|
+
const snapshot = await this.readSnapshot(resolved);
|
|
145
|
+
if (snapshot.lines.length === 0) {
|
|
146
|
+
return {
|
|
147
|
+
path: snapshot.path,
|
|
148
|
+
version: snapshot.version,
|
|
149
|
+
encoding: snapshot.encoding,
|
|
150
|
+
bom: snapshot.bom,
|
|
151
|
+
newline: snapshot.newline,
|
|
152
|
+
has_final_newline: snapshot.has_final_newline,
|
|
153
|
+
hash_length: this.#hashLength,
|
|
154
|
+
total_lines: 0,
|
|
155
|
+
start_line: 0,
|
|
156
|
+
end_line: 0,
|
|
157
|
+
lines: []
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const startLine = options.start_line ?? 1;
|
|
161
|
+
const endLine = options.end_line ?? snapshot.lines.length;
|
|
162
|
+
if (startLine < 1 || endLine < startLine || endLine > snapshot.lines.length) {
|
|
163
|
+
throw new InvalidOperationError(
|
|
164
|
+
`Invalid read window for ${snapshot.path}: start_line=${startLine}, end_line=${endLine}, total_lines=${snapshot.lines.length}`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
path: snapshot.path,
|
|
169
|
+
version: snapshot.version,
|
|
170
|
+
encoding: snapshot.encoding,
|
|
171
|
+
bom: snapshot.bom,
|
|
172
|
+
newline: snapshot.newline,
|
|
173
|
+
has_final_newline: snapshot.has_final_newline,
|
|
174
|
+
hash_length: this.#hashLength,
|
|
175
|
+
total_lines: snapshot.lines.length,
|
|
176
|
+
start_line: startLine,
|
|
177
|
+
end_line: endLine,
|
|
178
|
+
lines: renderLines(snapshot.lines.slice(startLine - 1, endLine), startLine, this.#hashLength)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
async edit(path, ops, options) {
|
|
182
|
+
const resolved = this.resolvePath(path);
|
|
183
|
+
const snapshot = await this.readSnapshot(resolved);
|
|
184
|
+
if (options.expected_version !== snapshot.version) {
|
|
185
|
+
throw new VersionConflictError(snapshot.path, options.expected_version, snapshot.version);
|
|
186
|
+
}
|
|
187
|
+
const operations = Array.isArray(ops) ? [...ops] : [ops];
|
|
188
|
+
if (operations.length === 0) {
|
|
189
|
+
throw new InvalidOperationError("edit() requires at least one operation");
|
|
190
|
+
}
|
|
191
|
+
const lines = [...snapshot.lines];
|
|
192
|
+
const mismatches = [];
|
|
193
|
+
const validateAnchor = (line, expectedHash) => {
|
|
194
|
+
if (line < 1 || line > lines.length) {
|
|
195
|
+
throw new InvalidOperationError(
|
|
196
|
+
`Line ${line} is out of range for ${snapshot.path} (total_lines=${lines.length})`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const actual = computeLineHash(line, lines[line - 1] ?? "", this.#hashLength);
|
|
200
|
+
if (actual !== expectedHash) {
|
|
201
|
+
mismatches.push({ line, expected: expectedHash, actual });
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
for (const operation of operations) {
|
|
205
|
+
validateAnchor(operation.line, operation.hash);
|
|
206
|
+
if ("end_line" in operation && operation.end_line !== void 0) {
|
|
207
|
+
if (!operation.end_hash) {
|
|
208
|
+
throw new InvalidOperationError("Range edits require end_hash");
|
|
209
|
+
}
|
|
210
|
+
validateAnchor(operation.end_line, operation.end_hash);
|
|
211
|
+
if (operation.end_line < operation.line) {
|
|
212
|
+
throw new InvalidOperationError("end_line must be >= line");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (mismatches.length > 0) {
|
|
217
|
+
throw new AnchorMismatchError(
|
|
218
|
+
mismatchMessage(snapshot.path, lines, this.#hashLength, mismatches),
|
|
219
|
+
mismatches
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const sortKey = (operation) => {
|
|
223
|
+
const lineNumber = "end_line" in operation && operation.end_line !== void 0 ? operation.end_line : operation.line;
|
|
224
|
+
const precedence = { replace: 0, delete: 0, insert_after: 1, insert_before: 2 }[operation.op];
|
|
225
|
+
return [-lineNumber, precedence];
|
|
226
|
+
};
|
|
227
|
+
let firstChangedLine = null;
|
|
228
|
+
for (const operation of [...operations].sort((left, right) => {
|
|
229
|
+
const [leftLine, leftPrec] = sortKey(left);
|
|
230
|
+
const [rightLine, rightPrec] = sortKey(right);
|
|
231
|
+
return leftLine - rightLine || leftPrec - rightPrec;
|
|
232
|
+
})) {
|
|
233
|
+
if (operation.op === "replace") {
|
|
234
|
+
const replacement = stripRenderPrefixes([...operation.lines]);
|
|
235
|
+
const endLine = operation.end_line ?? operation.line;
|
|
236
|
+
const current = lines.slice(operation.line - 1, endLine);
|
|
237
|
+
if (current.length === replacement.length && current.every((line, index) => line === replacement[index])) {
|
|
238
|
+
throw new InvalidOperationError(
|
|
239
|
+
`No changes made to ${snapshot.path}. Replacement for ${operation.line}:${operation.hash} is identical.`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
lines.splice(operation.line - 1, endLine - operation.line + 1, ...replacement);
|
|
243
|
+
firstChangedLine = firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line);
|
|
244
|
+
} else if (operation.op === "delete") {
|
|
245
|
+
const endLine = operation.end_line ?? operation.line;
|
|
246
|
+
lines.splice(operation.line - 1, endLine - operation.line + 1);
|
|
247
|
+
firstChangedLine = firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line);
|
|
248
|
+
} else if (operation.op === "insert_before") {
|
|
249
|
+
const inserted = stripRenderPrefixes([...operation.lines]);
|
|
250
|
+
const payload = inserted.length === 0 ? [""] : inserted;
|
|
251
|
+
lines.splice(operation.line - 1, 0, ...payload);
|
|
252
|
+
firstChangedLine = firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line);
|
|
253
|
+
} else {
|
|
254
|
+
const inserted = stripRenderPrefixes([...operation.lines]);
|
|
255
|
+
const payload = inserted.length === 0 ? [""] : inserted;
|
|
256
|
+
lines.splice(operation.line, 0, ...payload);
|
|
257
|
+
const insertionLine = operation.line + 1;
|
|
258
|
+
firstChangedLine = firstChangedLine === null ? insertionLine : Math.min(firstChangedLine, insertionLine);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const versionAfter = await this.writeSnapshot(resolved, {
|
|
262
|
+
lines,
|
|
263
|
+
bom: snapshot.bom,
|
|
264
|
+
newline: snapshot.newline,
|
|
265
|
+
has_final_newline: snapshot.has_final_newline
|
|
266
|
+
});
|
|
267
|
+
return {
|
|
268
|
+
path: snapshot.path,
|
|
269
|
+
version_before: snapshot.version,
|
|
270
|
+
version_after: versionAfter,
|
|
271
|
+
applied: operations.length,
|
|
272
|
+
first_changed_line: firstChangedLine,
|
|
273
|
+
encoding: snapshot.encoding,
|
|
274
|
+
bom: snapshot.bom,
|
|
275
|
+
newline: snapshot.newline,
|
|
276
|
+
has_final_newline: snapshot.has_final_newline,
|
|
277
|
+
total_lines: lines.length
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
async write(path, text, options = {}) {
|
|
281
|
+
const resolved = this.resolvePath(path);
|
|
282
|
+
const created = !await pathExists(resolved);
|
|
283
|
+
const input = detectNewlineStyle(text);
|
|
284
|
+
let bom = false;
|
|
285
|
+
let newline = input.newline;
|
|
286
|
+
let versionAfter;
|
|
287
|
+
if (created) {
|
|
288
|
+
versionAfter = await this.writeSnapshot(resolved, {
|
|
289
|
+
lines: input.lines,
|
|
290
|
+
bom: false,
|
|
291
|
+
newline: input.newline,
|
|
292
|
+
has_final_newline: input.has_final_newline
|
|
293
|
+
});
|
|
294
|
+
} else {
|
|
295
|
+
const snapshot = await this.readSnapshot(resolved);
|
|
296
|
+
if ((options.expected_version ?? null) !== snapshot.version) {
|
|
297
|
+
throw new VersionConflictError(
|
|
298
|
+
snapshot.path,
|
|
299
|
+
options.expected_version ?? null,
|
|
300
|
+
snapshot.version
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
bom = snapshot.bom;
|
|
304
|
+
newline = snapshot.newline === "none" ? input.newline : snapshot.newline;
|
|
305
|
+
versionAfter = await this.writeSnapshot(resolved, {
|
|
306
|
+
lines: input.lines,
|
|
307
|
+
bom,
|
|
308
|
+
newline,
|
|
309
|
+
has_final_newline: input.has_final_newline
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
path: relative(this.#root, resolved),
|
|
314
|
+
version_after: versionAfter,
|
|
315
|
+
created,
|
|
316
|
+
encoding: this.#encoding,
|
|
317
|
+
bom,
|
|
318
|
+
newline,
|
|
319
|
+
has_final_newline: input.has_final_newline,
|
|
320
|
+
total_lines: input.lines.length
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
async readSnapshot(path) {
|
|
324
|
+
const raw = Buffer.from(await readFile(path));
|
|
325
|
+
const version = versionForBytes(raw);
|
|
326
|
+
const bom = raw.subarray(0, UTF8_BOM.length).equals(UTF8_BOM);
|
|
327
|
+
const payload = bom ? raw.subarray(UTF8_BOM.length) : raw;
|
|
328
|
+
let text;
|
|
329
|
+
try {
|
|
330
|
+
text = new TextDecoder(this.#encoding, { fatal: true }).decode(payload);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw new FileEncodingError(`${relative(this.#root, path)} is not valid ${this.#encoding}`, {
|
|
333
|
+
cause: error
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const parsed = detectNewlineStyle(text);
|
|
337
|
+
return {
|
|
338
|
+
path: relative(this.#root, path),
|
|
339
|
+
version,
|
|
340
|
+
encoding: this.#encoding,
|
|
341
|
+
bom,
|
|
342
|
+
newline: parsed.newline,
|
|
343
|
+
has_final_newline: parsed.has_final_newline,
|
|
344
|
+
lines: parsed.lines
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
async writeSnapshot(path, options) {
|
|
348
|
+
await mkdir(dirname(path), { recursive: true });
|
|
349
|
+
const text = joinText(options.lines, options.newline, options.has_final_newline);
|
|
350
|
+
let payload = Buffer.from(text, this.#encoding);
|
|
351
|
+
if (options.bom) {
|
|
352
|
+
payload = Buffer.concat([UTF8_BOM, payload]);
|
|
353
|
+
}
|
|
354
|
+
const tempPath = `${path}.${randomUUID()}.tmp`;
|
|
355
|
+
let mode;
|
|
356
|
+
try {
|
|
357
|
+
mode = (await stat(path)).mode;
|
|
358
|
+
} catch {
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
await writeFile(tempPath, payload);
|
|
362
|
+
if (mode !== void 0) {
|
|
363
|
+
await chmod(tempPath, mode);
|
|
364
|
+
}
|
|
365
|
+
await rename(tempPath, path);
|
|
366
|
+
} finally {
|
|
367
|
+
await rm(tempPath, { force: true });
|
|
368
|
+
}
|
|
369
|
+
return versionForBytes(payload);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
function resolveExistingPath(path) {
|
|
373
|
+
const missingSegments = [];
|
|
374
|
+
let current = path;
|
|
375
|
+
while (true) {
|
|
376
|
+
try {
|
|
377
|
+
const resolved = realpathSync.native(current);
|
|
378
|
+
const suffix = [...missingSegments].reverse();
|
|
379
|
+
return missingSegments.length === 0 ? resolved : joinPath(resolved, ...suffix);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
const candidate = error;
|
|
382
|
+
if (candidate.code !== "ENOENT") {
|
|
383
|
+
throw candidate;
|
|
384
|
+
}
|
|
385
|
+
const parent = dirname(current);
|
|
386
|
+
if (parent === current) {
|
|
387
|
+
return joinPath(current, ...[...missingSegments].reverse());
|
|
388
|
+
}
|
|
389
|
+
missingSegments.push(basename(current));
|
|
390
|
+
current = parent;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
export {
|
|
395
|
+
AnchorMismatchError,
|
|
396
|
+
FileEncodingError,
|
|
397
|
+
HashEditError,
|
|
398
|
+
HashEditHarness,
|
|
399
|
+
InvalidOperationError,
|
|
400
|
+
MixedNewlineError,
|
|
401
|
+
PathEscapeError,
|
|
402
|
+
VersionConflictError,
|
|
403
|
+
computeLineHash,
|
|
404
|
+
renderLine,
|
|
405
|
+
renderLines,
|
|
406
|
+
stripRenderPrefixes
|
|
407
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@svilupp/hash-edit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Minimal AI-agent file read/edit/write tools with line-hash anchors",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"bun": "./src/index.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.cjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"files": ["dist", "src", "LICENSE"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
20
|
+
"test": "bun test",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"lint": "biome check src/ tests/",
|
|
23
|
+
"lint:fix": "biome check --write src/ tests/",
|
|
24
|
+
"lint:oxlint": "oxlint --tsconfig tsconfig.json -c .oxlintrc.json src/",
|
|
25
|
+
"format": "biome format --write src/ tests/",
|
|
26
|
+
"check": "bun run lint && bun run typecheck && bun test"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@biomejs/biome": "^2.4.8",
|
|
30
|
+
"@types/node": "^24.7.2",
|
|
31
|
+
"bun-types": "^1.3.10",
|
|
32
|
+
"oxlint": "^1.56.0",
|
|
33
|
+
"oxlint-tsgolint": "^0.17.1",
|
|
34
|
+
"tsup": "^8.5.1",
|
|
35
|
+
"typescript": "^5.9.0"
|
|
36
|
+
}
|
|
37
|
+
}
|