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,649 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import type { SgNode } from "@ast-grep/napi";
|
|
3
|
+
import {
|
|
4
|
+
type ImportGraph,
|
|
5
|
+
type ImportSource,
|
|
6
|
+
importClosure,
|
|
7
|
+
} from "./imports.ts";
|
|
8
|
+
import type { SyntaxRootParser } from "./syntax.ts";
|
|
9
|
+
import type { TestEntry } from "./test-discovery.ts";
|
|
10
|
+
import type { Declaration, EvidenceUnit } from "./units.ts";
|
|
11
|
+
|
|
12
|
+
export interface TestEvidenceLimit {
|
|
13
|
+
kind:
|
|
14
|
+
| "grammar_absent"
|
|
15
|
+
| "parse_failed"
|
|
16
|
+
| "parse_partial"
|
|
17
|
+
| "unsupported"
|
|
18
|
+
| "ambiguous"
|
|
19
|
+
| "unresolved"
|
|
20
|
+
| "dynamic"
|
|
21
|
+
| "outside_repo";
|
|
22
|
+
path: string;
|
|
23
|
+
specifier?: string;
|
|
24
|
+
declaration?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface TestEvidence {
|
|
27
|
+
paths: readonly string[];
|
|
28
|
+
units: readonly EvidenceUnit[];
|
|
29
|
+
imports: readonly ImportSource[];
|
|
30
|
+
usedBy: ReadonlyMap<string, readonly ImportSource[]>;
|
|
31
|
+
limits: readonly TestEvidenceLimit[];
|
|
32
|
+
uncertain: boolean;
|
|
33
|
+
}
|
|
34
|
+
interface Binding {
|
|
35
|
+
target: string;
|
|
36
|
+
name: string;
|
|
37
|
+
}
|
|
38
|
+
interface Reference {
|
|
39
|
+
name: string;
|
|
40
|
+
member?: string;
|
|
41
|
+
}
|
|
42
|
+
interface Definition {
|
|
43
|
+
file: string;
|
|
44
|
+
declaration: Declaration;
|
|
45
|
+
text: string;
|
|
46
|
+
references: Reference[];
|
|
47
|
+
}
|
|
48
|
+
interface Module {
|
|
49
|
+
definitions: Definition[];
|
|
50
|
+
bindings: Map<string, Binding>;
|
|
51
|
+
exports: Map<string, Binding[]>;
|
|
52
|
+
stars: string[];
|
|
53
|
+
references: Reference[];
|
|
54
|
+
limits: TestEvidenceLimit[];
|
|
55
|
+
}
|
|
56
|
+
function references(node: SgNode): Reference[] {
|
|
57
|
+
const found: Reference[] = [];
|
|
58
|
+
const memberKind =
|
|
59
|
+
node.kind() === "module" ||
|
|
60
|
+
node.ancestors().some((ancestor) => ancestor.kind() === "module")
|
|
61
|
+
? "attribute"
|
|
62
|
+
: "member_expression";
|
|
63
|
+
for (const part of node.findAll({
|
|
64
|
+
rule: { any: [{ kind: "identifier" }, { kind: memberKind }] },
|
|
65
|
+
})) {
|
|
66
|
+
const kind = String(part.kind());
|
|
67
|
+
if (/^(member_expression|attribute)$/.test(kind)) {
|
|
68
|
+
const object = part.field("object") ?? part.children()[0];
|
|
69
|
+
const property = part.field("property") ?? part.children().at(-1);
|
|
70
|
+
if (
|
|
71
|
+
object?.kind() === "identifier" &&
|
|
72
|
+
property &&
|
|
73
|
+
/identifier$/.test(String(property.kind()))
|
|
74
|
+
)
|
|
75
|
+
found.push({ name: object.text(), member: property.text() });
|
|
76
|
+
} else {
|
|
77
|
+
const parent = part.parent();
|
|
78
|
+
if (
|
|
79
|
+
parent &&
|
|
80
|
+
/^(import|export).*|^(member_expression|attribute)$/.test(
|
|
81
|
+
String(parent.kind()),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
continue;
|
|
85
|
+
found.push({ name: part.text() });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Builds one lazy, cached declaration index for an inventory. Unknown chains remain judgeable. */
|
|
92
|
+
export function prepareTestEvidence(
|
|
93
|
+
files: readonly ImportSource[],
|
|
94
|
+
graph: ImportGraph,
|
|
95
|
+
units: readonly EvidenceUnit[],
|
|
96
|
+
parser: SyntaxRootParser | undefined,
|
|
97
|
+
): (entry: TestEntry) => TestEvidence {
|
|
98
|
+
const sources = new Map(files.map((file) => [file.path, file]));
|
|
99
|
+
const modules = new Map<string, Module>();
|
|
100
|
+
const dependencyCache = new Map<
|
|
101
|
+
Definition,
|
|
102
|
+
{ definitions: Definition[]; limits: TestEvidenceLimit[] }
|
|
103
|
+
>();
|
|
104
|
+
const resolutionCache = new Map<
|
|
105
|
+
string,
|
|
106
|
+
{ definitions: Definition[]; limits: TestEvidenceLimit[] }
|
|
107
|
+
>();
|
|
108
|
+
const closureCache = new Map<
|
|
109
|
+
string,
|
|
110
|
+
{ paths: readonly string[]; limits: readonly TestEvidenceLimit[] }
|
|
111
|
+
>();
|
|
112
|
+
const rootCache = new Map<
|
|
113
|
+
string,
|
|
114
|
+
{ definitions: Definition[]; limits: TestEvidenceLimit[] }
|
|
115
|
+
>();
|
|
116
|
+
const declarationParents = new Map<Definition, Set<Definition>>();
|
|
117
|
+
const declarationHits = new Map<Definition, EvidenceUnit[]>();
|
|
118
|
+
const reachesUnit = new Set<Definition>();
|
|
119
|
+
const indexed = new Set<Definition>();
|
|
120
|
+
const propagationQueue: Definition[] = [];
|
|
121
|
+
let propagationOffset = 0;
|
|
122
|
+
const packageDirectories = new Map<string, string[]>();
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
if (posix.basename(file.path) !== "package.json") continue;
|
|
125
|
+
try {
|
|
126
|
+
const manifest = JSON.parse(file.text) as { name?: string };
|
|
127
|
+
if (typeof manifest.name === "string") {
|
|
128
|
+
const directories = packageDirectories.get(manifest.name) ?? [];
|
|
129
|
+
directories.push(posix.dirname(file.path));
|
|
130
|
+
packageDirectories.set(manifest.name, directories);
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Invalid manifests cannot establish package identity.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const reachesDiff = new Set(units.map((unit) => unit.file));
|
|
137
|
+
const importers = new Map<string, string[]>();
|
|
138
|
+
for (const [path, dependencies] of graph.edges)
|
|
139
|
+
for (const dependency of dependencies) {
|
|
140
|
+
const parents = importers.get(dependency) ?? [];
|
|
141
|
+
parents.push(path);
|
|
142
|
+
importers.set(dependency, parents);
|
|
143
|
+
}
|
|
144
|
+
const pending = [...reachesDiff];
|
|
145
|
+
for (const dependency of pending)
|
|
146
|
+
for (const path of importers.get(dependency) ?? [])
|
|
147
|
+
if (!reachesDiff.has(path)) {
|
|
148
|
+
reachesDiff.add(path);
|
|
149
|
+
pending.push(path);
|
|
150
|
+
}
|
|
151
|
+
const target = (
|
|
152
|
+
path: string,
|
|
153
|
+
specifier: string,
|
|
154
|
+
limits: TestEvidenceLimit[],
|
|
155
|
+
): string | undefined => {
|
|
156
|
+
const edges = graph.edges.get(path) ?? [];
|
|
157
|
+
let matches: readonly string[];
|
|
158
|
+
if (specifier.startsWith(".") || path.endsWith(".py")) {
|
|
159
|
+
let base: string;
|
|
160
|
+
if (path.endsWith(".py")) {
|
|
161
|
+
const dots = specifier.match(/^\.+/)?.[0].length ?? 0;
|
|
162
|
+
let dir = dots ? posix.dirname(path) : ".";
|
|
163
|
+
for (let i = 1; i < dots; i++) dir = posix.dirname(dir);
|
|
164
|
+
base = posix.join(dir, specifier.slice(dots).replaceAll(".", "/"));
|
|
165
|
+
} else base = posix.normalize(posix.join(posix.dirname(path), specifier));
|
|
166
|
+
const stem = base.replace(/\.[cm]?[jt]sx?$/, "");
|
|
167
|
+
matches = edges.filter(
|
|
168
|
+
(edge) =>
|
|
169
|
+
edge === base ||
|
|
170
|
+
edge.replace(/\.[cm]?[jt]sx?$/, "") === stem ||
|
|
171
|
+
edge === `${base}.py` ||
|
|
172
|
+
edge === `${base}/__init__.py` ||
|
|
173
|
+
(/^index\.[jt]s$/.test(posix.basename(edge)) &&
|
|
174
|
+
posix.dirname(edge) === base),
|
|
175
|
+
);
|
|
176
|
+
} else {
|
|
177
|
+
// Only claim a package target when its manifest identifies the graph edge.
|
|
178
|
+
const directories = packageDirectories.get(specifier) ?? [];
|
|
179
|
+
matches = edges.filter((edge) =>
|
|
180
|
+
directories.some(
|
|
181
|
+
(directory) => !posix.relative(directory, edge).startsWith("../"),
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (matches.length === 1) return matches[0];
|
|
186
|
+
if (matches.length > 1 || (specifier.startsWith(".") && edges.length))
|
|
187
|
+
limits.push({
|
|
188
|
+
path,
|
|
189
|
+
kind: matches.length > 1 ? "ambiguous" : "unsupported",
|
|
190
|
+
specifier,
|
|
191
|
+
});
|
|
192
|
+
return undefined;
|
|
193
|
+
};
|
|
194
|
+
const getModule = (path: string): Module => {
|
|
195
|
+
const cached = modules.get(path);
|
|
196
|
+
if (cached) return cached;
|
|
197
|
+
const result: Module = {
|
|
198
|
+
definitions: [],
|
|
199
|
+
bindings: new Map(),
|
|
200
|
+
exports: new Map(),
|
|
201
|
+
stars: [],
|
|
202
|
+
references: [],
|
|
203
|
+
limits: [],
|
|
204
|
+
};
|
|
205
|
+
modules.set(path, result);
|
|
206
|
+
const file = sources.get(path);
|
|
207
|
+
if (!file) {
|
|
208
|
+
result.limits.push({ path, kind: "unresolved" });
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
if (!parser?.supports(path)) {
|
|
212
|
+
result.limits.push({ path, kind: "grammar_absent" });
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
const parsed = parser.parseRaw(path, file.text);
|
|
216
|
+
if (!parsed.ok) {
|
|
217
|
+
result.limits.push({ path, kind: "parse_failed" });
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
// Declaration extraction reuses this AST rather than invoking the grammar a second time.
|
|
221
|
+
const cachedParser = Object.create(parser) as SyntaxRootParser;
|
|
222
|
+
cachedParser.parseRaw = () => parsed;
|
|
223
|
+
const declarations =
|
|
224
|
+
/(?:[._-]test|[._-]spec)\.[cm]?[jt]sx?$|(?:^|\/)test[^/]*\.py$/.test(path)
|
|
225
|
+
? {
|
|
226
|
+
declarations: [],
|
|
227
|
+
limits: parsed.root.find({ rule: { kind: "ERROR" } })
|
|
228
|
+
? [{ kind: "parse_partial" as const }]
|
|
229
|
+
: [],
|
|
230
|
+
}
|
|
231
|
+
: cachedParser.declarations(path, file.text, {
|
|
232
|
+
descendAbove: Infinity,
|
|
233
|
+
});
|
|
234
|
+
result.limits.push(
|
|
235
|
+
...declarations.limits.map((limit) => ({ ...limit, path })),
|
|
236
|
+
);
|
|
237
|
+
if (declarations.limits.some((limit) => limit.kind === "parse_failed")) {
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
const lines = file.text.split("\n");
|
|
241
|
+
const children = parsed.root.children().map((node) => ({
|
|
242
|
+
node,
|
|
243
|
+
start: node.range().start.line + 1,
|
|
244
|
+
end: node.range().end.line + (node.range().end.column ? 1 : 0),
|
|
245
|
+
refs: references(node),
|
|
246
|
+
}));
|
|
247
|
+
for (const declaration of declarations.declarations) {
|
|
248
|
+
const refs: Reference[] = [];
|
|
249
|
+
for (const child of children) {
|
|
250
|
+
if (child.start >= declaration.start && child.end <= declaration.end)
|
|
251
|
+
refs.push(...child.refs);
|
|
252
|
+
}
|
|
253
|
+
result.definitions.push({
|
|
254
|
+
file: path,
|
|
255
|
+
declaration,
|
|
256
|
+
text: lines.slice(declaration.start - 1, declaration.end).join("\n"),
|
|
257
|
+
references: refs,
|
|
258
|
+
});
|
|
259
|
+
if (declaration.exported || path.endsWith(".py"))
|
|
260
|
+
result.exports.set(declaration.name, [
|
|
261
|
+
{ target: path, name: declaration.name },
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
const addExport = (name: string, binding: Binding): void => {
|
|
265
|
+
result.exports.set(name, [...(result.exports.get(name) ?? []), binding]);
|
|
266
|
+
};
|
|
267
|
+
for (const { node, refs } of children) {
|
|
268
|
+
const kind = String(node.kind());
|
|
269
|
+
if (kind === "import_statement" && !path.endsWith(".py")) {
|
|
270
|
+
const string = node
|
|
271
|
+
.children()
|
|
272
|
+
.find((child) => child.kind() === "string");
|
|
273
|
+
if (!string) continue;
|
|
274
|
+
const specifier = string.text().slice(1, -1);
|
|
275
|
+
const destination = target(path, specifier, result.limits);
|
|
276
|
+
if (!destination) continue;
|
|
277
|
+
const clause = node
|
|
278
|
+
.children()
|
|
279
|
+
.find((child) => child.kind() === "import_clause");
|
|
280
|
+
if (!clause) continue;
|
|
281
|
+
for (const child of clause.children()) {
|
|
282
|
+
if (child.kind() === "identifier")
|
|
283
|
+
result.bindings.set(child.text(), {
|
|
284
|
+
target: destination,
|
|
285
|
+
name: "default",
|
|
286
|
+
});
|
|
287
|
+
if (child.kind() === "namespace_import")
|
|
288
|
+
result.bindings.set(child.children().at(-1)?.text() ?? "", {
|
|
289
|
+
target: destination,
|
|
290
|
+
name: "*",
|
|
291
|
+
});
|
|
292
|
+
if (child.kind() === "named_imports")
|
|
293
|
+
for (const spec of child
|
|
294
|
+
.children()
|
|
295
|
+
.filter((part) => part.kind() === "import_specifier")) {
|
|
296
|
+
const names = spec
|
|
297
|
+
.children()
|
|
298
|
+
.filter((part) => part.kind() === "identifier");
|
|
299
|
+
result.bindings.set(names.at(-1)?.text() ?? "", {
|
|
300
|
+
target: destination,
|
|
301
|
+
name: names[0]?.text() ?? "",
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} else if (kind === "export_statement") {
|
|
306
|
+
const string = node
|
|
307
|
+
.children()
|
|
308
|
+
.find((child) => child.kind() === "string");
|
|
309
|
+
const destination = string
|
|
310
|
+
? target(path, string.text().slice(1, -1), result.limits)
|
|
311
|
+
: path;
|
|
312
|
+
if (!destination) continue;
|
|
313
|
+
const clause = node
|
|
314
|
+
.children()
|
|
315
|
+
.find((child) => child.kind() === "export_clause");
|
|
316
|
+
if (clause)
|
|
317
|
+
for (const spec of clause
|
|
318
|
+
.children()
|
|
319
|
+
.filter((part) => part.kind() === "export_specifier")) {
|
|
320
|
+
const names = spec
|
|
321
|
+
.children()
|
|
322
|
+
.filter((part) => part.kind() === "identifier");
|
|
323
|
+
addExport(names.at(-1)?.text() ?? "", {
|
|
324
|
+
target: destination,
|
|
325
|
+
name: names[0]?.text() ?? "",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
else if (node.children().some((child) => child.kind() === "*"))
|
|
329
|
+
result.stars.push(destination);
|
|
330
|
+
else if (node.children().some((child) => child.kind() === "default")) {
|
|
331
|
+
const definition = result.definitions.find(
|
|
332
|
+
(def) =>
|
|
333
|
+
def.declaration.start <= node.range().start.line + 1 &&
|
|
334
|
+
def.declaration.end >= node.range().end.line + 1,
|
|
335
|
+
);
|
|
336
|
+
const identifier = node
|
|
337
|
+
.children()
|
|
338
|
+
.find((child) => child.kind() === "identifier");
|
|
339
|
+
if (definition || identifier)
|
|
340
|
+
addExport("default", {
|
|
341
|
+
target: path,
|
|
342
|
+
name: definition?.declaration.name ?? identifier?.text() ?? "",
|
|
343
|
+
});
|
|
344
|
+
else
|
|
345
|
+
result.limits.push({
|
|
346
|
+
path,
|
|
347
|
+
kind: "unsupported",
|
|
348
|
+
specifier: "default export expression",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
} else if (
|
|
352
|
+
path.endsWith(".py") &&
|
|
353
|
+
/^(import_from_statement|import_statement)$/.test(kind)
|
|
354
|
+
) {
|
|
355
|
+
const children = node.children();
|
|
356
|
+
const from = kind === "import_from_statement";
|
|
357
|
+
const moduleNode = from
|
|
358
|
+
? children.find((child) =>
|
|
359
|
+
/^(dotted_name|relative_import)$/.test(String(child.kind())),
|
|
360
|
+
)
|
|
361
|
+
: undefined;
|
|
362
|
+
const module = moduleNode?.text() ?? "";
|
|
363
|
+
for (const child of children) {
|
|
364
|
+
if (
|
|
365
|
+
!/^(aliased_import|dotted_name)$/.test(String(child.kind())) ||
|
|
366
|
+
child === moduleNode
|
|
367
|
+
)
|
|
368
|
+
continue;
|
|
369
|
+
const names =
|
|
370
|
+
child.kind() === "aliased_import"
|
|
371
|
+
? child
|
|
372
|
+
.children()
|
|
373
|
+
.filter((part) =>
|
|
374
|
+
/^(identifier|dotted_name)$/.test(String(part.kind())),
|
|
375
|
+
)
|
|
376
|
+
.map((part) => part.text())
|
|
377
|
+
: [child.text()];
|
|
378
|
+
const destination = target(
|
|
379
|
+
path,
|
|
380
|
+
from ? module : (names[0] ?? ""),
|
|
381
|
+
result.limits,
|
|
382
|
+
);
|
|
383
|
+
if (destination)
|
|
384
|
+
result.bindings.set(names.at(-1) ?? "", {
|
|
385
|
+
target: destination,
|
|
386
|
+
name: from ? (names[0] ?? "") : "*",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
} else result.references.push(...refs);
|
|
390
|
+
}
|
|
391
|
+
return result;
|
|
392
|
+
};
|
|
393
|
+
return (entry) => {
|
|
394
|
+
const starts = [entry.path];
|
|
395
|
+
if (entry.framework === "pytest")
|
|
396
|
+
for (const file of files)
|
|
397
|
+
if (
|
|
398
|
+
posix.basename(file.path) === "conftest.py" &&
|
|
399
|
+
(posix.dirname(file.path) === "." ||
|
|
400
|
+
entry.path.startsWith(`${posix.dirname(file.path)}/`))
|
|
401
|
+
)
|
|
402
|
+
starts.push(file.path);
|
|
403
|
+
const closures = starts.map((path) => {
|
|
404
|
+
let closure = closureCache.get(path);
|
|
405
|
+
if (!closure) {
|
|
406
|
+
closure = importClosure(graph, path);
|
|
407
|
+
closureCache.set(path, closure);
|
|
408
|
+
}
|
|
409
|
+
return closure;
|
|
410
|
+
});
|
|
411
|
+
const paths = [...new Set(closures.flatMap((closure) => closure.paths))];
|
|
412
|
+
const limits: TestEvidenceLimit[] = closures.flatMap(
|
|
413
|
+
(closure) => closure.limits,
|
|
414
|
+
);
|
|
415
|
+
// Missing lexical edges are already uncertain; AST analysis cannot invent a path.
|
|
416
|
+
if (!units.length || !starts.some((path) => reachesDiff.has(path))) {
|
|
417
|
+
return {
|
|
418
|
+
paths,
|
|
419
|
+
units: limits.length ? units : [],
|
|
420
|
+
imports: [],
|
|
421
|
+
usedBy: new Map(),
|
|
422
|
+
limits,
|
|
423
|
+
uncertain: limits.length > 0,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const resolveUncached = (
|
|
427
|
+
path: string,
|
|
428
|
+
name: string,
|
|
429
|
+
exported: boolean,
|
|
430
|
+
seen = new Set<string>(),
|
|
431
|
+
): Definition[] => {
|
|
432
|
+
if (!reachesDiff.has(path)) return [];
|
|
433
|
+
const key = `${path}:${name}:${exported}`;
|
|
434
|
+
if (seen.has(key)) return [];
|
|
435
|
+
const next = new Set(seen).add(key);
|
|
436
|
+
const module = getModule(path);
|
|
437
|
+
if (!exported) {
|
|
438
|
+
const local = module.definitions.filter(
|
|
439
|
+
(def) => def.declaration.name === name,
|
|
440
|
+
);
|
|
441
|
+
if (local.length) return local;
|
|
442
|
+
const binding = module.bindings.get(name);
|
|
443
|
+
return binding && binding.name !== "*"
|
|
444
|
+
? resolve(binding.target, binding.name, true, next)
|
|
445
|
+
: [];
|
|
446
|
+
}
|
|
447
|
+
if (module.stars.some((star) => !reachesDiff.has(star))) {
|
|
448
|
+
limits.push({ path, kind: "ambiguous", specifier: name });
|
|
449
|
+
}
|
|
450
|
+
const bindings = module.exports.get(name);
|
|
451
|
+
const found = bindings
|
|
452
|
+
? bindings.flatMap((binding) =>
|
|
453
|
+
binding.target === path && binding.name === name
|
|
454
|
+
? module.definitions.filter(
|
|
455
|
+
(def) => def.declaration.name === name,
|
|
456
|
+
)
|
|
457
|
+
: resolve(
|
|
458
|
+
binding.target,
|
|
459
|
+
binding.name,
|
|
460
|
+
binding.target !== path,
|
|
461
|
+
next,
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
: name === "default"
|
|
465
|
+
? []
|
|
466
|
+
: module.stars.flatMap((star) => resolve(star, name, true, next));
|
|
467
|
+
const unique = [...new Set(found)];
|
|
468
|
+
if (unique.length > 1)
|
|
469
|
+
limits.push({ path, kind: "ambiguous", specifier: name });
|
|
470
|
+
return unique;
|
|
471
|
+
};
|
|
472
|
+
const resolve = (
|
|
473
|
+
path: string,
|
|
474
|
+
name: string,
|
|
475
|
+
exported: boolean,
|
|
476
|
+
seen = new Set<string>(),
|
|
477
|
+
): Definition[] => {
|
|
478
|
+
if (seen.size) return resolveUncached(path, name, exported, seen);
|
|
479
|
+
const key = `${path}:${name}:${exported}`;
|
|
480
|
+
const cached = resolutionCache.get(key);
|
|
481
|
+
if (cached) {
|
|
482
|
+
limits.push(...cached.limits);
|
|
483
|
+
return cached.definitions;
|
|
484
|
+
}
|
|
485
|
+
const limitStart = limits.length;
|
|
486
|
+
const definitions = resolveUncached(path, name, exported, seen);
|
|
487
|
+
resolutionCache.set(key, {
|
|
488
|
+
definitions,
|
|
489
|
+
limits: limits.slice(limitStart),
|
|
490
|
+
});
|
|
491
|
+
return definitions;
|
|
492
|
+
};
|
|
493
|
+
const dependencies = (path: string, refs: Reference[]): Definition[] =>
|
|
494
|
+
refs.flatMap((ref) => {
|
|
495
|
+
const module = getModule(path);
|
|
496
|
+
const binding = module.bindings.get(ref.name);
|
|
497
|
+
const resolveImported = (name: string): Definition[] => {
|
|
498
|
+
if (!binding) return [];
|
|
499
|
+
if (!reachesDiff.has(binding.target)) return [];
|
|
500
|
+
const found = resolve(binding.target, name, true);
|
|
501
|
+
if (!found.length)
|
|
502
|
+
limits.push({
|
|
503
|
+
path,
|
|
504
|
+
kind: "unresolved",
|
|
505
|
+
specifier: `${ref.name}${ref.member ? `.${ref.member}` : ""}`,
|
|
506
|
+
});
|
|
507
|
+
return found;
|
|
508
|
+
};
|
|
509
|
+
if (ref.member && binding) {
|
|
510
|
+
if (binding.name === "*") return resolveImported(ref.member);
|
|
511
|
+
limits.push({
|
|
512
|
+
path,
|
|
513
|
+
kind: "unsupported",
|
|
514
|
+
specifier: `${ref.name}.${ref.member}`,
|
|
515
|
+
});
|
|
516
|
+
return resolveImported(binding.name);
|
|
517
|
+
}
|
|
518
|
+
if (binding?.name === "*") {
|
|
519
|
+
limits.push({ path, kind: "unsupported", specifier: ref.name });
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
return binding
|
|
523
|
+
? resolveImported(binding.name)
|
|
524
|
+
: resolve(path, ref.name, false);
|
|
525
|
+
});
|
|
526
|
+
const reachable = new Set<Definition>();
|
|
527
|
+
const visit = (definition: Definition): void => {
|
|
528
|
+
if (reachable.has(definition)) return;
|
|
529
|
+
reachable.add(definition);
|
|
530
|
+
let cached = dependencyCache.get(definition);
|
|
531
|
+
if (!cached) {
|
|
532
|
+
const limitStart = limits.length;
|
|
533
|
+
const definitions = [
|
|
534
|
+
...new Set(dependencies(definition.file, definition.references)),
|
|
535
|
+
].filter((dep) => dep !== definition);
|
|
536
|
+
cached = { definitions, limits: limits.slice(limitStart) };
|
|
537
|
+
dependencyCache.set(definition, cached);
|
|
538
|
+
} else limits.push(...cached.limits);
|
|
539
|
+
const deps = cached.definitions;
|
|
540
|
+
if (!indexed.has(definition)) {
|
|
541
|
+
indexed.add(definition);
|
|
542
|
+
const matched = units.filter(
|
|
543
|
+
(unit) =>
|
|
544
|
+
unit.file === definition.file &&
|
|
545
|
+
(unit.name === definition.declaration.name ||
|
|
546
|
+
(unit.afterRange &&
|
|
547
|
+
unit.afterRange.start <= definition.declaration.end &&
|
|
548
|
+
unit.afterRange.end >= definition.declaration.start)),
|
|
549
|
+
);
|
|
550
|
+
if (matched.length) declarationHits.set(definition, matched);
|
|
551
|
+
for (const dependency of deps) {
|
|
552
|
+
const parents =
|
|
553
|
+
declarationParents.get(dependency) ?? new Set<Definition>();
|
|
554
|
+
parents.add(definition);
|
|
555
|
+
declarationParents.set(dependency, parents);
|
|
556
|
+
}
|
|
557
|
+
if (
|
|
558
|
+
(matched.length ||
|
|
559
|
+
deps.some((dependency) => reachesUnit.has(dependency))) &&
|
|
560
|
+
!reachesUnit.has(definition)
|
|
561
|
+
) {
|
|
562
|
+
reachesUnit.add(definition);
|
|
563
|
+
propagationQueue.push(definition);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
for (const dep of deps) visit(dep);
|
|
567
|
+
};
|
|
568
|
+
for (const start of starts) {
|
|
569
|
+
let roots = rootCache.get(start);
|
|
570
|
+
if (!roots) {
|
|
571
|
+
const module = getModule(start);
|
|
572
|
+
const limitStart = limits.length;
|
|
573
|
+
roots = {
|
|
574
|
+
definitions: [
|
|
575
|
+
...dependencies(start, module.references),
|
|
576
|
+
...module.definitions,
|
|
577
|
+
],
|
|
578
|
+
limits: limits.slice(limitStart),
|
|
579
|
+
};
|
|
580
|
+
rootCache.set(start, roots);
|
|
581
|
+
} else limits.push(...roots.limits);
|
|
582
|
+
for (const definition of roots.definitions) visit(definition);
|
|
583
|
+
}
|
|
584
|
+
while (propagationOffset < propagationQueue.length) {
|
|
585
|
+
const dependency = propagationQueue[propagationOffset++];
|
|
586
|
+
if (!dependency) continue;
|
|
587
|
+
for (const parent of declarationParents.get(dependency) ?? []) {
|
|
588
|
+
if (reachesUnit.has(parent)) continue;
|
|
589
|
+
reachesUnit.add(parent);
|
|
590
|
+
propagationQueue.push(parent);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const hits = new Map(
|
|
594
|
+
[...reachable].flatMap((definition) => {
|
|
595
|
+
const matched = declarationHits.get(definition);
|
|
596
|
+
return matched ? [[definition, matched] as const] : [];
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
599
|
+
const relevant = new Set(
|
|
600
|
+
[...reachable].filter((definition) => reachesUnit.has(definition)),
|
|
601
|
+
);
|
|
602
|
+
for (const path of paths) limits.push(...(modules.get(path)?.limits ?? []));
|
|
603
|
+
const uniqueLimits = [
|
|
604
|
+
...new Map(
|
|
605
|
+
limits.map((limit) => [
|
|
606
|
+
`${limit.path}:${limit.kind}:${limit.specifier ?? ""}`,
|
|
607
|
+
limit,
|
|
608
|
+
]),
|
|
609
|
+
).values(),
|
|
610
|
+
];
|
|
611
|
+
// Unknown import edges stay conservative; declaration limits affect only
|
|
612
|
+
// branches whose files can reach the diff through known import edges.
|
|
613
|
+
const uncertain =
|
|
614
|
+
closures.some((closure) => closure.limits.length > 0) ||
|
|
615
|
+
uniqueLimits.some((limit) => reachesDiff.has(limit.path));
|
|
616
|
+
const reached = units.filter(
|
|
617
|
+
(unit) =>
|
|
618
|
+
paths.includes(unit.file) ||
|
|
619
|
+
closures.some((closure) => closure.limits.length),
|
|
620
|
+
);
|
|
621
|
+
const intermediates = [...relevant].filter(
|
|
622
|
+
(definition) => definition.file !== entry.path && !hits.has(definition),
|
|
623
|
+
);
|
|
624
|
+
const imports = intermediates
|
|
625
|
+
.filter(
|
|
626
|
+
(definition) => !units.some((unit) => unit.file === definition.file),
|
|
627
|
+
)
|
|
628
|
+
.map((definition) => ({ path: definition.file, text: definition.text }));
|
|
629
|
+
const usedBy = new Map<string, ImportSource[]>();
|
|
630
|
+
for (const unit of reached)
|
|
631
|
+
usedBy.set(
|
|
632
|
+
unit.id,
|
|
633
|
+
intermediates
|
|
634
|
+
.filter((definition) => definition.file === unit.file)
|
|
635
|
+
.map((definition) => ({
|
|
636
|
+
path: definition.file,
|
|
637
|
+
text: definition.text,
|
|
638
|
+
})),
|
|
639
|
+
);
|
|
640
|
+
return {
|
|
641
|
+
paths,
|
|
642
|
+
units: reached,
|
|
643
|
+
imports,
|
|
644
|
+
usedBy,
|
|
645
|
+
limits: uniqueLimits,
|
|
646
|
+
uncertain,
|
|
647
|
+
};
|
|
648
|
+
};
|
|
649
|
+
}
|