jev-agent-tools 0.1.3
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 +66 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/design.md +187 -0
- package/docs/tools/jev_ask.md +92 -0
- package/docs/tools/jev_ask_files.md +80 -0
- package/docs/tools/jev_check_diff.md +77 -0
- package/docs/tools/jev_find_files.md +59 -0
- package/docs/tools/jev_locate_in_file.md +51 -0
- package/docs/tools/jev_select_tests.md +59 -0
- package/package.json +85 -0
- package/rules/jev-ask.md +4 -0
- package/src/adapters/analysis-context.ts +100 -0
- package/src/adapters/ask-files.ts +236 -0
- package/src/adapters/ask-proof.ts +202 -0
- package/src/adapters/ask-syntax.ts +463 -0
- package/src/adapters/command.ts +240 -0
- package/src/adapters/docs.ts +222 -0
- package/src/adapters/files.ts +411 -0
- package/src/adapters/find.ts +151 -0
- package/src/adapters/git-base.ts +32 -0
- package/src/adapters/git-inventory.ts +94 -0
- package/src/adapters/git.ts +525 -0
- package/src/adapters/locate-file.ts +197 -0
- package/src/adapters/output-lines.ts +50 -0
- package/src/adapters/risk-callers.ts +525 -0
- package/src/adapters/runner-version.ts +102 -0
- package/src/adapters/syntax.ts +229 -0
- package/src/adapters/test-inventory.ts +168 -0
- package/src/adapters/usage.ts +21 -0
- package/src/adapters/utf8.ts +57 -0
- package/src/constants.ts +109 -0
- package/src/core/ask-closure.ts +419 -0
- package/src/core/ask-proof.ts +32 -0
- package/src/core/ask-references.ts +249 -0
- package/src/core/asks.ts +616 -0
- package/src/core/batches.ts +83 -0
- package/src/core/command-output.ts +249 -0
- package/src/core/diff.ts +226 -0
- package/src/core/docs.ts +399 -0
- package/src/core/find.ts +157 -0
- package/src/core/git.ts +5 -0
- package/src/core/import-boundaries.ts +102 -0
- package/src/core/imports.ts +691 -0
- package/src/core/integrity.ts +64 -0
- package/src/core/lexical.ts +130 -0
- package/src/core/locate.ts +213 -0
- package/src/core/output.ts +264 -0
- package/src/core/pointer.ts +51 -0
- package/src/core/risk-callers.ts +1270 -0
- package/src/core/runner-version.ts +66 -0
- package/src/core/sections.ts +269 -0
- package/src/core/state.ts +53 -0
- package/src/core/syntax.ts +8 -0
- package/src/core/test-commands.ts +430 -0
- package/src/core/test-coverage.ts +103 -0
- package/src/core/test-discovery.ts +1695 -0
- package/src/core/test-evidence.ts +649 -0
- package/src/core/test-state.ts +99 -0
- package/src/core/truncate.ts +14 -0
- package/src/core/units.ts +531 -0
- package/src/describe.ts +26 -0
- package/src/guide.ts +42 -0
- package/src/host.ts +22 -0
- package/src/index.ts +40 -0
- package/src/jev/client.ts +505 -0
- package/src/jev/pool.ts +60 -0
- package/src/jev/types.ts +60 -0
- package/src/presets/docs.ts +85 -0
- package/src/presets/risk.ts +263 -0
- package/src/presets/spec.ts +111 -0
- package/src/presets/witnesses.ts +313 -0
- package/src/render.ts +45 -0
- package/src/result.ts +4 -0
- package/src/run-end.ts +157 -0
- package/src/runtime.ts +16 -0
- package/src/session.ts +106 -0
- package/src/texts/ask-files.ts +2 -0
- package/src/texts/ask.ts +4 -0
- package/src/texts/check-diff.ts +24 -0
- package/src/texts/configuration.ts +2 -0
- package/src/texts/find.ts +14 -0
- package/src/texts/guide.ts +16 -0
- package/src/texts/locate.ts +10 -0
- package/src/texts/run-end.ts +13 -0
- package/src/texts/select-tests.ts +3 -0
- package/src/tools/ask-files.ts +263 -0
- package/src/tools/ask-schema.ts +116 -0
- package/src/tools/ask.ts +925 -0
- package/src/tools/check-diff.ts +510 -0
- package/src/tools/docs-check.ts +399 -0
- package/src/tools/find.ts +529 -0
- package/src/tools/locate.ts +369 -0
- package/src/tools/select-tests.ts +746 -0
- package/src/tools/spec-check.ts +210 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { OUTPUT_LINE_MAX_CHARS } from "../constants.ts";
|
|
3
|
+
|
|
4
|
+
/** Retains only a bounded prefix per physical line, discarding the remainder in chunks. */
|
|
5
|
+
export async function* outputLines(
|
|
6
|
+
path: string,
|
|
7
|
+
signal?: AbortSignal,
|
|
8
|
+
onOmitted?: (chars: number) => void,
|
|
9
|
+
): AsyncGenerator<string> {
|
|
10
|
+
let prefix = "";
|
|
11
|
+
let clipped = false;
|
|
12
|
+
const stream = createReadStream(path, {
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
highWaterMark: 16_384,
|
|
15
|
+
signal,
|
|
16
|
+
});
|
|
17
|
+
for await (const chunk of stream) {
|
|
18
|
+
signal?.throwIfAborted();
|
|
19
|
+
const text = String(chunk);
|
|
20
|
+
let offset = 0;
|
|
21
|
+
while (offset < text.length) {
|
|
22
|
+
const newline = text.indexOf("\n", offset);
|
|
23
|
+
const end = newline < 0 ? text.length : newline;
|
|
24
|
+
const remaining = OUTPUT_LINE_MAX_CHARS - prefix.length;
|
|
25
|
+
if (remaining > 0)
|
|
26
|
+
prefix += text.slice(offset, Math.min(end, offset + remaining));
|
|
27
|
+
if (end - offset > remaining) {
|
|
28
|
+
clipped = true;
|
|
29
|
+
onOmitted?.(end - offset - Math.max(0, remaining));
|
|
30
|
+
}
|
|
31
|
+
if (newline < 0) break;
|
|
32
|
+
if (clipped && /[\uD800-\uDBFF]$/.test(prefix)) {
|
|
33
|
+
prefix = prefix.slice(0, -1);
|
|
34
|
+
onOmitted?.(1);
|
|
35
|
+
}
|
|
36
|
+
yield prefix.replace(/\r$/, "") +
|
|
37
|
+
(clipped ? `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]` : "");
|
|
38
|
+
prefix = "";
|
|
39
|
+
clipped = false;
|
|
40
|
+
offset = newline + 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (clipped && /[\uD800-\uDBFF]$/.test(prefix)) {
|
|
44
|
+
prefix = prefix.slice(0, -1);
|
|
45
|
+
onOmitted?.(1);
|
|
46
|
+
}
|
|
47
|
+
if (prefix || clipped)
|
|
48
|
+
yield prefix +
|
|
49
|
+
(clipped ? `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]` : "");
|
|
50
|
+
}
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { type FileHandle, lstat } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { SgNode } from "@ast-grep/napi";
|
|
4
|
+
import { CONCURRENCY, STATE_MAX_CHARS, TIMEOUT_MS } from "../constants.ts";
|
|
5
|
+
import type { GitExec } from "../core/git.ts";
|
|
6
|
+
import {
|
|
7
|
+
type CallerParser,
|
|
8
|
+
type CallerPreparation,
|
|
9
|
+
type CallerSource,
|
|
10
|
+
prepareRiskCallers,
|
|
11
|
+
} from "../core/risk-callers.ts";
|
|
12
|
+
import type { SyntaxRootParser } from "../core/syntax.ts";
|
|
13
|
+
import type { EvidenceUnit, SourceFile } from "../core/units.ts";
|
|
14
|
+
import {
|
|
15
|
+
admitGitPath,
|
|
16
|
+
checkFileAdmission,
|
|
17
|
+
openRepoFile,
|
|
18
|
+
resolveInsideRepo,
|
|
19
|
+
} from "./files.ts";
|
|
20
|
+
import { loadSyntaxRootParser } from "./syntax.ts";
|
|
21
|
+
import { decodeUtf8, verifyGitUtf8 } from "./utf8.ts";
|
|
22
|
+
|
|
23
|
+
export async function loadCallerParser(
|
|
24
|
+
sourceParser?: SyntaxRootParser,
|
|
25
|
+
): Promise<CallerParser | undefined> {
|
|
26
|
+
const parser = sourceParser ?? (await loadSyntaxRootParser());
|
|
27
|
+
if (!parser) return undefined;
|
|
28
|
+
const versions = new Map<string, Map<string, SgNode | null>>();
|
|
29
|
+
return {
|
|
30
|
+
supports: parser.supports,
|
|
31
|
+
parse(path, text) {
|
|
32
|
+
let entries = versions.get(path);
|
|
33
|
+
if (!entries) {
|
|
34
|
+
entries = new Map();
|
|
35
|
+
versions.set(path, entries);
|
|
36
|
+
}
|
|
37
|
+
if (entries.has(text)) return entries.get(text) ?? null;
|
|
38
|
+
const result = parser.parseRaw(path, text);
|
|
39
|
+
const root = result.ok ? result.root : null;
|
|
40
|
+
entries.set(text, root);
|
|
41
|
+
return root;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function collectRiskCallers(
|
|
46
|
+
exec: GitExec,
|
|
47
|
+
options: { cwd: string; base?: string; signal?: AbortSignal },
|
|
48
|
+
units: readonly EvidenceUnit[],
|
|
49
|
+
changedFiles: readonly SourceFile[] = [],
|
|
50
|
+
sourceParser?: SyntaxRootParser,
|
|
51
|
+
): Promise<CallerPreparation> {
|
|
52
|
+
if (!units.length) return { proofs: [], limits: [] };
|
|
53
|
+
const parser = await loadCallerParser(sourceParser);
|
|
54
|
+
const local = prepareRiskCallers(
|
|
55
|
+
units,
|
|
56
|
+
changedFiles.map((file) => ({
|
|
57
|
+
path: file.path,
|
|
58
|
+
beforePath: file.oldPath,
|
|
59
|
+
before: file.before,
|
|
60
|
+
after: file.after,
|
|
61
|
+
limitation: file.limitation,
|
|
62
|
+
})),
|
|
63
|
+
parser,
|
|
64
|
+
);
|
|
65
|
+
const eligibleIds = new Set([
|
|
66
|
+
...local.proofs.map((proof) => proof.unitId),
|
|
67
|
+
...local.limits.map((limit) => limit.unitId),
|
|
68
|
+
]);
|
|
69
|
+
const nameFor = (unit: EvidenceUnit): string | undefined => {
|
|
70
|
+
const name = unit.name.split(".").at(-1) ?? unit.name;
|
|
71
|
+
if (!parser?.supports(unit.file)) return;
|
|
72
|
+
const probe = parser.parse(
|
|
73
|
+
unit.file,
|
|
74
|
+
unit.file.endsWith(".py")
|
|
75
|
+
? `def ${name}():\n pass`
|
|
76
|
+
: `function ${name}() {}`,
|
|
77
|
+
);
|
|
78
|
+
const declaration = probe
|
|
79
|
+
?.children()
|
|
80
|
+
.find((node) =>
|
|
81
|
+
["function_definition", "function_declaration"].includes(
|
|
82
|
+
String(node.kind()),
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
return declaration?.field("name")?.text() === name ? name : undefined;
|
|
86
|
+
};
|
|
87
|
+
const eligible = units.filter(
|
|
88
|
+
(unit) =>
|
|
89
|
+
eligibleIds.has(unit.id) &&
|
|
90
|
+
["function", "declaration", "slice"].includes(unit.kind) &&
|
|
91
|
+
nameFor(unit) !== undefined,
|
|
92
|
+
);
|
|
93
|
+
const retainedLimits = local.limits.filter(
|
|
94
|
+
(limit) => !eligible.some((unit) => unit.id === limit.unitId),
|
|
95
|
+
);
|
|
96
|
+
if (!eligible.length) return local;
|
|
97
|
+
let cwd = options.cwd;
|
|
98
|
+
const git = async (args: string[]) => {
|
|
99
|
+
options.signal?.throwIfAborted();
|
|
100
|
+
const result = await exec("git", args, {
|
|
101
|
+
cwd,
|
|
102
|
+
timeout: TIMEOUT_MS,
|
|
103
|
+
signal: options.signal,
|
|
104
|
+
});
|
|
105
|
+
if (result.code !== 0 || result.killed)
|
|
106
|
+
throw new Error(result.stderr || "git interrupted");
|
|
107
|
+
return result.stdout;
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
// The tool supplies the already resolved comparison reference, as for collectUnits.
|
|
111
|
+
cwd = (await git(["rev-parse", "--show-toplevel"])).trim();
|
|
112
|
+
const ref = (options.base ?? "HEAD").trim();
|
|
113
|
+
const sourceExtension: Record<string, string> = {
|
|
114
|
+
".js": ".ts",
|
|
115
|
+
".jsx": ".tsx",
|
|
116
|
+
".mjs": ".mts",
|
|
117
|
+
".cjs": ".cts",
|
|
118
|
+
};
|
|
119
|
+
const extensions = [
|
|
120
|
+
"ts",
|
|
121
|
+
"tsx",
|
|
122
|
+
"mts",
|
|
123
|
+
"cts",
|
|
124
|
+
"js",
|
|
125
|
+
"jsx",
|
|
126
|
+
"mjs",
|
|
127
|
+
"cjs",
|
|
128
|
+
"py",
|
|
129
|
+
];
|
|
130
|
+
const tracked = await git([
|
|
131
|
+
"ls-files",
|
|
132
|
+
"-z",
|
|
133
|
+
`--with-tree=${ref}`,
|
|
134
|
+
"--",
|
|
135
|
+
...extensions.map((extension) => `*.${extension}`),
|
|
136
|
+
]);
|
|
137
|
+
const beforePaths = new Set(tracked.split("\0"));
|
|
138
|
+
const baseInventory = new Map<string, string>();
|
|
139
|
+
for (const entry of (
|
|
140
|
+
await git([
|
|
141
|
+
"ls-tree",
|
|
142
|
+
"-r",
|
|
143
|
+
"--long",
|
|
144
|
+
"-z",
|
|
145
|
+
"--full-tree",
|
|
146
|
+
"--end-of-options",
|
|
147
|
+
ref,
|
|
148
|
+
])
|
|
149
|
+
).split("\0")) {
|
|
150
|
+
const separator = entry.indexOf("\t");
|
|
151
|
+
if (separator !== -1)
|
|
152
|
+
baseInventory.set(entry.slice(separator + 1), entry);
|
|
153
|
+
}
|
|
154
|
+
const modifiedPaths = new Set(
|
|
155
|
+
(
|
|
156
|
+
await git([
|
|
157
|
+
"diff",
|
|
158
|
+
"--name-only",
|
|
159
|
+
"-z",
|
|
160
|
+
"--no-renames",
|
|
161
|
+
"--no-ext-diff",
|
|
162
|
+
"--no-textconv",
|
|
163
|
+
ref,
|
|
164
|
+
])
|
|
165
|
+
).split("\0"),
|
|
166
|
+
);
|
|
167
|
+
const currentPaths = new Set(
|
|
168
|
+
(
|
|
169
|
+
await git([
|
|
170
|
+
"ls-files",
|
|
171
|
+
"-z",
|
|
172
|
+
"--cached",
|
|
173
|
+
"--others",
|
|
174
|
+
"--exclude-standard",
|
|
175
|
+
])
|
|
176
|
+
)
|
|
177
|
+
.split("\0")
|
|
178
|
+
.filter(Boolean),
|
|
179
|
+
);
|
|
180
|
+
const names = [
|
|
181
|
+
...new Set(
|
|
182
|
+
eligible
|
|
183
|
+
.map((unit) => nameFor(unit))
|
|
184
|
+
.filter((name): name is string => name !== undefined),
|
|
185
|
+
),
|
|
186
|
+
];
|
|
187
|
+
const matched = new Set<string>();
|
|
188
|
+
const inventory = [...beforePaths].filter((path) =>
|
|
189
|
+
/\.(?:[cm]?[jt]sx?|py)$/.test(path),
|
|
190
|
+
);
|
|
191
|
+
const patternArgs = names.flatMap((name) => ["-e", name]);
|
|
192
|
+
const priorSearch = exec(
|
|
193
|
+
"git",
|
|
194
|
+
[
|
|
195
|
+
"grep",
|
|
196
|
+
"-l",
|
|
197
|
+
"-z",
|
|
198
|
+
"-F",
|
|
199
|
+
"-w",
|
|
200
|
+
...patternArgs,
|
|
201
|
+
ref,
|
|
202
|
+
"--",
|
|
203
|
+
...extensions.map((extension) => `*.${extension}`),
|
|
204
|
+
],
|
|
205
|
+
{ cwd, timeout: TIMEOUT_MS, signal: options.signal },
|
|
206
|
+
).catch((error: unknown) => ({
|
|
207
|
+
stdout: "",
|
|
208
|
+
stderr: String(error),
|
|
209
|
+
code: 2,
|
|
210
|
+
killed: false,
|
|
211
|
+
}));
|
|
212
|
+
// Explicit tracked paths prevent rg from collecting ignored or untracked callers.
|
|
213
|
+
// Bound argument bytes as well as process concurrency, including long paths.
|
|
214
|
+
const batches: string[][] = [];
|
|
215
|
+
let batch: string[] = [];
|
|
216
|
+
let bytes = 0;
|
|
217
|
+
for (const path of inventory) {
|
|
218
|
+
const size = Buffer.byteLength(path) + 1;
|
|
219
|
+
if (batch.length && bytes + size > 96_000) {
|
|
220
|
+
batches.push(batch);
|
|
221
|
+
batch = [];
|
|
222
|
+
bytes = 0;
|
|
223
|
+
}
|
|
224
|
+
batch.push(path);
|
|
225
|
+
bytes += size;
|
|
226
|
+
}
|
|
227
|
+
if (batch.length) batches.push(batch);
|
|
228
|
+
for (let offset = 0; offset < batches.length; offset += CONCURRENCY) {
|
|
229
|
+
await Promise.all(
|
|
230
|
+
batches.slice(offset, offset + CONCURRENCY).map(async (paths) => {
|
|
231
|
+
options.signal?.throwIfAborted();
|
|
232
|
+
const metadata = await Promise.all(
|
|
233
|
+
paths.map(async (path) => {
|
|
234
|
+
try {
|
|
235
|
+
const admission = await checkFileAdmission(
|
|
236
|
+
cwd,
|
|
237
|
+
path,
|
|
238
|
+
undefined,
|
|
239
|
+
options.signal,
|
|
240
|
+
currentPaths,
|
|
241
|
+
);
|
|
242
|
+
if (!admission.ok) return undefined;
|
|
243
|
+
const location = await resolveInsideRepo(cwd, path);
|
|
244
|
+
if (!location.ok) return undefined;
|
|
245
|
+
return (await lstat(location.abs)).isFile() ? path : undefined;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
248
|
+
return undefined;
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
const existing = metadata.filter(
|
|
254
|
+
(path): path is string => path !== undefined,
|
|
255
|
+
);
|
|
256
|
+
if (!existing.length) return;
|
|
257
|
+
let current:
|
|
258
|
+
| { stdout: string; stderr: string; code: number; killed: boolean }
|
|
259
|
+
| undefined;
|
|
260
|
+
try {
|
|
261
|
+
current = await exec(
|
|
262
|
+
"rg",
|
|
263
|
+
["-l", "-0", "-F", "-w", ...patternArgs, "--", ...existing],
|
|
264
|
+
{ cwd, timeout: TIMEOUT_MS, signal: options.signal },
|
|
265
|
+
);
|
|
266
|
+
} catch {
|
|
267
|
+
options.signal?.throwIfAborted();
|
|
268
|
+
}
|
|
269
|
+
if (!current || current.code > 1 || current.killed)
|
|
270
|
+
current = await exec(
|
|
271
|
+
"git",
|
|
272
|
+
[
|
|
273
|
+
"grep",
|
|
274
|
+
"-l",
|
|
275
|
+
"-z",
|
|
276
|
+
"-F",
|
|
277
|
+
"-w",
|
|
278
|
+
...patternArgs,
|
|
279
|
+
"--",
|
|
280
|
+
...existing,
|
|
281
|
+
],
|
|
282
|
+
{ cwd, timeout: TIMEOUT_MS, signal: options.signal },
|
|
283
|
+
);
|
|
284
|
+
if (current.code > 1 || current.killed)
|
|
285
|
+
throw new Error(
|
|
286
|
+
current.stderr || "tracked caller name search failed",
|
|
287
|
+
);
|
|
288
|
+
for (const path of current.stdout.split("\0"))
|
|
289
|
+
if (path && beforePaths.has(path)) matched.add(path);
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const prior = await priorSearch;
|
|
294
|
+
if (prior.code > 1 || prior.killed)
|
|
295
|
+
throw new Error(prior.stderr || "before caller name search failed");
|
|
296
|
+
for (const path of prior.stdout.split("\0"))
|
|
297
|
+
if (path.startsWith(`${ref}:`)) matched.add(path.slice(ref.length + 1));
|
|
298
|
+
for (const file of changedFiles) matched.add(file.path);
|
|
299
|
+
const paths = [...matched];
|
|
300
|
+
const sources: CallerSource[] = [];
|
|
301
|
+
const readBefore = async (
|
|
302
|
+
path: string,
|
|
303
|
+
): Promise<{ text: string | null; limitation?: string }> => {
|
|
304
|
+
if (!beforePaths.has(path)) return { text: null };
|
|
305
|
+
if (
|
|
306
|
+
!(
|
|
307
|
+
await admitGitPath(
|
|
308
|
+
cwd,
|
|
309
|
+
path,
|
|
310
|
+
ref,
|
|
311
|
+
exec,
|
|
312
|
+
options.signal,
|
|
313
|
+
baseInventory,
|
|
314
|
+
)
|
|
315
|
+
).ok
|
|
316
|
+
)
|
|
317
|
+
return { text: null, limitation: "before file not admitted" };
|
|
318
|
+
const entry = baseInventory.get(path);
|
|
319
|
+
const metadata =
|
|
320
|
+
entry &&
|
|
321
|
+
/^(?:100644|100755|120000) blob ([0-9a-f]+)\s+(\d+)\t/.exec(entry);
|
|
322
|
+
if (!metadata) return { text: null };
|
|
323
|
+
const object = metadata[1] ?? "";
|
|
324
|
+
const size = Number(metadata[2]);
|
|
325
|
+
if (!Number.isFinite(size) || size > STATE_MAX_CHARS)
|
|
326
|
+
return { text: null, limitation: "before file exceeds read budget" };
|
|
327
|
+
const text = await git([
|
|
328
|
+
"show",
|
|
329
|
+
"--no-ext-diff",
|
|
330
|
+
"--no-textconv",
|
|
331
|
+
"--end-of-options",
|
|
332
|
+
object,
|
|
333
|
+
]);
|
|
334
|
+
const decoded = verifyGitUtf8(text, object, size);
|
|
335
|
+
if (!decoded.ok)
|
|
336
|
+
return { text: null, limitation: `${path}: ${decoded.error}` };
|
|
337
|
+
return text.length <= STATE_MAX_CHARS && !text.includes("\0")
|
|
338
|
+
? { text }
|
|
339
|
+
: { text: null, limitation: "before binary or oversized" };
|
|
340
|
+
};
|
|
341
|
+
const readAfter = async (
|
|
342
|
+
path: string,
|
|
343
|
+
): Promise<{ text: string | null; limitation?: string }> => {
|
|
344
|
+
let handle: FileHandle | undefined;
|
|
345
|
+
try {
|
|
346
|
+
options.signal?.throwIfAborted();
|
|
347
|
+
const location = await resolveInsideRepo(cwd, path);
|
|
348
|
+
if (!location.ok) {
|
|
349
|
+
const metadata = await admitGitPath(
|
|
350
|
+
cwd,
|
|
351
|
+
path,
|
|
352
|
+
ref,
|
|
353
|
+
exec,
|
|
354
|
+
options.signal,
|
|
355
|
+
baseInventory,
|
|
356
|
+
);
|
|
357
|
+
if (metadata.ok && location.error.includes("ENOENT"))
|
|
358
|
+
return { text: null };
|
|
359
|
+
return { text: null, limitation: "after file not admitted" };
|
|
360
|
+
}
|
|
361
|
+
const opened = await openRepoFile(cwd, path, {
|
|
362
|
+
exec,
|
|
363
|
+
signal: options.signal,
|
|
364
|
+
inventory: currentPaths,
|
|
365
|
+
});
|
|
366
|
+
if (!opened.ok)
|
|
367
|
+
return { text: null, limitation: "after file not admitted" };
|
|
368
|
+
handle = opened.handle;
|
|
369
|
+
const stat = { size: opened.size };
|
|
370
|
+
if (stat.size > STATE_MAX_CHARS)
|
|
371
|
+
return { text: null, limitation: "after file exceeds read budget" };
|
|
372
|
+
const buffer = Buffer.alloc(STATE_MAX_CHARS + 1);
|
|
373
|
+
let offset = 0;
|
|
374
|
+
while (offset < buffer.length) {
|
|
375
|
+
const read = await handle.read(
|
|
376
|
+
buffer,
|
|
377
|
+
offset,
|
|
378
|
+
buffer.length - offset,
|
|
379
|
+
null,
|
|
380
|
+
);
|
|
381
|
+
if (!read.bytesRead) break;
|
|
382
|
+
offset += read.bytesRead;
|
|
383
|
+
}
|
|
384
|
+
if (offset > STATE_MAX_CHARS || buffer.subarray(0, offset).includes(0))
|
|
385
|
+
return { text: null, limitation: "after binary or oversized" };
|
|
386
|
+
const decoded = decodeUtf8(buffer.subarray(0, offset));
|
|
387
|
+
return decoded.ok
|
|
388
|
+
? { text: decoded.text }
|
|
389
|
+
: { text: null, limitation: `${path}: ${decoded.error}` };
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
392
|
+
return { text: null };
|
|
393
|
+
return { text: null, limitation: "after unreadable" };
|
|
394
|
+
} finally {
|
|
395
|
+
await handle?.close();
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
for (let offset = 0; offset < paths.length; ) {
|
|
399
|
+
const selected = paths.slice(offset, offset + CONCURRENCY);
|
|
400
|
+
offset += selected.length;
|
|
401
|
+
const batch = await Promise.all(
|
|
402
|
+
selected.map(async (path) => {
|
|
403
|
+
const changed = changedFiles.find((f) => f.path === path);
|
|
404
|
+
if (changed)
|
|
405
|
+
return {
|
|
406
|
+
path,
|
|
407
|
+
beforePath: changed.oldPath,
|
|
408
|
+
before: changed.before,
|
|
409
|
+
after: changed.after,
|
|
410
|
+
limitation: changed.limitation,
|
|
411
|
+
};
|
|
412
|
+
if (!modifiedPaths.has(path)) {
|
|
413
|
+
const after = await readAfter(path);
|
|
414
|
+
if (after.text !== null)
|
|
415
|
+
return {
|
|
416
|
+
path,
|
|
417
|
+
before: after.text,
|
|
418
|
+
after: after.text,
|
|
419
|
+
limitation: after.limitation,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const [before, after] = await Promise.all([
|
|
423
|
+
readBefore(path),
|
|
424
|
+
readAfter(path),
|
|
425
|
+
]);
|
|
426
|
+
return {
|
|
427
|
+
path,
|
|
428
|
+
before: before.text,
|
|
429
|
+
after: after.text,
|
|
430
|
+
limitation: before.limitation ?? after.limitation,
|
|
431
|
+
};
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
sources.push(...batch);
|
|
435
|
+
// Follow static import AST nodes from candidate files so imported concrete providers remain visible.
|
|
436
|
+
for (const source of batch)
|
|
437
|
+
for (const side of ["before", "after"] as const) {
|
|
438
|
+
if (side === "after" && source.before === source.after) continue;
|
|
439
|
+
const text = source[side];
|
|
440
|
+
if (!text || !parser?.supports(source.path)) continue;
|
|
441
|
+
const ast = parser.parse(source.path, text);
|
|
442
|
+
if (!ast) continue;
|
|
443
|
+
const visit = (node: typeof ast) => {
|
|
444
|
+
if (
|
|
445
|
+
[
|
|
446
|
+
"import_statement",
|
|
447
|
+
"import_from_statement",
|
|
448
|
+
"export_statement",
|
|
449
|
+
].includes(String(node.kind()))
|
|
450
|
+
) {
|
|
451
|
+
const literal = node.field("source") ?? node.field("module_name");
|
|
452
|
+
if (literal) {
|
|
453
|
+
const specifier =
|
|
454
|
+
literal.kind() === "string"
|
|
455
|
+
? literal.text().slice(1, -1)
|
|
456
|
+
: literal.text();
|
|
457
|
+
const sourcePath =
|
|
458
|
+
side === "before" &&
|
|
459
|
+
"beforePath" in source &&
|
|
460
|
+
typeof source.beforePath === "string"
|
|
461
|
+
? source.beforePath
|
|
462
|
+
: source.path;
|
|
463
|
+
const basePath = specifier.startsWith(".")
|
|
464
|
+
? resolve("/", sourcePath, "..", specifier).slice(1)
|
|
465
|
+
: specifier.replaceAll(".", "/");
|
|
466
|
+
const runtimeSource = basePath.replace(
|
|
467
|
+
/\.(js|jsx|mjs|cjs)$/,
|
|
468
|
+
(extension) => sourceExtension[extension] ?? extension,
|
|
469
|
+
);
|
|
470
|
+
const candidates = new Set([
|
|
471
|
+
basePath,
|
|
472
|
+
runtimeSource,
|
|
473
|
+
...extensions.map((extension) => `${basePath}.${extension}`),
|
|
474
|
+
`${basePath}/index.ts`,
|
|
475
|
+
`${basePath}/__init__.py`,
|
|
476
|
+
]);
|
|
477
|
+
const matches = [...candidates].filter((path) =>
|
|
478
|
+
beforePaths.has(path),
|
|
479
|
+
);
|
|
480
|
+
if (matches.length === 1 && !matched.has(matches[0] ?? "")) {
|
|
481
|
+
matched.add(matches[0] ?? "");
|
|
482
|
+
paths.push(matches[0] ?? "");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
for (const child of node.children()) visit(child);
|
|
487
|
+
};
|
|
488
|
+
if (source.path.endsWith(".py")) visit(ast);
|
|
489
|
+
else
|
|
490
|
+
for (const statement of ast.children()) {
|
|
491
|
+
if (
|
|
492
|
+
["import_statement", "export_statement"].includes(
|
|
493
|
+
String(statement.kind()),
|
|
494
|
+
) &&
|
|
495
|
+
statement.field("source")
|
|
496
|
+
)
|
|
497
|
+
visit(statement);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const prepared = prepareRiskCallers(eligible, sources, parser);
|
|
502
|
+
prepared.limits.push(...retainedLimits);
|
|
503
|
+
prepared.limits.push(
|
|
504
|
+
...eligible.map((unit) => ({
|
|
505
|
+
unitId: unit.id,
|
|
506
|
+
kind: "dynamic_access_uncovered" as const,
|
|
507
|
+
reason: "callers found by name; dynamic access not covered",
|
|
508
|
+
paths: [unit.file],
|
|
509
|
+
missing: "dynamically named callers",
|
|
510
|
+
})),
|
|
511
|
+
);
|
|
512
|
+
return prepared;
|
|
513
|
+
} catch (error) {
|
|
514
|
+
return {
|
|
515
|
+
proofs: [],
|
|
516
|
+
limits: eligible.map((unit) => ({
|
|
517
|
+
unitId: unit.id,
|
|
518
|
+
kind: "unreadable",
|
|
519
|
+
reason: `caller collection failed: ${String(error)}`,
|
|
520
|
+
paths: [unit.file],
|
|
521
|
+
missing: "bounded tracked caller/provider source",
|
|
522
|
+
})),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { type FileHandle, open, realpath } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { RUNNER_PACKAGE_MAX_BYTES } from "../constants.ts";
|
|
5
|
+
import {
|
|
6
|
+
lockedRunnerVersion,
|
|
7
|
+
runnerVersion,
|
|
8
|
+
type VersionedRunner,
|
|
9
|
+
} from "../core/runner-version.ts";
|
|
10
|
+
import type { TestEntry } from "../core/test-discovery.ts";
|
|
11
|
+
|
|
12
|
+
/** Local-only ignored metadata exception: no contents enter Jev state. Closed runner names,
|
|
13
|
+
* bounded reads and realpath confinement allow pnpm links only inside the repository. */
|
|
14
|
+
export async function installedRunnerVersion(
|
|
15
|
+
root: string,
|
|
16
|
+
cwd: string,
|
|
17
|
+
runner: VersionedRunner,
|
|
18
|
+
): Promise<string | undefined> {
|
|
19
|
+
if (runner !== "vitest" && runner !== "jest" && runner !== "mocha")
|
|
20
|
+
return undefined;
|
|
21
|
+
if (isAbsolute(cwd) || cwd.split(/[\\/]/).includes("..")) return undefined;
|
|
22
|
+
let handle: FileHandle | undefined;
|
|
23
|
+
try {
|
|
24
|
+
const base = await realpath(root);
|
|
25
|
+
const path = await realpath(
|
|
26
|
+
resolve(base, cwd, "node_modules", runner, "package.json"),
|
|
27
|
+
);
|
|
28
|
+
const rel = relative(base, path);
|
|
29
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`))
|
|
30
|
+
return undefined;
|
|
31
|
+
handle = await open(
|
|
32
|
+
path,
|
|
33
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
34
|
+
);
|
|
35
|
+
const stat = await handle.stat();
|
|
36
|
+
if (!stat.isFile() || stat.size > RUNNER_PACKAGE_MAX_BYTES)
|
|
37
|
+
return undefined;
|
|
38
|
+
const bytes = Buffer.alloc(RUNNER_PACKAGE_MAX_BYTES + 1);
|
|
39
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
40
|
+
if (bytesRead > RUNNER_PACKAGE_MAX_BYTES) return undefined;
|
|
41
|
+
const value = JSON.parse(bytes.subarray(0, bytesRead).toString("utf8")) as {
|
|
42
|
+
version?: unknown;
|
|
43
|
+
};
|
|
44
|
+
return runnerVersion(value.version);
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
} finally {
|
|
48
|
+
await handle?.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function attachRunnerVersions(
|
|
53
|
+
root: string,
|
|
54
|
+
entries: readonly TestEntry[],
|
|
55
|
+
read: (path: string) => Promise<{ text: string } | undefined>,
|
|
56
|
+
known: ReadonlySet<string>,
|
|
57
|
+
): Promise<TestEntry[]> {
|
|
58
|
+
const cache = new Map<string, Promise<string | undefined>>();
|
|
59
|
+
return Promise.all(
|
|
60
|
+
entries.map(async (entry) => {
|
|
61
|
+
if (
|
|
62
|
+
entry.framework !== "vitest" &&
|
|
63
|
+
entry.framework !== "jest" &&
|
|
64
|
+
entry.framework !== "mocha"
|
|
65
|
+
)
|
|
66
|
+
return entry;
|
|
67
|
+
const runner = entry.framework;
|
|
68
|
+
const key = `${entry.cwd}:${runner}`;
|
|
69
|
+
let pending = cache.get(key);
|
|
70
|
+
if (!pending) {
|
|
71
|
+
pending = (async () => {
|
|
72
|
+
const installed = await installedRunnerVersion(
|
|
73
|
+
root,
|
|
74
|
+
entry.cwd,
|
|
75
|
+
runner,
|
|
76
|
+
);
|
|
77
|
+
if (installed) return installed;
|
|
78
|
+
const dirs = [entry.cwd];
|
|
79
|
+
for (const dir of dirs)
|
|
80
|
+
for (const name of [
|
|
81
|
+
"package-lock.json",
|
|
82
|
+
"pnpm-lock.yaml",
|
|
83
|
+
"yarn.lock",
|
|
84
|
+
"bun.lock",
|
|
85
|
+
]) {
|
|
86
|
+
const path = dir === "." ? name : `${dir}/${name}`;
|
|
87
|
+
if (!known.has(path)) continue;
|
|
88
|
+
const source = await read(path);
|
|
89
|
+
if (source) {
|
|
90
|
+
const version = lockedRunnerVersion(runner, path, source.text);
|
|
91
|
+
if (version) return version;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
})();
|
|
96
|
+
cache.set(key, pending);
|
|
97
|
+
}
|
|
98
|
+
const version = await pending;
|
|
99
|
+
return { ...entry, ...(version ? { runnerVersion: version } : {}) };
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|