balladeer 1.0.16 → 1.0.18

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.
Files changed (58) hide show
  1. package/dist/anchor-budget.d.ts +40 -0
  2. package/dist/anchor-budget.js +110 -0
  3. package/dist/anchoring.d.ts +241 -0
  4. package/dist/anchoring.js +766 -0
  5. package/dist/anchors-client.d.ts +62 -0
  6. package/dist/anchors-client.js +197 -0
  7. package/dist/anchors-schema.d.ts +85 -0
  8. package/dist/anchors-schema.js +246 -0
  9. package/dist/cli.d.ts +1 -1
  10. package/dist/cli.js +24 -7
  11. package/dist/commands/anchor-usage.d.ts +6 -0
  12. package/dist/commands/anchor-usage.js +19 -0
  13. package/dist/commands/anchor.d.ts +127 -0
  14. package/dist/commands/anchor.js +303 -0
  15. package/dist/commands/guidance.d.ts +2 -1
  16. package/dist/commands/guidance.js +55 -0
  17. package/dist/commands/judge.d.ts +219 -12
  18. package/dist/commands/judge.js +911 -103
  19. package/dist/commands/map.d.ts +46 -2
  20. package/dist/commands/map.js +244 -5
  21. package/dist/commands/mcp.js +2 -2
  22. package/dist/guidance-hook.mjs +282 -95
  23. package/dist/guidance.d.ts +7 -0
  24. package/dist/guidance.js +10 -1
  25. package/dist/headless-agent.d.ts +19 -1
  26. package/dist/headless-agent.js +22 -5
  27. package/dist/hook-trust.d.ts +41 -9
  28. package/dist/hook-trust.js +98 -16
  29. package/dist/judge-brief.d.ts +33 -3
  30. package/dist/judge-brief.js +39 -2
  31. package/dist/judge-hook.d.ts +188 -14
  32. package/dist/judge-hook.js +919 -61
  33. package/dist/judge-said.d.ts +52 -0
  34. package/dist/judge-said.js +181 -0
  35. package/dist/promise-meaning.d.ts +25 -0
  36. package/dist/promise-meaning.js +24 -7
  37. package/dist/relay.d.ts +71 -0
  38. package/dist/relay.js +193 -0
  39. package/dist/remove-earlier.js +4 -2
  40. package/dist/risk/git.d.ts +3 -1
  41. package/dist/risk/git.js +3 -3
  42. package/dist/risk/graph-cache.d.ts +83 -0
  43. package/dist/risk/graph-cache.js +291 -0
  44. package/dist/risk/import-graph.d.ts +43 -0
  45. package/dist/risk/import-graph.js +88 -34
  46. package/dist/risk/index.d.ts +1 -1
  47. package/dist/risk/index.js +1 -1
  48. package/dist/risk/pipeline.d.ts +13 -0
  49. package/dist/risk/pipeline.js +15 -4
  50. package/dist/risk/score.d.ts +10 -0
  51. package/dist/risk/score.js +11 -2
  52. package/dist/scratch-worktree.d.ts +5 -1
  53. package/dist/scratch-worktree.js +9 -2
  54. package/dist/user-scope.d.ts +74 -3
  55. package/dist/user-scope.js +270 -9
  56. package/dist/wire.d.ts +19 -3
  57. package/dist/wire.js +2 -2
  58. package/package.json +7 -2
@@ -0,0 +1,83 @@
1
+ import type { SymbolKind } from "./contract.js";
2
+ import { type ImportGraph } from "./import-graph.js";
3
+ /**
4
+ * The repository graph of one commit, built from a cache on this laptop.
5
+ *
6
+ * What a file imports and which names it declares depend only on its own
7
+ * content, so they are kept per blob, and a commit parses only the blobs this
8
+ * laptop has not read before. How those imports resolve depends on the whole
9
+ * tree, so that is worked out again on every build, from the cached
10
+ * specifiers; it is cheap. The cache sits in Balladeer's config home, one file
11
+ * per repository (all worktrees of a repository share it), and holds file
12
+ * paths' blob ids, import specifiers and declared names. It is never committed
13
+ * and never sent anywhere.
14
+ */
15
+ /** Bumped whenever what is read out of a blob changes, so old entries are read again. */
16
+ export declare const GRAPH_CACHE_VERSION = 1;
17
+ export declare const GRAPH_CACHE_DIRECTORY = "graph-cache";
18
+ export type ExportedName = Readonly<{
19
+ name: string;
20
+ kind: SymbolKind;
21
+ }>;
22
+ type BlobSymbols = {
23
+ /** Which reader found them: `typescript@<version>` or `regex`. */
24
+ reader: string;
25
+ /** Top-level names the file exports, in the order it declares them. */
26
+ exported: ExportedName[];
27
+ /** Every name the file declares or exports, which is what an anchor's symbol is checked against. */
28
+ names: string[];
29
+ };
30
+ export type GraphBuildStats = Readonly<{
31
+ /** Source files in the graph. */
32
+ files: number;
33
+ /** Blobs read and parsed in this build. */
34
+ parsed: number;
35
+ /** Blobs whose reading came from the cache. */
36
+ reused: number;
37
+ ms: number;
38
+ }>;
39
+ export type RepositoryGraph = Readonly<{
40
+ root: string;
41
+ /** The commit, as a full sha. */
42
+ sha: string;
43
+ graph: ImportGraph;
44
+ /** Every path in the commit's tree, of any kind. */
45
+ tree: ReadonlySet<string>;
46
+ /** The names a source file exports, read once per blob. Undefined for a file not in the graph. */
47
+ exportedNames(file: string): readonly ExportedName[] | undefined;
48
+ /** Every name a source file declares or exports. Undefined for a file not in the graph. */
49
+ declaredNames(file: string): ReadonlySet<string> | undefined;
50
+ /** Read the names of many files at once, so a whole index costs one git read. */
51
+ prepareNames(files: readonly string[]): void;
52
+ /** Keep what this build read for the next one. Never throws. */
53
+ save(): void;
54
+ stats(): GraphBuildStats;
55
+ }>;
56
+ /**
57
+ * Names a script file makes available beyond its declarations: `default` for
58
+ * `export default`, each name or alias in `export { a, b as c }`, a namespace
59
+ * re-export's name, and the names bound by `export const { a, b } = ...` or
60
+ * `export const [a, b] = ...`.
61
+ */
62
+ export declare function exportClauseNames(text: string): string[];
63
+ /**
64
+ * Whether a graph file belongs in the anchoring index: product source, so not
65
+ * a test, and (from `anchor-brief/v3`) not a story or a mock.
66
+ */
67
+ export declare function isIndexSource(file: string): boolean;
68
+ /** The names a file declares and exports, read the way the anchoring index reads them. */
69
+ export declare function readSymbols(file: string, text: string, root: string): Omit<BlobSymbols, "reader">;
70
+ /** The cache file of the repository a checkout belongs to, shared by all its worktrees. */
71
+ export declare function graphCachePath(root: string, environment: NodeJS.ProcessEnv): string;
72
+ /**
73
+ * The graph of `rev`, from this laptop's cache where it can be. With
74
+ * `cache: false` nothing is read from or written to disk, which is the plain
75
+ * build every time.
76
+ */
77
+ export declare function buildRepositoryGraph(input: Readonly<{
78
+ root: string;
79
+ rev: string;
80
+ environment?: NodeJS.ProcessEnv;
81
+ cache?: boolean;
82
+ }>): RepositoryGraph;
83
+ export {};
@@ -0,0 +1,291 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, constants, } from "node:fs";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { performance } from "node:perf_hooks";
5
+ import { configHome } from "../store.js";
6
+ import { readBlobs, resolveCommit, tryGit } from "./git.js";
7
+ import { buildImportGraphFromParsed, isGraphSource, parseImports, } from "./import-graph.js";
8
+ import { extensionOf, isScriptPath, isTestPath } from "./paths.js";
9
+ import { graphTreeEntries } from "./pipeline.js";
10
+ import { declaredSymbols, loadTypeScript } from "./symbols.js";
11
+ /**
12
+ * The repository graph of one commit, built from a cache on this laptop.
13
+ *
14
+ * What a file imports and which names it declares depend only on its own
15
+ * content, so they are kept per blob, and a commit parses only the blobs this
16
+ * laptop has not read before. How those imports resolve depends on the whole
17
+ * tree, so that is worked out again on every build, from the cached
18
+ * specifiers; it is cheap. The cache sits in Balladeer's config home, one file
19
+ * per repository (all worktrees of a repository share it), and holds file
20
+ * paths' blob ids, import specifiers and declared names. It is never committed
21
+ * and never sent anywhere.
22
+ */
23
+ /** Bumped whenever what is read out of a blob changes, so old entries are read again. */
24
+ export const GRAPH_CACHE_VERSION = 1;
25
+ export const GRAPH_CACHE_DIRECTORY = "graph-cache";
26
+ const MAX_CACHE_BYTES = 256 * 1024 * 1024;
27
+ /**
28
+ * Names a script file makes available beyond its declarations: `default` for
29
+ * `export default`, each name or alias in `export { a, b as c }`, a namespace
30
+ * re-export's name, and the names bound by `export const { a, b } = ...` or
31
+ * `export const [a, b] = ...`.
32
+ */
33
+ export function exportClauseNames(text) {
34
+ const found = new Set();
35
+ const source = text.replace(/\/\*[\s\S]*?\*\//g, "");
36
+ if (/(?:^|[\s;])export\s+default\b/.test(source))
37
+ found.add("default");
38
+ for (const match of source.matchAll(/\bexport\s+(?:type\s+)?\{([^}]*)\}/g)) {
39
+ for (const part of (match[1] ?? "").split(",")) {
40
+ const words = part
41
+ .trim()
42
+ .replace(/^type\s+/, "")
43
+ .split(/\s+as\s+/);
44
+ const name = (words[1] ?? words[0] ?? "").trim();
45
+ if (/^[A-Za-z_$][\w$]*$/.test(name))
46
+ found.add(name);
47
+ }
48
+ }
49
+ for (const match of source.matchAll(/\bexport\s+\*\s+as\s+([A-Za-z_$][\w$]*)/g))
50
+ if (match[1] !== undefined)
51
+ found.add(match[1]);
52
+ for (const match of source.matchAll(/\bexport\s+(?:const|let|var)\s+\{([^}]*)\}\s*=/g)) {
53
+ for (const part of (match[1] ?? "").split(",")) {
54
+ const name = (part.split(":")[1] ?? part.split(":")[0] ?? "").split("=")[0]?.trim() ?? "";
55
+ if (/^[A-Za-z_$][\w$]*$/.test(name))
56
+ found.add(name);
57
+ }
58
+ }
59
+ for (const match of source.matchAll(/\bexport\s+(?:const|let|var)\s+\[([^\]]*)\]\s*=/g)) {
60
+ for (const part of (match[1] ?? "").split(",")) {
61
+ const name = part
62
+ .split("=")[0]
63
+ ?.replace(/^\.\.\./, "")
64
+ .trim() ?? "";
65
+ if (/^[A-Za-z_$][\w$]*$/.test(name))
66
+ found.add(name);
67
+ }
68
+ }
69
+ return [...found];
70
+ }
71
+ /**
72
+ * Whether a graph file belongs in the anchoring index: product source, so not
73
+ * a test, and (from `anchor-brief/v3`) not a story or a mock.
74
+ */
75
+ export function isIndexSource(file) {
76
+ return (!isTestPath(file) &&
77
+ /\.(?:[cm]?[jt]sx?|py)$/.test(file) &&
78
+ !/\.stories\.|(?:^|\/)(?:__mocks__|mocks?|\.storybook)\//.test(file));
79
+ }
80
+ /** Which symbol reader a repository gets: its own TypeScript compiler, or the patterns. */
81
+ function symbolReader(root) {
82
+ const typescript = loadTypeScript(root);
83
+ return typescript === undefined ? "regex" : `typescript@${typescript.version}`;
84
+ }
85
+ /** The names a file declares and exports, read the way the anchoring index reads them. */
86
+ export function readSymbols(file, text, root) {
87
+ const lines = text.split("\n");
88
+ const exported = [];
89
+ const names = new Set();
90
+ for (const symbol of declaredSymbols(file, text, root).symbols) {
91
+ names.add(symbol.name);
92
+ const line = lines[symbol.startLine - 1] ?? "";
93
+ if (!/^\s*export\b/.test(line) && !/\.py$/.test(file))
94
+ continue;
95
+ if (exported.some((entry) => entry.name === symbol.name))
96
+ continue;
97
+ exported.push({ name: symbol.name, kind: symbol.kind });
98
+ }
99
+ if (isScriptPath(file))
100
+ for (const name of exportClauseNames(text))
101
+ names.add(name);
102
+ return { exported, names: [...names] };
103
+ }
104
+ function sha256(text) {
105
+ return createHash("sha256").update(text).digest("hex");
106
+ }
107
+ /** The cache file of the repository a checkout belongs to, shared by all its worktrees. */
108
+ export function graphCachePath(root, environment) {
109
+ let common = tryGit(root, ["rev-parse", "--git-common-dir"])?.trim() ?? "";
110
+ if (common === "")
111
+ common = join(root, ".git");
112
+ if (!isAbsolute(common))
113
+ common = resolve(root, common);
114
+ try {
115
+ common = realpathSync(common);
116
+ }
117
+ catch {
118
+ /* A path that cannot be resolved is keyed as written. */
119
+ }
120
+ return join(configHome(environment), GRAPH_CACHE_DIRECTORY, `${sha256(common).slice(0, 32)}.json`);
121
+ }
122
+ function readCache(path) {
123
+ let fd;
124
+ try {
125
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
126
+ const stat = fstatSync(fd);
127
+ if (!stat.isFile() || stat.size > MAX_CACHE_BYTES)
128
+ return new Map();
129
+ const parsed = JSON.parse(readFileSync(fd, "utf8"));
130
+ if (parsed?.version !== GRAPH_CACHE_VERSION || parsed.blobs === null)
131
+ return new Map();
132
+ if (typeof parsed.blobs !== "object")
133
+ return new Map();
134
+ return new Map(Object.entries(parsed.blobs));
135
+ }
136
+ catch {
137
+ return new Map();
138
+ }
139
+ finally {
140
+ if (fd !== undefined)
141
+ closeSync(fd);
142
+ }
143
+ }
144
+ function writeCache(path, blobs) {
145
+ const directory = join(path, "..");
146
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
147
+ const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
148
+ const document = {
149
+ version: GRAPH_CACHE_VERSION,
150
+ blobs: Object.fromEntries(blobs),
151
+ };
152
+ try {
153
+ writeFileSync(temporary, JSON.stringify(document), { flag: "wx", mode: 0o600 });
154
+ renameSync(temporary, path);
155
+ }
156
+ finally {
157
+ try {
158
+ unlinkSync(temporary);
159
+ }
160
+ catch {
161
+ /* Renamed, or never written. */
162
+ }
163
+ }
164
+ }
165
+ /**
166
+ * The graph of `rev`, from this laptop's cache where it can be. With
167
+ * `cache: false` nothing is read from or written to disk, which is the plain
168
+ * build every time.
169
+ */
170
+ export function buildRepositoryGraph(input) {
171
+ const started = performance.now();
172
+ const { root } = input;
173
+ const environment = input.environment ?? process.env;
174
+ const sha = resolveCommit(root, input.rev);
175
+ const { entries, all } = graphTreeEntries(root, sha);
176
+ const useCache = input.cache !== false;
177
+ const path = useCache ? graphCachePath(root, environment) : undefined;
178
+ const cache = path === undefined ? new Map() : readCache(path);
179
+ const reader = symbolReader(root);
180
+ const byPath = new Map(entries.map((entry) => [entry.path, entry]));
181
+ const keyOf = (entry) => `${entry.sha}.${extensionOf(entry.path)}`;
182
+ const touched = new Set();
183
+ let parsed = 0;
184
+ let reused = 0;
185
+ const sources = entries.filter((entry) => isGraphSource(entry.path));
186
+ const missing = sources.filter((entry) => !cache.has(keyOf(entry)));
187
+ const manifests = entries
188
+ .filter((entry) => !isGraphSource(entry.path))
189
+ .map((entry) => entry.path);
190
+ const texts = readBlobs(root, sha, [...manifests, ...missing.map((entry) => entry.path)]);
191
+ for (const entry of sources) {
192
+ const key = keyOf(entry);
193
+ touched.add(key);
194
+ if (cache.has(key)) {
195
+ reused += 1;
196
+ continue;
197
+ }
198
+ parsed += 1;
199
+ const text = texts.get(entry.path);
200
+ const blob = {
201
+ imports: text === undefined ? null : (parseImports(entry.path, text) ?? null),
202
+ };
203
+ // The index reads every product source file's names, so they are read now, in this
204
+ // same pass over the blob; a test's names are read only if something asks.
205
+ if (text !== undefined && isIndexSource(entry.path))
206
+ blob.symbols = { reader, ...readSymbols(entry.path, text, root) };
207
+ cache.set(key, blob);
208
+ }
209
+ const graph = buildImportGraphFromParsed(entries.map((entry) => entry.path), (file) => texts.get(file), (file) => {
210
+ const entry = byPath.get(file);
211
+ return entry === undefined ? undefined : (cache.get(keyOf(entry))?.imports ?? undefined);
212
+ });
213
+ const graphFiles = new Set(graph.files);
214
+ const prepareNames = (files) => {
215
+ const stale = files
216
+ .map((file) => byPath.get(file))
217
+ .filter((entry) => entry !== undefined && graphFiles.has(entry.path))
218
+ .filter((entry) => cache.get(keyOf(entry))?.symbols?.reader !== reader);
219
+ if (stale.length === 0)
220
+ return;
221
+ const read = readBlobs(root, sha, stale.map((entry) => entry.path));
222
+ for (const entry of stale) {
223
+ const blob = cache.get(keyOf(entry)) ?? { imports: null };
224
+ const text = read.get(entry.path);
225
+ blob.symbols =
226
+ text === undefined
227
+ ? { reader, exported: [], names: [] }
228
+ : { reader, ...readSymbols(entry.path, text, root) };
229
+ cache.set(keyOf(entry), blob);
230
+ parsed += 1;
231
+ }
232
+ };
233
+ const symbolsOf = (file) => {
234
+ const entry = byPath.get(file);
235
+ if (entry === undefined || !graphFiles.has(file))
236
+ return undefined;
237
+ prepareNames([file]);
238
+ return cache.get(keyOf(entry))?.symbols;
239
+ };
240
+ const namesMemo = new Map();
241
+ return {
242
+ root,
243
+ sha,
244
+ graph,
245
+ tree: new Set(all.map((entry) => entry.path)),
246
+ exportedNames: (file) => symbolsOf(file)?.exported,
247
+ declaredNames: (file) => {
248
+ const memo = namesMemo.get(file);
249
+ if (memo !== undefined)
250
+ return memo;
251
+ const names = symbolsOf(file)?.names;
252
+ if (names === undefined)
253
+ return undefined;
254
+ const set = new Set(names);
255
+ namesMemo.set(file, set);
256
+ return set;
257
+ },
258
+ prepareNames,
259
+ save: () => {
260
+ if (path === undefined)
261
+ return;
262
+ try {
263
+ // What this commit used first, then what earlier builds kept, up to twice as much
264
+ // again, so moving between branches stays warm without the file growing forever.
265
+ const kept = new Map();
266
+ for (const key of touched) {
267
+ const blob = cache.get(key);
268
+ if (blob !== undefined)
269
+ kept.set(key, blob);
270
+ }
271
+ const limit = Math.max(kept.size * 3, 5000);
272
+ for (const [key, blob] of cache) {
273
+ if (kept.size >= limit)
274
+ break;
275
+ if (!kept.has(key))
276
+ kept.set(key, blob);
277
+ }
278
+ writeCache(path, kept);
279
+ }
280
+ catch {
281
+ /* The cache is a convenience; a build without it is only slower. */
282
+ }
283
+ },
284
+ stats: () => ({
285
+ files: graph.files.length,
286
+ parsed,
287
+ reused,
288
+ ms: Math.round(performance.now() - started),
289
+ }),
290
+ };
291
+ }
@@ -25,6 +25,42 @@ export type FileReader = (path: string) => string | undefined;
25
25
  export declare function workingTreeReader(root: string): FileReader;
26
26
  /** JSON with comments and trailing commas, the way tsconfig files are written. */
27
27
  export declare function parseLooseJson(text: string): unknown;
28
+ /**
29
+ * One Python import statement as written, before it is resolved against the
30
+ * repository. What a file says it imports depends only on its own text, so it
31
+ * can be read once per blob and kept; what that resolves to depends on the
32
+ * whole tree and is worked out on every build.
33
+ */
34
+ export type PythonImport = Readonly<{
35
+ kind: "relative";
36
+ dots: number;
37
+ module: string;
38
+ names: readonly string[];
39
+ }> | Readonly<{
40
+ kind: "from";
41
+ module: string;
42
+ names: readonly string[];
43
+ }> | Readonly<{
44
+ kind: "import";
45
+ modules: readonly string[];
46
+ }>;
47
+ /** What one file imports, as written: script specifiers, or Python statements. */
48
+ export type ParsedImports = Readonly<{
49
+ script: readonly string[];
50
+ }> | Readonly<{
51
+ python: readonly PythonImport[];
52
+ }>;
53
+ /** The Python import statements in a file, in order, as written. */
54
+ export declare function parsePythonImports(source: string): PythonImport[];
55
+ /** The largest file whose imports are read; a bigger one is in the graph with no edges out. */
56
+ export declare const MAX_IMPORT_SOURCE_CHARACTERS = 2000000;
57
+ /** Whether the graph scans a path at all: TypeScript, JavaScript and Python, not `.d.ts`. */
58
+ export declare function isGraphSource(path: string): boolean;
59
+ /**
60
+ * What one file imports, as written, or nothing when the file is not one the
61
+ * graph reads or is too large to read.
62
+ */
63
+ export declare function parseImports(path: string, source: string): ParsedImports | undefined;
28
64
  /**
29
65
  * Builds the import graph over `files` (repository-relative paths). Only
30
66
  * TypeScript, JavaScript and Python files are scanned; `package.json` and
@@ -32,6 +68,13 @@ export declare function parseLooseJson(text: string): unknown;
32
68
  * `read` defaults to the working tree under `root`.
33
69
  */
34
70
  export declare function buildImportGraph(root: string, files: readonly string[], read?: FileReader): ImportGraph;
71
+ /**
72
+ * The same graph, from each file's imports as written. `read` is asked only
73
+ * for the `package.json` and `tsconfig` files that say how specifiers resolve;
74
+ * `parsed` answers for every source file, which is what lets a laptop keep
75
+ * what it read per blob and parse only the files a commit changed.
76
+ */
77
+ export declare function buildImportGraphFromParsed(files: readonly string[], read: FileReader, parsed: (file: string) => ParsedImports | undefined): ImportGraph;
35
78
  /**
36
79
  * Everything the starting files depend on: follow imports outward. A file at
37
80
  * distance 2 is imported by something the starting files import.
@@ -335,7 +335,45 @@ function importedNames(list) {
335
335
  ?.trim() ?? "")
336
336
  .filter((name) => /^\w+$/.test(name));
337
337
  }
338
- function pythonImports(workspace, from, source) {
338
+ /** The Python import statements in a file, in order, as written. */
339
+ export function parsePythonImports(source) {
340
+ const found = [];
341
+ // Parenthesized name lists joined, so `from x import (\n a,\n b)` reads as one line.
342
+ const text = source.replace(/\(([^()]*)\)/g, (whole, inner) => inner.includes("\n") ? inner.replace(/\n/g, " ") : whole);
343
+ for (const line of text.split("\n")) {
344
+ const relative = /^\s*from\s+(\.+)([\w.]*)\s+import\s+(.+)$/.exec(line);
345
+ if (relative) {
346
+ found.push({
347
+ kind: "relative",
348
+ dots: (relative[1] ?? ".").length,
349
+ module: relative[2] ?? "",
350
+ names: importedNames(relative[3] ?? ""),
351
+ });
352
+ continue;
353
+ }
354
+ const absoluteFrom = /^\s*from\s+([A-Za-z_][\w.]*)\s+import\s+(.+)$/.exec(line);
355
+ if (absoluteFrom) {
356
+ found.push({
357
+ kind: "from",
358
+ module: absoluteFrom[1] ?? "",
359
+ names: importedNames(absoluteFrom[2] ?? ""),
360
+ });
361
+ continue;
362
+ }
363
+ const absolute = /^\s*import\s+([A-Za-z_][\w.]*(?:\s*,\s*[A-Za-z_][\w.]*)*)/.exec(line);
364
+ if (absolute) {
365
+ found.push({
366
+ kind: "import",
367
+ modules: (absolute[1] ?? "").split(",").map((raw) => raw
368
+ .trim()
369
+ .split(/\s+as\s+/)[0]
370
+ ?.trim() ?? ""),
371
+ });
372
+ }
373
+ }
374
+ return found;
375
+ }
376
+ function resolvePythonImports(workspace, from, statements) {
339
377
  const found = new Set();
340
378
  const add = (file) => {
341
379
  if (file !== undefined && file !== from)
@@ -357,43 +395,30 @@ function pythonImports(workspace, from, source) {
357
395
  }
358
396
  return undefined;
359
397
  };
360
- // Parenthesized name lists joined, so `from x import (\n a,\n b)` reads as one line.
361
- const text = source.replace(/\(([^()]*)\)/g, (whole, inner) => inner.includes("\n") ? inner.replace(/\n/g, " ") : whole);
362
- for (const line of text.split("\n")) {
363
- const relative = /^\s*from\s+(\.+)([\w.]*)\s+import\s+(.+)$/.exec(line);
364
- if (relative) {
365
- const directory = relativeDirectory((relative[1] ?? ".").length);
398
+ for (const statement of statements) {
399
+ if (statement.kind === "relative") {
400
+ const directory = relativeDirectory(statement.dots);
366
401
  if (directory === undefined)
367
402
  continue;
368
- const dotted = relative[2] ?? "";
369
- const names = importedNames(relative[3] ?? "");
403
+ const dotted = statement.module;
370
404
  if (dotted.length > 0) {
371
405
  add(moduleFile(directory, dotted));
372
- for (const name of names)
406
+ for (const name of statement.names)
373
407
  add(moduleFile(directory, `${dotted}.${name}`));
374
408
  }
375
409
  else {
376
410
  // `from . import x`: x is a module beside this file, or a name its package defines.
377
- for (const name of names)
411
+ for (const name of statement.names)
378
412
  add(moduleFile(directory, name) ?? moduleFile(directory, "__init__"));
379
413
  }
380
- continue;
381
414
  }
382
- const absoluteFrom = /^\s*from\s+([A-Za-z_][\w.]*)\s+import\s+(.+)$/.exec(line);
383
- if (absoluteFrom) {
384
- const dotted = absoluteFrom[1] ?? "";
385
- for (const name of importedNames(absoluteFrom[2] ?? ""))
386
- add(resolvePythonModule(workspace, `${dotted}.${name}`));
387
- add(resolvePythonModule(workspace, dotted));
388
- continue;
415
+ else if (statement.kind === "from") {
416
+ for (const name of statement.names)
417
+ add(resolvePythonModule(workspace, `${statement.module}.${name}`));
418
+ add(resolvePythonModule(workspace, statement.module));
389
419
  }
390
- const absolute = /^\s*import\s+([A-Za-z_][\w.]*(?:\s*,\s*[A-Za-z_][\w.]*)*)/.exec(line);
391
- if (absolute) {
392
- for (const raw of (absolute[1] ?? "").split(",")) {
393
- const dotted = raw
394
- .trim()
395
- .split(/\s+as\s+/)[0]
396
- ?.trim() ?? "";
420
+ else {
421
+ for (const dotted of statement.modules) {
397
422
  // `import a.b.c` binds a, a.b and a.b.c; the deepest file is the one used.
398
423
  const segments = dotted.split(".");
399
424
  for (let length = segments.length; length > 0; length -= 1) {
@@ -408,6 +433,25 @@ function pythonImports(workspace, from, source) {
408
433
  }
409
434
  return [...found];
410
435
  }
436
+ /** The largest file whose imports are read; a bigger one is in the graph with no edges out. */
437
+ export const MAX_IMPORT_SOURCE_CHARACTERS = 2_000_000;
438
+ /** Whether the graph scans a path at all: TypeScript, JavaScript and Python, not `.d.ts`. */
439
+ export function isGraphSource(path) {
440
+ return isScript(path) || isPython(path);
441
+ }
442
+ /**
443
+ * What one file imports, as written, or nothing when the file is not one the
444
+ * graph reads or is too large to read.
445
+ */
446
+ export function parseImports(path, source) {
447
+ if (source.length > MAX_IMPORT_SOURCE_CHARACTERS)
448
+ return undefined;
449
+ if (isScript(path))
450
+ return { script: scriptSpecifiers(source) };
451
+ if (isPython(path))
452
+ return { python: parsePythonImports(source) };
453
+ return undefined;
454
+ }
411
455
  /**
412
456
  * Builds the import graph over `files` (repository-relative paths). Only
413
457
  * TypeScript, JavaScript and Python files are scanned; `package.json` and
@@ -415,30 +459,40 @@ function pythonImports(workspace, from, source) {
415
459
  * `read` defaults to the working tree under `root`.
416
460
  */
417
461
  export function buildImportGraph(root, files, read = workingTreeReader(root)) {
462
+ return buildImportGraphFromParsed(files, read, (file) => {
463
+ const source = read(file);
464
+ return source === undefined ? undefined : parseImports(file, source);
465
+ });
466
+ }
467
+ /**
468
+ * The same graph, from each file's imports as written. `read` is asked only
469
+ * for the `package.json` and `tsconfig` files that say how specifiers resolve;
470
+ * `parsed` answers for every source file, which is what lets a laptop keep
471
+ * what it read per blob and parse only the files a commit changed.
472
+ */
473
+ export function buildImportGraphFromParsed(files, read, parsed) {
418
474
  const normalized = files.map((file) => file.replace(/\\/g, "/").replace(/^\.\//, ""));
419
475
  const workspace = buildWorkspace(normalized, read);
420
476
  const imports = {};
421
477
  const importedBy = {};
422
478
  const scanned = [];
423
479
  for (const file of normalized) {
424
- const script = isScript(file);
425
- const python = isPython(file);
426
- if (!script && !python)
480
+ if (!isGraphSource(file))
427
481
  continue;
428
482
  scanned.push(file);
429
- const source = read(file);
430
- if (source === undefined || source.length > 2_000_000)
483
+ const written = parsed(file);
484
+ if (written === undefined)
431
485
  continue;
432
486
  const targets = new Set();
433
- if (script) {
434
- for (const specifier of scriptSpecifiers(source)) {
487
+ if ("script" in written) {
488
+ for (const specifier of written.script) {
435
489
  const target = resolveScript(workspace, file, specifier);
436
490
  if (target !== undefined && target !== file)
437
491
  targets.add(target);
438
492
  }
439
493
  }
440
494
  else {
441
- for (const target of pythonImports(workspace, file, source))
495
+ for (const target of resolvePythonImports(workspace, file, written.python))
442
496
  targets.add(target);
443
497
  }
444
498
  if (targets.size === 0)
@@ -14,7 +14,7 @@ export { isTestPath, languageOf } from "./paths.js";
14
14
  export { assessChange, buildGraphAt, readBehaviorMaps, watchedResources, type AssessOptions, type Assessment, type MapProblem, } from "./pipeline.js";
15
15
  export { churnPriors, priorBreaksFromJudgeLog, readPriors } from "./priors.js";
16
16
  export { mentionsResource, normalizeRoute, parseResourceRef, resourceKey, resourcesInLine, resourcesMatch, routeFromPath, type ResourceRef, } from "./resources.js";
17
- export { BAND_THRESHOLDS, bandFor, byRisk, directness, forwardDecay, mapSeeds, reverseDecay, scoreChange, seededShuffle, WEIGHTS, type Prior, type ScoreOptions, type Weights, } from "./score.js";
17
+ export { BAND_THRESHOLDS, bandFor, byRisk, directness, forwardDecay, mapSeeds, reverseDecay, riskOrder, scoreChange, seededShuffle, WEIGHTS, type Prior, type RiskOrderKey, type ScoreOptions, type Weights, } from "./score.js";
18
18
  export { declaredSymbols, loadTypeScript, type DeclaredSymbol } from "./symbols.js";
19
19
  export { buildIdf, cosine, identifierTokens, STOP_WORDS, words } from "./text.js";
20
20
  export { parseChangeExtraction, parseRiskReport, type ParseResult } from "./validate.js";
@@ -14,7 +14,7 @@ export { isTestPath, languageOf } from "./paths.js";
14
14
  export { assessChange, buildGraphAt, readBehaviorMaps, watchedResources, } from "./pipeline.js";
15
15
  export { churnPriors, priorBreaksFromJudgeLog, readPriors } from "./priors.js";
16
16
  export { mentionsResource, normalizeRoute, parseResourceRef, resourceKey, resourcesInLine, resourcesMatch, routeFromPath, } from "./resources.js";
17
- export { BAND_THRESHOLDS, bandFor, byRisk, directness, forwardDecay, mapSeeds, reverseDecay, scoreChange, seededShuffle, WEIGHTS, } from "./score.js";
17
+ export { BAND_THRESHOLDS, bandFor, byRisk, directness, forwardDecay, mapSeeds, reverseDecay, riskOrder, scoreChange, seededShuffle, WEIGHTS, } from "./score.js";
18
18
  export { declaredSymbols, loadTypeScript } from "./symbols.js";
19
19
  export { buildIdf, cosine, identifierTokens, STOP_WORDS, words } from "./text.js";
20
20
  export { parseChangeExtraction, parseRiskReport } from "./validate.js";
@@ -1,3 +1,4 @@
1
+ import { type TreeEntry } from "./git.js";
1
2
  import { type ImportGraph } from "./import-graph.js";
2
3
  import { type ResourceRef } from "./resources.js";
3
4
  import type { BehaviorMap, ChangeExtraction, Delta, Intent, RiskReport } from "./contract.js";
@@ -16,6 +17,16 @@ export declare function readBehaviorMaps(directory: string): {
16
17
  };
17
18
  /** Every resource the maps name, including sink resources, for the extraction to look for directly. */
18
19
  export declare function watchedResources(maps: readonly BehaviorMap[]): ResourceRef[];
20
+ /**
21
+ * The blobs of a commit's tree the import graph reads: source files and the
22
+ * manifests that say how their imports resolve, without vendored or built
23
+ * folders and without anything over a megabyte. `all` is the whole listing,
24
+ * for a caller that also needs to know what else the tree holds.
25
+ */
26
+ export declare function graphTreeEntries(root: string, rev: string): {
27
+ entries: TreeEntry[];
28
+ all: TreeEntry[];
29
+ };
19
30
  /** The import graph of the tree at a commit, read from git rather than the working tree. */
20
31
  export declare function buildGraphAt(root: string, rev: string): ImportGraph;
21
32
  export type AssessOptions = Readonly<{
@@ -32,6 +43,8 @@ export type AssessOptions = Readonly<{
32
43
  deltas?: readonly Delta[];
33
44
  /** The change's intent from its description or a model; else it is read from the commits. */
34
45
  intent?: Intent;
46
+ /** The import graph at head when the caller already built it, so it is not built twice. */
47
+ graph?: ImportGraph;
35
48
  }>;
36
49
  export type Assessment = Readonly<{
37
50
  report: RiskReport;