balladeer 1.0.17 → 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.js +5 -1
- 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/judge.d.ts +69 -21
- package/dist/commands/judge.js +354 -89
- package/dist/commands/map.js +2 -0
- package/dist/guidance-hook.mjs +5 -2
- 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/judge-brief.d.ts +20 -2
- package/dist/judge-brief.js +26 -2
- package/dist/promise-meaning.d.ts +17 -0
- package/dist/promise-meaning.js +15 -6
- 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/pipeline.d.ts +13 -0
- package/dist/risk/pipeline.js +15 -4
- package/dist/wire.d.ts +18 -2
- package/dist/wire.js +1 -1
- package/package.json +7 -2
|
@@ -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/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;
|
package/dist/risk/pipeline.js
CHANGED
|
@@ -65,9 +65,15 @@ export function watchedResources(maps) {
|
|
|
65
65
|
}
|
|
66
66
|
const GRAPH_EXTENSIONS = new Set(["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "py"]);
|
|
67
67
|
const MAX_GRAPH_FILE_BYTES = 1_000_000;
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The blobs of a commit's tree the import graph reads: source files and the
|
|
70
|
+
* manifests that say how their imports resolve, without vendored or built
|
|
71
|
+
* folders and without anything over a megabyte. `all` is the whole listing,
|
|
72
|
+
* for a caller that also needs to know what else the tree holds.
|
|
73
|
+
*/
|
|
74
|
+
export function graphTreeEntries(root, rev) {
|
|
75
|
+
const all = listTree(root, rev);
|
|
76
|
+
const entries = all.filter((entry) => {
|
|
71
77
|
if (entry.size > MAX_GRAPH_FILE_BYTES)
|
|
72
78
|
return false;
|
|
73
79
|
if (/(?:^|\/)(?:node_modules|vendor|dist|build|\.next|coverage)\//.test(entry.path))
|
|
@@ -79,6 +85,11 @@ export function buildGraphAt(root, rev) {
|
|
|
79
85
|
base === "jsconfig.json" ||
|
|
80
86
|
/^tsconfig\..*json$/.test(base));
|
|
81
87
|
});
|
|
88
|
+
return { entries, all };
|
|
89
|
+
}
|
|
90
|
+
/** The import graph of the tree at a commit, read from git rather than the working tree. */
|
|
91
|
+
export function buildGraphAt(root, rev) {
|
|
92
|
+
const { entries } = graphTreeEntries(root, rev);
|
|
82
93
|
const paths = entries.map((entry) => entry.path);
|
|
83
94
|
const blobs = readBlobs(root, rev, paths);
|
|
84
95
|
return buildImportGraph(root, paths, (path) => blobs.get(path));
|
|
@@ -103,7 +114,7 @@ export async function assessChange(options) {
|
|
|
103
114
|
...(options.deltas === undefined ? {} : { deltas: [...options.deltas] }),
|
|
104
115
|
...(intent === undefined ? {} : { intent }),
|
|
105
116
|
};
|
|
106
|
-
const graph = buildGraphAt(options.root, head);
|
|
117
|
+
const graph = options.graph ?? buildGraphAt(options.root, head);
|
|
107
118
|
const priors = options.priors === false
|
|
108
119
|
? undefined
|
|
109
120
|
: typeof options.priors === "object"
|
package/dist/wire.d.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* runtime dependency at all: Node 22 builtins and global fetch, nothing else.
|
|
6
6
|
* A contract test compares the scope list below against the server's.
|
|
7
7
|
*/
|
|
8
|
-
export declare const CLI_VERSION = "1.0.
|
|
8
|
+
export declare const CLI_VERSION = "1.0.18";
|
|
9
9
|
export declare const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export declare const CLIENT_HEADER = "x-balladeer-client";
|
|
11
11
|
/** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
|
|
12
12
|
export declare const UPDATE_STATE_HEADER = "x-balladeer-update-state";
|
|
13
13
|
/** Which host's hook made this guidance fetch: `claude` or `codex`. */
|
|
14
14
|
export declare const HOOK_HOST_HEADER = "x-balladeer-hook";
|
|
15
|
-
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.
|
|
15
|
+
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.18";
|
|
16
16
|
export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
17
17
|
export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
|
|
18
18
|
export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
|
|
@@ -729,7 +729,23 @@ export type JsonStep = Readonly<{
|
|
|
729
729
|
* this machine, or judges still running when the time ran out. Verdicts
|
|
730
730
|
* themselves are printed as `balladeer-judge-verdict/v1` objects.
|
|
731
731
|
*/
|
|
732
|
+
/**
|
|
733
|
+
* What the push check's anchoring did before it selected promises: which it
|
|
734
|
+
* anchored, which it could not and why, what the calls cost and how many
|
|
735
|
+
* anchorings are left today on this laptop. `message` is the line a person
|
|
736
|
+
* reads.
|
|
737
|
+
*/
|
|
732
738
|
| Readonly<{
|
|
739
|
+
step: "anchors";
|
|
740
|
+
message: string;
|
|
741
|
+
anchored: readonly string[];
|
|
742
|
+
notAnchored: readonly Readonly<{
|
|
743
|
+
promiseId: string;
|
|
744
|
+
reason: string;
|
|
745
|
+
}>[];
|
|
746
|
+
tokens: number;
|
|
747
|
+
remainingToday: number;
|
|
748
|
+
}> | Readonly<{
|
|
733
749
|
step: "judge";
|
|
734
750
|
status: "not_judged" | "still_judging" | "nothing_to_judge";
|
|
735
751
|
reason: string;
|
package/dist/wire.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* runtime dependency at all: Node 22 builtins and global fetch, nothing else.
|
|
6
6
|
* A contract test compares the scope list below against the server's.
|
|
7
7
|
*/
|
|
8
|
-
export const CLI_VERSION = "1.0.
|
|
8
|
+
export const CLI_VERSION = "1.0.18";
|
|
9
9
|
export const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export const CLIENT_HEADER = "x-balladeer-client";
|
|
11
11
|
/** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
|
package/package.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "balladeer",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.18",
|
|
4
4
|
"description": "Set up Balladeer from your terminal, or from a coding agent's.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"private": false,
|
|
7
|
-
"//": "repository
|
|
7
|
+
"//": "repository names this private GitHub repository because npm trusted publishing requires it: the release workflow (.github/workflows/release.yml) publishes with no npm token and no passkey only when package.json's repository matches the repository the workflow runs in. Robert approved the registry page showing the repository name on 30 September 2026. homepage stays absent while the repository is private.",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Bobby-tables1/balladeer.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
8
13
|
"type": "module",
|
|
9
14
|
"publishConfig": {
|
|
10
15
|
"access": "public"
|