pi-supernova 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +376 -221
- package/docs/CHANGELOG.md +107 -5
- package/docs/TOKEN_COSTS.md +149 -324
- package/index.js +40 -11
- package/package.json +4 -3
- package/src/adapters/bash.js +40 -10
- package/src/adapters/edit.js +77 -23
- package/src/adapters/errors.js +4 -0
- package/src/adapters/index.js +1 -0
- package/src/adapters/list.js +6 -3
- package/src/adapters/read-focus.js +6 -0
- package/src/adapters/read-image.js +17 -9
- package/src/adapters/read-indexed.js +72 -0
- package/src/adapters/read-text.js +19 -2
- package/src/adapters/read.js +57 -13
- package/src/adapters/refs.js +17 -7
- package/src/adapters/write.js +14 -21
- package/src/bridge/host-bridge.js +152 -39
- package/src/bridge/invoke.js +3 -1
- package/src/bridge/tool-registry.js +132 -16
- package/src/context/evidence-graph.js +1 -0
- package/src/context/evidence-rank.js +1 -0
- package/src/context/evidence.js +4 -2
- package/src/context/fuzzy.js +31 -8
- package/src/context/outline.js +33 -9
- package/src/context/query.js +7 -2
- package/src/context/repo-index.js +8 -3
- package/src/context/search-files.js +20 -0
- package/src/context/search.js +25 -10
- package/src/context/snap-search.js +28 -6
- package/src/context/snap.js +8 -3
- package/src/context/source-entry.js +64 -28
- package/src/context/spans.js +1 -0
- package/src/contract/bash.js +16 -7
- package/src/contract/edit.js +26 -7
- package/src/contract/program.js +2 -1
- package/src/contract/read.js +46 -46
- package/src/fs/background.js +10 -2
- package/src/fs/check.js +844 -46
- package/src/fs/commit.js +89 -15
- package/src/fs/diff.js +12 -9
- package/src/fs/directory.js +14 -4
- package/src/fs/file-io.js +28 -7
- package/src/fs/glob.js +93 -11
- package/src/fs/json-read.js +3 -2
- package/src/fs/json-size.js +2 -1
- package/src/fs/lines.js +49 -2
- package/src/fs/patch.js +26 -10
- package/src/fs/process-tree.js +19 -1
- package/src/fs/read-window.js +36 -20
- package/src/fs/source-window.js +5 -10
- package/src/fs/text-ops.js +39 -19
- package/src/fs/vfs.js +296 -41
- package/src/fs/workspace.js +24 -6
- package/src/output/bottleneck.js +50 -25
- package/src/output/final.js +20 -0
- package/src/output/format.js +97 -36
- package/src/output/outcome.js +25 -9
- package/src/runtime/batch-input.js +17 -3
- package/src/runtime/guest-api.js +32 -7
- package/src/runtime/guest-worker.js +22 -4
- package/src/runtime/parallel.js +9 -49
- package/src/runtime/program-batch.js +53 -28
- package/src/runtime/program-file.js +2 -1
- package/src/runtime/program.js +94 -41
- package/src/runtime/reference.js +10 -9
- package/src/runtime/runtime.js +86 -17
- package/src/runtime/worker-pool.js +10 -1
- package/src/shared/decode.js +9 -8
- package/src/shared/image-worker.js +7 -0
- package/src/shared/image.js +58 -15
- package/src/shared/png.js +18 -0
- package/src/shared/result.js +30 -9
- package/src/shared/syntax-context.js +7 -1
- package/src/shared/utf8.js +11 -13
- package/src/ui/host-render.js +4 -0
- package/src/ui/omp-frame.js +2 -0
- package/src/ui/progress.js +2 -1
- package/src/ui/render-measure.js +11 -0
- package/src/ui/render.js +92 -31
- package/src/ui/trace.js +8 -5
package/src/fs/commit.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import {randomUUID} from 'node:crypto';
|
|
4
|
-
import {fileSignature,sameSignature,textSignature} from './file-io.js';
|
|
4
|
+
import {fileSignature,sameSignature,sameFileVersion,textSignature} from './file-io.js';
|
|
5
5
|
|
|
6
6
|
// realpath() cannot resolve a missing leaf. Canonicalize its nearest existing
|
|
7
7
|
// ancestor so two symlink spellings still share one commit destination.
|
|
@@ -20,12 +20,23 @@ async function canonicalNewPath(target) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export async function resolveFileIdentity(target) {
|
|
24
|
+
try { return { path: await fs.realpath(target) }; }
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== "ENOENT") throw error;
|
|
27
|
+
|
|
28
|
+
return { path: await canonicalNewPath(target), missing: error };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
async function resolveExistingFile(logicalPath) {
|
|
24
33
|
const target = await fs.realpath(logicalPath);
|
|
25
34
|
const stat = await fs.stat(target);
|
|
26
35
|
|
|
27
36
|
if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
|
|
28
37
|
|
|
38
|
+
if (stat.nlink > 1) throw new Error("cannot replace a hard-linked file without splitting its aliases: " + logicalPath);
|
|
39
|
+
|
|
29
40
|
return { target, stat };
|
|
30
41
|
}
|
|
31
42
|
|
|
@@ -87,7 +98,52 @@ async function stageReplacement(entry, content, stat, target) {
|
|
|
87
98
|
function makeStageEntry(logicalPath, target, content, parent, stat) {
|
|
88
99
|
const token = ".supernova-" + randomUUID();
|
|
89
100
|
|
|
90
|
-
return { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
|
|
101
|
+
return { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, observed: stat, replaced: false };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Windows readers and antivirus can briefly deny replacement. Bound retries and
|
|
105
|
+
// renew the destination/ownership guard after waiting; never replay a stale CAS.
|
|
106
|
+
async function renameWithSharingRetry(from, to, validate) {
|
|
107
|
+
for (let attempt = 0; ; attempt++) {
|
|
108
|
+
try {
|
|
109
|
+
await fs.rename(from, to);
|
|
110
|
+
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (process.platform !== "win32" || !["EPERM", "EBUSY"].includes(error.code) || attempt >= 2) throw error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
118
|
+
await validate();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function assertPublishedContent(entry) {
|
|
123
|
+
const expected = textSignature(entry.content);
|
|
124
|
+
const stat = await fs.stat(entry.target);
|
|
125
|
+
|
|
126
|
+
// Recovery must not destroy changes made after our publication. Check
|
|
127
|
+
// cheap type/size differences before hashing a possibly replaced file.
|
|
128
|
+
if (!stat.isFile() || stat.size !== expected.size || !sameSignature(await fileSignature(entry.target, undefined, stat), expected)) {
|
|
129
|
+
throw new Error("destination changed after publication; left unchanged");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function assertStageReady(vfs, entry) {
|
|
134
|
+
vfs.assertCurrent?.();
|
|
135
|
+
vfs.signal?.throwIfAborted();
|
|
136
|
+
let current;
|
|
137
|
+
|
|
138
|
+
try { current = await fs.stat(entry.target); }
|
|
139
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
140
|
+
|
|
141
|
+
if (!current || !sameFileVersion(entry.observed, current)) throw new Error("write conflict: file changed during commit staging: " + entry.logicalPath + "; read it again before retrying");
|
|
142
|
+
|
|
143
|
+
// Windows metadata can alias a rewrite while staging releases its handles.
|
|
144
|
+
if (process.platform === "win32") await assertExpectedSignature(vfs, entry.logicalPath, entry.target, current);
|
|
145
|
+
vfs.assertCurrent?.();
|
|
146
|
+
vfs.signal?.throwIfAborted();
|
|
91
147
|
}
|
|
92
148
|
|
|
93
149
|
async function recoverReplaced(staged) {
|
|
@@ -97,12 +153,14 @@ async function recoverReplaced(staged) {
|
|
|
97
153
|
if (!entry.replaced) continue;
|
|
98
154
|
|
|
99
155
|
try {
|
|
100
|
-
|
|
156
|
+
await assertPublishedContent(entry);
|
|
157
|
+
|
|
158
|
+
if (entry.existed) await renameWithSharingRetry(entry.backup, entry.target, () => assertPublishedContent(entry));
|
|
101
159
|
else await fs.unlink(entry.target);
|
|
102
160
|
} catch (err) {
|
|
103
161
|
// Keep the backup if recovery fails; never delete the remaining original.
|
|
104
|
-
entry.keepBackup =
|
|
105
|
-
recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
|
|
162
|
+
entry.keepBackup = entry.existed;
|
|
163
|
+
recoveryErrors.push(entry.target + ": " + err.message + (entry.existed ? " (backup: " + entry.backup + ")" : ""));
|
|
106
164
|
}
|
|
107
165
|
}
|
|
108
166
|
|
|
@@ -111,9 +169,9 @@ async function recoverReplaced(staged) {
|
|
|
111
169
|
|
|
112
170
|
async function cleanupStaged(staged, failed, createdDirs) {
|
|
113
171
|
for (const entry of staged) {
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
|
|
172
|
+
// Existing-file rename consumes the staging name; new-file linking does
|
|
173
|
+
// not. Remove only our staging names, never a conflicting destination.
|
|
174
|
+
if (!entry.replaced || !entry.existed) await fs.unlink(entry.temporary).catch(() => {});
|
|
117
175
|
|
|
118
176
|
if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
|
|
119
177
|
}
|
|
@@ -122,26 +180,42 @@ async function cleanupStaged(staged, failed, createdDirs) {
|
|
|
122
180
|
}
|
|
123
181
|
|
|
124
182
|
async function assertExpectedSignature(vfs, logicalPath, target, stat) {
|
|
125
|
-
if (!vfs.expected.has(
|
|
183
|
+
if (!vfs.expected.has(target)) return;
|
|
126
184
|
const current = stat ? await fileSignature(target, vfs.signal) : null;
|
|
127
185
|
|
|
128
|
-
if (!sameSignature(current, vfs.expected.get(
|
|
186
|
+
if (!sameSignature(current, vfs.expected.get(target))) {
|
|
129
187
|
throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
|
|
130
188
|
}
|
|
131
189
|
}
|
|
132
190
|
|
|
133
191
|
async function installStaged(vfs, staged) {
|
|
134
192
|
for (const entry of staged) {
|
|
135
|
-
|
|
136
|
-
|
|
193
|
+
if (entry.existed) await assertStageReady(vfs, entry);
|
|
194
|
+
|
|
195
|
+
// Staging, version checks and earlier publications await I/O. Recheck
|
|
196
|
+
// ownership immediately before each disk effect; failCommit handles recovery.
|
|
137
197
|
vfs.assertCurrent?.();
|
|
138
198
|
vfs.signal?.throwIfAborted();
|
|
139
|
-
|
|
199
|
+
|
|
200
|
+
if (entry.existed) await renameWithSharingRetry(entry.temporary, entry.target, () => assertStageReady(vfs, entry));
|
|
201
|
+
else {
|
|
202
|
+
// Let the filesystem enforce absence, including case/Unicode aliases.
|
|
203
|
+
// A preflight stat followed by rename would still clobber a racing file.
|
|
204
|
+
try { await fs.link(entry.temporary, entry.target); }
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error.code === "EEXIST") throw new Error("write conflict: destination appeared before publication: " + entry.logicalPath + "; read it again before retrying", {cause:error});
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
140
211
|
entry.replaced = true;
|
|
212
|
+
// Record publication first so invalidation during awaited I/O triggers recovery.
|
|
213
|
+
vfs.assertCurrent?.();
|
|
214
|
+
vfs.signal?.throwIfAborted();
|
|
141
215
|
}
|
|
142
216
|
|
|
143
217
|
for (const entry of staged) {
|
|
144
|
-
vfs.expected.set(entry.
|
|
218
|
+
vfs.expected.set(entry.target, textSignature(entry.content));
|
|
145
219
|
}
|
|
146
220
|
|
|
147
221
|
// Canonical commit destinations must not rewrite established event paths
|
|
@@ -164,4 +238,4 @@ async function failCommit(vfs, staged, error) {
|
|
|
164
238
|
throw error;
|
|
165
239
|
}
|
|
166
240
|
|
|
167
|
-
export {assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged};
|
|
241
|
+
export {canonicalNewPath,assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged};
|
package/src/fs/diff.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
import { isString } from "../shared/decode.js";
|
|
3
|
+
import { lineNumberAt } from "./lines.js";
|
|
3
4
|
|
|
4
5
|
/** Receipts render at most this many matches; totals still cover every match. */
|
|
5
6
|
export const MAX_DIFF_MATCHES = 32;
|
|
@@ -38,21 +39,23 @@ export function buildEditDiff(filePath, originalText, oldText, newText) {
|
|
|
38
39
|
|
|
39
40
|
export function buildMultiEditDiff(filePath, originalText, replacements) {
|
|
40
41
|
const rendered = replacements.slice(0, MAX_DIFF_MATCHES);
|
|
41
|
-
const parts = rendered.map(({ oldText, newText }) =>
|
|
42
|
-
buildEditDiff(filePath, originalText, oldText, newText),
|
|
43
|
-
);
|
|
44
42
|
|
|
45
43
|
const lines = [];
|
|
46
44
|
let shift = 0;
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
// Multi-edit receipts omit context. Do not split the entire original for
|
|
47
|
+
// each replacement just to build context rows that would be discarded.
|
|
48
|
+
for (const { oldText, newText } of rendered) {
|
|
49
|
+
const idx = isString(originalText) ? originalText.indexOf(oldText) : -1;
|
|
50
|
+
const startLine = idx >= 0 ? lineNumberAt(originalText, idx) : 1;
|
|
51
|
+
const oldLines = contentLines(oldText);
|
|
52
|
+
|
|
53
|
+
for (const [i, text] of oldLines.entries()) {
|
|
54
|
+
lines.push({ type: "remove", lineNum: startLine + i, text, newLineNum: Math.max(1, startLine + i + shift) });
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
appendDiffLines(lines, "add", contentLines(newText), startLine + shift);
|
|
58
|
+
shift += newText.split("\n").length - oldText.split("\n").length;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
return {
|
package/src/fs/directory.js
CHANGED
|
@@ -25,6 +25,7 @@ export async function formatLsEntry(dirPath, entry) {
|
|
|
25
25
|
|
|
26
26
|
return formatDirectoryEntry(entry.name, typeLabel, size);
|
|
27
27
|
}
|
|
28
|
+
|
|
28
29
|
function admitEntry(rows, name, dirPath) {
|
|
29
30
|
if (!rows.has(name) && rows.size >= MAX_DIRECTORY_ENTRIES) {
|
|
30
31
|
throw new Error("directory exceeds " + MAX_DIRECTORY_ENTRIES + " entries: " + dirPath + "; read a subdirectory or use bash with a bounded directory parser");
|
|
@@ -34,46 +35,55 @@ function admitEntry(rows, name, dirPath) {
|
|
|
34
35
|
async function formatDirBatch(dirPath, batch, rows, signal) {
|
|
35
36
|
const results = await Promise.all(batch.map(entry=>formatLsEntry(dirPath,entry)));
|
|
36
37
|
signal?.throwIfAborted();
|
|
38
|
+
|
|
37
39
|
for (let i=0;i<batch.length;i++) rows.set(batch[i].name,results[i]);
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export function createDirectoryReader(vfs) {
|
|
41
|
-
function overlayDirRows(dirPath, rows) {
|
|
42
|
-
for (const file of vfs.getOverlayPaths()) {
|
|
43
|
+
async function overlayDirRows(dirPath, rows) {
|
|
44
|
+
for (const file of await vfs.getOverlayPaths(dirPath)) {
|
|
43
45
|
const relative = path.relative(dirPath,file);
|
|
46
|
+
|
|
44
47
|
if (!relative || relative === ".." || relative.startsWith(".."+path.sep) || path.isAbsolute(relative)) continue;
|
|
45
48
|
const [name,child] = relative.split(path.sep);
|
|
46
49
|
admitEntry(rows,name,dirPath);
|
|
47
50
|
rows.set(name,child === undefined
|
|
48
|
-
? formatDirectoryEntry(name,"file",Buffer.byteLength(vfs.getOverlay(file),"utf8"))
|
|
51
|
+
? formatDirectoryEntry(name,"file",Buffer.byteLength(await vfs.getOverlay(file),"utf8"))
|
|
49
52
|
: formatDirectoryEntry(name,"dir"));
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
async function diskDirRows(dirPath, rows, signal) {
|
|
54
57
|
let directory;
|
|
58
|
+
|
|
55
59
|
try { directory = await fs.opendir(dirPath,{bufferSize:128}); }
|
|
56
60
|
catch (error) { if (error.code === "ENOENT" && rows.size) return; throw error; }
|
|
61
|
+
|
|
57
62
|
let batch = [];
|
|
63
|
+
|
|
58
64
|
// Do not allocate the entire directory first or serialize every stat. Eight
|
|
59
65
|
// metadata operations overlap; directory handles close on success/error/abort.
|
|
60
66
|
for await (const entry of directory) {
|
|
61
67
|
signal?.throwIfAborted();
|
|
68
|
+
|
|
62
69
|
if (rows.has(entry.name)) continue;
|
|
63
70
|
admitEntry(rows,entry.name,dirPath);
|
|
64
71
|
rows.set(entry.name,undefined);
|
|
65
72
|
batch.push(entry);
|
|
73
|
+
|
|
66
74
|
if (batch.length === 8) { await formatDirBatch(dirPath,batch,rows,signal); batch = []; }
|
|
67
75
|
}
|
|
76
|
+
|
|
68
77
|
await formatDirBatch(dirPath,batch,rows,signal);
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
return async function readDirectory(dirPath, signal) {
|
|
72
81
|
signal?.throwIfAborted();
|
|
73
82
|
const rows = new Map();
|
|
74
|
-
overlayDirRows(dirPath,rows);
|
|
83
|
+
await overlayDirRows(dirPath,rows);
|
|
75
84
|
await diskDirRows(dirPath,rows,signal);
|
|
76
85
|
const values = [...rows.values()];
|
|
86
|
+
|
|
77
87
|
return readResult(values,{path:dirPath,directory:true,count:rows.size},values.slice(0,20).join("\n"),undefined,()=>values.join("\n"));
|
|
78
88
|
};
|
|
79
89
|
}
|
package/src/fs/file-io.js
CHANGED
|
@@ -12,8 +12,8 @@ function sameFileVersion(a, b) {
|
|
|
12
12
|
|
|
13
13
|
// FileHandle streams with an already-aborted signal can emit a second, unhandled
|
|
14
14
|
// error even after for-await rejects (Node and Bun). Own the bounded reads instead.
|
|
15
|
-
async function* fileChunks(file, signal, maxBytes = Infinity) {
|
|
16
|
-
const buffer = Buffer.allocUnsafe(Math.min(
|
|
15
|
+
async function* fileChunks(file, signal, maxBytes = Infinity, chunkBytes = 64 * 1024) {
|
|
16
|
+
const buffer = Buffer.allocUnsafe(Math.min(chunkBytes, maxBytes));
|
|
17
17
|
let remaining = maxBytes;
|
|
18
18
|
|
|
19
19
|
while (remaining > 0) {
|
|
@@ -38,13 +38,20 @@ async function fileSignature(target, signal, observed) {
|
|
|
38
38
|
|
|
39
39
|
if (observed && !sameFileVersion(observed, actual)) throw new Error("file changed while reading: " + target);
|
|
40
40
|
const hash = createHash("sha256");
|
|
41
|
+
let size = 0;
|
|
42
|
+
|
|
43
|
+
const chunkBytes = Math.min(256 * 1024, Math.max(64 * 1024, actual.size));
|
|
44
|
+
|
|
45
|
+
for await (const chunk of fileChunks(file, signal, Infinity, chunkBytes)) {
|
|
46
|
+
hash.update(chunk);
|
|
47
|
+
size += chunk.length;
|
|
48
|
+
}
|
|
41
49
|
|
|
42
|
-
for await (const chunk of fileChunks(file, signal)) hash.update(chunk);
|
|
43
50
|
const after = await file.stat();
|
|
44
51
|
|
|
45
52
|
if (!after.isFile() || !sameFileVersion(actual, after)) throw new Error("file changed while signing: " + target);
|
|
46
53
|
|
|
47
|
-
return { size
|
|
54
|
+
return { size, sha256: hash.digest("hex") };
|
|
48
55
|
} finally {
|
|
49
56
|
await file.close();
|
|
50
57
|
}
|
|
@@ -73,17 +80,31 @@ function assertReadableFile(stat, target) {
|
|
|
73
80
|
|
|
74
81
|
async function readLimitedBytes(file, stat, maxBytes, label, signal, overflow = () => tooLargeRead(label, maxBytes)) {
|
|
75
82
|
if (stat.size > maxBytes) throw overflow();
|
|
76
|
-
|
|
83
|
+
signal?.throwIfAborted();
|
|
84
|
+
// Reserve from stat, but read through EOF: files can grow, shrink or report zero size.
|
|
85
|
+
const buffer = Buffer.allocUnsafe(stat.size + 1);
|
|
77
86
|
let size = 0;
|
|
78
87
|
|
|
79
|
-
|
|
88
|
+
while (size < buffer.length) {
|
|
89
|
+
const { bytesRead } = await file.read(buffer, size, buffer.length - size, null);
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
|
|
92
|
+
if (!bytesRead) return buffer.subarray(0, size);
|
|
93
|
+
size += bytesRead;
|
|
94
|
+
|
|
95
|
+
if (size > maxBytes) throw overflow();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const chunks = [buffer];
|
|
99
|
+
|
|
100
|
+
for await (const chunk of fileChunks(file, signal, maxBytes + 1 - size)) {
|
|
80
101
|
size += chunk.length;
|
|
81
102
|
|
|
82
103
|
if (size > maxBytes) throw overflow();
|
|
83
104
|
chunks.push(Buffer.from(chunk));
|
|
84
105
|
}
|
|
85
106
|
|
|
86
|
-
return Buffer.concat(chunks);
|
|
107
|
+
return Buffer.concat(chunks, size);
|
|
87
108
|
}
|
|
88
109
|
|
|
89
110
|
async function hasFileParent(target) {
|
package/src/fs/glob.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
|
-
const REGEX_SPECIAL = /[
|
|
1
|
+
const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
|
|
2
|
+
|
|
3
|
+
const utf8Bytes = text => Buffer.from(text, "utf8").toString("latin1");
|
|
4
|
+
|
|
5
|
+
// Ripgrep glob wildcards and classes consume UTF-8 bytes, not UTF-16 characters.
|
|
6
|
+
class Utf8GlobRegExp extends RegExp {
|
|
7
|
+
test(filePath) {
|
|
8
|
+
return super.test(utf8Bytes(filePath));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
2
11
|
|
|
3
12
|
/** Translate one glob token at index i → [regexSource, nextIndex]. */
|
|
4
|
-
function globToken(glob, i) {
|
|
13
|
+
function globToken(glob, i, branchStart = false, inGroup = false) {
|
|
5
14
|
const ch = glob[i];
|
|
6
15
|
|
|
16
|
+
if (ch === "\\" && i + 1 < glob.length) return [glob[i + 1].replace(REGEX_SPECIAL, "\\$&"), i + 2];
|
|
17
|
+
|
|
7
18
|
if (ch === "*" && glob[i + 1] === "*") {
|
|
8
19
|
const slashAfter = glob[i + 2] === "/";
|
|
20
|
+
// Brace endings terminate slash-prefixed recursive suffixes, not bare ** branches.
|
|
21
|
+
const branchSuffix = inGroup && glob[i - 1] === "/" && (glob[i + 2] === "," || glob[i + 2] === "}");
|
|
22
|
+
const recursive = (branchStart || i === 0 || glob[i - 1] === "/") && (slashAfter || i + 2 === glob.length || branchSuffix);
|
|
23
|
+
|
|
24
|
+
if (!recursive) return ["[^/]*", i + 2];
|
|
9
25
|
|
|
10
26
|
return [slashAfter ? "(?:.*/)?" : ".*", i + (slashAfter ? 3 : 2)];
|
|
11
27
|
}
|
|
@@ -21,14 +37,59 @@ function globToken(glob, i) {
|
|
|
21
37
|
|
|
22
38
|
/** {a,b} alternation or [..] class starting at i. */
|
|
23
39
|
function globGroup(glob, i, open) {
|
|
24
|
-
|
|
25
|
-
|
|
40
|
+
if (open === "{") {
|
|
41
|
+
const alternatives = [];
|
|
42
|
+
let source = "", next = i + 1, branchStart = true;
|
|
43
|
+
|
|
44
|
+
while (next < glob.length) {
|
|
45
|
+
if (glob[next] === "}" || glob[next] === ",") {
|
|
46
|
+
// rg discards empty alternatives; an all-empty group contributes no text.
|
|
47
|
+
if (source) alternatives.push(source);
|
|
48
|
+
|
|
49
|
+
if (glob[next] === "}") return [alternatives.length ? "(?:" + alternatives.join("|") + ")" : "", next + 1];
|
|
50
|
+
source = "";
|
|
51
|
+
next++;
|
|
52
|
+
branchStart = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Nested groups and escaped punctuation belong to a single alternative.
|
|
57
|
+
const [piece, end] = globToken(glob, next, branchStart, true);
|
|
58
|
+
source += piece;
|
|
59
|
+
next = end;
|
|
60
|
+
branchStart = false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new SyntaxError("unclosed { in glob");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let start = i + 1;
|
|
67
|
+
|
|
68
|
+
if (glob[start] === "!" || glob[start] === "^") start++;
|
|
26
69
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
70
|
+
// A leading ] after optional negation is a member, not the class terminator.
|
|
71
|
+
if (glob[start] === "]") start++;
|
|
72
|
+
|
|
73
|
+
const end = glob.indexOf("]", start);
|
|
74
|
+
|
|
75
|
+
if (end < 0) throw new SyntaxError("unclosed [ in glob");
|
|
76
|
+
// rg treats class backslashes literally and chained ranges replace the last
|
|
77
|
+
// range's endpoint: [a-c-e] is a-e, while [a-c-b] is a-b.
|
|
78
|
+
const negated = glob[i + 1] === "!" || glob[i + 1] === "^";
|
|
79
|
+
const inner = glob.slice(i + 1 + (negated ? 1 : 0), end);
|
|
80
|
+
const ranges = [];
|
|
81
|
+
|
|
82
|
+
for (let j = 0; j < inner.length; j++) {
|
|
83
|
+
if (inner[j] === "-" && ranges.length && j + 1 < inner.length) {
|
|
84
|
+
const range = ranges.at(-1);
|
|
85
|
+
range[1] = inner[++j];
|
|
86
|
+
|
|
87
|
+
if (range[0] > range[1]) throw new SyntaxError("invalid range in glob");
|
|
88
|
+
} else ranges.push([inner[j], inner[j]]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const escapeByte = byte => "\\][^-".includes(byte) ? "\\" + byte : byte;
|
|
92
|
+
const source = "[" + (negated ? "^" : "") + ranges.map(([first, last]) => escapeByte(first) + (first === last ? "" : "-" + escapeByte(last))).join("") + "]";
|
|
32
93
|
|
|
33
94
|
return [source, end + 1];
|
|
34
95
|
}
|
|
@@ -48,7 +109,28 @@ function globBody(glob) {
|
|
|
48
109
|
|
|
49
110
|
/** gitignore-style glob (rg -g) → RegExp over a "/"-separated relative path. No slash ⇒ basename match anywhere. */
|
|
50
111
|
export function globToRegExp(glob) {
|
|
51
|
-
const
|
|
112
|
+
const excluded = glob.startsWith("!");
|
|
113
|
+
|
|
114
|
+
if (excluded) glob = glob.slice(1);
|
|
115
|
+
const rooted = glob.startsWith("/");
|
|
116
|
+
const directoryOnly = excluded && glob.endsWith("/");
|
|
117
|
+
|
|
118
|
+
if (directoryOnly) {
|
|
119
|
+
glob = glob.slice(0, -1);
|
|
120
|
+
// Removing an escaped separator must not leave its escape as filename payload.
|
|
121
|
+
const escapes = /\\+$/.exec(glob)?.[0].length ?? 0;
|
|
122
|
+
|
|
123
|
+
if (escapes % 2) glob = glob.slice(0, -1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const anchored = rooted || glob.includes("/");
|
|
127
|
+
|
|
128
|
+
if (glob.startsWith("/")) glob = glob.slice(1);
|
|
129
|
+
const body = globBody(utf8Bytes(glob));
|
|
130
|
+
|
|
131
|
+
// An excluded directory prunes its descendants, not only an exact path.
|
|
132
|
+
// Dot-all keeps CR/LF filename bytes inside recursive and basename prefixes.
|
|
133
|
+
if (excluded) return new Utf8GlobRegExp("^(?!" + (anchored ? "" : "(?:.*/)?") + body + (directoryOnly ? "/" : "(?:/|$)") + ")[\\s\\S]*$", "s");
|
|
52
134
|
|
|
53
|
-
return new
|
|
135
|
+
return new Utf8GlobRegExp(anchored ? "^" + body + "$" : "(?:^|/)" + body + "$", "s");
|
|
54
136
|
}
|
package/src/fs/json-read.js
CHANGED
|
@@ -5,7 +5,7 @@ export const MAX_JSON_BYTES = 16 * 1024 * 1024;
|
|
|
5
5
|
const SELECTOR_HELP = 'JSON selector supports .field, .nested[0], .items[0:3], .items.length, .["quoted.key"], or . (whole value); not full jq';
|
|
6
6
|
|
|
7
7
|
function parseIdentStep(rest, first) {
|
|
8
|
-
const match = (first ? /^([A-Za-z_$][\w
|
|
8
|
+
const match = (first ? /^([A-Za-z_$][\w$-]*)/ : /^\.([A-Za-z_$][\w$-]*)/).exec(rest);
|
|
9
9
|
|
|
10
10
|
return match ? { step: { key: match[1] }, match } : null;
|
|
11
11
|
}
|
|
@@ -57,7 +57,8 @@ function missingJsonField(value, key) {
|
|
|
57
57
|
const keys = isObject(value) ? Object.keys(value) : [];
|
|
58
58
|
const preview = keys.length ? "; available keys: " + keys.slice(0, 24).map(key => JSON.stringify(key)).join(", ") + (keys.length > 24 ? ", …" : "") : "";
|
|
59
59
|
|
|
60
|
-
return new Error("JSON field not found: " + JSON.stringify(key) + preview
|
|
60
|
+
return new Error("JSON field not found: " + JSON.stringify(key) + preview
|
|
61
|
+
+ "; for optional fields, select the parent object (json:true for the root) and apply ?? defaults in guest code");
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
function selectKey(value, key) {
|
package/src/fs/json-size.js
CHANGED
|
@@ -37,7 +37,8 @@ export function jsonStringLength(s) {
|
|
|
37
37
|
return n;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** Largest prefix whose JSON.stringify length is <= limit.
|
|
40
|
+
/** Largest prefix whose JSON.stringify length is <= limit when limit >= 2.
|
|
41
|
+
* Below 2, returns 0 as a sentinel: even the empty quoted string cannot fit. */
|
|
41
42
|
export function maxJsonStringPrefix(s, limit) {
|
|
42
43
|
let used = 2;
|
|
43
44
|
let i = 0;
|
package/src/fs/lines.js
CHANGED
|
@@ -39,6 +39,8 @@ export function sliceLinesRawInfo(text, offset, limit) {
|
|
|
39
39
|
|
|
40
40
|
/** Read-window slicing preserves the selected lines' own line ending. */
|
|
41
41
|
export function sliceLinesRaw(text, offset, limit) {
|
|
42
|
+
if (!isNumber(offset) && !isNumber(limit)) return text;
|
|
43
|
+
|
|
42
44
|
return sliceLinesRawInfo(text, offset, limit).text;
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -51,9 +53,25 @@ export function sourceLines(content) {
|
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
export function lineNumberAt(content, index) {
|
|
54
|
-
|
|
56
|
+
// Limit the search to the prefix, including offsets inside a surrogate pair.
|
|
57
|
+
const text = content.slice(0, Math.max(0, Math.ceil(index)));
|
|
58
|
+
let line = 1, sampleStart = 0, sampled = 0;
|
|
59
|
+
|
|
60
|
+
for (let at = text.indexOf("\n"); at >= 0; at = text.indexOf("\n", at + 1)) {
|
|
61
|
+
line++;
|
|
55
62
|
|
|
56
|
-
|
|
63
|
+
if (++sampled === 64) {
|
|
64
|
+
// Native search wins on source lines, but repeated calls regress dense text.
|
|
65
|
+
if (at - sampleStart < 512) {
|
|
66
|
+
for (let i = at + 1; i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
|
|
67
|
+
|
|
68
|
+
return line;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
sampleStart = at;
|
|
72
|
+
sampled = 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
57
75
|
|
|
58
76
|
return line;
|
|
59
77
|
}
|
|
@@ -100,6 +118,7 @@ export function contentLineInfo(text, previewLimit = 0) {
|
|
|
100
118
|
const preview = [];
|
|
101
119
|
let count = 0;
|
|
102
120
|
let start = 0;
|
|
121
|
+
let sampleStart = 0, sampled = 0;
|
|
103
122
|
|
|
104
123
|
do {
|
|
105
124
|
const newline = text.indexOf("\n", start);
|
|
@@ -107,8 +126,36 @@ export function contentLineInfo(text, previewLimit = 0) {
|
|
|
107
126
|
|
|
108
127
|
if (preview.length < previewLimit) preview.push(text.slice(start, end).replace(/\r$/, ""));
|
|
109
128
|
count++;
|
|
129
|
+
|
|
110
130
|
if (newline < 0) break;
|
|
111
131
|
start = newline + 1;
|
|
132
|
+
|
|
133
|
+
if (++sampled === 64) {
|
|
134
|
+
// Once the preview is complete, dense text is cheaper to scan by code
|
|
135
|
+
// unit than to make a native substring search for every short line.
|
|
136
|
+
if (newline - sampleStart < 512 && preview.length >= previewLimit) {
|
|
137
|
+
let newlines = count;
|
|
138
|
+
|
|
139
|
+
let index = start;
|
|
140
|
+
|
|
141
|
+
while (index < text.length) {
|
|
142
|
+
const end = Math.min(index + 512, text.length);
|
|
143
|
+
const previous = newlines;
|
|
144
|
+
|
|
145
|
+
for (; index < end; index++) if (text.charCodeAt(index) === 10) newlines++;
|
|
146
|
+
|
|
147
|
+
// A dense prefix does not justify scanning a large sparse tail.
|
|
148
|
+
if (index < text.length && newlines - previous < 64) break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (index === text.length) return { count: newlines + Number(!text.endsWith("\n")), preview, newlines };
|
|
152
|
+
count = newlines;
|
|
153
|
+
start = index;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
sampleStart = start;
|
|
157
|
+
sampled = 0;
|
|
158
|
+
}
|
|
112
159
|
} while (start < text.length);
|
|
113
160
|
|
|
114
161
|
return { count, preview, newlines: count - Number(!text.endsWith("\n")) };
|