@mrciphersmith/keryx 0.3.118 → 0.3.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/cli.js +104554 -96795
  2. package/dist/core.js +1767 -1061
  3. package/package.json +1 -1
  4. package/src/gdgraph/service.ts +4 -0
  5. package/src/gdgraph/symbol-reach.ts +1069 -0
  6. package/src/gdskills/bundled/skills/orchestration/feature-dev/SKILL.md +1 -1
  7. package/src/gdskills/bundled/skills/orchestration/flow-orchestrator/SKILL.md +37 -40
  8. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.md +6 -6
  9. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/orchestrator-prompt.md +1 -1
  10. package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.md +12 -18
  11. package/src/gdskills/bundled/skills/orchestration/task-implementer/input-contract.schema.json +8 -0
  12. package/src/gdskills/bundled/skills/orchestration/task-implementer/output-contract.schema.json +19 -0
  13. package/src/gdskills/bundled/skills/platform/scheduled-tasks/SKILL.md +2 -2
  14. package/src/gdskills/bundled/skills/review/review-architecture/SKILL.md +1 -1
  15. package/src/gdskills/bundled/skills/review/review-backend/SKILL.md +1 -1
  16. package/src/gdskills/bundled/skills/review/review-clean-code/SKILL.md +1 -1
  17. package/src/gdskills/bundled/skills/review/review-core-boundaries/SKILL.md +1 -1
  18. package/src/gdskills/bundled/skills/review/review-flow-graph/SKILL.md +1 -1
  19. package/src/gdskills/bundled/skills/review/review-frontend/SKILL.md +3 -7
  20. package/src/gdskills/bundled/skills/review/review-frontend-conventions/SKILL.md +4 -6
  21. package/src/gdskills/bundled/skills/review/review-highload/SKILL.md +1 -1
  22. package/src/gdskills/bundled/skills/review/review-logic/SKILL.md +2 -2
  23. package/src/gdskills/bundled/skills/review/review-orchestrator/SKILL.detail.md +82 -17
  24. package/src/gdskills/bundled/skills/review/review-orchestrator/SKILL.md +78 -111
  25. package/src/gdskills/bundled/skills/review/review-orchestrator/reviewer-finding.schema.json +13 -0
  26. package/src/gdskills/bundled/skills/review/review-orchestrator/reviewer-input.schema.json +38 -0
  27. package/src/gdskills/bundled/skills/review/review-performance/SKILL.md +1 -1
  28. package/src/gdskills/bundled/skills/review/review-pr-feedback/SKILL.md +1 -1
  29. package/src/gdskills/bundled/skills/review/review-regression/SKILL.md +2 -2
  30. package/src/gdskills/bundled/skills/review/review-security-code/SKILL.md +1 -1
  31. package/src/gdskills/bundled/skills/review/review-style/SKILL.md +1 -1
  32. package/src/gdskills/bundled/skills/review/review-testing-practices/SKILL.md +1 -1
  33. package/src/gdskills/bundled/skills/review/review-verifier/SKILL.md +3 -3
  34. package/src/gdskills/contracts/review-finding.schema.json +25 -0
@@ -0,0 +1,1069 @@
1
+ // Symbol-level reach for the review blast radius (flow 457, AC1).
2
+ //
3
+ // `computeAffected` walks every reverse import edge, so a one-line change to
4
+ // one exported function of a module imported by 1,700 files puts 1,700 files
5
+ // in the radius, most of which never call that function. This walk carries the
6
+ // NAMES that changed instead of the file:
7
+ //
8
+ // - Seeds are the exported names a changed file's diff actually touched: an
9
+ // edited declaration, an `export { a, b }` list, a body edit (hunk lines that
10
+ // fall inside an exported declaration), and a non-exported helper or import
11
+ // the edit touched, propagated to every exported declaration that mentions
12
+ // it. Names exported at the base and gone at the head are changed too.
13
+ // - At each importer the import clause naming the target is re-parsed. The
14
+ // importer is a consumer only when it imports a changed name, a namespace
15
+ // (`* as m`), or the default export when the default changed. Its own
16
+ // exported names that use those bindings become the names walked next.
17
+ // - A barrel (`export * from`, `export { x } from`) passes the names through to
18
+ // its importers without counting a hop: it re-exports, it does not consume.
19
+ // - Hubs are damped: the weight of a reached file is the product over the
20
+ // expanded nodes of `min(1, log2(2 + HUB_FREE_FAN_IN) / log2(2 + fanIn))`,
21
+ // i.e. `1/log2(2 + fanIn)` scaled so a node with at most HUB_FREE_FAN_IN
22
+ // importers costs nothing. A relevant importer under HUB_WEIGHT_CUTOFF is
23
+ // recorded as damped, not walked. A direct (hop 1) importer of a changed
24
+ // file is never damped: it is judged, and kept when it consumes a changed
25
+ // name; damping only limits the walk beyond it.
26
+ //
27
+ // Whenever the names cannot be known the walk falls back to the plain file
28
+ // edge (every importer is relevant, names `*`): a file that is not JS/TS, a
29
+ // source that does not parse, a diff with no hunks for the file, an import
30
+ // clause that cannot be found, or a change to module-level code (a side
31
+ // effect) rather than to an exported declaration. The fallback is the old
32
+ // behaviour, never something narrower.
33
+ //
34
+ // The parse is a small top-level statement scanner validated by Bun.Transpiler
35
+ // (which throws on a syntax error and lists the runtime exports, which must be
36
+ // a subset of what the scanner found). TypeScript is a dev dependency only and
37
+ // tree-sitter grammars are downloaded on demand, so neither is used here.
38
+
39
+ import { spawnSync } from "node:child_process";
40
+ import { readFileSync } from "node:fs";
41
+ import path from "node:path";
42
+ import type { GraphData } from "./types";
43
+
44
+ /** A node with at most this many importers is not damped. */
45
+ export const HUB_FREE_FAN_IN = 16;
46
+ /** A relevant importer whose weight falls under this is recorded as hub-damped and not walked. */
47
+ export const HUB_WEIGHT_CUTOFF = 0.5;
48
+
49
+ const JS_TS = /\.(?:[cm]?[jt]sx?)$/i;
50
+
51
+ export type DiffHunk = { oldStart: number; oldLines: number; newStart: number; newLines: number };
52
+ export type FileDiff = { path: string; oldPath: string; hunks: DiffHunk[] };
53
+
54
+ /**
55
+ * Counts the body lines of the hunk being read, so a content line that starts with `--- ` or `+++ ` (a removed
56
+ * `-- comment` or an added `++ x`) is never read as a file header. Header lines are parsed only while no hunk body
57
+ * is open. Shared with `review/text-links.ts`.
58
+ */
59
+ export class HunkBodyTracker {
60
+ private oldLeft = 0;
61
+ private newLeft = 0;
62
+
63
+ /** True when `line` is a body line of the open hunk (and consumes it). */
64
+ consume(line: string): boolean {
65
+ if (this.oldLeft <= 0 && this.newLeft <= 0) return false;
66
+ // A body line starts with ' ', '+', '-' or '\'; anything else (`diff --git`, `@@`, a truncated hunk) ends the body.
67
+ if (line !== "" && !/^[ +\-\\]/.test(line)) {
68
+ this.reset();
69
+ return false;
70
+ }
71
+ if (line.startsWith("\\")) return true; // ""
72
+ if (line.startsWith("-")) this.oldLeft -= 1;
73
+ else if (line.startsWith("+")) this.newLeft -= 1;
74
+ else {
75
+ this.oldLeft -= 1;
76
+ this.newLeft -= 1;
77
+ }
78
+ return true;
79
+ }
80
+
81
+ /** Opens a hunk body from its `@@` header; returns the parsed hunk, or undefined when `line` is not one. */
82
+ open(line: string): DiffHunk | undefined {
83
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
84
+ if (match === null) return undefined;
85
+ const hunk = {
86
+ oldStart: Number(match[1]),
87
+ oldLines: match[2] === undefined ? 1 : Number(match[2]),
88
+ newStart: Number(match[3]),
89
+ newLines: match[4] === undefined ? 1 : Number(match[4]),
90
+ };
91
+ this.oldLeft = hunk.oldLines;
92
+ this.newLeft = hunk.newLines;
93
+ return hunk;
94
+ }
95
+
96
+ reset(): void {
97
+ this.oldLeft = 0;
98
+ this.newLeft = 0;
99
+ }
100
+ }
101
+
102
+ /** Unified diff text to per-file hunks, keyed by the head path. Deleted files are skipped. */
103
+ export function parseDiffHunks(diff: string): Map<string, FileDiff> {
104
+ const files = new Map<string, FileDiff>();
105
+ let oldPath: string | undefined;
106
+ let current: FileDiff | undefined;
107
+ const body = new HunkBodyTracker();
108
+ for (const line of diff.split("\n")) {
109
+ if (body.consume(line)) continue;
110
+ if (line.startsWith("diff --git ")) {
111
+ oldPath = undefined;
112
+ current = undefined;
113
+ continue;
114
+ }
115
+ if (line.startsWith("--- ")) {
116
+ const value = line.slice(4).trim();
117
+ oldPath = value === "/dev/null" ? "" : value.replace(/^a\//, "");
118
+ continue;
119
+ }
120
+ if (line.startsWith("+++ ")) {
121
+ const value = line.slice(4).trim();
122
+ if (value === "/dev/null") {
123
+ current = undefined;
124
+ continue;
125
+ }
126
+ const head = value.replace(/^b\//, "");
127
+ current = files.get(head) ?? { path: head, oldPath: oldPath ?? head, hunks: [] };
128
+ files.set(head, current);
129
+ continue;
130
+ }
131
+ const hunk = body.open(line);
132
+ if (hunk !== undefined && current !== undefined) current.hunks.push(hunk);
133
+ }
134
+ return files;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // The scanner
139
+ // ---------------------------------------------------------------------------
140
+
141
+ type ImportBinding = { imported: string; local: string };
142
+ type Reexport = { imported: string; exported: string };
143
+
144
+ type StatementKind = "import" | "export-from" | "export-list" | "declaration" | "export-default" | "code";
145
+
146
+ type Statement = {
147
+ kind: StatementKind;
148
+ startLine: number;
149
+ endLine: number;
150
+ /** Comments and string contents blanked: what identifier mentions are searched in. */
151
+ masked: string;
152
+ /** Local names this statement declares. */
153
+ names: string[];
154
+ /** Exported names this statement declares directly (`export function f` → f, `export default` → default). */
155
+ exports: string[];
156
+ specifier?: string;
157
+ bindings?: ImportBinding[];
158
+ reexports?: Reexport[];
159
+ /** `export { a as b }` without `from`. */
160
+ exportList?: Reexport[];
161
+ };
162
+
163
+ export type ModuleOutline = {
164
+ statements: Statement[];
165
+ /** Lines (1-based) carrying anything but whitespace and comments. */
166
+ codeLines: Set<number>;
167
+ /** local name → exported names. */
168
+ exportsOf: Map<string, string[]>;
169
+ exportedNames: Set<string>;
170
+ /** `import("x")` / `require("x")` specifiers, found anywhere. */
171
+ dynamicSpecifiers: Set<string>;
172
+ };
173
+
174
+ const REGEX_PREV = new Set(["", "(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "<", ">", "~", "^"]);
175
+ const REGEX_KEYWORDS = /^(?:return|typeof|instanceof|in|of|new|delete|void|throw|case|do|else|yield|await)$/;
176
+ // Unindented continuations are rare in formatted code; `<`/`>` are left out so `type A = B<C>` ends at its newline.
177
+ const CONTINUES = new Set(["=", ",", "+", "-", "*", "&", "|", "?", ":", "."]);
178
+ const CONTINUED_BY = /^(?:[.)\]}?:+\-*%&|^,=<>]|\/[^/*])/;
179
+
180
+ /**
181
+ * Splits a module into top-level statements. Returns `undefined` when the
182
+ * nesting does not balance, which the caller treats as a parse failure.
183
+ */
184
+ function scanStatements(source: string): { statements: Array<{ start: number; end: number }>; masked: string; noComments: string; lineAt: number[] } | undefined {
185
+ const n = source.length;
186
+ const masked: string[] = new Array(n);
187
+ const noComments: string[] = new Array(n);
188
+ const lineAt: number[] = new Array(n + 1);
189
+ let line = 1;
190
+ let depth = 0;
191
+ const templateDepths: number[] = [];
192
+ /** Newlines inside a template literal's body: never a statement boundary. */
193
+ const sticky = new Set<number>();
194
+ let lastSignificant = "";
195
+ let lastWord = "";
196
+
197
+ const put = (index: number, maskedChar: string, plainChar: string): void => {
198
+ masked[index] = maskedChar;
199
+ noComments[index] = plainChar;
200
+ };
201
+ const blank = (ch: string): string => (ch === "\n" ? "\n" : " ");
202
+
203
+ let i = 0;
204
+ while (i < n) {
205
+ const ch = source[i] as string;
206
+ lineAt[i] = line;
207
+ const next = source[i + 1];
208
+ // Comments
209
+ if (ch === "/" && next === "/") {
210
+ while (i < n && source[i] !== "\n") {
211
+ lineAt[i] = line;
212
+ put(i, " ", " ");
213
+ i += 1;
214
+ }
215
+ continue;
216
+ }
217
+ if (ch === "/" && next === "*") {
218
+ const end = source.indexOf("*/", i + 2);
219
+ const stop = end < 0 ? n : end + 2;
220
+ for (; i < stop; i += 1) {
221
+ lineAt[i] = line;
222
+ const c = source[i] as string;
223
+ put(i, blank(c), blank(c));
224
+ if (c === "\n") line += 1;
225
+ }
226
+ continue;
227
+ }
228
+ // Quoted strings end at their quote or at a newline (so a stray apostrophe in JSX text costs one line, not the file).
229
+ if (ch === '"' || ch === "'") {
230
+ put(i, ch, ch);
231
+ i += 1;
232
+ while (i < n && source[i] !== ch && source[i] !== "\n") {
233
+ lineAt[i] = line;
234
+ if (source[i] === "\\" && i + 1 < n && source[i + 1] !== "\n") {
235
+ put(i, " ", source[i] as string);
236
+ lineAt[i + 1] = line;
237
+ put(i + 1, " ", source[i + 1] as string);
238
+ i += 2;
239
+ continue;
240
+ }
241
+ put(i, " ", source[i] as string);
242
+ i += 1;
243
+ }
244
+ if (i < n && source[i] === ch) {
245
+ lineAt[i] = line;
246
+ put(i, ch, ch);
247
+ i += 1;
248
+ }
249
+ lastSignificant = ch;
250
+ lastWord = "";
251
+ continue;
252
+ }
253
+ if (ch === "`" || (ch === "}" && templateDepths.length > 0 && templateDepths[templateDepths.length - 1] === depth)) {
254
+ // Template body: from a backtick, or from the `}` closing a `${` back into the template.
255
+ if (ch === "}") templateDepths.pop();
256
+ put(i, ch, ch);
257
+ i += 1;
258
+ let reopened = false;
259
+ while (i < n) {
260
+ lineAt[i] = line;
261
+ const c = source[i] as string;
262
+ if (c === "\\") {
263
+ put(i, " ", c);
264
+ if (i + 1 < n) {
265
+ lineAt[i + 1] = line;
266
+ const escaped = source[i + 1] as string;
267
+ put(i + 1, blank(escaped), escaped);
268
+ if (escaped === "\n") {
269
+ line += 1;
270
+ sticky.add(i + 1);
271
+ }
272
+ }
273
+ i += 2;
274
+ continue;
275
+ }
276
+ if (c === "`") {
277
+ put(i, c, c);
278
+ i += 1;
279
+ break;
280
+ }
281
+ if (c === "$" && source[i + 1] === "{") {
282
+ put(i, "$", "$");
283
+ lineAt[i + 1] = line;
284
+ put(i + 1, "{", "{");
285
+ i += 2;
286
+ templateDepths.push(depth);
287
+ reopened = true;
288
+ break;
289
+ }
290
+ put(i, blank(c), c);
291
+ if (c === "\n") {
292
+ line += 1;
293
+ sticky.add(i);
294
+ }
295
+ i += 1;
296
+ }
297
+ lastSignificant = reopened ? "{" : "`";
298
+ lastWord = "";
299
+ continue;
300
+ }
301
+ // Regular expression literal, decided by what precedes it; single-line.
302
+ if (ch === "/" && (REGEX_PREV.has(lastSignificant) || REGEX_KEYWORDS.test(lastWord))) {
303
+ put(i, "/", "/");
304
+ i += 1;
305
+ let inClass = false;
306
+ while (i < n && source[i] !== "\n") {
307
+ lineAt[i] = line;
308
+ const c = source[i] as string;
309
+ if (c === "\\" && i + 1 < n) {
310
+ put(i, " ", c);
311
+ lineAt[i + 1] = line;
312
+ put(i + 1, " ", source[i + 1] as string);
313
+ i += 2;
314
+ continue;
315
+ }
316
+ if (c === "[") inClass = true;
317
+ else if (c === "]") inClass = false;
318
+ else if (c === "/" && !inClass) {
319
+ put(i, "/", "/");
320
+ i += 1;
321
+ break;
322
+ }
323
+ put(i, " ", c);
324
+ i += 1;
325
+ }
326
+ while (i < n && /[a-z]/i.test(source[i] as string)) {
327
+ lineAt[i] = line;
328
+ put(i, source[i] as string, source[i] as string);
329
+ i += 1;
330
+ }
331
+ lastSignificant = "/x";
332
+ lastWord = "";
333
+ continue;
334
+ }
335
+ put(i, ch, ch);
336
+ if (ch === "\n") line += 1;
337
+ if (/\s/.test(ch)) {
338
+ i += 1;
339
+ continue;
340
+ }
341
+ if (ch === "(" || ch === "[" || ch === "{") depth += 1;
342
+ else if (ch === ")" || ch === "]" || ch === "}") {
343
+ depth -= 1;
344
+ if (depth < 0) return undefined;
345
+ }
346
+ lastWord = /[\w$]/.test(ch) ? (i > 0 && /[\w$]/.test(source[i - 1] as string) ? lastWord + ch : ch) : "";
347
+ lastSignificant = ch;
348
+ i += 1;
349
+ }
350
+ lineAt[n] = line;
351
+ if (depth !== 0 || templateDepths.length > 0) return undefined;
352
+ const maskedText = masked.join("");
353
+ return { statements: splitStatements(maskedText, sticky), masked: maskedText, noComments: noComments.join(""), lineAt };
354
+ }
355
+
356
+ /**
357
+ * Top-level statement spans over the masked text (comments, strings, template
358
+ * bodies and regex bodies already blanked, so every bracket left is code). A
359
+ * statement ends at a top-level `;`, or at a top-level newline when the next
360
+ * code starts at column 0 and neither side reads as a continuation.
361
+ */
362
+ function splitStatements(masked: string, sticky: ReadonlySet<number>): Array<{ start: number; end: number }> {
363
+ const out: Array<{ start: number; end: number }> = [];
364
+ let depth = 0;
365
+ let start = -1;
366
+ let last = "";
367
+ const n = masked.length;
368
+ for (let i = 0; i < n; i += 1) {
369
+ const ch = masked[i] as string;
370
+ if (ch === "\n") {
371
+ if (depth !== 0 || start < 0 || sticky.has(i)) continue;
372
+ let j = i + 1;
373
+ while (j < n && /\s/.test(masked[j] as string)) j += 1;
374
+ if (j >= n) continue;
375
+ const atColumnZero = masked[j - 1] === "\n";
376
+ const rest = masked.slice(j, j + 2);
377
+ if (atColumnZero && !CONTINUED_BY.test(rest) && !CONTINUES.has(last) && masked[start] !== "@") {
378
+ out.push({ start, end: i });
379
+ start = -1;
380
+ }
381
+ continue;
382
+ }
383
+ if (/\s/.test(ch)) continue;
384
+ if (start < 0) start = i;
385
+ if (ch === "(" || ch === "[" || ch === "{") depth += 1;
386
+ else if (ch === ")" || ch === "]" || ch === "}") depth -= 1;
387
+ last = ch;
388
+ if (ch === ";" && depth === 0) {
389
+ out.push({ start, end: i + 1 });
390
+ start = -1;
391
+ }
392
+ }
393
+ if (start >= 0) out.push({ start, end: n });
394
+ return out;
395
+ }
396
+
397
+ const IDENT = /[A-Za-z_$][\w$]*/g;
398
+
399
+ function splitList(body: string): Reexport[] {
400
+ const out: Reexport[] = [];
401
+ for (const raw of body.split(",")) {
402
+ const part = raw.trim().replace(/^type\s+/, "");
403
+ if (part === "") continue;
404
+ const match = /^([\w$]+|"[^"]*"|'[^']*')(?:\s+as\s+([\w$]+|"[^"]*"|'[^']*'))?$/.exec(part);
405
+ if (match === null) continue;
406
+ const unquote = (value: string): string => value.replace(/^["']|["']$/g, "");
407
+ const imported = unquote(match[1] as string);
408
+ out.push({ imported, exported: unquote(match[2] ?? imported) });
409
+ }
410
+ return out;
411
+ }
412
+
413
+ /** Binding names of `const a = …, { b, c: d } = …, [e] = …` (after the keyword). Approximate on purpose. */
414
+ function bindingNames(text: string): string[] {
415
+ const names: string[] = [];
416
+ let i = 0;
417
+ const n = text.length;
418
+ const skipTo = (stops: string): void => {
419
+ let depth = 0;
420
+ while (i < n) {
421
+ const c = text[i] as string;
422
+ if (c === "(" || c === "[" || c === "{" || c === "<") depth += 1;
423
+ else if (c === ")" || c === "]" || c === "}" || c === ">") depth = Math.max(0, depth - 1);
424
+ else if (depth === 0 && stops.includes(c)) return;
425
+ i += 1;
426
+ }
427
+ };
428
+ while (i < n) {
429
+ while (i < n && /[\s,]/.test(text[i] as string)) i += 1;
430
+ if (i >= n) break;
431
+ const c = text[i] as string;
432
+ if (c === "{" || c === "[") {
433
+ const open = c;
434
+ const closeChar = open === "{" ? "}" : "]";
435
+ let depth = 0;
436
+ const begin = i;
437
+ for (; i < n; i += 1) {
438
+ if (text[i] === open) depth += 1;
439
+ else if (text[i] === closeChar) {
440
+ depth -= 1;
441
+ if (depth === 0) {
442
+ i += 1;
443
+ break;
444
+ }
445
+ }
446
+ }
447
+ const pattern = text.slice(begin, i);
448
+ // Identifiers not followed by `:` (a property key) and not after `=` (a default value).
449
+ const cleaned = pattern.replace(/=[^,}\]]*/g, "");
450
+ for (const match of cleaned.matchAll(/([A-Za-z_$][\w$]*)\s*(:)?/g)) if (match[2] === undefined) names.push(match[1] as string);
451
+ } else {
452
+ const match = /^[A-Za-z_$][\w$]*/.exec(text.slice(i));
453
+ if (match === null) break;
454
+ names.push(match[0]);
455
+ i += match[0].length;
456
+ }
457
+ skipTo("=,;");
458
+ if (text[i] === "=") {
459
+ i += 1;
460
+ skipTo(",;");
461
+ }
462
+ if (text[i] === ";") break;
463
+ }
464
+ return names;
465
+ }
466
+
467
+ function classify(masked: string, plain: string): Omit<Statement, "startLine" | "endLine" | "masked"> {
468
+ const head = masked.trimStart();
469
+ const text = plain.trimStart();
470
+ const empty = { names: [] as string[], exports: [] as string[] };
471
+ let match: RegExpExecArray | null;
472
+ if ((match = /^import\s+([\w$]+)\s*=\s*require\s*\(\s*["']([^"']+)["']\s*\)/.exec(text)) !== null) {
473
+ return { kind: "import", ...empty, names: [match[1] as string], specifier: match[2] as string, bindings: [{ imported: "*", local: match[1] as string }] };
474
+ }
475
+ if (/^import\s*["']/.test(text)) {
476
+ const spec = /^import\s*["']([^"']+)["']/.exec(text)?.[1];
477
+ return { kind: "import", ...empty, ...(spec === undefined ? {} : { specifier: spec }), bindings: [] };
478
+ }
479
+ if (/^import[\s{*]/.test(head) && !/^import\s*\(/.test(head)) {
480
+ match = /^import\s+(?:type\s+)?([\s\S]*?)\s*from\s*["']([^"']+)["']/.exec(text);
481
+ if (match === null) return { kind: "code", ...empty };
482
+ const clause = (match[1] as string).trim();
483
+ const bindings: ImportBinding[] = [];
484
+ const namespace = /\*\s*as\s+([\w$]+)/.exec(clause);
485
+ if (namespace !== null) bindings.push({ imported: "*", local: namespace[1] as string });
486
+ const braces = /\{([\s\S]*)\}/.exec(clause);
487
+ if (braces !== null) for (const item of splitList(braces[1] as string)) bindings.push({ imported: item.imported, local: item.exported });
488
+ const defaultName = /^([\w$]+)\s*(?:,|$)/.exec(clause);
489
+ if (defaultName !== null && defaultName[1] !== "type") bindings.push({ imported: "default", local: defaultName[1] as string });
490
+ return { kind: "import", ...empty, names: bindings.map((b) => b.local), specifier: match[2] as string, bindings };
491
+ }
492
+ if ((match = /^export\s+(?:type\s+)?\*\s*(?:as\s+([\w$]+)\s*)?from\s*["']([^"']+)["']/.exec(text)) !== null) {
493
+ const ns = match[1];
494
+ return {
495
+ kind: "export-from",
496
+ ...empty,
497
+ exports: ns === undefined ? [] : [ns],
498
+ specifier: match[2] as string,
499
+ reexports: [{ imported: "*", exported: ns ?? "*" }],
500
+ };
501
+ }
502
+ if ((match = /^export\s+(?:type\s+)?\{([\s\S]*?)\}\s*from\s*["']([^"']+)["']/.exec(text)) !== null) {
503
+ const reexports = splitList(match[1] as string);
504
+ return { kind: "export-from", ...empty, exports: reexports.map((r) => r.exported), specifier: match[2] as string, reexports };
505
+ }
506
+ if ((match = /^export\s+(?:type\s+)?\{([\s\S]*?)\}/.exec(head)) !== null) {
507
+ const list = splitList((/^export\s+(?:type\s+)?\{([\s\S]*?)\}/.exec(text)?.[1] ?? match[1]) as string);
508
+ return { kind: "export-list", ...empty, exports: list.map((r) => r.exported), exportList: list };
509
+ }
510
+ if (/^export\s+default\b/.test(head)) {
511
+ const named = /^export\s+default\s+(?:abstract\s+)?(?:async\s+)?(?:function\s*\*?|class)\s+([\w$]+)/.exec(head);
512
+ return { kind: "export-default", names: named === null ? [] : [named[1] as string], exports: ["default"] };
513
+ }
514
+ if (/^export\s*=/.test(head)) return { kind: "export-default", names: [], exports: ["default"] };
515
+ const exported = /^export\s+/.test(head);
516
+ const body = exported ? head.replace(/^export\s+/, "") : head;
517
+ const decl = /^(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\s*\*?|class|interface|type|enum|const\s+enum|namespace|module)\s+([\w$]+)/.exec(body);
518
+ if (decl !== null && !/^(?:declare\s+)?(?:module|namespace)\s+["']/.test(body)) {
519
+ const name = decl[1] as string;
520
+ return { kind: "declaration", names: [name], exports: exported ? [name] : [] };
521
+ }
522
+ const variable = /^(?:declare\s+)?(?:const|let|var|using)\s+/.exec(body);
523
+ if (variable !== null) {
524
+ const names = bindingNames(body.slice(variable[0].length));
525
+ return { kind: "declaration", names, exports: exported ? names : [] };
526
+ }
527
+ return { kind: "code", ...empty };
528
+ }
529
+
530
+ const DYNAMIC = /(?:\bimport|\brequire)\s*\(\s*["']([^"']+)["']\s*\)/g;
531
+
532
+ function loaderFor(file: string): "ts" | "tsx" | "jsx" {
533
+ if (/\.(?:tsx|jsx)$/i.test(file)) return "tsx";
534
+ if (/\.[cm]?ts$/i.test(file)) return "ts";
535
+ return "jsx";
536
+ }
537
+
538
+ /**
539
+ * The top-level outline of a JS/TS module, or `undefined` when it cannot be
540
+ * trusted: not JS/TS, a syntax error, unbalanced nesting, or a runtime export
541
+ * Bun.Transpiler sees that the scanner did not find.
542
+ */
543
+ export function outlineModule(file: string, source: string): ModuleOutline | undefined {
544
+ if (!JS_TS.test(file)) return undefined;
545
+ let runtimeExports: string[];
546
+ try {
547
+ runtimeExports = new Bun.Transpiler({ loader: loaderFor(file) }).scan(source).exports;
548
+ } catch {
549
+ return undefined;
550
+ }
551
+ const scanned = scanStatements(source);
552
+ if (scanned === undefined) return undefined;
553
+ const statements: Statement[] = [];
554
+ const codeLines = new Set<number>();
555
+ for (let index = 0; index < scanned.masked.length; index += 1) {
556
+ const c = scanned.masked[index] as string;
557
+ if (!/\s/.test(c)) codeLines.add(scanned.lineAt[index] as number);
558
+ }
559
+ for (const span of scanned.statements) {
560
+ const masked = scanned.masked.slice(span.start, span.end);
561
+ if (masked.trim() === "") continue;
562
+ const plain = scanned.noComments.slice(span.start, span.end);
563
+ const leading = masked.length - masked.trimStart().length;
564
+ const trailing = masked.trimEnd().length;
565
+ const startLine = scanned.lineAt[span.start + leading] as number;
566
+ const endLine = scanned.lineAt[Math.max(span.start, span.start + trailing - 1)] as number;
567
+ statements.push({ ...classify(masked, plain), startLine, endLine, masked });
568
+ }
569
+ const exportsOf = new Map<string, string[]>();
570
+ const exportedNames = new Set<string>();
571
+ const addExport = (local: string, exported: string): void => {
572
+ exportedNames.add(exported);
573
+ const list = exportsOf.get(local) ?? [];
574
+ if (!list.includes(exported)) list.push(exported);
575
+ exportsOf.set(local, list);
576
+ };
577
+ for (const statement of statements) {
578
+ if (statement.kind === "declaration") for (const name of statement.exports) addExport(name, name);
579
+ if (statement.kind === "export-default") {
580
+ exportedNames.add("default");
581
+ for (const name of statement.names) addExport(name, "default");
582
+ }
583
+ if (statement.kind === "export-list") for (const item of statement.exportList ?? []) addExport(item.imported, item.exported);
584
+ if (statement.kind === "export-from") for (const name of statement.exports) exportedNames.add(name);
585
+ }
586
+ const hasStar = statements.some((s) => s.kind === "export-from" && (s.reexports ?? []).some((r) => r.exported === "*"));
587
+ if (!hasStar && runtimeExports.some((name) => !exportedNames.has(name))) return undefined;
588
+ const dynamicSpecifiers = new Set<string>();
589
+ for (const match of scanned.noComments.matchAll(DYNAMIC)) dynamicSpecifiers.add(match[1] as string);
590
+ return { statements, codeLines, exportsOf, exportedNames, dynamicSpecifiers };
591
+ }
592
+
593
+ // ---------------------------------------------------------------------------
594
+ // Names a change affects, inside one module
595
+ // ---------------------------------------------------------------------------
596
+
597
+ /** `"*"`: every export (module-level code changed, or the names could not be known). */
598
+ export type ChangedNames = Set<string> | "*";
599
+
600
+ function mentions(masked: string, names: ReadonlySet<string>): boolean {
601
+ for (const match of masked.matchAll(IDENT)) {
602
+ const index = match.index ?? 0;
603
+ if (index > 0 && masked[index - 1] === ".") continue;
604
+ if (names.has(match[0])) return true;
605
+ }
606
+ return false;
607
+ }
608
+
609
+ /**
610
+ * The exported names a module's change reaches, from touched lines and/or
611
+ * changed local bindings, propagated through every top-level statement that
612
+ * mentions a changed local until nothing moves.
613
+ */
614
+ export function affectedExports(
615
+ outline: ModuleOutline,
616
+ input: {
617
+ touchedLines?: ReadonlySet<number>;
618
+ changedLocals?: Iterable<string>;
619
+ /**
620
+ * Module-level code that uses a changed name makes the module a leaf instead
621
+ * of changing every export: it is how an importer is judged, where such code
622
+ * runs the changed symbol but exports nothing that depends on it.
623
+ */
624
+ leafCode?: boolean;
625
+ },
626
+ ): { names: ChangedNames; moduleLevel: boolean } {
627
+ const locals = new Set<string>(input.changedLocals ?? []);
628
+ const exported = new Set<string>();
629
+ const done = new Set<Statement>();
630
+ let star = false;
631
+ const touched = input.touchedLines;
632
+ const touches = (s: Statement): boolean => {
633
+ if (touched === undefined) return false;
634
+ for (let line = s.startLine; line <= s.endLine; line += 1) if (touched.has(line) && outline.codeLines.has(line)) return true;
635
+ return false;
636
+ };
637
+ const mark = (s: Statement): void => {
638
+ done.add(s);
639
+ switch (s.kind) {
640
+ case "import":
641
+ for (const name of s.names) locals.add(name);
642
+ if ((s.bindings ?? []).length === 0) star = true; // a side-effect import changed: module-level behaviour
643
+ break;
644
+ case "export-from":
645
+ if ((s.reexports ?? []).some((r) => r.exported === "*")) star = true;
646
+ for (const name of s.exports) exported.add(name);
647
+ break;
648
+ case "export-list":
649
+ for (const name of s.exports) exported.add(name);
650
+ break;
651
+ case "export-default":
652
+ exported.add("default");
653
+ for (const name of s.names) locals.add(name);
654
+ break;
655
+ case "declaration":
656
+ for (const name of s.names) locals.add(name);
657
+ for (const name of s.exports) exported.add(name);
658
+ break;
659
+ case "code":
660
+ if (input.leafCode !== true) star = true;
661
+ break;
662
+ }
663
+ };
664
+ for (const statement of outline.statements) if (touches(statement)) mark(statement);
665
+ let moved = true;
666
+ while (moved && !star) {
667
+ moved = false;
668
+ for (const statement of outline.statements) {
669
+ if (done.has(statement) || statement.kind === "import" || statement.kind === "export-list" || statement.kind === "export-from") continue;
670
+ if (locals.size > 0 && mentions(statement.masked, locals)) {
671
+ mark(statement);
672
+ moved = true;
673
+ }
674
+ }
675
+ }
676
+ if (star) return { names: "*", moduleLevel: true };
677
+ for (const local of locals) for (const name of outline.exportsOf.get(local) ?? []) exported.add(name);
678
+ return { names: exported, moduleLevel: false };
679
+ }
680
+
681
+ // ---------------------------------------------------------------------------
682
+ // The walk
683
+ // ---------------------------------------------------------------------------
684
+
685
+ export type SourceReader = {
686
+ /** The file's text at the head, or undefined when it cannot be read. */
687
+ head(file: string): string | undefined;
688
+ /** The file's text at the base, or undefined (a new file, or no base). */
689
+ base?(file: string): string | undefined;
690
+ };
691
+
692
+ export type SymbolReachSeed = {
693
+ file: string;
694
+ /** Changed exported names, or `*` when the walk falls back to the file edge. */
695
+ names: string[] | "*";
696
+ mode: "symbol" | "file-edge";
697
+ detail: string;
698
+ };
699
+
700
+ export type SymbolReachEntry = {
701
+ file: string;
702
+ hop: number;
703
+ weight: number;
704
+ fanIn: number;
705
+ via: string;
706
+ /** `[file, …, via]`, barrels included. */
707
+ path: string[];
708
+ /** Its own exports that depend on the change, walked next (`*` for a file-edge reach; empty for a leaf). */
709
+ names: string[] | "*";
710
+ /** The changed names of the module it imports that it uses (`*` = all of them: namespace, dynamic or file edge). */
711
+ uses: string[] | "*";
712
+ mode: "symbol" | "file-edge";
713
+ };
714
+
715
+ export type SymbolReachOmission = {
716
+ file: string;
717
+ hop: number;
718
+ fanIn: number;
719
+ via: string;
720
+ path: string[];
721
+ reason: "unrelated-symbol" | "hub-damped";
722
+ detail: string;
723
+ };
724
+
725
+ export type SymbolReachResult = {
726
+ seeds: SymbolReachSeed[];
727
+ reached: SymbolReachEntry[];
728
+ omitted: SymbolReachOmission[];
729
+ /** Pure re-export files the walk passed through without counting a hop. */
730
+ barrels: string[];
731
+ };
732
+
733
+ export type SymbolReachInput = {
734
+ graph: GraphData;
735
+ /** Changed files that are graph nodes. */
736
+ seeds: readonly string[];
737
+ /** Every changed file: none is reported as reached. */
738
+ changedFiles: readonly string[];
739
+ /** Per changed file, its hunks; a changed file with none falls back to the file edge. */
740
+ diff?: ReadonlyMap<string, FileDiff> | undefined;
741
+ read: SourceReader;
742
+ depth: number;
743
+ hubFreeFanIn?: number | undefined;
744
+ weightCutoff?: number | undefined;
745
+ };
746
+
747
+ function namesOut(names: ChangedNames): string[] | "*" {
748
+ return names === "*" ? "*" : [...names].sort();
749
+ }
750
+
751
+ function seedNames(file: string, diff: FileDiff | undefined, read: SourceReader): SymbolReachSeed {
752
+ const fallback = (detail: string): SymbolReachSeed => ({ file, names: "*", mode: "file-edge", detail });
753
+ if (!JS_TS.test(file)) return fallback("not a JS/TS module: every importer is reached by the file edge");
754
+ if (diff === undefined || diff.hunks.length === 0) return fallback("no hunks for this file in the diff: the changed names are unknown");
755
+ const head = read.head(file);
756
+ const outline = head === undefined ? undefined : outlineModule(file, head);
757
+ if (outline === undefined) return fallback(head === undefined ? "the head source could not be read" : "the head source did not parse");
758
+ const baseText = diff.hunks.some((hunk) => hunk.oldLines > 0) ? read.base?.(diff.oldPath) : undefined;
759
+ const touched = new Set<number>();
760
+ for (const hunk of diff.hunks) {
761
+ if (hunk.newLines === 0 && hunk.oldLines > 0 && baseText === undefined) {
762
+ // A pure deletion with no base to read sits between two head lines: both neighbours are touched.
763
+ touched.add(hunk.newStart);
764
+ touched.add(hunk.newStart + 1);
765
+ }
766
+ for (let line = hunk.newStart; line < hunk.newStart + hunk.newLines; line += 1) touched.add(line);
767
+ }
768
+ const after = affectedExports(outline, { touchedLines: touched });
769
+ if (after.names === "*") return fallback("the change touches module-level code (a side effect), not only exported declarations");
770
+ const names = new Set(after.names);
771
+ if (baseText !== undefined) {
772
+ const baseOutline = outlineModule(diff.oldPath, baseText);
773
+ if (baseOutline === undefined) return fallback("the base source did not parse");
774
+ const oldTouched = new Set<number>();
775
+ for (const hunk of diff.hunks) for (let line = hunk.oldStart; line < hunk.oldStart + hunk.oldLines; line += 1) oldTouched.add(line);
776
+ const before = affectedExports(baseOutline, { touchedLines: oldTouched });
777
+ if (before.names === "*") return fallback("the change removes or edits module-level code at the base");
778
+ for (const name of before.names) names.add(name);
779
+ for (const name of baseOutline.exportedNames) if (!outline.exportedNames.has(name)) names.add(name);
780
+ }
781
+ const list = [...names].sort();
782
+ return {
783
+ file,
784
+ names: list,
785
+ mode: "symbol",
786
+ detail: list.length === 0 ? "the change touches no exported declaration" : `changed exports: ${list.join(", ")}`,
787
+ };
788
+ }
789
+
790
+ type ImporterVerdict =
791
+ | { kind: "fallback"; detail: string }
792
+ | { kind: "analysed"; consumer: boolean; imported: string[]; uses: ChangedNames; passThrough: ChangedNames | undefined; next: ChangedNames };
793
+
794
+ function unionNames(a: ChangedNames | undefined, b: ChangedNames | undefined): ChangedNames {
795
+ if (a === "*" || b === "*") return "*";
796
+ return new Set([...(a ?? []), ...(b ?? [])]);
797
+ }
798
+
799
+ function judgeImporter(importer: string, specifiers: readonly string[], names: ChangedNames, read: SourceReader): ImporterVerdict {
800
+ if (!JS_TS.test(importer)) return { kind: "fallback", detail: "importer is not a JS/TS module" };
801
+ const source = read.head(importer);
802
+ if (source === undefined) return { kind: "fallback", detail: "importer source could not be read" };
803
+ const outline = outlineModule(importer, source);
804
+ if (outline === undefined) return { kind: "fallback", detail: "importer did not parse" };
805
+ // A dynamic `import()`/`require()` names no binding: the importer is a consumer, and the exports whose statements
806
+ // hold the call are the ones that depend on the module. A call the line scan cannot place falls back to the file edge.
807
+ const dynamicLines = new Set<number>();
808
+ if (specifiers.some((spec) => outline.dynamicSpecifiers.has(spec))) {
809
+ const lines = source.split("\n");
810
+ for (const spec of specifiers) {
811
+ const call = new RegExp(`(?:\\bimport|\\brequire)\\s*\\(\\s*["']${spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`);
812
+ lines.forEach((line, index) => {
813
+ if (call.test(line)) dynamicLines.add(index + 1);
814
+ });
815
+ }
816
+ if (dynamicLines.size === 0) return { kind: "fallback", detail: "importer loads the module dynamically at a call the scan cannot place" };
817
+ }
818
+ const matching = outline.statements.filter((s) => s.specifier !== undefined && specifiers.includes(s.specifier));
819
+ if (matching.length === 0 && dynamicLines.size === 0) return { kind: "fallback", detail: "the import clause naming the module was not found" };
820
+ const has = (name: string): boolean => names === "*" || names.has(name);
821
+ const locals = new Set<string>();
822
+ const imported = new Set<string>();
823
+ let sideEffect = false;
824
+ let passThrough: ChangedNames | undefined;
825
+ let uses: ChangedNames = new Set<string>();
826
+ for (const statement of matching) {
827
+ if (statement.kind === "import") {
828
+ const bindings = statement.bindings ?? [];
829
+ if (bindings.length === 0 && names === "*") sideEffect = true;
830
+ for (const binding of bindings) {
831
+ imported.add(binding.imported);
832
+ if (binding.imported === "*" || has(binding.imported)) {
833
+ locals.add(binding.local);
834
+ uses = binding.imported === "*" ? unionNames(uses, names) : unionNames(uses, new Set([binding.imported]));
835
+ }
836
+ }
837
+ } else if (statement.kind === "export-from") {
838
+ for (const item of statement.reexports ?? []) {
839
+ if (item.imported === "*" && item.exported === "*") {
840
+ const passed: ChangedNames = names === "*" ? "*" : new Set([...names].filter((name) => name !== "default"));
841
+ if (passed === "*" || passed.size > 0) passThrough = unionNames(passThrough, passed);
842
+ } else if (item.imported === "*") {
843
+ passThrough = unionNames(passThrough, new Set([item.exported]));
844
+ } else if (has(item.imported)) {
845
+ passThrough = unionNames(passThrough, new Set([item.exported]));
846
+ }
847
+ }
848
+ }
849
+ }
850
+ const consumer = sideEffect || locals.size > 0 || dynamicLines.size > 0;
851
+ // An importer walks on only through its own exports that depend on the changed names: an export that re-exports
852
+ // them, or whose body or signature mentions them (directly or through a local helper). One that only uses them
853
+ // internally or in module-level code is a leaf: reached, not expanded.
854
+ const own = consumer
855
+ ? sideEffect
856
+ ? ({ names: "*" } as const)
857
+ : affectedExports(outline, { changedLocals: locals, touchedLines: dynamicLines, leafCode: true })
858
+ : { names: new Set<string>() as ChangedNames };
859
+ if (sideEffect || dynamicLines.size > 0) uses = unionNames(uses, names);
860
+ return { kind: "analysed", consumer, imported: [...imported].sort(), uses, passThrough, next: unionNames(own.names, passThrough) };
861
+ }
862
+
863
+ /**
864
+ * The symbol-level reach of a change. Deterministic for a given graph, diff and
865
+ * sources; reads sources only through `read`.
866
+ */
867
+ export function computeSymbolReach(input: SymbolReachInput): SymbolReachResult {
868
+ const graph = input.graph;
869
+ const free = Math.log2(2 + (input.hubFreeFanIn ?? HUB_FREE_FAN_IN));
870
+ const cutoff = input.weightCutoff ?? HUB_WEIGHT_CUTOFF;
871
+ const depth = Math.max(1, Math.trunc(input.depth));
872
+ const changed = new Set(input.changedFiles);
873
+
874
+ const importersOf = new Map<string, Map<string, string[]>>();
875
+ const fanIn = new Map<string, number>();
876
+ for (const edge of graph.edges) {
877
+ if (edge.kind === "unresolved") continue;
878
+ fanIn.set(edge.to, (fanIn.get(edge.to) ?? 0) + 1);
879
+ const byImporter = importersOf.get(edge.to) ?? new Map<string, string[]>();
880
+ const specs = byImporter.get(edge.from) ?? [];
881
+ if (!specs.includes(edge.specifier)) specs.push(edge.specifier);
882
+ byImporter.set(edge.from, specs);
883
+ importersOf.set(edge.to, byImporter);
884
+ }
885
+ const damp = (file: string): number => Math.min(1, free / Math.log2(2 + (fanIn.get(file) ?? 0)));
886
+
887
+ const seeds = [...new Set(input.seeds)].sort().map((file) => seedNames(file, input.diff?.get(file), input.read));
888
+ type Item = { file: string; hop: number; weight: number; names: ChangedNames; via: string; path: string[]; mode: "symbol" | "file-edge" };
889
+ const queue: Item[] = seeds.map((seed) => ({
890
+ file: seed.file,
891
+ hop: 0,
892
+ weight: 1,
893
+ names: seed.names === "*" ? "*" : new Set(seed.names),
894
+ via: seed.file,
895
+ path: [seed.file],
896
+ mode: seed.mode,
897
+ }));
898
+ const reached = new Map<string, SymbolReachEntry>();
899
+ const omitted = new Map<string, SymbolReachOmission>();
900
+ const barrels = new Set<string>();
901
+ const expanded = new Map<string, { hop: number; names: ChangedNames }>();
902
+
903
+ const better = (a: { hop: number; weight: number; file: string }, b: { hop: number; weight: number }): boolean => a.hop < b.hop || (a.hop === b.hop && a.weight > b.weight);
904
+
905
+ while (queue.length > 0) {
906
+ queue.sort((a, b) => a.hop - b.hop || b.weight - a.weight || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
907
+ const item = queue.shift() as Item;
908
+ if (item.names !== "*" && item.names.size === 0) continue;
909
+ const prior = expanded.get(item.file);
910
+ if (prior !== undefined && prior.hop <= item.hop) {
911
+ // Re-expand only for names not walked yet.
912
+ if (prior.names === "*") continue;
913
+ if (item.names !== "*" && [...item.names].every((name) => (prior.names as Set<string>).has(name))) continue;
914
+ }
915
+ expanded.set(item.file, { hop: Math.min(item.hop, prior?.hop ?? item.hop), names: unionNames(prior?.names, item.names) });
916
+ if (item.hop >= depth) continue;
917
+ const weight = item.weight * damp(item.file);
918
+ for (const [importer, specifiers] of [...(importersOf.get(item.file) ?? new Map<string, string[]>())].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
919
+ if (importer === item.file) continue;
920
+ const path = [importer, ...item.path];
921
+ const hop = item.hop + 1;
922
+ const importerFanIn = fanIn.get(importer) ?? 0;
923
+ // A direct consumer of a changed file (hop 1, also behind a barrel) is never damped: relevance decides, so a
924
+ // caller of the changed function in an i18n-like hub stays in. Damping only limits the walk beyond it.
925
+ if (weight < cutoff && item.hop > 0) {
926
+ if (!changed.has(importer) && !reached.has(importer) && !omitted.has(importer)) {
927
+ omitted.set(importer, {
928
+ file: importer,
929
+ hop,
930
+ fanIn: importerFanIn,
931
+ via: item.via,
932
+ path,
933
+ reason: "hub-damped",
934
+ detail: `weight ${weight.toFixed(2)} < ${cutoff}: reached through ${item.file} (fan-in ${fanIn.get(item.file) ?? 0}); the hub's importers are not walked one by one`,
935
+ });
936
+ }
937
+ continue;
938
+ }
939
+ // Even behind a file edge (`*`: every name of the module counts as changed) a parseable importer is judged:
940
+ // it consumes whatever it imports, and walks on only through its exports that depend on those bindings.
941
+ const verdict = judgeImporter(importer, specifiers, item.names, input.read);
942
+ if (verdict.kind === "fallback") {
943
+ record({ file: importer, hop, weight, via: item.via, path, names: "*", uses: "*", mode: "file-edge" }, importerFanIn);
944
+ queue.push({ file: importer, hop, weight, names: "*", via: item.via, path, mode: "file-edge" });
945
+ continue;
946
+ }
947
+ if (!verdict.consumer && verdict.passThrough === undefined) {
948
+ if (!changed.has(importer) && !reached.has(importer) && !omitted.has(importer)) {
949
+ const wanted = item.names === "*" ? "*" : [...item.names].sort().join(", ");
950
+ omitted.set(importer, {
951
+ file: importer,
952
+ hop,
953
+ fanIn: importerFanIn,
954
+ via: item.via,
955
+ path,
956
+ reason: "unrelated-symbol",
957
+ detail: `imports ${verdict.imported.length === 0 ? "nothing by name" : `{${verdict.imported.join(", ")}}`} from ${item.file}; changed: {${wanted}}`,
958
+ });
959
+ }
960
+ continue;
961
+ }
962
+ if (!verdict.consumer) {
963
+ // A pure barrel: the names pass through at the same hop.
964
+ barrels.add(importer);
965
+ queue.push({ file: importer, hop: item.hop, weight, names: verdict.passThrough as ChangedNames, via: item.via, path, mode: "symbol" });
966
+ continue;
967
+ }
968
+ record({ file: importer, hop, weight, via: item.via, path, names: namesOut(verdict.next), uses: namesOut(verdict.uses), mode: "symbol" }, importerFanIn);
969
+ queue.push({ file: importer, hop, weight, names: verdict.next, via: item.via, path, mode: "symbol" });
970
+ }
971
+ }
972
+
973
+ function record(entry: Omit<SymbolReachEntry, "fanIn">, entryFanIn: number): void {
974
+ if (changed.has(entry.file)) return;
975
+ const existing = reached.get(entry.file);
976
+ if (existing !== undefined && !better(entry, existing)) return;
977
+ omitted.delete(entry.file);
978
+ reached.set(entry.file, { ...entry, fanIn: entryFanIn });
979
+ }
980
+
981
+ for (const file of reached.keys()) omitted.delete(file);
982
+ for (const file of barrels) if (reached.has(file)) barrels.delete(file);
983
+ const order = <T extends { hop: number; file: string }>(a: T, b: T): number => a.hop - b.hop || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
984
+ return {
985
+ seeds,
986
+ reached: [...reached.values()].sort(order),
987
+ omitted: [...omitted.values()].sort(order),
988
+ barrels: [...barrels].sort(),
989
+ };
990
+ }
991
+
992
+ // ---------------------------------------------------------------------------
993
+ // Readers
994
+ // ---------------------------------------------------------------------------
995
+
996
+ function gitText(cwd: string, args: string[]): string | undefined {
997
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
998
+ return result.status === 0 ? result.stdout : undefined;
999
+ }
1000
+
1001
+ /** Head = the working tree (what an unpinned radius measures); base read from git when given. */
1002
+ export function workingTreeSourceReader(cwd: string, base?: string): SourceReader {
1003
+ const cache = new Map<string, string | undefined>();
1004
+ const read = (key: string, load: () => string | undefined): string | undefined => {
1005
+ if (!cache.has(key)) cache.set(key, load());
1006
+ return cache.get(key);
1007
+ };
1008
+ return {
1009
+ head: (file) =>
1010
+ read(`head\0${file}`, () => {
1011
+ try {
1012
+ return readFileSync(path.join(cwd, file), "utf8");
1013
+ } catch {
1014
+ return undefined;
1015
+ }
1016
+ }),
1017
+ ...(base === undefined ? {} : { base: (file: string) => read(`base\0${file}`, () => gitText(cwd, ["show", "--end-of-options", `${base}:${file}`])) }),
1018
+ };
1019
+ }
1020
+
1021
+ /**
1022
+ * The commit the old side of `git diff base...head` is: `git merge-base base head`. The hunk line numbers of a
1023
+ * three-dot diff count lines at that commit, not at `base`, which may have moved on since the branch forked. Falls back
1024
+ * to `base` itself when there is no merge base (unrelated histories, a revision git cannot resolve).
1025
+ */
1026
+ export function diffOldSide(cwd: string, base: string, head: string): string {
1027
+ if (base.startsWith("-") || head.startsWith("-")) return base;
1028
+ return gitText(cwd, ["merge-base", "--end-of-options", base, head])?.trim() || base;
1029
+ }
1030
+
1031
+ /**
1032
+ * Reads sources at `head` (and the old side of `base...head`) from git. The base text is read at
1033
+ * `git merge-base base head`, the commit the three-dot diff's hunks are numbered against. When the checkout's HEAD is
1034
+ * `head`, a file without uncommitted changes is read from disk instead, which is the same bytes without a process per
1035
+ * file.
1036
+ */
1037
+ export function gitSourceReader(cwd: string, head: string, base?: string): SourceReader {
1038
+ const oldSide = base === undefined ? undefined : diffOldSide(cwd, base, head);
1039
+ const checkoutHead = gitText(cwd, ["rev-parse", "HEAD"])?.trim();
1040
+ const headFull = gitText(cwd, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${head}^{commit}`])?.trim();
1041
+ const fromDisk = checkoutHead !== undefined && checkoutHead === headFull;
1042
+ const dirty = new Set<string>();
1043
+ if (fromDisk) {
1044
+ for (const line of (gitText(cwd, ["status", "--porcelain", "--no-renames"]) ?? "").split("\n")) {
1045
+ if (line.length > 3) dirty.add(line.slice(3).trim());
1046
+ }
1047
+ }
1048
+ const cache = new Map<string, string | undefined>();
1049
+ const at = (rev: string, file: string, disk: boolean): string | undefined => {
1050
+ const key = `${rev}\0${file}`;
1051
+ if (cache.has(key)) return cache.get(key);
1052
+ let text: string | undefined;
1053
+ if (disk && !dirty.has(file)) {
1054
+ try {
1055
+ text = readFileSync(path.join(cwd, file), "utf8");
1056
+ } catch {
1057
+ text = undefined;
1058
+ }
1059
+ } else {
1060
+ text = gitText(cwd, ["show", "--end-of-options", `${rev}:${file}`]);
1061
+ }
1062
+ cache.set(key, text);
1063
+ return text;
1064
+ };
1065
+ return {
1066
+ head: (file) => at(head, file, fromDisk),
1067
+ ...(oldSide === undefined ? {} : { base: (file: string) => at(oldSide, file, false) }),
1068
+ };
1069
+ }