pi-supernova 0.8.2 → 0.9.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 +188 -51
- package/docs/CHANGELOG.md +86 -1
- package/docs/TOKEN_COSTS.md +38 -0
- package/index.js +10 -175
- package/package.json +2 -1
- package/src/adapters/bash.js +14 -30
- package/src/adapters/errors.js +1 -9
- package/src/adapters/read-focus.js +98 -0
- package/src/adapters/read-image.js +51 -0
- package/src/adapters/read-json.js +42 -0
- package/src/adapters/read-text.js +71 -0
- package/src/adapters/read.js +66 -635
- package/src/bridge/catalog.js +3 -2
- package/src/bridge/host-bridge.js +35 -167
- package/src/bridge/tool-registry.js +104 -0
- package/src/bridge/trace.js +41 -0
- package/src/context/evidence-graph.js +249 -0
- package/src/context/evidence-rank.js +153 -0
- package/src/context/evidence.js +10 -424
- package/src/context/fuzzy.js +116 -43
- package/src/context/query.js +80 -0
- package/src/context/repo-index.js +23 -166
- package/src/context/search-files.js +19 -0
- package/src/context/search.js +2 -24
- package/src/context/snap-search.js +203 -0
- package/src/context/snap.js +5 -266
- package/src/context/source-entry.js +112 -0
- package/src/contract/bash.js +6 -1
- package/src/contract/program.js +36 -0
- package/src/contract/read.js +8 -53
- package/src/fs/check.js +1 -1
- package/src/fs/commit.js +161 -0
- package/src/fs/diff.js +11 -15
- package/src/fs/directory.js +79 -0
- package/src/fs/file-io.js +100 -0
- package/src/fs/glob.js +54 -0
- package/src/fs/json-size.js +54 -0
- package/src/fs/lines.js +117 -0
- package/src/fs/read-window.js +74 -0
- package/src/fs/session-resource.js +50 -0
- package/src/fs/text-ops.js +7 -227
- package/src/fs/vfs.js +5 -239
- package/src/fs/workspace.js +2 -1
- package/src/output/bottleneck.js +13 -67
- package/src/output/final.js +114 -0
- package/src/output/format.js +94 -5
- package/src/output/outcome.js +91 -0
- package/src/runtime/batch-input.js +68 -0
- package/src/runtime/guest-api.js +281 -0
- package/src/runtime/guest-worker.js +62 -333
- package/src/runtime/parallel.js +41 -39
- package/src/runtime/program-batch.js +21 -75
- package/src/runtime/program-file.js +3 -11
- package/src/runtime/program.js +141 -0
- package/src/runtime/reference.js +6 -5
- package/src/runtime/runtime.js +77 -253
- package/src/runtime/worker-pool.js +91 -0
- package/src/shared/decode.js +22 -8
- package/src/shared/image-worker.js +30 -0
- package/src/shared/image.js +78 -0
- package/src/shared/png.js +57 -0
- package/src/shared/result.js +77 -0
- package/src/shared/syntax-context.js +61 -3
- package/src/ui/host-render.js +104 -0
- package/src/ui/progress.js +51 -0
- package/src/ui/render.js +21 -421
- package/src/ui/trace.js +277 -0
package/src/contract/read.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isString, isObject, isNumber, looksLikePath } from "../shared/decode.js";
|
|
1
|
+
import { errorMessage, isString, isObject, isNumber, looksLikePath } from "../shared/decode.js";
|
|
2
2
|
import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
|
|
3
3
|
|
|
4
4
|
export const SESSION_URI = /^(?:agent|artifact):\/\//i;
|
|
@@ -85,12 +85,7 @@ export function normalizeRead(params) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
export function needsProbe(params) {
|
|
88
|
-
|
|
89
|
-
if (params.evidence === true) return false;
|
|
90
|
-
if (isString(params.query)) return false;
|
|
91
|
-
if (params.outline === true) return false;
|
|
92
|
-
|
|
93
|
-
return true;
|
|
88
|
+
return !(isSessionUri(params.path) || params.evidence === true || isString(params.query) || params.outline === true);
|
|
94
89
|
}
|
|
95
90
|
|
|
96
91
|
/**
|
|
@@ -160,8 +155,6 @@ export const ROUTING_STATUS = "too_large";
|
|
|
160
155
|
|
|
161
156
|
const ROUTING_PREFIX = '{"status":"too_large",';
|
|
162
157
|
|
|
163
|
-
export const ROUTING_KEYS_MAX = 32;
|
|
164
|
-
|
|
165
158
|
export function isRoutingPayload(value) {
|
|
166
159
|
return isString(value) && value.startsWith(ROUTING_PREFIX);
|
|
167
160
|
}
|
|
@@ -171,49 +164,6 @@ function isRoutingObject(parsed) {
|
|
|
171
164
|
&& isNumber(parsed.chars) && (Array.isArray(parsed.keys) || isNumber(parsed.length));
|
|
172
165
|
}
|
|
173
166
|
|
|
174
|
-
/** Shape of an over-bound JSON document for in-band routing. Throws when text is not JSON. */
|
|
175
|
-
export function buildJsonRouting(rel, text) {
|
|
176
|
-
const document = JSON.parse(text);
|
|
177
|
-
const base = { status: ROUTING_STATUS, path: rel, chars: text.length };
|
|
178
|
-
|
|
179
|
-
if (Array.isArray(document)) return { ...base, length: document.length };
|
|
180
|
-
|
|
181
|
-
if (isObject(document)) {
|
|
182
|
-
const keys = Object.keys(document);
|
|
183
|
-
|
|
184
|
-
return keys.length > ROUTING_KEYS_MAX
|
|
185
|
-
? { ...base, keys: keys.slice(0, ROUTING_KEYS_MAX), keysTruncated: true }
|
|
186
|
-
: { ...base, keys };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return base;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function routingText(routing) {
|
|
193
|
-
const text = JSON.stringify(routing);
|
|
194
|
-
|
|
195
|
-
if (!isRoutingPayload(text)) throw new Error("routing payload must start with the shared marker");
|
|
196
|
-
|
|
197
|
-
return text;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Shape of one over-budget selection for in-band routing; value is already parsed. */
|
|
201
|
-
export function buildSelectionRouting(rel, selector, value, chars) {
|
|
202
|
-
const base = { status: ROUTING_STATUS, path: rel, selector, chars };
|
|
203
|
-
|
|
204
|
-
if (Array.isArray(value)) return { ...base, length: value.length };
|
|
205
|
-
|
|
206
|
-
if (isObject(value)) {
|
|
207
|
-
const keys = Object.keys(value);
|
|
208
|
-
|
|
209
|
-
return keys.length > ROUTING_KEYS_MAX
|
|
210
|
-
? { ...base, keys: keys.slice(0, ROUTING_KEYS_MAX), keysTruncated: true }
|
|
211
|
-
: { ...base, keys };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return base;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
167
|
function decodeByArgs(args, value) {
|
|
218
168
|
return (args.resolve || args.json !== undefined || args.outline || args.evidence) && isString(value);
|
|
219
169
|
}
|
|
@@ -230,6 +180,11 @@ export function decodeReadValue(args, value) {
|
|
|
230
180
|
} catch (error) {
|
|
231
181
|
if (sniffed && !decodeByArgs(args, value)) return value;
|
|
232
182
|
|
|
233
|
-
throw
|
|
183
|
+
throw jsonReadError(args, error);
|
|
234
184
|
}
|
|
235
185
|
}
|
|
186
|
+
|
|
187
|
+
function jsonReadError(args, error) {
|
|
188
|
+
const target = String(args.path ?? args.target ?? "resource");
|
|
189
|
+
return new Error("JSON read failed for " + target + jsonSelectorNote(args) + ": " + errorMessage(error));
|
|
190
|
+
}
|
package/src/fs/check.js
CHANGED
|
@@ -145,7 +145,7 @@ function consumeLiteral(text, i, stack, prev, rust) {
|
|
|
145
145
|
if (lifetime && text[end] !== "'") return { end, prev: "value" };
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
if (
|
|
148
|
+
if (['"', "'", "`"].includes(c)) return consumeQuoted(text, i, stack);
|
|
149
149
|
|
|
150
150
|
if (c !== "/") return null;
|
|
151
151
|
|
package/src/fs/commit.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {fileSignature,sameSignature,textSignature} from './file-io.js';
|
|
5
|
+
|
|
6
|
+
// realpath() cannot resolve a missing leaf. Canonicalize its nearest existing
|
|
7
|
+
// ancestor so two symlink spellings still share one commit destination.
|
|
8
|
+
async function canonicalNewPath(target) {
|
|
9
|
+
let ancestor = path.dirname(target);
|
|
10
|
+
|
|
11
|
+
for (;;) {
|
|
12
|
+
try { return path.join(await fs.realpath(ancestor), path.relative(ancestor, target)); }
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code !== "ENOENT") throw error;
|
|
15
|
+
const parent = path.dirname(ancestor);
|
|
16
|
+
|
|
17
|
+
if (parent === ancestor) throw error;
|
|
18
|
+
ancestor = parent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function resolveExistingFile(logicalPath) {
|
|
24
|
+
const target = await fs.realpath(logicalPath);
|
|
25
|
+
const stat = await fs.stat(target);
|
|
26
|
+
|
|
27
|
+
if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
|
|
28
|
+
|
|
29
|
+
return { target, stat };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function resolveCommitTarget(logicalPath) {
|
|
33
|
+
try {
|
|
34
|
+
return await resolveExistingFile(logicalPath);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err.code !== "ENOENT") throw err;
|
|
37
|
+
|
|
38
|
+
return { target: await canonicalNewPath(logicalPath), stat: undefined };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function collectMissingAncestors(parent) {
|
|
43
|
+
const missing = [];
|
|
44
|
+
let probe = parent;
|
|
45
|
+
|
|
46
|
+
for (;;) {
|
|
47
|
+
try { await fs.stat(probe); break; } catch (err) {
|
|
48
|
+
if (err.code !== "ENOENT") throw err;
|
|
49
|
+
missing.push(probe);
|
|
50
|
+
probe = path.dirname(probe);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return missing;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function writeTemporary(entry, content, stat) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
// Never leak the temporary name: name the destination and the real cause.
|
|
62
|
+
if (error?.code === "EACCES" || error?.code === "EPERM") throw new Error("permission denied writing " + entry.target + ": the directory or file is not writable");
|
|
63
|
+
if (error?.code === "EROFS") throw new Error("cannot write " + entry.target + ": the file system is read-only");
|
|
64
|
+
if (error?.code === "ENOSPC") throw new Error("cannot write " + entry.target + ": no space left on device");
|
|
65
|
+
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function stageReplacement(entry, content, stat, target) {
|
|
73
|
+
const replacement = writeTemporary(entry, content, stat);
|
|
74
|
+
// These touch separate staging files. Settle both before cleanup, even
|
|
75
|
+
// on failure: Promise.all could leave a late backup after rollback.
|
|
76
|
+
const staging = [replacement];
|
|
77
|
+
|
|
78
|
+
if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
|
|
79
|
+
const outcomes = await Promise.allSettled(staging);
|
|
80
|
+
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
81
|
+
|
|
82
|
+
if (failure) throw failure.reason;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeStageEntry(logicalPath, target, content, parent, stat) {
|
|
86
|
+
const token = ".supernova-" + randomUUID();
|
|
87
|
+
|
|
88
|
+
return { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function recoverReplaced(staged) {
|
|
92
|
+
const recoveryErrors = [];
|
|
93
|
+
|
|
94
|
+
for (const entry of staged.toReversed()) {
|
|
95
|
+
if (!entry.replaced) continue;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
if (entry.existed) await fs.rename(entry.backup, entry.target);
|
|
99
|
+
else await fs.unlink(entry.target);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// Keep the backup if recovery fails; never delete the remaining original.
|
|
102
|
+
entry.keepBackup = true;
|
|
103
|
+
recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return recoveryErrors;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function cleanupStaged(staged, failed, createdDirs) {
|
|
111
|
+
for (const entry of staged) {
|
|
112
|
+
// A successful rename consumed the temporary path. These are known
|
|
113
|
+
// files, so unlink avoids rm's extra type probe; missing files stay benign.
|
|
114
|
+
if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
|
|
115
|
+
|
|
116
|
+
if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function assertExpectedSignature(vfs, logicalPath, target, stat) {
|
|
123
|
+
if (!vfs.expected.has(logicalPath)) return;
|
|
124
|
+
const current = stat ? await fileSignature(target, vfs.signal) : null;
|
|
125
|
+
|
|
126
|
+
if (!sameSignature(current, vfs.expected.get(logicalPath))) {
|
|
127
|
+
throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function installStaged(vfs, staged) {
|
|
132
|
+
for (const entry of staged) {
|
|
133
|
+
vfs.signal?.throwIfAborted();
|
|
134
|
+
await fs.rename(entry.temporary, entry.target);
|
|
135
|
+
entry.replaced = true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const entry of staged) {
|
|
139
|
+
vfs.expected.set(entry.logicalPath, textSignature(entry.content));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Canonical commit destinations must not rewrite established event paths
|
|
143
|
+
// for newly created files, whose callers supplied a logical cwd spelling.
|
|
144
|
+
if (staged.length) vfs.onNewFile?.(staged.map(entry => entry.existed ? entry.target : entry.logicalPath));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function failCommit(vfs, staged, error) {
|
|
148
|
+
const recoveryErrors = await recoverReplaced(staged);
|
|
149
|
+
// Keep CAS baselines: a failed commit must not forgive conflicts on files it
|
|
150
|
+
// never touched. If recovery left disk diverging from a baseline, the next
|
|
151
|
+
// write to that path fails loudly and forces a re-read instead of silently
|
|
152
|
+
// re-capturing unknown bytes as the new truth.
|
|
153
|
+
|
|
154
|
+
if (recoveryErrors.length) vfs.mutations.recoveryFailed = true;
|
|
155
|
+
|
|
156
|
+
if (recoveryErrors.length) vfs.onNewFile?.(null);
|
|
157
|
+
|
|
158
|
+
if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
export {assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged};
|
package/src/fs/diff.js
CHANGED
|
@@ -18,13 +18,8 @@ export function buildEditDiff(filePath, originalText, oldText, newText) {
|
|
|
18
18
|
lines.push({ type: "context", lineNum: startLine - 1, text: fileLines[startLine - 2] });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
for (let i = 0; i < newLines.length; i++) {
|
|
26
|
-
lines.push({ type: "add", lineNum: startLine + i, text: newLines[i] });
|
|
27
|
-
}
|
|
21
|
+
appendDiffLines(lines, "remove", oldLines, startLine);
|
|
22
|
+
appendDiffLines(lines, "add", newLines, startLine);
|
|
28
23
|
|
|
29
24
|
const afterSourceLine = startLine + oldLines.length;
|
|
30
25
|
|
|
@@ -102,7 +97,7 @@ export function buildPatchDiff(filePath, patchText, relocations = []) {
|
|
|
102
97
|
continue;
|
|
103
98
|
}
|
|
104
99
|
|
|
105
|
-
if (!inHunk
|
|
100
|
+
if (!inHunk) continue;
|
|
106
101
|
const kind = classifyPatchLine(patchLine);
|
|
107
102
|
|
|
108
103
|
if (!kind) continue;
|
|
@@ -140,13 +135,8 @@ export function buildWriteDiff(filePath, previousText, newText) {
|
|
|
140
135
|
const maxStoredLines = 64;
|
|
141
136
|
const lines = [];
|
|
142
137
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
for (let i = 0; i < newLines.length && lines.length < maxStoredLines; i++) {
|
|
148
|
-
lines.push({ type: "add", lineNum: i + 1, text: newLines[i] });
|
|
149
|
-
}
|
|
138
|
+
appendDiffLines(lines, "remove", oldLines, 1, maxStoredLines);
|
|
139
|
+
appendDiffLines(lines, "add", newLines, 1, maxStoredLines);
|
|
150
140
|
|
|
151
141
|
return {
|
|
152
142
|
path: filePath,
|
|
@@ -157,3 +147,9 @@ export function buildWriteDiff(filePath, previousText, newText) {
|
|
|
157
147
|
lines,
|
|
158
148
|
};
|
|
159
149
|
}
|
|
150
|
+
|
|
151
|
+
function appendDiffLines(lines, type, text, start, limit = Infinity) {
|
|
152
|
+
for (let i = 0; i < text.length && lines.length < limit; i++) {
|
|
153
|
+
lines.push({type, lineNum: start + i, text: text[i]});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import {readResult} from '../shared/result.js';
|
|
4
|
+
|
|
5
|
+
export const MAX_DIRECTORY_ENTRIES = 10000;
|
|
6
|
+
|
|
7
|
+
export function formatDirectoryEntry(name, type, size = 0) {
|
|
8
|
+
const sizeSuffix = size ? `, ${size} bytes` : "";
|
|
9
|
+
|
|
10
|
+
return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function formatLsEntry(dirPath, entry) {
|
|
14
|
+
const isDir = entry.isDirectory();
|
|
15
|
+
const isSym = entry.isSymbolicLink();
|
|
16
|
+
const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
|
|
17
|
+
let size = 0;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
if (!isDir && !isSym) {
|
|
21
|
+
const st = await fs.stat(path.join(dirPath, entry.name));
|
|
22
|
+
size = st.size;
|
|
23
|
+
}
|
|
24
|
+
} catch {}
|
|
25
|
+
|
|
26
|
+
return formatDirectoryEntry(entry.name, typeLabel, size);
|
|
27
|
+
}
|
|
28
|
+
function admitEntry(rows, name, dirPath) {
|
|
29
|
+
if (!rows.has(name) && rows.size >= MAX_DIRECTORY_ENTRIES) {
|
|
30
|
+
throw new Error("directory exceeds " + MAX_DIRECTORY_ENTRIES + " entries: " + dirPath + "; read a subdirectory or use bash with a bounded directory parser");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function formatDirBatch(dirPath, batch, rows, signal) {
|
|
35
|
+
const results = await Promise.all(batch.map(entry=>formatLsEntry(dirPath,entry)));
|
|
36
|
+
signal?.throwIfAborted();
|
|
37
|
+
for (let i=0;i<batch.length;i++) rows.set(batch[i].name,results[i]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createDirectoryReader(vfs) {
|
|
41
|
+
function overlayDirRows(dirPath, rows) {
|
|
42
|
+
for (const file of vfs.getOverlayPaths()) {
|
|
43
|
+
const relative = path.relative(dirPath,file);
|
|
44
|
+
if (!relative || relative === ".." || relative.startsWith(".."+path.sep) || path.isAbsolute(relative)) continue;
|
|
45
|
+
const [name,child] = relative.split(path.sep);
|
|
46
|
+
admitEntry(rows,name,dirPath);
|
|
47
|
+
rows.set(name,child === undefined
|
|
48
|
+
? formatDirectoryEntry(name,"file",Buffer.byteLength(vfs.getOverlay(file),"utf8"))
|
|
49
|
+
: formatDirectoryEntry(name,"dir"));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function diskDirRows(dirPath, rows, signal) {
|
|
54
|
+
let directory;
|
|
55
|
+
try { directory = await fs.opendir(dirPath,{bufferSize:128}); }
|
|
56
|
+
catch (error) { if (error.code === "ENOENT" && rows.size) return; throw error; }
|
|
57
|
+
let batch = [];
|
|
58
|
+
// Do not allocate the entire directory first or serialize every stat. Eight
|
|
59
|
+
// metadata operations overlap; directory handles close on success/error/abort.
|
|
60
|
+
for await (const entry of directory) {
|
|
61
|
+
signal?.throwIfAborted();
|
|
62
|
+
if (rows.has(entry.name)) continue;
|
|
63
|
+
admitEntry(rows,entry.name,dirPath);
|
|
64
|
+
rows.set(entry.name,undefined);
|
|
65
|
+
batch.push(entry);
|
|
66
|
+
if (batch.length === 8) { await formatDirBatch(dirPath,batch,rows,signal); batch = []; }
|
|
67
|
+
}
|
|
68
|
+
await formatDirBatch(dirPath,batch,rows,signal);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return async function readDirectory(dirPath, signal) {
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
const rows = new Map();
|
|
74
|
+
overlayDirRows(dirPath,rows);
|
|
75
|
+
await diskDirRows(dirPath,rows,signal);
|
|
76
|
+
const values = [...rows.values()];
|
|
77
|
+
return readResult(values,{path:dirPath,directory:true,count:rows.size},values.slice(0,20).join("\n"),undefined,()=>values.join("\n"));
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
function textSignature(text) {
|
|
5
|
+
return { size: Buffer.byteLength(text, "utf8"), sha256: createHash("sha256").update(text, "utf8").digest("hex") };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function sameFileVersion(a, b) {
|
|
9
|
+
return ["dev", "ino", "size", "mtimeMs", "ctimeMs"].every(key => a[key] === b[key]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// FileHandle streams with an already-aborted signal can emit a second, unhandled
|
|
13
|
+
// error even after for-await rejects (Node and Bun). Own the bounded reads instead.
|
|
14
|
+
async function* fileChunks(file, signal, maxBytes = Infinity) {
|
|
15
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes));
|
|
16
|
+
let remaining = maxBytes;
|
|
17
|
+
|
|
18
|
+
while (remaining > 0) {
|
|
19
|
+
signal?.throwIfAborted();
|
|
20
|
+
const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, remaining), null);
|
|
21
|
+
signal?.throwIfAborted();
|
|
22
|
+
if (!bytesRead) break;
|
|
23
|
+
remaining -= bytesRead;
|
|
24
|
+
// Consumers retaining a chunk must copy it before the next read.
|
|
25
|
+
yield buffer.subarray(0, bytesRead);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fileSignature(target, signal, observed) {
|
|
30
|
+
const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const actual = await file.stat();
|
|
34
|
+
|
|
35
|
+
if (!actual.isFile()) throw new Error("read requires a regular file: " + target);
|
|
36
|
+
if (observed && !sameFileVersion(observed, actual)) throw new Error("file changed while reading: " + target);
|
|
37
|
+
const hash = createHash("sha256");
|
|
38
|
+
|
|
39
|
+
for await (const chunk of fileChunks(file, signal)) hash.update(chunk);
|
|
40
|
+
const after = await file.stat();
|
|
41
|
+
|
|
42
|
+
if (!after.isFile() || !sameFileVersion(actual, after)) throw new Error("file changed while signing: " + target);
|
|
43
|
+
|
|
44
|
+
return { size: actual.size, sha256: hash.digest("hex") };
|
|
45
|
+
} finally {
|
|
46
|
+
await file.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sameSignature(a, b) {
|
|
51
|
+
return a === b || (a !== null && b !== null && a.size === b.size && a.sha256 === b.sha256);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tooLargeRead(label, maxBytes, target) {
|
|
55
|
+
return new Error(label + " exceeds " + maxBytes + " bytes" + (target ? ": " + target : "") + "; use a streaming parser through bash");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function overlayOrThrow(overlay, maxBytes, label, target) {
|
|
59
|
+
if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw tooLargeRead(label, maxBytes, target);
|
|
60
|
+
|
|
61
|
+
return overlay;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertReadableFile(stat, target) {
|
|
65
|
+
// Callers are reads, writes, edits and patch application: name the path, not the caller.
|
|
66
|
+
if (stat.isDirectory()) throw new Error("path is a directory, not a file: " + target);
|
|
67
|
+
|
|
68
|
+
if (!stat.isFile()) throw new Error("path is not a regular file: " + target);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readLimitedBytes(file, stat, maxBytes, label, signal, overflow = () => tooLargeRead(label, maxBytes)) {
|
|
72
|
+
if (stat.size > maxBytes) throw overflow();
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let size = 0;
|
|
75
|
+
|
|
76
|
+
for await (const chunk of fileChunks(file, signal, maxBytes + 1)) {
|
|
77
|
+
size += chunk.length;
|
|
78
|
+
|
|
79
|
+
if (size > maxBytes) throw overflow();
|
|
80
|
+
chunks.push(Buffer.from(chunk));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Buffer.concat(chunks);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function remapReadError(err, target) {
|
|
87
|
+
if (err.code === "EISDIR") throw new Error("path is a directory, not a file: " + target);
|
|
88
|
+
|
|
89
|
+
if (err.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
|
|
90
|
+
if (err.code === "EACCES" || err.code === "EPERM") throw new Error("permission denied reading " + target + ": check the file mode (for example bash chmod)");
|
|
91
|
+
|
|
92
|
+
if (err.code === "ENOENT") {
|
|
93
|
+
const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question; use Promise.allSettled for optional reads to retain successful siblings)');
|
|
94
|
+
missing.code = "ENOENT";
|
|
95
|
+
throw missing;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
export { textSignature, sameFileVersion, fileChunks, fileSignature, sameSignature, tooLargeRead, overlayOrThrow, assertReadableFile, readLimitedBytes, remapReadError };
|
package/src/fs/glob.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const REGEX_SPECIAL = /[.+^${}()|\\]/g;
|
|
2
|
+
|
|
3
|
+
/** Translate one glob token at index i → [regexSource, nextIndex]. */
|
|
4
|
+
function globToken(glob, i) {
|
|
5
|
+
const ch = glob[i];
|
|
6
|
+
|
|
7
|
+
if (ch === "*" && glob[i + 1] === "*") {
|
|
8
|
+
const slashAfter = glob[i + 2] === "/";
|
|
9
|
+
|
|
10
|
+
return [slashAfter ? "(?:.*/)?" : ".*", i + (slashAfter ? 3 : 2)];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (ch === "*") return ["[^/]*", i + 1];
|
|
14
|
+
|
|
15
|
+
if (ch === "?") return ["[^/]", i + 1];
|
|
16
|
+
|
|
17
|
+
if (ch === "{" || ch === "[") return globGroup(glob, i, ch);
|
|
18
|
+
|
|
19
|
+
return [ch.replace(REGEX_SPECIAL, "\\$&"), i + 1];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** {a,b} alternation or [..] class starting at i. */
|
|
23
|
+
function globGroup(glob, i, open) {
|
|
24
|
+
const close = open === "{" ? "}" : "]";
|
|
25
|
+
const end = glob.indexOf(close, i);
|
|
26
|
+
|
|
27
|
+
if (end < 0) throw new SyntaxError("unclosed " + open + " in glob");
|
|
28
|
+
const inner = glob.slice(i + 1, end);
|
|
29
|
+
const source = open === "{"
|
|
30
|
+
? "(?:" + inner.split(",").map(globBody).join("|") + ")"
|
|
31
|
+
: "[" + (inner.startsWith("!") ? "^" + inner.slice(1) : inner) + "]";
|
|
32
|
+
|
|
33
|
+
return [source, end + 1];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function globBody(glob) {
|
|
37
|
+
let source = "";
|
|
38
|
+
let i = 0;
|
|
39
|
+
|
|
40
|
+
while (i < glob.length) {
|
|
41
|
+
const [piece, next] = globToken(glob, i);
|
|
42
|
+
source += piece;
|
|
43
|
+
i = next;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return source;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** gitignore-style glob (rg -g) → RegExp over a "/"-separated relative path. No slash ⇒ basename match anywhere. */
|
|
50
|
+
export function globToRegExp(glob) {
|
|
51
|
+
const body = globBody(glob);
|
|
52
|
+
|
|
53
|
+
return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
|
|
54
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const JSON_TWO_BYTE = new Set([0x22, 0x5c, 8, 9, 10, 12, 13]);
|
|
2
|
+
|
|
3
|
+
function jsonAsciiWidth(c) {
|
|
4
|
+
if (JSON_TWO_BYTE.has(c)) return 2;
|
|
5
|
+
|
|
6
|
+
if (c < 32) return 6;
|
|
7
|
+
|
|
8
|
+
return 1;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function jsonUnitWidth(s, i) {
|
|
12
|
+
const c = s.charCodeAt(i);
|
|
13
|
+
|
|
14
|
+
if (c >= 0xD800 && c <= 0xDBFF && i + 1 < s.length) {
|
|
15
|
+
const d = s.charCodeAt(i + 1);
|
|
16
|
+
|
|
17
|
+
if (d >= 0xDC00 && d <= 0xDFFF) return { add: 2, skip: 2 };
|
|
18
|
+
|
|
19
|
+
return { add: 6, skip: 1 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (c >= 0xD800 && c <= 0xDFFF) return { add: 6, skip: 1 };
|
|
23
|
+
|
|
24
|
+
return { add: jsonAsciiWidth(c), skip: 1 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** UTF-16 length of JSON.stringify(s) for a string, without allocating the JSON. */
|
|
28
|
+
export function jsonStringLength(s) {
|
|
29
|
+
let n = 2;
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < s.length; ) {
|
|
32
|
+
const unit = jsonUnitWidth(s, i);
|
|
33
|
+
n += unit.add;
|
|
34
|
+
i += unit.skip;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Largest prefix whose JSON.stringify length is <= limit. */
|
|
41
|
+
export function maxJsonStringPrefix(s, limit) {
|
|
42
|
+
let used = 2;
|
|
43
|
+
let i = 0;
|
|
44
|
+
|
|
45
|
+
while (i < s.length) {
|
|
46
|
+
const unit = jsonUnitWidth(s, i);
|
|
47
|
+
|
|
48
|
+
if (used + unit.add > limit) break;
|
|
49
|
+
used += unit.add;
|
|
50
|
+
i += unit.skip;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return i;
|
|
54
|
+
}
|