@giovannijecha/jecode 0.8.4 → 0.8.6
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 +28 -9
- package/dist/accounts.js +47 -10
- package/dist/atomic.js +24 -14
- package/dist/batch-view.js +27 -2
- package/dist/batch.js +39 -4
- package/dist/bounded-file.js +212 -0
- package/dist/cli-info.js +0 -1
- package/dist/commands.js +2 -0
- package/dist/config.js +16 -7
- package/dist/context/automatic.js +35 -0
- package/dist/context/compactor.js +32 -2
- package/dist/context/manual.js +20 -3
- package/dist/context/request-projection.js +130 -0
- package/dist/controller-request.js +33 -11
- package/dist/credential-commands.js +25 -9
- package/dist/credentials.js +56 -18
- package/dist/directory-anchor.js +91 -0
- package/dist/file-identity.js +12 -0
- package/dist/model-command.js +5 -4
- package/dist/openai-account-command.js +23 -10
- package/dist/openai-account.js +7 -5
- package/dist/openai-oauth-callback.js +13 -4
- package/dist/openai-oauth-tokens.js +9 -7
- package/dist/openai-oauth.js +8 -6
- package/dist/permission-command.js +1 -1
- package/dist/process-lease.js +329 -0
- package/dist/provider-commands.js +11 -37
- package/dist/provider-errors.js +31 -7
- package/dist/provider-label.js +9 -3
- package/dist/providers/anthropic-stream.js +4 -1
- package/dist/providers/anthropic-wire.js +8 -3
- package/dist/providers/anthropic.js +39 -20
- package/dist/providers/catalog.js +4 -4
- package/dist/providers/failure.js +181 -0
- package/dist/providers/http.js +82 -23
- package/dist/providers/index.js +1 -5
- package/dist/providers/ollama-context.js +42 -0
- package/dist/providers/ollama-endpoint.js +7 -34
- package/dist/providers/ollama-stream.js +5 -1
- package/dist/providers/ollama.js +36 -158
- package/dist/providers/openai-codex.js +71 -45
- package/dist/providers/openai-stream.js +26 -2
- package/dist/providers/openai.js +51 -25
- package/dist/providers/sse.js +52 -8
- package/dist/request-identity.js +32 -0
- package/dist/sessions/bucket.js +55 -0
- package/dist/sessions/catalog-io.js +162 -0
- package/dist/sessions/catalog.js +3 -1
- package/dist/sessions/codec-messages.js +122 -0
- package/dist/sessions/codec-transcript.js +93 -0
- package/dist/sessions/codec-values.js +52 -0
- package/dist/sessions/codec.js +4 -257
- package/dist/sessions/files.js +158 -0
- package/dist/sessions/lease.js +132 -49
- package/dist/sessions/load.js +90 -0
- package/dist/sessions/runtime.js +15 -8
- package/dist/sessions/snapshot.js +33 -0
- package/dist/sessions/store.js +183 -378
- package/dist/settings-command.js +10 -5
- package/dist/settings.js +75 -22
- package/dist/stable-directory.js +148 -0
- package/dist/start.js +1 -2
- package/dist/store-lock.js +68 -84
- package/dist/tools/args.js +2 -2
- package/dist/tools/file-read.js +192 -0
- package/dist/tools/file-summary.js +9 -0
- package/dist/tools/{fs.js → file-write.js} +5 -171
- package/dist/tools/glob.js +107 -0
- package/dist/tools/index.js +2 -1
- package/dist/tools/search.js +66 -205
- package/dist/tools/text-boundary.js +7 -33
- package/dist/tui/app-workflows.js +9 -334
- package/dist/tui/approve.js +5 -3
- package/dist/tui/blocks.js +8 -7
- package/dist/tui/command-workflow.js +106 -0
- package/dist/tui/components/command-menu.js +7 -10
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/components/menu.js +74 -43
- package/dist/tui/components/messages.js +16 -9
- package/dist/tui/components/tool-evidence.js +107 -0
- package/dist/tui/components/tool-motion.js +32 -0
- package/dist/tui/components/tool.js +48 -202
- package/dist/tui/feedback.js +4 -0
- package/dist/tui/help.js +1 -1
- package/dist/tui/picker-layout.js +40 -0
- package/dist/tui/picker.js +7 -71
- package/dist/tui/session-view.js +7 -2
- package/dist/tui/tool-details.js +135 -0
- package/dist/tui/transcript-grammar.js +8 -1
- package/dist/tui/transcript-view.js +26 -112
- package/dist/tui/turn-workflow.js +264 -0
- package/dist/tui/turn.js +7 -140
- package/dist/tui/workflow-types.js +2 -0
- package/dist/tui/workspace.js +21 -7
- package/dist/user-store.js +23 -31
- package/package.json +14 -15
- package/dist/ollama-settings-command.js +0 -74
- package/dist/tools/ripgrep.js +0 -230
- package/dist/tui/motion.js +0 -32
package/dist/tools/search.js
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
// Bounded, read-only workspace discovery without borrowing a shell.
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { BoundedFileError, readBoundedFile } from "../bounded-file.js";
|
|
5
|
+
import { readStableDirectory, StableDirectoryError, } from "../stable-directory.js";
|
|
4
6
|
import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
|
|
5
7
|
import { displayPath, resolveExistingInRoot } from "./paths.js";
|
|
6
|
-
import { trySearchWithRipgrep } from "./ripgrep.js";
|
|
7
8
|
import { leadingText } from "./text-boundary.js";
|
|
9
|
+
import { glob } from "./glob.js";
|
|
8
10
|
const DEFAULT_RESULTS = 100;
|
|
9
11
|
const MAX_RESULTS = 500;
|
|
10
12
|
const MAX_VISITED = 20_000;
|
|
11
13
|
const MAX_FILE_BYTES = 1_000_000;
|
|
12
14
|
const MAX_MATCH_LINE = 500;
|
|
13
|
-
const MAX_GLOB_CHARS = 512;
|
|
14
15
|
const PORTABLE_SEARCH_CONCURRENCY = 8;
|
|
15
|
-
const RG_PREFIX_BYTES = 2_000_000;
|
|
16
|
-
const RG_PREFIX_FILES = 500;
|
|
17
|
-
const MIN_RG_TAIL_BYTES = 2_000_000;
|
|
18
|
-
const MIN_RG_TAIL_FILES = 500;
|
|
19
16
|
const SKIP = new Set([".git", ".hg", ".svn", "node_modules"]);
|
|
20
17
|
export const findFiles = {
|
|
21
18
|
name: "find_files",
|
|
@@ -84,80 +81,30 @@ export const searchText = {
|
|
|
84
81
|
const match = pattern === undefined || pattern === "" ? () => true : glob(pattern);
|
|
85
82
|
const limit = resultLimit(args);
|
|
86
83
|
const found = [];
|
|
87
|
-
const
|
|
88
|
-
const prefix = [];
|
|
89
|
-
const tail = [];
|
|
84
|
+
const pending = [];
|
|
90
85
|
let skipped = 0;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
let tailBytes = 0;
|
|
94
|
-
const flushPrefix = async () => {
|
|
95
|
-
if (prefix.length === 0)
|
|
86
|
+
const flushPending = async () => {
|
|
87
|
+
if (pending.length === 0)
|
|
96
88
|
return false;
|
|
97
|
-
const searched = await portableSearch(
|
|
89
|
+
const searched = await portableSearch(pending.splice(0), scoped, needle, sensitive, limit - found.length);
|
|
98
90
|
found.push(...searched.matches);
|
|
99
91
|
skipped += searched.skipped;
|
|
100
92
|
return found.length >= limit;
|
|
101
93
|
};
|
|
102
|
-
const
|
|
103
|
-
if (candidates.length === 0)
|
|
104
|
-
return false;
|
|
105
|
-
const inspected = await Promise.all(candidates.splice(0).map((lexical) => inspectCandidate(scoped.root, lexical)));
|
|
106
|
-
checkAbort(ctx.signal);
|
|
107
|
-
for (const result of inspected) {
|
|
108
|
-
if ("error" in result) {
|
|
109
|
-
if (!skippable(result.error))
|
|
110
|
-
throw result.error;
|
|
111
|
-
skipped++;
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
const candidate = result.file;
|
|
115
|
-
if (candidate.bytes > MAX_FILE_BYTES) {
|
|
116
|
-
skipped++;
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
if (prefixFiles + 1 > RG_PREFIX_FILES ||
|
|
120
|
-
prefixBytes + candidate.bytes > RG_PREFIX_BYTES) {
|
|
121
|
-
tail.push(candidate);
|
|
122
|
-
tailBytes += candidate.bytes;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
prefixFiles++;
|
|
126
|
-
prefixBytes += candidate.bytes;
|
|
127
|
-
prefix.push(candidate);
|
|
128
|
-
if (prefix.length >= PORTABLE_SEARCH_CONCURRENCY && await flushPrefix())
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
return false;
|
|
132
|
-
};
|
|
133
|
-
const walked = await walk(start, scoped, async (lexical) => {
|
|
94
|
+
const walked = await walk(start, scoped, async (lexical, expected) => {
|
|
134
95
|
const relative = displayPath(scoped.root, lexical);
|
|
135
96
|
if (!match(relative))
|
|
136
97
|
return false;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
98
|
+
const bytes = Number(expected.size);
|
|
99
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
100
|
+
skipped++;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
pending.push({ path: lexical, bytes, expected });
|
|
104
|
+
return pending.length >= PORTABLE_SEARCH_CONCURRENCY && await flushPending();
|
|
141
105
|
});
|
|
142
106
|
if (found.length < limit)
|
|
143
|
-
await
|
|
144
|
-
if (found.length < limit)
|
|
145
|
-
await flushPrefix();
|
|
146
|
-
const accelerated = found.length < limit && preferRipgrep(tail, tailBytes)
|
|
147
|
-
? await trySearchWithRipgrep({
|
|
148
|
-
root: scoped.root,
|
|
149
|
-
files: tail,
|
|
150
|
-
query,
|
|
151
|
-
caseSensitive: sensitive,
|
|
152
|
-
limit: limit - found.length,
|
|
153
|
-
signal: ctx.signal,
|
|
154
|
-
})
|
|
155
|
-
: undefined;
|
|
156
|
-
const portable = found.length < limit && accelerated === undefined && tail.length > 0
|
|
157
|
-
? await portableSearch(tail, scoped, needle, sensitive, limit - found.length)
|
|
158
|
-
: undefined;
|
|
159
|
-
found.push(...(portable?.matches ?? accelerated?.matches.map((match) => (`${displayPath(scoped.root, match.path)}:${match.line}:${clip(match.text)}`)) ?? []));
|
|
160
|
-
skipped += portable?.skipped ?? accelerated?.binaryPaths.length ?? 0;
|
|
107
|
+
await flushPending();
|
|
161
108
|
const extra = skipped === 0 ? "" : ` · skipped ${skipped} binary/large/unreadable`;
|
|
162
109
|
return {
|
|
163
110
|
output: found.length === 0 ? "[no matches]" : found.join("\n"),
|
|
@@ -165,7 +112,7 @@ export const searchText = {
|
|
|
165
112
|
};
|
|
166
113
|
},
|
|
167
114
|
};
|
|
168
|
-
export async function portableSearch(files, ctx, needle, sensitive, limit, read
|
|
115
|
+
export async function portableSearch(files, ctx, needle, sensitive, limit, read) {
|
|
169
116
|
const found = [];
|
|
170
117
|
let skipped = 0;
|
|
171
118
|
for (let start = 0; start < files.length; start += PORTABLE_SEARCH_CONCURRENCY) {
|
|
@@ -189,19 +136,12 @@ export async function portableSearch(files, ctx, needle, sensitive, limit, read
|
|
|
189
136
|
}
|
|
190
137
|
return { matches: found, skipped };
|
|
191
138
|
}
|
|
192
|
-
async function inspectCandidate(root, lexical) {
|
|
193
|
-
try {
|
|
194
|
-
const file = await resolveExistingInRoot(root, lexical);
|
|
195
|
-
return { file: { path: file, bytes: (await fs.stat(file)).size } };
|
|
196
|
-
}
|
|
197
|
-
catch (error) {
|
|
198
|
-
return { error };
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
139
|
async function searchFile(file, ctx, needle, sensitive, limit, read) {
|
|
202
140
|
let data;
|
|
203
141
|
try {
|
|
204
|
-
data =
|
|
142
|
+
data = read === undefined
|
|
143
|
+
? await readVerifiedSearchFile(file, ctx)
|
|
144
|
+
: await read(file.path, ctx.signal);
|
|
205
145
|
}
|
|
206
146
|
catch (error) {
|
|
207
147
|
checkAbort(ctx.signal);
|
|
@@ -225,55 +165,79 @@ async function searchFile(file, ctx, needle, sensitive, limit, read) {
|
|
|
225
165
|
}
|
|
226
166
|
return found;
|
|
227
167
|
}
|
|
228
|
-
function
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return
|
|
168
|
+
async function readVerifiedSearchFile(file, ctx) {
|
|
169
|
+
if (file.expected === undefined) {
|
|
170
|
+
throw new BoundedFileError("changed", "search file has no verified identity");
|
|
171
|
+
}
|
|
172
|
+
return readBoundedFile(file.path, MAX_FILE_BYTES, {
|
|
173
|
+
label: "search file",
|
|
174
|
+
signal: ctx.signal,
|
|
175
|
+
expected: file.expected,
|
|
176
|
+
validate: async () => {
|
|
177
|
+
const confirmed = await resolveExistingInRoot(ctx.root, file.path);
|
|
178
|
+
if (confirmed !== file.path) {
|
|
179
|
+
throw new BoundedFileError("changed", "search file left the workspace");
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
233
183
|
}
|
|
234
184
|
async function walk(start, ctx, visit) {
|
|
235
185
|
const pending = [];
|
|
236
186
|
let seen = 0;
|
|
187
|
+
let buffered = 0;
|
|
188
|
+
let capped = false;
|
|
237
189
|
const enter = async (lexical) => {
|
|
238
190
|
checkAbort(ctx.signal);
|
|
239
191
|
const directory = await resolveExistingInRoot(ctx.root, lexical);
|
|
240
|
-
|
|
192
|
+
const room = MAX_VISITED - seen - buffered;
|
|
193
|
+
if (room <= 0) {
|
|
194
|
+
capped = true;
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
241
197
|
try {
|
|
242
|
-
|
|
198
|
+
const inspected = await readStableDirectory(ctx.root, directory, {
|
|
199
|
+
maxEntries: room,
|
|
200
|
+
signal: ctx.signal,
|
|
201
|
+
});
|
|
202
|
+
if (inspected.capped)
|
|
203
|
+
capped = true;
|
|
204
|
+
const entries = [...inspected.entries];
|
|
205
|
+
buffered += entries.length;
|
|
206
|
+
pending.push({ directory, entries, next: 0 });
|
|
243
207
|
}
|
|
244
208
|
catch (error) {
|
|
245
209
|
if (skippable(error))
|
|
246
|
-
return;
|
|
210
|
+
return true;
|
|
247
211
|
throw error;
|
|
248
212
|
}
|
|
249
|
-
|
|
250
|
-
pending.push({ directory, entries, next: 0 });
|
|
213
|
+
return true;
|
|
251
214
|
};
|
|
252
|
-
await enter(start)
|
|
215
|
+
if (!await enter(start))
|
|
216
|
+
return { capped: true };
|
|
253
217
|
while (pending.length > 0) {
|
|
254
218
|
checkAbort(ctx.signal);
|
|
255
219
|
const frame = pending[pending.length - 1];
|
|
256
220
|
if (frame === undefined)
|
|
257
221
|
break;
|
|
258
|
-
const
|
|
222
|
+
const index = frame.next++;
|
|
223
|
+
const entry = frame.entries[index];
|
|
259
224
|
if (entry === undefined) {
|
|
260
225
|
pending.pop();
|
|
261
226
|
continue;
|
|
262
227
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
continue;
|
|
228
|
+
frame.entries[index] = undefined;
|
|
229
|
+
buffered--;
|
|
230
|
+
seen++;
|
|
267
231
|
const target = path.join(frame.directory, entry.name);
|
|
268
|
-
if (entry.
|
|
232
|
+
if (entry.kind === "directory") {
|
|
269
233
|
if (!SKIP.has(entry.name))
|
|
270
234
|
await enter(target);
|
|
271
235
|
}
|
|
272
|
-
else if (
|
|
273
|
-
return { capped
|
|
236
|
+
else if (await visit(target, entry.expected)) {
|
|
237
|
+
return { capped };
|
|
274
238
|
}
|
|
275
239
|
}
|
|
276
|
-
return { capped
|
|
240
|
+
return { capped };
|
|
277
241
|
}
|
|
278
242
|
async function startAt(args, ctx) {
|
|
279
243
|
const candidate = optionalString(args, "path") ?? ".";
|
|
@@ -291,110 +255,6 @@ function resultLimit(args) {
|
|
|
291
255
|
throw new Error('"max_results" must be a positive integer');
|
|
292
256
|
return Math.min(requested, MAX_RESULTS);
|
|
293
257
|
}
|
|
294
|
-
function glob(pattern) {
|
|
295
|
-
const normalized = pattern.replace(/\\/g, "/");
|
|
296
|
-
if (normalized.length > MAX_GLOB_CHARS) {
|
|
297
|
-
throw new Error(`"pattern" must be at most ${MAX_GLOB_CHARS} characters`);
|
|
298
|
-
}
|
|
299
|
-
const tokens = tokenizeGlob(normalized.toLowerCase());
|
|
300
|
-
const basenameOnly = !normalized.includes("/");
|
|
301
|
-
return (relative) => {
|
|
302
|
-
const candidate = relative.replace(/\\/g, "/");
|
|
303
|
-
const target = (basenameOnly ? path.posix.basename(candidate) : candidate).toLowerCase();
|
|
304
|
-
return matchGlob(tokens, Array.from(target));
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
function tokenizeGlob(pattern) {
|
|
308
|
-
const chars = Array.from(pattern);
|
|
309
|
-
const tokens = [];
|
|
310
|
-
let index = 0;
|
|
311
|
-
while (index < chars.length) {
|
|
312
|
-
const char = chars[index];
|
|
313
|
-
if (char === "*") {
|
|
314
|
-
let end = index + 1;
|
|
315
|
-
while (chars[end] === "*")
|
|
316
|
-
end++;
|
|
317
|
-
if (end - index >= 2) {
|
|
318
|
-
if (chars[end] === "/") {
|
|
319
|
-
tokens.push({ kind: "globdir-start" }, { kind: "globdir-body" });
|
|
320
|
-
index = end + 1;
|
|
321
|
-
}
|
|
322
|
-
else {
|
|
323
|
-
tokens.push({ kind: "globstar" });
|
|
324
|
-
index = end;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
else {
|
|
328
|
-
tokens.push({ kind: "star" });
|
|
329
|
-
index = end;
|
|
330
|
-
}
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
tokens.push(char === "?" ? { kind: "one" } : { kind: "literal", value: char });
|
|
334
|
-
index++;
|
|
335
|
-
}
|
|
336
|
-
return tokens;
|
|
337
|
-
}
|
|
338
|
-
/** Thompson-style wildcard matching: O(pattern × path), with no regex backtracking. */
|
|
339
|
-
function matchGlob(tokens, text) {
|
|
340
|
-
let states = epsilonClosure(new Set([0]), tokens);
|
|
341
|
-
for (const char of text) {
|
|
342
|
-
const next = new Set();
|
|
343
|
-
for (const state of states) {
|
|
344
|
-
const token = tokens[state];
|
|
345
|
-
if (token === undefined)
|
|
346
|
-
continue;
|
|
347
|
-
switch (token.kind) {
|
|
348
|
-
case "literal":
|
|
349
|
-
if (token.value === char)
|
|
350
|
-
next.add(state + 1);
|
|
351
|
-
break;
|
|
352
|
-
case "one":
|
|
353
|
-
if (char !== "/")
|
|
354
|
-
next.add(state + 1);
|
|
355
|
-
break;
|
|
356
|
-
case "star":
|
|
357
|
-
if (char !== "/")
|
|
358
|
-
next.add(state);
|
|
359
|
-
break;
|
|
360
|
-
case "globstar":
|
|
361
|
-
next.add(state);
|
|
362
|
-
break;
|
|
363
|
-
case "globdir-body":
|
|
364
|
-
next.add(state);
|
|
365
|
-
if (char === "/")
|
|
366
|
-
next.add(state + 1);
|
|
367
|
-
break;
|
|
368
|
-
case "globdir-start":
|
|
369
|
-
break;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
states = epsilonClosure(next, tokens);
|
|
373
|
-
if (states.size === 0)
|
|
374
|
-
return false;
|
|
375
|
-
}
|
|
376
|
-
return epsilonClosure(states, tokens).has(tokens.length);
|
|
377
|
-
}
|
|
378
|
-
function epsilonClosure(seed, tokens) {
|
|
379
|
-
const states = new Set(seed);
|
|
380
|
-
const pending = [...seed];
|
|
381
|
-
while (pending.length > 0) {
|
|
382
|
-
const state = pending.pop();
|
|
383
|
-
const token = tokens[state];
|
|
384
|
-
const targets = token?.kind === "globdir-start"
|
|
385
|
-
? [state + 1, state + 2]
|
|
386
|
-
: token?.kind === "star" || token?.kind === "globstar"
|
|
387
|
-
? [state + 1]
|
|
388
|
-
: [];
|
|
389
|
-
for (const target of targets) {
|
|
390
|
-
if (states.has(target))
|
|
391
|
-
continue;
|
|
392
|
-
states.add(target);
|
|
393
|
-
pending.push(target);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
return states;
|
|
397
|
-
}
|
|
398
258
|
function summary(count, limit, capped, one, many) {
|
|
399
259
|
const noun = count === 1 ? one : many;
|
|
400
260
|
if (count >= limit)
|
|
@@ -414,5 +274,6 @@ function checkAbort(signal) {
|
|
|
414
274
|
throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
|
|
415
275
|
}
|
|
416
276
|
function skippable(error) {
|
|
417
|
-
return
|
|
277
|
+
return error instanceof BoundedFileError || error instanceof StableDirectoryError ||
|
|
278
|
+
["EACCES", "EPERM", "ENOENT"].includes(error.code ?? "");
|
|
418
279
|
}
|
|
@@ -1,48 +1,22 @@
|
|
|
1
1
|
// Shared size and truncation boundaries for text handled by workspace tools.
|
|
2
|
-
import {
|
|
3
|
-
import { lstat, open } from "node:fs/promises";
|
|
2
|
+
import { BoundedFileError, readBoundedText } from "../bounded-file.js";
|
|
4
3
|
export { leadingText, trailingText } from "../text-boundary.js";
|
|
5
4
|
export const MAX_EDITABLE_BYTES = 4_000_000;
|
|
6
5
|
export const MAX_EDITABLE_CHARS = 1_000_000;
|
|
7
6
|
export const MAX_EDITABLE_LINES = 20_000;
|
|
8
|
-
const READ_CHUNK_BYTES = 64 * 1024;
|
|
9
7
|
/** Read a regular UTF-8 file without allowing an unbounded allocation. */
|
|
10
8
|
export async function readEditableText(file, options = {}) {
|
|
11
9
|
const label = options.label ?? "file";
|
|
12
|
-
const details = await lstat(file);
|
|
13
|
-
if (!details.isFile())
|
|
14
|
-
throw new Error(`${label} must be a regular file`);
|
|
15
|
-
const flags = process.platform === "win32"
|
|
16
|
-
? "r"
|
|
17
|
-
: constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
|
|
18
|
-
const handle = await open(file, flags);
|
|
19
10
|
try {
|
|
20
|
-
const
|
|
21
|
-
if (!stat.isFile())
|
|
22
|
-
throw new Error(`${label} must be a regular file`);
|
|
23
|
-
if (stat.size > MAX_EDITABLE_BYTES) {
|
|
24
|
-
throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
|
|
25
|
-
}
|
|
26
|
-
const chunks = [];
|
|
27
|
-
let total = 0;
|
|
28
|
-
while (total <= MAX_EDITABLE_BYTES) {
|
|
29
|
-
const room = MAX_EDITABLE_BYTES + 1 - total;
|
|
30
|
-
const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, room));
|
|
31
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length, total);
|
|
32
|
-
if (bytesRead === 0)
|
|
33
|
-
break;
|
|
34
|
-
chunks.push(buffer.subarray(0, bytesRead));
|
|
35
|
-
total += bytesRead;
|
|
36
|
-
}
|
|
37
|
-
if (total > MAX_EDITABLE_BYTES) {
|
|
38
|
-
throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
|
|
39
|
-
}
|
|
40
|
-
const text = Buffer.concat(chunks, total).toString("utf8");
|
|
11
|
+
const text = await readBoundedText(file, MAX_EDITABLE_BYTES, { label });
|
|
41
12
|
assertEditableText(text, label);
|
|
42
13
|
return text;
|
|
43
14
|
}
|
|
44
|
-
|
|
45
|
-
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error instanceof BoundedFileError && error.kind === "too-large") {
|
|
17
|
+
throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
46
20
|
}
|
|
47
21
|
}
|
|
48
22
|
/** Reject whole-file writes that exceed Jecode's mutation budget. */
|