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.
@@ -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)
@@ -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;
@@ -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
- /** The import graph of the tree at a commit, read from git rather than the working tree. */
69
- export function buildGraphAt(root, rev) {
70
- const entries = listTree(root, rev).filter((entry) => {
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.17";
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.17";
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.17";
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.17",
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 and homepage are deliberately absent: the source repository is private, and a registry page linking somewhere a reader cannot open is worse than one that links nowhere. Add both when the repository is public, and delete this note when you do.",
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"