jev-agent-tools 0.1.4 → 0.2.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/CHANGELOG.md +70 -1
- package/CONTRIBUTING.md +40 -0
- package/README.md +42 -9
- package/SECURITY.md +27 -0
- package/dist/adapters/analysis-context.js +75 -0
- package/dist/adapters/ask-files.js +189 -0
- package/dist/adapters/ask-proof.js +144 -0
- package/dist/adapters/ask-syntax.js +385 -0
- package/dist/adapters/canonical-path.js +17 -0
- package/dist/adapters/command.js +181 -0
- package/dist/adapters/docs.js +172 -0
- package/dist/adapters/exec.js +207 -0
- package/dist/adapters/files.js +293 -0
- package/dist/adapters/find.js +122 -0
- package/dist/adapters/git-base.js +26 -0
- package/dist/adapters/git-inventory.js +71 -0
- package/dist/adapters/git.js +439 -0
- package/dist/adapters/locate-file.js +159 -0
- package/dist/adapters/output-lines.js +46 -0
- package/dist/adapters/private-storage.js +98 -0
- package/dist/adapters/risk-callers.js +426 -0
- package/dist/adapters/runner-version.js +78 -0
- package/dist/adapters/shell.js +76 -0
- package/dist/adapters/syntax.js +187 -0
- package/dist/adapters/test-inventory.js +131 -0
- package/dist/adapters/usage.js +20 -0
- package/dist/adapters/utf8.js +47 -0
- package/dist/configuration.js +257 -0
- package/dist/constants.js +119 -0
- package/dist/core/ask-closure.js +282 -0
- package/dist/core/ask-proof.js +1 -0
- package/dist/core/ask-references.js +194 -0
- package/dist/core/asks.js +436 -0
- package/dist/core/batches.js +65 -0
- package/dist/core/command-output.js +224 -0
- package/dist/core/diff.js +178 -0
- package/dist/core/docs.js +302 -0
- package/dist/core/find.js +108 -0
- package/dist/core/git.js +1 -0
- package/dist/core/imports.js +550 -0
- package/dist/core/integrity.js +45 -0
- package/dist/core/lexical.js +132 -0
- package/dist/core/locate.js +169 -0
- package/dist/core/output.js +120 -0
- package/dist/core/pointer.js +29 -0
- package/dist/core/risk-callers.js +851 -0
- package/dist/core/runner-version.js +45 -0
- package/dist/core/sections.js +230 -0
- package/dist/core/state.js +44 -0
- package/dist/core/syntax.js +1 -0
- package/dist/core/test-commands.js +334 -0
- package/dist/core/test-coverage.js +74 -0
- package/dist/core/test-discovery.js +1382 -0
- package/dist/core/test-evidence.js +527 -0
- package/dist/core/test-state.js +81 -0
- package/dist/core/truncate.js +12 -0
- package/dist/core/units.js +349 -0
- package/dist/describe.js +23 -0
- package/dist/guide.js +33 -0
- package/dist/host.js +24 -0
- package/dist/jev/client.js +434 -0
- package/dist/jev/pool.js +54 -0
- package/dist/jev/types.js +1 -0
- package/dist/mcp/main.js +124 -0
- package/dist/mcp/protocol.js +187 -0
- package/dist/mcp/tools.js +116 -0
- package/dist/presets/docs.js +62 -0
- package/dist/presets/risk.js +179 -0
- package/dist/presets/spec.js +81 -0
- package/dist/presets/witnesses.js +249 -0
- package/dist/render.js +42 -0
- package/dist/result.js +3 -0
- package/dist/runtime.js +1 -0
- package/dist/session.js +147 -0
- package/dist/texts/ask-files.js +1 -0
- package/dist/texts/ask.js +2 -0
- package/dist/texts/check-diff.js +17 -0
- package/dist/texts/configuration.js +1 -0
- package/dist/texts/find.js +14 -0
- package/dist/texts/guide.js +16 -0
- package/dist/texts/locate.js +10 -0
- package/dist/texts/select-tests.js +2 -0
- package/dist/tools/ask-files.js +217 -0
- package/dist/tools/ask-schema.js +70 -0
- package/dist/tools/ask.js +686 -0
- package/dist/tools/check-diff.js +402 -0
- package/dist/tools/docs-check.js +299 -0
- package/dist/tools/find.js +389 -0
- package/dist/tools/locate.js +303 -0
- package/dist/tools/select-tests.js +567 -0
- package/dist/tools/spec-check.js +166 -0
- package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
- package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
- package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
- package/docs/adr/0004-compiled-typed-intents.md +19 -0
- package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
- package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
- package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
- package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
- package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
- package/docs/adr/0010-mcp-server-thin-host.md +23 -0
- package/docs/agent-instructions.md +91 -0
- package/docs/design.md +3 -3
- package/docs/mcp.md +231 -0
- package/package.json +19 -4
- package/server.json +57 -0
- package/src/adapters/canonical-path.ts +18 -0
- package/src/adapters/command.ts +7 -4
- package/src/adapters/exec.ts +226 -0
- package/src/adapters/private-storage.ts +143 -0
- package/src/adapters/risk-callers.ts +4 -2
- package/src/adapters/shell.ts +97 -0
- package/src/configuration.ts +39 -12
- package/src/constants.ts +11 -0
- package/src/core/command-output.ts +17 -1
- package/src/host.ts +11 -0
- package/src/jev/client.ts +12 -0
- package/src/jev/types.ts +6 -0
- package/src/mcp/main.ts +135 -0
- package/src/mcp/protocol.ts +282 -0
- package/src/mcp/tools.ts +166 -0
- package/src/session.ts +59 -0
- package/src/setup.ts +13 -5
- package/src/tools/ask-files.ts +5 -7
- package/src/tools/ask.ts +26 -22
- package/src/tools/check-diff.ts +8 -5
- package/src/tools/docs-check.ts +1 -0
- package/src/tools/find.ts +5 -2
- package/src/tools/locate.ts +5 -8
- package/src/tools/select-tests.ts +7 -4
- package/src/tools/spec-check.ts +1 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { lstat, readlink } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { GIT_BLOB_BATCH_SIZE, STATE_MAX_CHARS, TIMEOUT_MS, } from "../constants.js";
|
|
4
|
+
import { parseDiff } from "../core/diff.js";
|
|
5
|
+
import { buildUnits, } from "../core/units.js";
|
|
6
|
+
import { admitGitPath, admitRepoMetadata, openRepoFile } from "./files.js";
|
|
7
|
+
import { shareGitInventory } from "./git-inventory.js";
|
|
8
|
+
import { loadSyntaxParser } from "./syntax.js";
|
|
9
|
+
import { decodeUtf8, verifyGitUtf8 } from "./utf8.js";
|
|
10
|
+
const config = [
|
|
11
|
+
"-c",
|
|
12
|
+
"color.ui=false",
|
|
13
|
+
"-c",
|
|
14
|
+
"core.quotePath=true",
|
|
15
|
+
"-c",
|
|
16
|
+
"diff.suppressBlankEmpty=false",
|
|
17
|
+
"-c",
|
|
18
|
+
"diff.relative=false",
|
|
19
|
+
"-c",
|
|
20
|
+
"diff.noprefix=false",
|
|
21
|
+
"-c",
|
|
22
|
+
"diff.mnemonicPrefix=false",
|
|
23
|
+
"-c",
|
|
24
|
+
"diff.algorithm=myers",
|
|
25
|
+
"-c",
|
|
26
|
+
"diff.indentHeuristic=false",
|
|
27
|
+
];
|
|
28
|
+
async function git(exec, options, args) {
|
|
29
|
+
try {
|
|
30
|
+
options.signal?.throwIfAborted();
|
|
31
|
+
const result = await exec("git", [...config, ...args], {
|
|
32
|
+
cwd: options.cwd,
|
|
33
|
+
timeout: TIMEOUT_MS,
|
|
34
|
+
signal: options.signal,
|
|
35
|
+
});
|
|
36
|
+
if (result.killed)
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
error: "Git interrupted (cancelled or timed out).",
|
|
40
|
+
};
|
|
41
|
+
if (result.code !== 0)
|
|
42
|
+
return { ok: false, error: `Git failed: ${result.stderr}` };
|
|
43
|
+
return { ok: true, text: result.stdout };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return { ok: false, error: `Unable to execute git: ${String(error)}` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function readWorktree(options, exec, path, inventory) {
|
|
50
|
+
let handle;
|
|
51
|
+
try {
|
|
52
|
+
options.signal?.throwIfAborted();
|
|
53
|
+
const location = await admitRepoMetadata(options.cwd, path, exec, options.signal, inventory);
|
|
54
|
+
if (!location.ok)
|
|
55
|
+
return { text: "", binary: false, limitation: "unreadable" };
|
|
56
|
+
const stat = await lstat(location.abs);
|
|
57
|
+
if (stat.isSymbolicLink())
|
|
58
|
+
return { text: await readlink(location.abs), binary: false };
|
|
59
|
+
if (stat.size > STATE_MAX_CHARS)
|
|
60
|
+
return { text: "", binary: false, limitation: "too_large" };
|
|
61
|
+
const opened = await openRepoFile(options.cwd, path, {
|
|
62
|
+
exec,
|
|
63
|
+
signal: options.signal,
|
|
64
|
+
inventory,
|
|
65
|
+
});
|
|
66
|
+
if (!opened.ok)
|
|
67
|
+
return { text: "", binary: false, limitation: "unreadable" };
|
|
68
|
+
handle = opened.handle;
|
|
69
|
+
const buffer = Buffer.alloc(STATE_MAX_CHARS + 1);
|
|
70
|
+
let offset = 0;
|
|
71
|
+
while (offset < buffer.length) {
|
|
72
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null);
|
|
73
|
+
if (!bytesRead)
|
|
74
|
+
break;
|
|
75
|
+
offset += bytesRead;
|
|
76
|
+
}
|
|
77
|
+
if (offset > STATE_MAX_CHARS)
|
|
78
|
+
return { text: "", binary: false, limitation: "too_large" };
|
|
79
|
+
const bytes = buffer.subarray(0, offset);
|
|
80
|
+
const decoded = decodeUtf8(bytes);
|
|
81
|
+
return decoded.ok
|
|
82
|
+
? { text: decoded.text, binary: bytes.includes(0) }
|
|
83
|
+
: { text: "", binary: false, limitation: "not UTF-8 text" };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { text: "", binary: false, limitation: "unreadable" };
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await handle?.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function readGitBlobs(exec, options, objects) {
|
|
93
|
+
const blobs = new Map();
|
|
94
|
+
const invalid = new Set();
|
|
95
|
+
for (let offset = 0; offset < objects.length; offset += GIT_BLOB_BATCH_SIZE) {
|
|
96
|
+
const batch = objects.slice(offset, offset + GIT_BLOB_BATCH_SIZE);
|
|
97
|
+
const result = await git(exec, options, [
|
|
98
|
+
"show",
|
|
99
|
+
"--no-ext-diff",
|
|
100
|
+
"--no-textconv",
|
|
101
|
+
"--end-of-options",
|
|
102
|
+
...batch.map((object) => object.id),
|
|
103
|
+
]);
|
|
104
|
+
if (!result.ok)
|
|
105
|
+
return result;
|
|
106
|
+
const bytes = Buffer.from(result.text, "utf8");
|
|
107
|
+
if (bytes.length !== batch.reduce((sum, object) => sum + object.size, 0)) {
|
|
108
|
+
// Host decoding can replace invalid UTF-8: never split subsequent blobs at shifted offsets.
|
|
109
|
+
for (const object of batch) {
|
|
110
|
+
const single = await git(exec, options, [
|
|
111
|
+
"show",
|
|
112
|
+
"--no-ext-diff",
|
|
113
|
+
"--no-textconv",
|
|
114
|
+
"--end-of-options",
|
|
115
|
+
object.id,
|
|
116
|
+
]);
|
|
117
|
+
if (!single.ok)
|
|
118
|
+
return single;
|
|
119
|
+
const decoded = verifyGitUtf8(single.text, object.id, object.size);
|
|
120
|
+
if (decoded.ok)
|
|
121
|
+
blobs.set(object.id, decoded.text);
|
|
122
|
+
else
|
|
123
|
+
invalid.add(object.id);
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
let position = 0;
|
|
128
|
+
for (const object of batch) {
|
|
129
|
+
const decoded = verifyGitUtf8(bytes.subarray(position, position + object.size).toString("utf8"), object.id, object.size);
|
|
130
|
+
if (decoded.ok)
|
|
131
|
+
blobs.set(object.id, decoded.text);
|
|
132
|
+
else
|
|
133
|
+
invalid.add(object.id);
|
|
134
|
+
position += object.size;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, blobs, invalid };
|
|
138
|
+
}
|
|
139
|
+
export async function collectDiff(exec, options) {
|
|
140
|
+
exec = shareGitInventory(exec);
|
|
141
|
+
const root = await git(exec, options, ["rev-parse", "--show-toplevel"]);
|
|
142
|
+
if (!root.ok)
|
|
143
|
+
return root;
|
|
144
|
+
const cwd = root.text.replace(/\n$/, "");
|
|
145
|
+
const scoped = { ...options, cwd };
|
|
146
|
+
const location = await git(exec, options, ["rev-parse", "--show-prefix"]);
|
|
147
|
+
if (!location.ok)
|
|
148
|
+
return location;
|
|
149
|
+
const repositoryPrefix = location.text.replace(/\n$/, "");
|
|
150
|
+
const paths = [
|
|
151
|
+
"--",
|
|
152
|
+
...(options.paths ?? []).map((path) => `:(top,literal)${repositoryPrefix}${path}`),
|
|
153
|
+
];
|
|
154
|
+
const rawResult = await git(exec, scoped, [
|
|
155
|
+
"diff",
|
|
156
|
+
"--raw",
|
|
157
|
+
"-z",
|
|
158
|
+
"--no-ext-diff",
|
|
159
|
+
"--no-textconv",
|
|
160
|
+
"--ignore-submodules=none",
|
|
161
|
+
"--end-of-options",
|
|
162
|
+
options.base,
|
|
163
|
+
...paths,
|
|
164
|
+
]);
|
|
165
|
+
if (!rawResult.ok)
|
|
166
|
+
return rawResult;
|
|
167
|
+
const untrackedResult = await git(exec, scoped, [
|
|
168
|
+
"ls-files",
|
|
169
|
+
"--full-name",
|
|
170
|
+
"--others",
|
|
171
|
+
"--exclude-standard",
|
|
172
|
+
"-z",
|
|
173
|
+
...paths,
|
|
174
|
+
]);
|
|
175
|
+
if (!untrackedResult.ok)
|
|
176
|
+
return untrackedResult;
|
|
177
|
+
let candidates;
|
|
178
|
+
try {
|
|
179
|
+
candidates = parseDiff({
|
|
180
|
+
raw: rawResult.text,
|
|
181
|
+
numstat: "",
|
|
182
|
+
patch: "",
|
|
183
|
+
untracked: untrackedResult.text.split("\0").filter(Boolean),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: "Inconsistent Git snapshots; collect again.",
|
|
190
|
+
kind: "inconsistent_diff",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const unsupported = [];
|
|
194
|
+
await Promise.all(candidates.map(async (file) => {
|
|
195
|
+
try {
|
|
196
|
+
const stat = await lstat(resolve(cwd, file.path));
|
|
197
|
+
if (!stat.isFile() &&
|
|
198
|
+
!stat.isSymbolicLink() &&
|
|
199
|
+
file.oldMode !== "160000" &&
|
|
200
|
+
file.newMode !== "160000")
|
|
201
|
+
unsupported.push(file.path);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* Missing tracked paths remain deletions. */
|
|
205
|
+
}
|
|
206
|
+
}));
|
|
207
|
+
for (const path of unsupported)
|
|
208
|
+
paths.push(`:(top,exclude,literal)${path}`);
|
|
209
|
+
const prefix = [
|
|
210
|
+
"diff",
|
|
211
|
+
"--no-color",
|
|
212
|
+
"--no-ext-diff",
|
|
213
|
+
"--no-textconv",
|
|
214
|
+
"--find-renames",
|
|
215
|
+
"--src-prefix=a/",
|
|
216
|
+
"--dst-prefix=b/",
|
|
217
|
+
"--no-relative",
|
|
218
|
+
"--line-prefix=",
|
|
219
|
+
"--submodule=short",
|
|
220
|
+
"--ignore-submodules=none",
|
|
221
|
+
];
|
|
222
|
+
const results = await Promise.all([
|
|
223
|
+
git(exec, scoped, [
|
|
224
|
+
...prefix,
|
|
225
|
+
"--raw",
|
|
226
|
+
"-z",
|
|
227
|
+
"--end-of-options",
|
|
228
|
+
options.base,
|
|
229
|
+
...paths,
|
|
230
|
+
]),
|
|
231
|
+
git(exec, scoped, [
|
|
232
|
+
...prefix,
|
|
233
|
+
"--numstat",
|
|
234
|
+
"-z",
|
|
235
|
+
"--end-of-options",
|
|
236
|
+
options.base,
|
|
237
|
+
...paths,
|
|
238
|
+
]),
|
|
239
|
+
git(exec, scoped, [
|
|
240
|
+
...prefix,
|
|
241
|
+
"--patch",
|
|
242
|
+
"--unified=8",
|
|
243
|
+
"--end-of-options",
|
|
244
|
+
options.base,
|
|
245
|
+
...paths,
|
|
246
|
+
]),
|
|
247
|
+
git(exec, scoped, [
|
|
248
|
+
"ls-files",
|
|
249
|
+
"--full-name",
|
|
250
|
+
"--others",
|
|
251
|
+
"--exclude-standard",
|
|
252
|
+
"-z",
|
|
253
|
+
...paths,
|
|
254
|
+
]),
|
|
255
|
+
git(exec, scoped, [
|
|
256
|
+
"ls-files",
|
|
257
|
+
"--full-name",
|
|
258
|
+
"--cached",
|
|
259
|
+
"--others",
|
|
260
|
+
"--exclude-standard",
|
|
261
|
+
"-z",
|
|
262
|
+
]),
|
|
263
|
+
git(exec, scoped, [
|
|
264
|
+
"ls-tree",
|
|
265
|
+
"-r",
|
|
266
|
+
"--long",
|
|
267
|
+
"-z",
|
|
268
|
+
"--full-tree",
|
|
269
|
+
"--end-of-options",
|
|
270
|
+
options.base,
|
|
271
|
+
]),
|
|
272
|
+
]);
|
|
273
|
+
for (const result of results)
|
|
274
|
+
if (!result.ok)
|
|
275
|
+
return result;
|
|
276
|
+
const [raw, numstat, patch, untracked, current, base] = results;
|
|
277
|
+
if (!raw.ok ||
|
|
278
|
+
!numstat.ok ||
|
|
279
|
+
!patch.ok ||
|
|
280
|
+
!untracked.ok ||
|
|
281
|
+
!current.ok ||
|
|
282
|
+
!base.ok)
|
|
283
|
+
return { ok: false, error: "Incomplete Git collection." };
|
|
284
|
+
const currentInventory = new Set(current.text.split("\0").filter(Boolean));
|
|
285
|
+
const baseInventory = new Map();
|
|
286
|
+
for (const entry of base.text.split("\0")) {
|
|
287
|
+
const separator = entry.indexOf("\t");
|
|
288
|
+
if (separator !== -1)
|
|
289
|
+
baseInventory.set(entry.slice(separator + 1), entry);
|
|
290
|
+
}
|
|
291
|
+
let files;
|
|
292
|
+
try {
|
|
293
|
+
files = parseDiff({
|
|
294
|
+
raw: raw.text,
|
|
295
|
+
numstat: numstat.text,
|
|
296
|
+
patch: patch.text,
|
|
297
|
+
verifyPaths: true,
|
|
298
|
+
untracked: untracked.text.split("\0").filter(Boolean),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
error: "Inconsistent Git snapshots; collect again.",
|
|
305
|
+
kind: "inconsistent_diff",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
files.push(...candidates.filter((file) => unsupported.includes(file.path)));
|
|
309
|
+
const objects = new Map();
|
|
310
|
+
for (const file of files) {
|
|
311
|
+
if (/^[A?]/.test(file.status) ||
|
|
312
|
+
file.binary ||
|
|
313
|
+
unsupported.includes(file.path))
|
|
314
|
+
continue;
|
|
315
|
+
const admission = await admitGitPath(cwd, file.oldPath, options.base, exec, options.signal, baseInventory);
|
|
316
|
+
if (!admission.ok)
|
|
317
|
+
continue;
|
|
318
|
+
const entry = baseInventory.get(file.oldPath);
|
|
319
|
+
const match = entry &&
|
|
320
|
+
/^(?:100644|100755|120000) blob ([0-9a-f]+)\s+(\d+)\t/.exec(entry);
|
|
321
|
+
if (match)
|
|
322
|
+
objects.set(file.oldPath, { id: match[1] ?? "", size: Number(match[2]) });
|
|
323
|
+
}
|
|
324
|
+
const uniqueObjects = new Map([...objects.values()]
|
|
325
|
+
.filter((object) => object.size <= STATE_MAX_CHARS)
|
|
326
|
+
.map((object) => [object.id, object]));
|
|
327
|
+
const loaded = await readGitBlobs(exec, scoped, [...uniqueObjects.values()]);
|
|
328
|
+
if (!loaded.ok)
|
|
329
|
+
return loaded;
|
|
330
|
+
const collectFile = async (file) => {
|
|
331
|
+
const historical = !/^[A?]/.test(file.status);
|
|
332
|
+
const admission = historical
|
|
333
|
+
? await admitGitPath(cwd, file.oldPath, options.base, exec, options.signal, baseInventory)
|
|
334
|
+
: await admitRepoMetadata(cwd, file.path, exec, options.signal, currentInventory);
|
|
335
|
+
if (!admission.ok)
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
file: {
|
|
339
|
+
...file,
|
|
340
|
+
hunks: [],
|
|
341
|
+
before: null,
|
|
342
|
+
after: null,
|
|
343
|
+
limitation: "unreadable",
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
if (unsupported.includes(file.path)) {
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
file: { ...file, before: null, after: null, limitation: "unreadable" },
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (file.binary) {
|
|
353
|
+
return { ok: true, file: { ...file, before: null, after: null } };
|
|
354
|
+
}
|
|
355
|
+
if (file.oldMode === "160000" || file.newMode === "160000") {
|
|
356
|
+
return {
|
|
357
|
+
ok: true,
|
|
358
|
+
file: { ...file, before: null, after: null, limitation: "unreadable" },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
if (file.status === "D") {
|
|
362
|
+
try {
|
|
363
|
+
const replacement = await lstat(resolve(cwd, file.path));
|
|
364
|
+
if (!replacement.isFile() && !replacement.isSymbolicLink()) {
|
|
365
|
+
return {
|
|
366
|
+
ok: true,
|
|
367
|
+
file: {
|
|
368
|
+
...file,
|
|
369
|
+
before: null,
|
|
370
|
+
after: null,
|
|
371
|
+
limitation: "unreadable",
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
/* A genuinely deleted path has no worktree version. */
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
let before = "";
|
|
381
|
+
let limitation;
|
|
382
|
+
if (!/^[A?]/.test(file.status)) {
|
|
383
|
+
const object = objects.get(file.oldPath);
|
|
384
|
+
if (!object)
|
|
385
|
+
return { ok: false, error: "Incomplete Git base tree; collect again." };
|
|
386
|
+
if (object.size > STATE_MAX_CHARS)
|
|
387
|
+
limitation = "too_large";
|
|
388
|
+
else if (loaded.invalid.has(object.id))
|
|
389
|
+
limitation = "not UTF-8 text";
|
|
390
|
+
else
|
|
391
|
+
before = loaded.blobs.get(object.id) ?? "";
|
|
392
|
+
}
|
|
393
|
+
const after = file.status === "D"
|
|
394
|
+
? { text: "", binary: false }
|
|
395
|
+
: await readWorktree(scoped, exec, file.path, currentInventory);
|
|
396
|
+
limitation = after.limitation ?? limitation;
|
|
397
|
+
const unverifiableHunk = limitation === "too_large" &&
|
|
398
|
+
file.hunks.some((hunk) => (hunk.beforeText ?? "").includes("\uFFFD") ||
|
|
399
|
+
(hunk.afterText ?? "").includes("\uFFFD"));
|
|
400
|
+
if (unverifiableHunk)
|
|
401
|
+
limitation = "not UTF-8 text";
|
|
402
|
+
return {
|
|
403
|
+
ok: true,
|
|
404
|
+
file: {
|
|
405
|
+
...file,
|
|
406
|
+
hunks: after.limitation === "unreadable" || limitation === "not UTF-8 text"
|
|
407
|
+
? file.hunks.map(({ beforeText: _before, afterText: _after, ...hunk }) => hunk)
|
|
408
|
+
: file.hunks,
|
|
409
|
+
binary: after.binary,
|
|
410
|
+
limitation,
|
|
411
|
+
before: /^[A?]/.test(file.status) ||
|
|
412
|
+
limitation === "too_large" ||
|
|
413
|
+
limitation === "not UTF-8 text"
|
|
414
|
+
? null
|
|
415
|
+
: before,
|
|
416
|
+
after: file.status === "D" || limitation ? null : after.text,
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
const sources = [];
|
|
421
|
+
for (let offset = 0; offset < files.length; offset += 4) {
|
|
422
|
+
const batch = await Promise.all(files.slice(offset, offset + 4).map(collectFile));
|
|
423
|
+
for (const result of batch) {
|
|
424
|
+
if (!result.ok)
|
|
425
|
+
return result;
|
|
426
|
+
sources.push(result.file);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { ok: true, files: sources };
|
|
430
|
+
}
|
|
431
|
+
export async function collectUnits(exec, options, sourceParser) {
|
|
432
|
+
const [diff, parser] = await Promise.all([
|
|
433
|
+
collectDiff(exec, options),
|
|
434
|
+
sourceParser ?? loadSyntaxParser(),
|
|
435
|
+
]);
|
|
436
|
+
if (!diff.ok)
|
|
437
|
+
return diff;
|
|
438
|
+
return { ok: true, files: diff.files, ...buildUnits(diff.files, parser) };
|
|
439
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { LOCATE_LABEL_MAX_CHARS, LOCATE_READ_BUFFER_BYTES, LOCATE_WHOLE_MAX_BYTES, LOCATE_WHOLE_MAX_CHARS, LOCATE_WINDOW_LINES, LOCATE_WINDOW_MAX_SERIALIZED_CHARS, STATE_MAX_CHARS, } from "../constants.js";
|
|
2
|
+
import { truncate } from "../core/truncate.js";
|
|
3
|
+
import { openRepoFile } from "./files.js";
|
|
4
|
+
import { createUtf8Decoder, decodeUtf8 } from "./utf8.js";
|
|
5
|
+
/** The initial read is bounded; large-file outlines retain labels rather than source. */
|
|
6
|
+
export async function readLocateFile(cwd, path, signal, exec) {
|
|
7
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
|
|
8
|
+
return {
|
|
9
|
+
ok: false,
|
|
10
|
+
error: `Internal URLs are not files; use read for ${path}.`,
|
|
11
|
+
};
|
|
12
|
+
const opened = await openRepoFile(cwd, path, { exec, signal });
|
|
13
|
+
if (!opened.ok)
|
|
14
|
+
return opened;
|
|
15
|
+
const handle = opened.handle;
|
|
16
|
+
try {
|
|
17
|
+
const stat = await handle.stat();
|
|
18
|
+
if (!stat.isFile())
|
|
19
|
+
return { ok: false, error: `Not a file: ${path}.` };
|
|
20
|
+
const buffer = Buffer.alloc(Math.min(stat.size + 1, LOCATE_WHOLE_MAX_BYTES + 1));
|
|
21
|
+
let count = 0;
|
|
22
|
+
while (count < buffer.length) {
|
|
23
|
+
signal?.throwIfAborted();
|
|
24
|
+
const read = await handle.read(buffer, count, buffer.length - count, null);
|
|
25
|
+
if (!read.bytesRead)
|
|
26
|
+
break;
|
|
27
|
+
count += read.bytesRead;
|
|
28
|
+
}
|
|
29
|
+
if (buffer.subarray(0, count).includes(0))
|
|
30
|
+
return { ok: false, error: `Not a text file: ${path}.` };
|
|
31
|
+
if (count < buffer.length) {
|
|
32
|
+
const decoded = decodeUtf8(buffer.subarray(0, count));
|
|
33
|
+
if (!decoded.ok)
|
|
34
|
+
return { ok: false, error: `${path}: ${decoded.error}` };
|
|
35
|
+
const text = decoded.text;
|
|
36
|
+
if (text.length <= LOCATE_WHOLE_MAX_CHARS)
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
kind: "whole",
|
|
40
|
+
bytes: count,
|
|
41
|
+
lines: text
|
|
42
|
+
? text.split("\n").length - Number(text.endsWith("\n"))
|
|
43
|
+
: 0,
|
|
44
|
+
text,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const scan = await scanRange(cwd, path, undefined, signal, exec);
|
|
48
|
+
return scan.ok
|
|
49
|
+
? {
|
|
50
|
+
ok: true,
|
|
51
|
+
kind: "outline",
|
|
52
|
+
bytes: stat.size,
|
|
53
|
+
lines: scan.lines,
|
|
54
|
+
windows: scan.windows,
|
|
55
|
+
}
|
|
56
|
+
: scan;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return { ok: false, error: `Cannot read ${path}: ${String(error)}` };
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function scanRange(cwd, path, range, signal, exec) {
|
|
66
|
+
const opened = await openRepoFile(cwd, path, { exec, signal });
|
|
67
|
+
if (!opened.ok)
|
|
68
|
+
return opened;
|
|
69
|
+
const handle = opened.handle;
|
|
70
|
+
const decode = createUtf8Decoder();
|
|
71
|
+
const buffer = Buffer.alloc(LOCATE_READ_BUFFER_BYTES);
|
|
72
|
+
let reachedEof = false;
|
|
73
|
+
let line = 1, label = "", pending = false, text = "", bytes = 0;
|
|
74
|
+
const windows = [];
|
|
75
|
+
let windowStart = 1, head = "", tail = "", serializedTextChars = 2;
|
|
76
|
+
const finishWindow = (end) => {
|
|
77
|
+
windows.push({
|
|
78
|
+
start: windowStart,
|
|
79
|
+
end,
|
|
80
|
+
label: label || `lines ${windowStart}-${end}`,
|
|
81
|
+
text: `${head}\n…\n${tail}`,
|
|
82
|
+
serializedTextChars,
|
|
83
|
+
});
|
|
84
|
+
windowStart = end + 1;
|
|
85
|
+
label = "";
|
|
86
|
+
head = "";
|
|
87
|
+
tail = "";
|
|
88
|
+
serializedTextChars = 2;
|
|
89
|
+
};
|
|
90
|
+
const consume = (chunk) => {
|
|
91
|
+
for (const piece of chunk.split(/(?<=\n)/)) {
|
|
92
|
+
if (!piece)
|
|
93
|
+
continue;
|
|
94
|
+
pending = true;
|
|
95
|
+
const excerptLimit = LOCATE_WINDOW_MAX_SERIALIZED_CHARS;
|
|
96
|
+
serializedTextChars += JSON.stringify(piece).length - 2;
|
|
97
|
+
head = truncate(head + piece, excerptLimit);
|
|
98
|
+
const combined = tail + piece;
|
|
99
|
+
const tailStart = Math.max(0, combined.length - excerptLimit);
|
|
100
|
+
tail = combined.slice(tailStart +
|
|
101
|
+
(tailStart > 0 && /[\uDC00-\uDFFF]/.test(combined[tailStart] ?? "")
|
|
102
|
+
? 1
|
|
103
|
+
: 0));
|
|
104
|
+
if ((line - 1) % LOCATE_WINDOW_LINES === 0 &&
|
|
105
|
+
label.length < LOCATE_LABEL_MAX_CHARS)
|
|
106
|
+
label = truncate(label + piece.trim(), LOCATE_LABEL_MAX_CHARS);
|
|
107
|
+
if (range && line >= range.start && line <= range.end)
|
|
108
|
+
text += piece;
|
|
109
|
+
if (text.length > STATE_MAX_CHARS)
|
|
110
|
+
return false;
|
|
111
|
+
if (piece.endsWith("\n")) {
|
|
112
|
+
if (line - windowStart + 1 >= LOCATE_WINDOW_LINES ||
|
|
113
|
+
serializedTextChars >= LOCATE_WINDOW_MAX_SERIALIZED_CHARS)
|
|
114
|
+
finishWindow(line);
|
|
115
|
+
line++;
|
|
116
|
+
pending = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
while (true) {
|
|
123
|
+
signal?.throwIfAborted();
|
|
124
|
+
const read = await handle.read(buffer, 0, buffer.length, null);
|
|
125
|
+
if (!read.bytesRead) {
|
|
126
|
+
reachedEof = true;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
if (buffer.subarray(0, read.bytesRead).includes(0))
|
|
130
|
+
return { ok: false, error: `Not a text file: ${path}.` };
|
|
131
|
+
bytes += read.bytesRead;
|
|
132
|
+
const decoded = decode(buffer.subarray(0, read.bytesRead));
|
|
133
|
+
if (!decoded.ok)
|
|
134
|
+
return { ok: false, error: `${path}: ${decoded.error}` };
|
|
135
|
+
if (!consume(decoded.text))
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
error: `Selected range exceeds ${STATE_MAX_CHARS} chars; read a narrower range directly.`,
|
|
139
|
+
};
|
|
140
|
+
if (range && line > range.end)
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
const end = reachedEof ? decode() : { ok: true, text: "" };
|
|
144
|
+
if (!end.ok)
|
|
145
|
+
return { ok: false, error: `${path}: ${end.error}` };
|
|
146
|
+
if (!consume(end.text))
|
|
147
|
+
return { ok: false, error: "Selected range too large." };
|
|
148
|
+
const lines = line - Number(!pending);
|
|
149
|
+
if (lines > 0 && (windows.at(-1)?.end ?? 0) < lines)
|
|
150
|
+
finishWindow(lines);
|
|
151
|
+
return { ok: true, bytes, lines, windows, text };
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return { ok: false, error: `Cannot read ${path}: ${String(error)}` };
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await handle.close();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { OUTPUT_LINE_MAX_CHARS } from "../constants.js";
|
|
3
|
+
/** Retains only a bounded prefix per physical line, discarding the remainder in chunks. */
|
|
4
|
+
export async function* outputLines(path, signal, onOmitted) {
|
|
5
|
+
let prefix = "";
|
|
6
|
+
let clipped = false;
|
|
7
|
+
const stream = createReadStream(path, {
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
highWaterMark: 16_384,
|
|
10
|
+
signal,
|
|
11
|
+
});
|
|
12
|
+
for await (const chunk of stream) {
|
|
13
|
+
signal?.throwIfAborted();
|
|
14
|
+
const text = String(chunk);
|
|
15
|
+
let offset = 0;
|
|
16
|
+
while (offset < text.length) {
|
|
17
|
+
const newline = text.indexOf("\n", offset);
|
|
18
|
+
const end = newline < 0 ? text.length : newline;
|
|
19
|
+
const remaining = OUTPUT_LINE_MAX_CHARS - prefix.length;
|
|
20
|
+
if (remaining > 0)
|
|
21
|
+
prefix += text.slice(offset, Math.min(end, offset + remaining));
|
|
22
|
+
if (end - offset > remaining) {
|
|
23
|
+
clipped = true;
|
|
24
|
+
onOmitted?.(end - offset - Math.max(0, remaining));
|
|
25
|
+
}
|
|
26
|
+
if (newline < 0)
|
|
27
|
+
break;
|
|
28
|
+
if (clipped && /[\uD800-\uDBFF]$/.test(prefix)) {
|
|
29
|
+
prefix = prefix.slice(0, -1);
|
|
30
|
+
onOmitted?.(1);
|
|
31
|
+
}
|
|
32
|
+
yield prefix.replace(/\r$/, "") +
|
|
33
|
+
(clipped ? `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]` : "");
|
|
34
|
+
prefix = "";
|
|
35
|
+
clipped = false;
|
|
36
|
+
offset = newline + 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (clipped && /[\uD800-\uDBFF]$/.test(prefix)) {
|
|
40
|
+
prefix = prefix.slice(0, -1);
|
|
41
|
+
onOmitted?.(1);
|
|
42
|
+
}
|
|
43
|
+
if (prefix || clipped)
|
|
44
|
+
yield prefix +
|
|
45
|
+
(clipped ? `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]` : "");
|
|
46
|
+
}
|