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.
- package/dist/anchor-budget.d.ts +40 -0
- package/dist/anchor-budget.js +110 -0
- package/dist/anchoring.d.ts +241 -0
- package/dist/anchoring.js +766 -0
- package/dist/anchors-client.d.ts +62 -0
- package/dist/anchors-client.js +197 -0
- package/dist/anchors-schema.d.ts +85 -0
- package/dist/anchors-schema.js +246 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +24 -7
- package/dist/commands/anchor-usage.d.ts +6 -0
- package/dist/commands/anchor-usage.js +19 -0
- package/dist/commands/anchor.d.ts +127 -0
- package/dist/commands/anchor.js +303 -0
- package/dist/commands/guidance.d.ts +2 -1
- package/dist/commands/guidance.js +55 -0
- package/dist/commands/judge.d.ts +219 -12
- package/dist/commands/judge.js +911 -103
- package/dist/commands/map.d.ts +46 -2
- package/dist/commands/map.js +244 -5
- package/dist/commands/mcp.js +2 -2
- package/dist/guidance-hook.mjs +282 -95
- package/dist/guidance.d.ts +7 -0
- package/dist/guidance.js +10 -1
- package/dist/headless-agent.d.ts +19 -1
- package/dist/headless-agent.js +22 -5
- package/dist/hook-trust.d.ts +41 -9
- package/dist/hook-trust.js +98 -16
- package/dist/judge-brief.d.ts +33 -3
- package/dist/judge-brief.js +39 -2
- package/dist/judge-hook.d.ts +188 -14
- package/dist/judge-hook.js +919 -61
- package/dist/judge-said.d.ts +52 -0
- package/dist/judge-said.js +181 -0
- package/dist/promise-meaning.d.ts +25 -0
- package/dist/promise-meaning.js +24 -7
- package/dist/relay.d.ts +71 -0
- package/dist/relay.js +193 -0
- package/dist/remove-earlier.js +4 -2
- package/dist/risk/git.d.ts +3 -1
- package/dist/risk/git.js +3 -3
- package/dist/risk/graph-cache.d.ts +83 -0
- package/dist/risk/graph-cache.js +291 -0
- package/dist/risk/import-graph.d.ts +43 -0
- package/dist/risk/import-graph.js +88 -34
- package/dist/risk/index.d.ts +1 -1
- package/dist/risk/index.js +1 -1
- package/dist/risk/pipeline.d.ts +13 -0
- package/dist/risk/pipeline.js +15 -4
- package/dist/risk/score.d.ts +10 -0
- package/dist/risk/score.js +11 -2
- package/dist/scratch-worktree.d.ts +5 -1
- package/dist/scratch-worktree.js +9 -2
- package/dist/user-scope.d.ts +74 -3
- package/dist/user-scope.js +270 -9
- package/dist/wire.d.ts +19 -3
- package/dist/wire.js +2 -2
- 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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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 =
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
425
|
-
const python = isPython(file);
|
|
426
|
-
if (!script && !python)
|
|
480
|
+
if (!isGraphSource(file))
|
|
427
481
|
continue;
|
|
428
482
|
scanned.push(file);
|
|
429
|
-
const
|
|
430
|
-
if (
|
|
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
|
|
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
|
|
495
|
+
for (const target of resolvePythonImports(workspace, file, written.python))
|
|
442
496
|
targets.add(target);
|
|
443
497
|
}
|
|
444
498
|
if (targets.size === 0)
|
package/dist/risk/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/risk/index.js
CHANGED
|
@@ -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";
|
package/dist/risk/pipeline.d.ts
CHANGED
|
@@ -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;
|