pi-hashline-edit-pro 2.8.0 → 2.8.1
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.md +5 -0
- package/package.json +9 -9
- package/src/commit.ts +2 -1
- package/src/edit-common.ts +45 -0
- package/src/file-kind.ts +6 -2
- package/src/file-reader.ts +9 -2
- package/src/fs-write.ts +42 -11
- package/src/grep.ts +102 -5
- package/src/hash-store/cache.ts +18 -0
- package/src/hash-store/retry.ts +48 -0
- package/src/hash-store/validation.ts +62 -0
- package/src/hash-store.ts +45 -146
- package/src/insert.ts +7 -23
- package/src/normalize.ts +27 -0
- package/src/replace-diff.ts +8 -30
- package/src/replace-undo.ts +16 -4
- package/src/replace.ts +12 -28
- package/src/validation.ts +2 -2
package/README.md
CHANGED
|
@@ -148,6 +148,7 @@ Notes:
|
|
|
148
148
|
Notes:
|
|
149
149
|
- Results are grouped per file under a `=== path ===` header; every shown row carries the anchor it would have in `read` output.
|
|
150
150
|
- Directory searches skip `node_modules`, `.git`, `.tmp`, and `coverage`. Binary, image, and oversized files are skipped silently.
|
|
151
|
+
- Regex patterns with backreferences, nested quantifiers, quantified alternation, or multiple variable quantifiers are rejected with `[E_UNSAFE_REGEX]` before any files are scanned; use `literal: true` when regex behavior is unnecessary.
|
|
151
152
|
- Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line). Directory scans stop after 4000 files with a hint; results may be incomplete.
|
|
152
153
|
- `file_path` works as an alias for `path`.
|
|
153
154
|
- Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
|
|
@@ -206,6 +207,8 @@ Anchors are unique by construction. If a line's base hash collides with an alrea
|
|
|
206
207
|
|
|
207
208
|
Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
|
|
208
209
|
|
|
210
|
+
On POSIX systems, the state directory is restricted to mode `0700` and the SQLite database plus its WAL/SHM sidecars to `0600`. The undo table contains the complete pre-edit and post-edit text for the latest edit to each file, so the store should still be treated as sensitive data.
|
|
211
|
+
|
|
209
212
|
The store also keeps a per-file record of the hashes the model was last served (`read` rows, auto-read blocks, post-edit diff rows), pruned to the file's current hashes on every update so removed lines' hashes do not accumulate. `replace` verifies every line of the resolved range against that record before writing; a line whose hash is missing from the record means it either changed on disk after it was shown or was never shown, and the edit is refused with `[E_RANGE_STALE]`. A `write` clears the record, so edits after a write are verified against whatever the next `read` or auto-read block serves.
|
|
210
213
|
|
|
211
214
|
Two guarantees make this safe even with duplicated content:
|
|
@@ -236,6 +239,8 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
236
239
|
| `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
|
|
237
240
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
|
|
238
241
|
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
|
|
242
|
+
| `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
|
|
243
|
+
| `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
|
|
239
244
|
|
|
240
245
|
## Troubleshooting
|
|
241
246
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
]
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"diff": "^
|
|
43
|
-
"file-type": "^
|
|
42
|
+
"diff": "^9.0.0",
|
|
43
|
+
"file-type": "^22.0.2",
|
|
44
44
|
"typebox": "^1.3.7",
|
|
45
45
|
"xxhash-wasm": "^1.1.0"
|
|
46
46
|
},
|
|
@@ -63,12 +63,12 @@
|
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
65
65
|
"@eslint/js": "^10.0.1",
|
|
66
|
-
"@types/node": "^24
|
|
67
|
-
"@vitest/coverage-v8": "^4.1.
|
|
68
|
-
"eslint": "^10.
|
|
69
|
-
"typescript": "^5.
|
|
70
|
-
"typescript-eslint": "^8.
|
|
71
|
-
"vitest": "^4.1.
|
|
66
|
+
"@types/node": "^24",
|
|
67
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
68
|
+
"eslint": "^10.9.1",
|
|
69
|
+
"typescript": "^5.9.3",
|
|
70
|
+
"typescript-eslint": "^8.68.0",
|
|
71
|
+
"vitest": "^4.1.11"
|
|
72
72
|
},
|
|
73
73
|
"allowScripts": {
|
|
74
74
|
"@google/genai@1.52.0": true,
|
package/src/commit.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { saveUndo } from "./replace-undo";
|
|
|
5
5
|
import { safeSnapId } from "./file-reader";
|
|
6
6
|
import { writeAtomic } from "./fs-write";
|
|
7
7
|
import { recordServedDiffSafe } from "./served";
|
|
8
|
-
import { restoreEndings } from "./
|
|
8
|
+
import { restoreEndings } from "./normalize";
|
|
9
9
|
|
|
10
10
|
export interface CommitMeta {
|
|
11
11
|
path: string;
|
|
@@ -72,6 +72,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
72
72
|
await writeAtomic(
|
|
73
73
|
absolutePath,
|
|
74
74
|
pipe.bom + restoreEndings(pipe.result, pipe.originalEnding),
|
|
75
|
+
pipe.identity,
|
|
75
76
|
);
|
|
76
77
|
} catch (error) {
|
|
77
78
|
await undo.restore();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { resolveInCwd } from "./fs-write";
|
|
3
|
+
import { abortIf, makePrepareArguments } from "./utils";
|
|
4
|
+
import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
|
|
5
|
+
import type { ReplaceDetails } from "./replace";
|
|
6
|
+
|
|
7
|
+
export const editPrepare = makePrepareArguments();
|
|
8
|
+
|
|
9
|
+
export function editRenderResultWrapper(
|
|
10
|
+
result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
|
|
11
|
+
opts: { isPartial: boolean; expanded?: boolean } | boolean,
|
|
12
|
+
theme: FgT,
|
|
13
|
+
context: any,
|
|
14
|
+
) {
|
|
15
|
+
return renderEditResult(result, opts, theme, context);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function editRenderCallWrapper(
|
|
19
|
+
preview: (args: unknown, cwd: string) => Promise<RPreview>,
|
|
20
|
+
getInput?: (args: unknown) => { path?: string } | null,
|
|
21
|
+
toolName?: string,
|
|
22
|
+
) {
|
|
23
|
+
return makeRenderCall(preview, { getInput, toolName });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const editToolBase = {
|
|
27
|
+
prepareArguments: editPrepare,
|
|
28
|
+
executionMode: "sequential" as const,
|
|
29
|
+
renderShell: "default" as const,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function queuedEdit<T>(
|
|
33
|
+
path: string,
|
|
34
|
+
cwd: string,
|
|
35
|
+
signal: AbortSignal | undefined,
|
|
36
|
+
work: (absolute: string, resolved: string) => Promise<T>,
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
abortIf(signal);
|
|
39
|
+
const { absolute, resolved } = await resolveInCwd(path, cwd);
|
|
40
|
+
return withFileMutationQueue(resolved, async () => {
|
|
41
|
+
abortIf(signal);
|
|
42
|
+
return work(absolute, resolved);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
package/src/file-kind.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { open as fsOpen, stat as fsStat } from "fs/promises";
|
|
|
2
2
|
import { fileTypeFromBuffer } from "file-type";
|
|
3
3
|
import { SNIFF_BYTES, MAX_BYTES } from "./constants";
|
|
4
4
|
import { assertLineLimit, lineLimitMoreThanMessage } from "./utils";
|
|
5
|
+
import type { FileIdentity } from "./fs-write";
|
|
5
6
|
|
|
6
7
|
const IMG_TYPES = new Set<string>([
|
|
7
8
|
"image/bmp",
|
|
@@ -54,7 +55,7 @@ function looksLikeText(sample: Uint8Array): boolean {
|
|
|
54
55
|
export type LFile =
|
|
55
56
|
| { kind: "directory" }
|
|
56
57
|
| { kind: "image"; mimeType: string }
|
|
57
|
-
| { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
|
|
58
|
+
| { kind: "text"; text: string; identity?: FileIdentity; hadUtf8DecodeErrors?: true }
|
|
58
59
|
| { kind: "binary"; description: string }
|
|
59
60
|
| { kind: "too_large"; description: string };
|
|
60
61
|
|
|
@@ -87,6 +88,8 @@ export async function loadFileKindAndText(
|
|
|
87
88
|
|
|
88
89
|
const fileHandle = await fsOpen(filePath, "r");
|
|
89
90
|
try {
|
|
91
|
+
const openedStats = await fileHandle.stat();
|
|
92
|
+
const identity = { dev: openedStats.dev, ino: openedStats.ino };
|
|
90
93
|
const buffer = Buffer.alloc(SNIFF_BYTES);
|
|
91
94
|
const { bytesRead } = await fileHandle.read(
|
|
92
95
|
buffer,
|
|
@@ -95,7 +98,7 @@ export async function loadFileKindAndText(
|
|
|
95
98
|
0,
|
|
96
99
|
);
|
|
97
100
|
if (bytesRead === 0) {
|
|
98
|
-
return { kind: "text", text: "" };
|
|
101
|
+
return { kind: "text", text: "", identity };
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
const sample = buffer.subarray(0, bytesRead);
|
|
@@ -179,6 +182,7 @@ export async function loadFileKindAndText(
|
|
|
179
182
|
return {
|
|
180
183
|
kind: "text",
|
|
181
184
|
text,
|
|
185
|
+
identity,
|
|
182
186
|
...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
|
|
183
187
|
};
|
|
184
188
|
} finally {
|
package/src/file-reader.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { stat } from "fs/promises";
|
|
|
3
3
|
import { relative } from "path";
|
|
4
4
|
import { lineHashes } from "./hashline";
|
|
5
5
|
import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
6
|
-
import { resolveTarget } from "./fs-write";
|
|
6
|
+
import { resolveTarget, type FileIdentity } from "./fs-write";
|
|
7
7
|
import { toCwd } from "./paths";
|
|
8
|
-
import { detectEnding, toLF, stripBOM, type LineEnding } from "./
|
|
8
|
+
import { detectEnding, toLF, stripBOM, type LineEnding } from "./normalize";
|
|
9
9
|
import { abortIf, errCode, assertLineLimit } from "./utils";
|
|
10
10
|
import { valKind, valAccess } from "./validation";
|
|
11
11
|
import type { HashStore } from "./hash-store";
|
|
@@ -16,6 +16,7 @@ export interface NormFile {
|
|
|
16
16
|
originalEnding: LineEnding;
|
|
17
17
|
fileHashes: string[];
|
|
18
18
|
hadUtf8DecodeErrors: boolean;
|
|
19
|
+
identity: FileIdentity;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export type SnapInfo = {
|
|
@@ -94,12 +95,18 @@ export async function readNormFile(
|
|
|
94
95
|
if (options?.maxLines !== undefined) assertLineLimit(normalized, path, options.maxLines);
|
|
95
96
|
|
|
96
97
|
const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true);
|
|
98
|
+
let identity = file.identity;
|
|
99
|
+
if (!identity) {
|
|
100
|
+
const { dev, ino } = await stat(resolvedPath);
|
|
101
|
+
identity = { dev, ino };
|
|
102
|
+
}
|
|
97
103
|
return {
|
|
98
104
|
absolutePath: resolvedPath,
|
|
99
105
|
normalized,
|
|
100
106
|
bom,
|
|
101
107
|
originalEnding,
|
|
102
108
|
fileHashes,
|
|
109
|
+
identity,
|
|
103
110
|
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
|
|
104
111
|
};
|
|
105
112
|
}
|
package/src/fs-write.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
+
import { constants } from "fs";
|
|
2
3
|
import {
|
|
3
|
-
chmod,
|
|
4
4
|
lstat,
|
|
5
5
|
mkdir,
|
|
6
6
|
open,
|
|
@@ -15,6 +15,22 @@ import { dirname, join, parse, resolve, sep } from "path";
|
|
|
15
15
|
import { toCwd } from "./paths";
|
|
16
16
|
import { errCode } from "./utils";
|
|
17
17
|
|
|
18
|
+
export interface FileIdentity {
|
|
19
|
+
dev: number;
|
|
20
|
+
ino: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameIdentity(
|
|
24
|
+
actual: Pick<Awaited<ReturnType<typeof stat>>, "dev" | "ino">,
|
|
25
|
+
expected: FileIdentity,
|
|
26
|
+
): boolean {
|
|
27
|
+
return actual.dev === expected.dev && actual.ino === expected.ino;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pathChanged(path: string): Error {
|
|
31
|
+
return new Error(`[E_PATH_CHANGED] Refusing to write ${path}: the target changed after it was read.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
export async function resolveTarget(path: string): Promise<string> {
|
|
19
35
|
const absolutePath = resolve(path);
|
|
20
36
|
const { root } = parse(absolutePath);
|
|
@@ -128,6 +144,7 @@ export async function resolveInCwd(path: string, cwd: string): Promise<{ absolut
|
|
|
128
144
|
export async function writeAtomic(
|
|
129
145
|
path: string,
|
|
130
146
|
content: string,
|
|
147
|
+
expectedIdentity?: FileIdentity,
|
|
131
148
|
): Promise<void> {
|
|
132
149
|
const targetPath = await resolveTarget(path);
|
|
133
150
|
|
|
@@ -140,19 +157,25 @@ export async function writeAtomic(
|
|
|
140
157
|
}
|
|
141
158
|
}
|
|
142
159
|
|
|
160
|
+
if (expectedIdentity && (!existingStats || !sameIdentity(existingStats, expectedIdentity))) {
|
|
161
|
+
throw pathChanged(path);
|
|
162
|
+
}
|
|
163
|
+
|
|
143
164
|
if (existingStats && existingStats.nlink > 1) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
await chmod(targetPath, existingStats.mode & 0o7777);
|
|
147
|
-
} catch {}
|
|
165
|
+
const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
|
|
166
|
+
const handle = await open(targetPath, constants.O_WRONLY | noFollow);
|
|
148
167
|
try {
|
|
149
|
-
const
|
|
168
|
+
const openedStats = await handle.stat();
|
|
169
|
+
if (!sameIdentity(openedStats, existingStats)) throw pathChanged(path);
|
|
170
|
+
await handle.writeFile(content, "utf-8");
|
|
171
|
+
await handle.truncate(Buffer.byteLength(content, "utf-8"));
|
|
150
172
|
try {
|
|
151
|
-
await handle.
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
173
|
+
await handle.chmod(existingStats.mode & 0o7777);
|
|
174
|
+
} catch {}
|
|
175
|
+
await handle.sync();
|
|
176
|
+
} finally {
|
|
177
|
+
await handle.close();
|
|
178
|
+
}
|
|
156
179
|
return;
|
|
157
180
|
}
|
|
158
181
|
|
|
@@ -174,6 +197,14 @@ export async function writeAtomic(
|
|
|
174
197
|
}
|
|
175
198
|
try {
|
|
176
199
|
await tempHandle.close();
|
|
200
|
+
try {
|
|
201
|
+
const finalStats = await lstat(targetPath);
|
|
202
|
+
if (!existingStats || finalStats.isSymbolicLink() || !sameIdentity(finalStats, existingStats)) {
|
|
203
|
+
throw pathChanged(path);
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (errCode(error) !== "ENOENT" || existingStats) throw error;
|
|
207
|
+
}
|
|
177
208
|
await rename(tempPath, targetPath);
|
|
178
209
|
await syncDir(dir);
|
|
179
210
|
} catch (error: unknown) {
|
package/src/grep.ts
CHANGED
|
@@ -47,6 +47,7 @@ export function assertGrepReq(request: unknown): asserts request is GrepReq {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
|
|
50
|
+
if (!literal) assertSafeRegex(pattern);
|
|
50
51
|
const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
|
|
51
52
|
try {
|
|
52
53
|
return new RegExp(source, ignoreCase ? "ui" : "u");
|
|
@@ -55,6 +56,95 @@ function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): Reg
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
interface RegexGroupRisk {
|
|
60
|
+
hasQuantifier: boolean;
|
|
61
|
+
hasAlternation: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function unsafeRegex(pattern: string): never {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`[E_UNSAFE_REGEX] Refusing potentially exponential regex: ${pattern}. Use literal: true or simplify the expression.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertSafeRegex(pattern: string): void {
|
|
71
|
+
if (pattern.length > 4096) unsafeRegex(pattern);
|
|
72
|
+
|
|
73
|
+
const groups: RegexGroupRisk[] = [];
|
|
74
|
+
let inClass = false;
|
|
75
|
+
let escaped = false;
|
|
76
|
+
let variableQuantifiers = 0;
|
|
77
|
+
let lastAtom: { groupRisky: boolean; quantified: boolean } | undefined;
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
80
|
+
const ch = pattern[i]!;
|
|
81
|
+
if (escaped) {
|
|
82
|
+
if (!inClass && (/[1-9]/.test(ch) || (ch === "k" && pattern[i + 1] === "<"))) {
|
|
83
|
+
unsafeRegex(pattern);
|
|
84
|
+
}
|
|
85
|
+
escaped = false;
|
|
86
|
+
lastAtom = { groupRisky: false, quantified: false };
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (ch === "\\") {
|
|
90
|
+
escaped = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (inClass) {
|
|
94
|
+
if (ch === "]") {
|
|
95
|
+
inClass = false;
|
|
96
|
+
lastAtom = { groupRisky: false, quantified: false };
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === "[") {
|
|
101
|
+
inClass = true;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (ch === "(") {
|
|
105
|
+
groups.push({ hasQuantifier: false, hasAlternation: false });
|
|
106
|
+
lastAtom = undefined;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ch === ")") {
|
|
110
|
+
const group = groups.pop();
|
|
111
|
+
if (group) {
|
|
112
|
+
lastAtom = {
|
|
113
|
+
groupRisky: group.hasQuantifier || group.hasAlternation,
|
|
114
|
+
quantified: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (ch === "|") {
|
|
120
|
+
const group = groups.at(-1);
|
|
121
|
+
if (group) group.hasAlternation = true;
|
|
122
|
+
lastAtom = undefined;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let quantifierLength = 0;
|
|
127
|
+
if (ch === "*" || ch === "+" || ch === "?") {
|
|
128
|
+
quantifierLength = 1;
|
|
129
|
+
} else if (ch === "{") {
|
|
130
|
+
quantifierLength = /^\{\d+(?:,\d*)?\}/.exec(pattern.slice(i))?.[0].length ?? 0;
|
|
131
|
+
}
|
|
132
|
+
if (quantifierLength > 0 && lastAtom) {
|
|
133
|
+
if (ch === "?" && lastAtom.quantified) continue;
|
|
134
|
+
const variable = ch !== "{" || pattern.slice(i, i + quantifierLength).includes(",");
|
|
135
|
+
if (variable && ++variableQuantifiers > 1) unsafeRegex(pattern);
|
|
136
|
+
if (lastAtom.groupRisky) unsafeRegex(pattern);
|
|
137
|
+
const group = groups.at(-1);
|
|
138
|
+
if (group) group.hasQuantifier = true;
|
|
139
|
+
lastAtom.quantified = true;
|
|
140
|
+
i += quantifierLength - 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
lastAtom = { groupRisky: false, quantified: false };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
58
148
|
function globToRegex(glob: string): RegExp {
|
|
59
149
|
if (glob.startsWith("/")) glob = glob.slice(1);
|
|
60
150
|
let source = "";
|
|
@@ -138,10 +228,12 @@ async function walkFiles(
|
|
|
138
228
|
root: string,
|
|
139
229
|
state: ScanState,
|
|
140
230
|
onFile: (absPath: string) => Promise<void>,
|
|
231
|
+
signal?: AbortSignal,
|
|
141
232
|
): Promise<void> {
|
|
142
233
|
const queue: string[] = [root];
|
|
143
234
|
let head = 0;
|
|
144
235
|
while (head < queue.length && !state.stopped) {
|
|
236
|
+
abortIf(signal);
|
|
145
237
|
const dir = queue[head++]!;
|
|
146
238
|
let entries;
|
|
147
239
|
try {
|
|
@@ -150,8 +242,10 @@ async function walkFiles(
|
|
|
150
242
|
continue;
|
|
151
243
|
}
|
|
152
244
|
entries.sort((a, b) => cmp(a.name, b.name));
|
|
153
|
-
for (
|
|
245
|
+
for (let ei = 0; ei < entries.length; ei++) {
|
|
246
|
+
if ((ei & 127) === 0) abortIf(signal);
|
|
154
247
|
if (state.stopped) break;
|
|
248
|
+
const entry = entries[ei]!;
|
|
155
249
|
const full = join(dir, entry.name);
|
|
156
250
|
if (entry.isDirectory()) {
|
|
157
251
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
@@ -176,17 +270,20 @@ async function searchFile(
|
|
|
176
270
|
globRegex: RegExp | undefined,
|
|
177
271
|
context: number,
|
|
178
272
|
maxMatches: number,
|
|
273
|
+
signal?: AbortSignal,
|
|
179
274
|
): Promise<FileHit | undefined> {
|
|
180
275
|
const displayPath = relative(cwd, absPath).replace(/\\/g, "/");
|
|
181
276
|
if (globRegex) {
|
|
182
277
|
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
183
278
|
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) return undefined;
|
|
184
279
|
}
|
|
185
|
-
const norm = await tryReadNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, noPersist: true });
|
|
280
|
+
const norm = await tryReadNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
|
|
186
281
|
if (!norm) return undefined;
|
|
187
282
|
const lines = visLines(norm.normalized);
|
|
188
283
|
const matchLines: number[] = [];
|
|
189
284
|
for (let i = 0; i < lines.length; i++) {
|
|
285
|
+
if ((i & 1023) === 0) abortIf(signal);
|
|
286
|
+
if (i !== 0 && (i & 4095) === 0) await new Promise<void>((r) => setImmediate(r));
|
|
190
287
|
if (regex.test(lines[i]!)) matchLines.push(i);
|
|
191
288
|
}
|
|
192
289
|
if (matchLines.length === 0) return undefined;
|
|
@@ -306,7 +403,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
306
403
|
} else {
|
|
307
404
|
await walkFiles(base, state, async (absPath) => {
|
|
308
405
|
files.push(absPath);
|
|
309
|
-
});
|
|
406
|
+
}, signal);
|
|
310
407
|
files.sort(cmp);
|
|
311
408
|
}
|
|
312
409
|
const hits: FileHit[] = [];
|
|
@@ -324,7 +421,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
324
421
|
abortIf(signal);
|
|
325
422
|
const absPath = files[f]!;
|
|
326
423
|
if (countOnly) {
|
|
327
|
-
const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, Number.MAX_SAFE_INTEGER);
|
|
424
|
+
const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, Number.MAX_SAFE_INTEGER, signal);
|
|
328
425
|
if (!hit) continue;
|
|
329
426
|
totalRows += hit.rows.length;
|
|
330
427
|
for (const row of hit.rows) totalBytes += Buffer.byteLength(row, "utf-8") + 1;
|
|
@@ -342,7 +439,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
342
439
|
limitTruncated = true;
|
|
343
440
|
break;
|
|
344
441
|
}
|
|
345
|
-
const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining);
|
|
442
|
+
const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining, signal);
|
|
346
443
|
if (!hit) continue;
|
|
347
444
|
const keptRows: string[] = [];
|
|
348
445
|
const keptHashes: string[] = [];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface SnapshotCacheEntry {
|
|
2
|
+
checksum: string;
|
|
3
|
+
lineCount: number;
|
|
4
|
+
hashes: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const SNAPSHOT_CACHE_LIMIT = 256;
|
|
8
|
+
|
|
9
|
+
export const snapshotCache = new Map<string, SnapshotCacheEntry>();
|
|
10
|
+
|
|
11
|
+
export function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
|
|
12
|
+
snapshotCache.delete(path);
|
|
13
|
+
snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
|
|
14
|
+
if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
|
|
15
|
+
const oldest = snapshotCache.keys().next().value;
|
|
16
|
+
if (oldest !== undefined) snapshotCache.delete(oldest);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isBusyError } from "./validation";
|
|
2
|
+
|
|
3
|
+
const sleepSab = new Int32Array(new SharedArrayBuffer(4));
|
|
4
|
+
|
|
5
|
+
function sleepSync(ms: number): void {
|
|
6
|
+
Atomics.wait(sleepSab, 0, 0, ms);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const BUSY_RETRIES = 3;
|
|
10
|
+
const BUSY_RETRY_DELAY_MS = 50;
|
|
11
|
+
|
|
12
|
+
export function withBusyRetry<T>(fn: () => T): T {
|
|
13
|
+
let lastError: unknown;
|
|
14
|
+
for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
|
|
15
|
+
try {
|
|
16
|
+
return fn();
|
|
17
|
+
} catch (error) {
|
|
18
|
+
lastError = error;
|
|
19
|
+
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
20
|
+
sleepSync(BUSY_RETRY_DELAY_MS * (1 << attempt));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw lastError;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
|
|
27
|
+
let lastError: unknown;
|
|
28
|
+
for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
return fn();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
lastError = error;
|
|
33
|
+
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
34
|
+
await new Promise<void>((r) => setTimeout(r, BUSY_RETRY_DELAY_MS * (1 << attempt)));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw lastError;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function retriedWrite(stmt: { run(...params: (string | number)[]): unknown }): (...params: (string | number)[]) => void {
|
|
41
|
+
return (...params) => {
|
|
42
|
+
withBusyRetry(() => { stmt.run(...params); });
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function openDbWithBusyRetryAsync<T>(fn: () => T): Promise<T> {
|
|
47
|
+
return withBusyRetryAsync(fn);
|
|
48
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { HASH_RE } from "../hashline/alphabet";
|
|
2
|
+
|
|
3
|
+
export function isValidHashList(value: unknown): value is string[] {
|
|
4
|
+
if (!Array.isArray(value)) return false;
|
|
5
|
+
for (const hash of value) {
|
|
6
|
+
if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
|
|
7
|
+
}
|
|
8
|
+
if (new Set(value).size !== value.length) return false;
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
|
|
13
|
+
let parsed: unknown;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(raw);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error(`[parseHashList]${context ? ` ${context}:` : ""} failed to parse stored hashes JSON:`, error);
|
|
18
|
+
onInvalid();
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (!isValidHashList(parsed)) {
|
|
22
|
+
console.error(`[parseHashList]${context ? ` ${context}:` : ""} stored hashes did not pass validation:`, Array.isArray(parsed) ? `length=${parsed.length} sample=${JSON.stringify(parsed.slice(0, 3))}` : (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
|
|
23
|
+
onInvalid();
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseStoredHashes(row: Record<string, unknown> | undefined, onInvalid: () => void): string[] | undefined {
|
|
30
|
+
if (!row) return undefined;
|
|
31
|
+
return parseHashList(row.hashes as string, onInvalid);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isValidSnapshot(value: unknown): value is { content: string; hashes: string[] } {
|
|
35
|
+
if (typeof value !== "object" || value === null) return false;
|
|
36
|
+
const v = value as Record<string, unknown>;
|
|
37
|
+
if (typeof v.content !== "string") return false;
|
|
38
|
+
return isValidHashList(v.hashes);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isCorruptionError(error: unknown): boolean {
|
|
42
|
+
if (error && typeof error === "object") {
|
|
43
|
+
const errcode = (error as { errcode?: unknown }).errcode;
|
|
44
|
+
if (typeof errcode === "number") {
|
|
45
|
+
return errcode === 11 || errcode === 24 || errcode === 26;
|
|
46
|
+
}
|
|
47
|
+
const code = (error as { code?: unknown }).code;
|
|
48
|
+
if (typeof code === "string" && /NOTADB|CORRUPT/.test(code)) return true;
|
|
49
|
+
}
|
|
50
|
+
return (
|
|
51
|
+
error instanceof Error &&
|
|
52
|
+
/corrupt|not a database|malformed|database disk image/i.test(error.message)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isBusyError(error: unknown): boolean {
|
|
57
|
+
if (error && typeof error === "object") {
|
|
58
|
+
const errcode = (error as { errcode?: unknown }).errcode;
|
|
59
|
+
if (typeof errcode === "number") return errcode === 5 || errcode === 6;
|
|
60
|
+
}
|
|
61
|
+
return error instanceof Error && /busy|locked/i.test(error.message);
|
|
62
|
+
}
|
package/src/hash-store.ts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
-
import { readFile, rename, mkdir, stat } from "fs/promises";
|
|
2
|
+
import { chmod, readFile, rename, mkdir, stat } from "fs/promises";
|
|
3
3
|
import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
|
|
4
4
|
import { errCode, isRec, splitLines } from "./utils";
|
|
5
5
|
import { initHasher, contentChecksum } from "./hashline/hasher";
|
|
6
|
-
import { HASH_RE } from "./hashline/alphabet";
|
|
7
6
|
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
isValidHashList,
|
|
9
|
+
parseStoredHashes,
|
|
10
|
+
isValidSnapshot,
|
|
11
|
+
isCorruptionError,
|
|
12
|
+
parseHashList,
|
|
13
|
+
} from "./hash-store/validation";
|
|
14
|
+
import {
|
|
15
|
+
withBusyRetry,
|
|
16
|
+
retriedWrite,
|
|
17
|
+
openDbWithBusyRetryAsync,
|
|
18
|
+
} from "./hash-store/retry";
|
|
19
|
+
import {
|
|
20
|
+
snapshotCache,
|
|
21
|
+
cacheSnapshot,
|
|
22
|
+
SNAPSHOT_CACHE_LIMIT,
|
|
23
|
+
} from "./hash-store/cache";
|
|
24
|
+
|
|
25
|
+
export { isValidHashList, parseHashList, parseStoredHashes, isCorruptionError };
|
|
26
|
+
export { SNAPSHOT_CACHE_LIMIT };
|
|
9
27
|
export const STORE_NOT_OPEN_MESSAGE = "Hash store is not open; transactional update aborted";
|
|
10
28
|
|
|
11
29
|
type SqlParams = (string | number)[];
|
|
@@ -92,133 +110,10 @@ export interface UndoRecord {
|
|
|
92
110
|
resultContent: string;
|
|
93
111
|
}
|
|
94
112
|
|
|
95
|
-
interface LegacySnapshot {
|
|
96
|
-
content: string;
|
|
97
|
-
hashes: string[];
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function isValidHashList(value: unknown): value is string[] {
|
|
101
|
-
if (!Array.isArray(value)) return false;
|
|
102
|
-
for (const hash of value) {
|
|
103
|
-
if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
|
|
104
|
-
}
|
|
105
|
-
if (new Set(value).size !== value.length) return false;
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
|
|
110
|
-
let parsed: unknown;
|
|
111
|
-
try {
|
|
112
|
-
parsed = JSON.parse(raw);
|
|
113
|
-
} catch (error) {
|
|
114
|
-
console.error(`[parseHashList]${context ? ` ${context}:` : ""} failed to parse stored hashes JSON:`, error);
|
|
115
|
-
onInvalid();
|
|
116
|
-
return undefined;
|
|
117
|
-
}
|
|
118
|
-
if (!isValidHashList(parsed)) {
|
|
119
|
-
console.error(`[parseHashList]${context ? ` ${context}:` : ""} stored hashes did not pass validation:`, Array.isArray(parsed) ? `length=${parsed.length} sample=${JSON.stringify(parsed.slice(0, 3))}` : (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
|
|
120
|
-
onInvalid();
|
|
121
|
-
return undefined;
|
|
122
|
-
}
|
|
123
|
-
return parsed;
|
|
124
|
-
}
|
|
125
|
-
export function parseStoredHashes(
|
|
126
|
-
row: Record<string, unknown> | undefined,
|
|
127
|
-
onInvalid: () => void,
|
|
128
|
-
): string[] | undefined {
|
|
129
|
-
if (!row) return undefined;
|
|
130
|
-
return parseHashList(row.hashes as string, onInvalid);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function isValidSnapshot(value: unknown): value is LegacySnapshot {
|
|
134
|
-
if (typeof value !== "object" || value === null) return false;
|
|
135
|
-
const v = value as Record<string, unknown>;
|
|
136
|
-
if (typeof v.content !== "string") return false;
|
|
137
|
-
return isValidHashList(v.hashes);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function isCorruptionError(error: unknown): boolean {
|
|
141
|
-
if (error && typeof error === "object") {
|
|
142
|
-
const errcode = (error as { errcode?: unknown }).errcode;
|
|
143
|
-
if (typeof errcode === "number") {
|
|
144
|
-
return errcode === 11 || errcode === 24 || errcode === 26;
|
|
145
|
-
}
|
|
146
|
-
const code = (error as { code?: unknown }).code;
|
|
147
|
-
if (typeof code === "string" && /NOTADB|CORRUPT/.test(code)) return true;
|
|
148
|
-
}
|
|
149
|
-
return (
|
|
150
|
-
error instanceof Error &&
|
|
151
|
-
/corrupt|not a database|malformed|database disk image/i.test(error.message)
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function isBusyError(error: unknown): boolean {
|
|
156
|
-
if (error && typeof error === "object") {
|
|
157
|
-
const errcode = (error as { errcode?: unknown }).errcode;
|
|
158
|
-
if (typeof errcode === "number") return errcode === 5 || errcode === 6;
|
|
159
|
-
}
|
|
160
|
-
return error instanceof Error && /busy|locked/i.test(error.message);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const sleepSab = new Int32Array(new SharedArrayBuffer(4));
|
|
164
|
-
|
|
165
|
-
function sleepSync(ms: number): void {
|
|
166
|
-
Atomics.wait(sleepSab, 0, 0, ms);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const BUSY_RETRIES = 3;
|
|
170
|
-
const BUSY_RETRY_DELAY_MS = 50;
|
|
171
|
-
|
|
172
|
-
function withBusyRetry<T>(fn: () => T): T {
|
|
173
|
-
let lastError: unknown;
|
|
174
|
-
for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
|
|
175
|
-
try {
|
|
176
|
-
return fn();
|
|
177
|
-
} catch (error) {
|
|
178
|
-
lastError = error;
|
|
179
|
-
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
180
|
-
sleepSync(BUSY_RETRY_DELAY_MS * (1 << attempt));
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
throw lastError;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
|
|
187
|
-
let lastError: unknown;
|
|
188
|
-
for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
|
|
189
|
-
try {
|
|
190
|
-
return fn();
|
|
191
|
-
} catch (error) {
|
|
192
|
-
lastError = error;
|
|
193
|
-
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
194
|
-
await new Promise<void>((r) => setTimeout(r, BUSY_RETRY_DELAY_MS * (1 << attempt)));
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
throw lastError;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function openDbWithBusyRetryAsync(storePath: string): Promise<{ db: RawDb; stmts: Prepared }> {
|
|
201
|
-
return withBusyRetryAsync(() => openDb(storePath));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function retriedWrite(
|
|
205
|
-
stmt: { run(...params: SqlParams): unknown },
|
|
206
|
-
): (...params: SqlParams) => void {
|
|
207
|
-
return (...params) => {
|
|
208
|
-
withBusyRetry(() => { stmt.run(...params); });
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
113
|
let cachedDb: { path: string; db: RawDb; stmts: Prepared } | null = null;
|
|
213
114
|
let opening: { path: string; promise: Promise<HashStore> } | null = null;
|
|
214
115
|
let exitHandlerRegistered = false;
|
|
215
|
-
|
|
216
|
-
checksum: string;
|
|
217
|
-
lineCount: number;
|
|
218
|
-
hashes: string[];
|
|
219
|
-
}
|
|
220
|
-
const snapshotCache = new Map<string, SnapshotCacheEntry>();
|
|
221
|
-
export const SNAPSHOT_CACHE_LIMIT = 256;
|
|
116
|
+
|
|
222
117
|
function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
|
|
223
118
|
const db = openDbFn(storePath);
|
|
224
119
|
try {
|
|
@@ -231,9 +126,7 @@ function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
|
|
|
231
126
|
}
|
|
232
127
|
}
|
|
233
128
|
|
|
234
|
-
function buildStore(
|
|
235
|
-
db: RawDb,
|
|
236
|
-
): { db: RawDb; stmts: Prepared } {
|
|
129
|
+
function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
237
130
|
db.exec("PRAGMA journal_mode = WAL");
|
|
238
131
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
239
132
|
db.exec(
|
|
@@ -351,30 +244,45 @@ function shutdownDb(db: RawDb): void {
|
|
|
351
244
|
}
|
|
352
245
|
|
|
353
246
|
async function openStore(storePath: string): Promise<HashStore> {
|
|
354
|
-
|
|
355
|
-
|
|
247
|
+
if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
|
|
248
|
+
return { stmts: cachedDb.stmts, engine: sqliteEngine };
|
|
249
|
+
}
|
|
250
|
+
if (cachedDb) shutdownHashStore();
|
|
356
251
|
await initHasher();
|
|
357
|
-
await mkdir(hashStoreDir(), { recursive: true });
|
|
252
|
+
await mkdir(hashStoreDir(), { recursive: true, mode: 0o700 });
|
|
253
|
+
if (process.platform !== "win32") {
|
|
254
|
+
await chmod(hashStoreDir(), 0o700);
|
|
255
|
+
}
|
|
358
256
|
|
|
359
257
|
let existed = existsSync(storePath);
|
|
360
258
|
let opened: { db: RawDb; stmts: Prepared };
|
|
361
259
|
try {
|
|
362
|
-
opened = await openDbWithBusyRetryAsync(storePath);
|
|
260
|
+
opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
|
|
363
261
|
} catch (error) {
|
|
364
262
|
if (!isCorruptionError(error)) throw error;
|
|
365
263
|
console.error("Hash store failed to open, rebuilding:", error);
|
|
366
264
|
await quarantineStore(storePath);
|
|
367
265
|
existed = false;
|
|
368
|
-
opened = await openDbWithBusyRetryAsync(storePath);
|
|
266
|
+
opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
|
|
369
267
|
}
|
|
370
268
|
if (!isHealthy(opened.db)) {
|
|
371
269
|
shutdownDb(opened.db);
|
|
372
270
|
await quarantineStore(storePath);
|
|
373
271
|
existed = false;
|
|
374
|
-
opened = await openDbWithBusyRetryAsync(storePath);
|
|
272
|
+
opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
|
|
375
273
|
}
|
|
376
274
|
const { db, stmts } = opened;
|
|
377
275
|
|
|
276
|
+
if (process.platform !== "win32") {
|
|
277
|
+
for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
|
|
278
|
+
try {
|
|
279
|
+
await chmod(candidate, 0o600);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (errCode(error) !== "ENOENT") throw error;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
378
286
|
if (!existed) {
|
|
379
287
|
try {
|
|
380
288
|
await migrateLegacy(db);
|
|
@@ -422,7 +330,7 @@ export function shutdownHashStore(): void {
|
|
|
422
330
|
}
|
|
423
331
|
|
|
424
332
|
export function withStore(fn: () => void): void {
|
|
425
|
-
if (!cachedDb) {
|
|
333
|
+
if (!cachedDb || !cachedDb.db.isOpen) {
|
|
426
334
|
throw new Error(STORE_NOT_OPEN_MESSAGE);
|
|
427
335
|
}
|
|
428
336
|
withBusyRetry(() => {
|
|
@@ -503,15 +411,6 @@ async function migrateLegacy(db: RawDb): Promise<void> {
|
|
|
503
411
|
}
|
|
504
412
|
}
|
|
505
413
|
|
|
506
|
-
function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
|
|
507
|
-
snapshotCache.delete(path);
|
|
508
|
-
snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
|
|
509
|
-
if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
|
|
510
|
-
const oldest = snapshotCache.keys().next().value;
|
|
511
|
-
if (oldest !== undefined) snapshotCache.delete(oldest);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
414
|
export function getSnapshot(
|
|
516
415
|
store: HashStore,
|
|
517
416
|
path: string,
|
package/src/insert.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import { Type } from "typebox";
|
|
4
3
|
import { constants } from "fs";
|
|
5
4
|
import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
6
5
|
import { commitEdit } from "./commit";
|
|
7
6
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
-
import { resolveInCwd } from "./fs-write";
|
|
9
7
|
import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
|
|
10
8
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
9
|
import { loadP, loadGuide } from "./prompts";
|
|
12
10
|
import { normReq } from "./payload-contract";
|
|
13
|
-
import {
|
|
14
|
-
import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
|
|
11
|
+
import { isRec, rejectUnknownFields, splitLines } from "./utils";
|
|
15
12
|
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
13
|
+
import type { RPreview, RRState } from "./replace-render";
|
|
14
|
+
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
|
|
16
15
|
|
|
17
16
|
const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
|
|
18
17
|
|
|
@@ -158,32 +157,17 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
158
157
|
description: loadP("../prompts/insert.md"),
|
|
159
158
|
promptSnippet: loadP("../prompts/insert-snippet.md"),
|
|
160
159
|
promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
|
|
161
|
-
|
|
162
|
-
executionMode: "sequential",
|
|
160
|
+
...editToolBase,
|
|
163
161
|
parameters: insertToolSchema,
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
167
|
-
return renderEditResult(
|
|
168
|
-
result as {
|
|
169
|
-
content?: Array<{ type: string; text?: string }>;
|
|
170
|
-
details?: ReplaceDetails;
|
|
171
|
-
},
|
|
172
|
-
{ isPartial, expanded },
|
|
173
|
-
theme,
|
|
174
|
-
context,
|
|
175
|
-
);
|
|
176
|
-
},
|
|
177
|
-
|
|
162
|
+
renderCall: editRenderCallWrapper(insertPreview, getInsertInput, "insert"),
|
|
163
|
+
renderResult: editRenderResultWrapper,
|
|
178
164
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
179
165
|
const canonical = normReq(params);
|
|
180
166
|
assertInsertReq(canonical);
|
|
181
167
|
const req = canonical;
|
|
182
168
|
const path = req.path;
|
|
183
169
|
const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
|
|
184
|
-
|
|
185
|
-
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
186
|
-
abortIf(signal);
|
|
170
|
+
return queuedEdit(path, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
187
171
|
const preload = await readNormFile(path, ctx.cwd, {
|
|
188
172
|
signal,
|
|
189
173
|
accessMode: constants.R_OK | constants.W_OK,
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type LineEnding = "\r\n" | "\n" | "\r";
|
|
2
|
+
|
|
3
|
+
export function detectEnding(content: string): LineEnding {
|
|
4
|
+
const crIdx = content.indexOf("\r");
|
|
5
|
+
const lfIdx = content.indexOf("\n");
|
|
6
|
+
if (crIdx === -1 && lfIdx === -1) return "\n";
|
|
7
|
+
if (crIdx === -1) return "\n";
|
|
8
|
+
if (lfIdx === -1) return "\r";
|
|
9
|
+
if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
|
|
10
|
+
return "\n";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toLF(text: string): string {
|
|
14
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function restoreEndings(text: string, ending: LineEnding): string {
|
|
18
|
+
if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
|
|
19
|
+
if (ending === "\r") return text.replace(/\n/g, "\r");
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function stripBOM(content: string): { bom: string; text: string } {
|
|
24
|
+
return content.startsWith("\uFEFF")
|
|
25
|
+
? { bom: "\uFEFF", text: content.slice(1) }
|
|
26
|
+
: { bom: "", text: content };
|
|
27
|
+
}
|
package/src/replace-diff.ts
CHANGED
|
@@ -5,37 +5,15 @@ import {
|
|
|
5
5
|
ANCHOR_LEN,
|
|
6
6
|
HASH_SEP,
|
|
7
7
|
} from "./hashline";
|
|
8
|
+
import {
|
|
9
|
+
detectEnding,
|
|
10
|
+
toLF,
|
|
11
|
+
restoreEndings,
|
|
12
|
+
stripBOM,
|
|
13
|
+
type LineEnding,
|
|
14
|
+
} from "./normalize";
|
|
8
15
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
export function detectEnding(content: string): LineEnding {
|
|
12
|
-
const crIdx = content.indexOf("\r");
|
|
13
|
-
const lfIdx = content.indexOf("\n");
|
|
14
|
-
if (crIdx === -1 && lfIdx === -1) return "\n";
|
|
15
|
-
if (crIdx === -1) return "\n";
|
|
16
|
-
if (lfIdx === -1) return "\r";
|
|
17
|
-
if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
|
|
18
|
-
return "\n";
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function toLF(text: string): string {
|
|
22
|
-
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function restoreEndings(
|
|
26
|
-
text: string,
|
|
27
|
-
ending: LineEnding,
|
|
28
|
-
): string {
|
|
29
|
-
if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
|
|
30
|
-
if (ending === "\r") return text.replace(/\n/g, "\r");
|
|
31
|
-
return text;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function stripBOM(content: string): { bom: string; text: string } {
|
|
35
|
-
return content.startsWith("\uFEFF")
|
|
36
|
-
? { bom: "\uFEFF", text: content.slice(1) }
|
|
37
|
-
: { bom: "", text: content };
|
|
38
|
-
}
|
|
16
|
+
export { detectEnding, toLF, restoreEndings, stripBOM, type LineEnding };
|
|
39
17
|
|
|
40
18
|
function fmtDiffLine(
|
|
41
19
|
prefix: " " | "+" | "-",
|
package/src/replace-undo.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants } from "fs";
|
|
2
|
+
import { open } from "fs/promises";
|
|
2
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { Type } from "typebox";
|
|
5
6
|
import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
|
|
6
7
|
import { recordServedDiff } from "./served";
|
|
7
|
-
import { resolveInCwd, writeAtomic } from "./fs-write";
|
|
8
|
-
import { toLF, stripBOM,
|
|
8
|
+
import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
|
|
9
|
+
import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
|
|
10
|
+
import { genDiff, genPatch } from "./replace-diff";
|
|
9
11
|
import { cntDiff, errCode, makePrepareArguments } from "./utils";
|
|
10
12
|
import { loadP, loadGuide } from "./prompts";
|
|
11
13
|
import { buildMetrics } from "./replace-response";
|
|
@@ -121,8 +123,17 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
121
123
|
|
|
122
124
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
123
125
|
let currentRaw: string | undefined;
|
|
126
|
+
let currentIdentity: FileIdentity | undefined;
|
|
124
127
|
try {
|
|
125
|
-
|
|
128
|
+
const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
|
|
129
|
+
const handle = await open(mutationTargetPath, constants.O_RDONLY | noFollow);
|
|
130
|
+
try {
|
|
131
|
+
const { dev, ino } = await handle.stat();
|
|
132
|
+
currentIdentity = { dev, ino };
|
|
133
|
+
currentRaw = await handle.readFile("utf-8");
|
|
134
|
+
} finally {
|
|
135
|
+
await handle.close();
|
|
136
|
+
}
|
|
126
137
|
} catch (error) {
|
|
127
138
|
if (errCode(error) !== "ENOENT") throw error;
|
|
128
139
|
}
|
|
@@ -146,6 +157,7 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
146
157
|
await writeAtomic(
|
|
147
158
|
mutationTargetPath,
|
|
148
159
|
undo.bom + restoreEndings(undo.content, undo.originalEnding),
|
|
160
|
+
currentIdentity,
|
|
149
161
|
);
|
|
150
162
|
|
|
151
163
|
const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
|
package/src/replace.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ToolDefinition,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
import { constants } from "fs";
|
|
7
6
|
import {
|
|
8
7
|
genDiff,
|
|
@@ -10,9 +9,9 @@ import {
|
|
|
10
9
|
} from "./replace-diff";
|
|
11
10
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
12
11
|
import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
|
|
13
|
-
import { isRec
|
|
12
|
+
import { isRec } from "./utils";
|
|
14
13
|
import { loadP, loadGuide } from "./prompts";
|
|
15
|
-
import {
|
|
14
|
+
import { type FileIdentity } from "./fs-write";
|
|
16
15
|
import { applyEdit,
|
|
17
16
|
lineHashes,
|
|
18
17
|
resEdit,
|
|
@@ -26,14 +25,13 @@ import { applyEdit,
|
|
|
26
25
|
import { commitEdit } from "./commit";
|
|
27
26
|
import type { RMetrics } from "./replace-response";
|
|
28
27
|
import {
|
|
29
|
-
makeRenderCall,
|
|
30
|
-
renderEditResult,
|
|
31
28
|
type RPreview,
|
|
32
29
|
type RRState,
|
|
33
30
|
} from "./replace-render";
|
|
34
31
|
import { loadHashStore, findSnapshotPaths, findServedPaths, type HashStore } from "./hash-store";
|
|
35
32
|
import { getServed, recordServedSafe } from "./served";
|
|
36
33
|
import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
|
|
34
|
+
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
|
|
37
35
|
|
|
38
36
|
export { editToolSchema, type ReqParams, assertReq };
|
|
39
37
|
|
|
@@ -65,6 +63,7 @@ export interface PipelineResult {
|
|
|
65
63
|
totalRemovedLines: number;
|
|
66
64
|
hadBoundaryDedup: boolean;
|
|
67
65
|
boundaryRemovedLines: number;
|
|
66
|
+
identity: FileIdentity;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
async function resolveMissingPath(
|
|
@@ -170,7 +169,7 @@ export async function execPipeline(
|
|
|
170
169
|
);
|
|
171
170
|
|
|
172
171
|
const hashStore = options?.store ?? await loadHashStore();
|
|
173
|
-
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
|
|
172
|
+
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath, identity } = await readNormFile(
|
|
174
173
|
path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, preloadedNorm: options?.preloadedNorm },
|
|
175
174
|
);
|
|
176
175
|
|
|
@@ -227,6 +226,7 @@ export async function execPipeline(
|
|
|
227
226
|
totalRemovedLines,
|
|
228
227
|
hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
|
|
229
228
|
boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
|
|
229
|
+
identity,
|
|
230
230
|
};
|
|
231
231
|
}
|
|
232
232
|
|
|
@@ -277,22 +277,9 @@ export function buildToolDef(): ToolDef {
|
|
|
277
277
|
parameters,
|
|
278
278
|
promptSnippet: E_SNIPPET,
|
|
279
279
|
promptGuidelines: E_GUIDE,
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
renderCall: makeRenderCall(compPreview),
|
|
284
|
-
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
285
|
-
return renderEditResult(
|
|
286
|
-
result as {
|
|
287
|
-
content?: Array<{ type: string; text?: string }>;
|
|
288
|
-
details?: ReplaceDetails;
|
|
289
|
-
},
|
|
290
|
-
{ isPartial, expanded },
|
|
291
|
-
theme,
|
|
292
|
-
context,
|
|
293
|
-
);
|
|
294
|
-
},
|
|
295
|
-
|
|
280
|
+
...editToolBase,
|
|
281
|
+
renderCall: editRenderCallWrapper(compPreview),
|
|
282
|
+
renderResult: editRenderResultWrapper,
|
|
296
283
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
297
284
|
const canonical = normReq(params);
|
|
298
285
|
const resolution = isRec(canonical) ? await resolveMissingPath(canonical) : undefined;
|
|
@@ -300,14 +287,11 @@ export function buildToolDef(): ToolDef {
|
|
|
300
287
|
canonical.path = resolution.path;
|
|
301
288
|
}
|
|
302
289
|
assertReq(canonical);
|
|
303
|
-
|
|
304
290
|
const normalizedParams = canonical;
|
|
305
291
|
const path = normalizedParams.path;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
310
|
-
abortIf(signal);
|
|
292
|
+
return queuedEdit(path, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
293
|
+
const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
|
|
294
|
+
const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
|
|
311
295
|
const pipe = await execPipeline(
|
|
312
296
|
normalizedParams,
|
|
313
297
|
ctx.cwd,
|
package/src/validation.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { constants } from "fs";
|
|
2
2
|
import { access as fsAccess } from "fs/promises";
|
|
3
3
|
import type { LFile } from "./file-kind";
|
|
4
|
+
import type { FileIdentity } from "./fs-write";
|
|
4
5
|
import { errCode } from "./utils";
|
|
5
6
|
|
|
6
7
|
export async function valAccess(
|
|
@@ -26,7 +27,7 @@ export async function valAccess(
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export function valKind(file: LFile, path: string): asserts file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
|
|
30
|
+
export function valKind(file: LFile, path: string): asserts file is { kind: "text"; text: string; identity?: FileIdentity; hadUtf8DecodeErrors?: true } {
|
|
30
31
|
if (file.kind === "directory") {
|
|
31
32
|
throw new Error(`[E_NOT_TEXT] Path is a directory: ${path}. Use ls to inspect directories.`);
|
|
32
33
|
}
|
|
@@ -42,4 +43,3 @@ export function valKind(file: LFile, path: string): asserts file is { kind: "tex
|
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
|