ts-axi 0.1.2 → 0.1.4

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.
@@ -1,11 +1,16 @@
1
1
  import { relative, sep } from "node:path";
2
2
  import { bool, expectPositionals, flag, intFlag, parseArgs } from "../args.js";
3
- import { createSemanticProject, findOutsideProject, nearestTsconfig } from "../project.js";
3
+ import { importerClosure } from "../importers.js";
4
+ import { textualMatches } from "./impact.js";
5
+ import { findOutsideProject, nearestTsconfig } from "../project.js";
4
6
  import { viaLine } from "../references.js";
5
7
  import { block, helpBlock, out } from "../render.js";
8
+ import { importerIndexFor, REFS_HOPS, scopeFields, scopeHelp, semanticFor } from "../scope.js";
9
+ import { byDepthThenPath } from "../walk.js";
10
+ import { detectWorkspace, isMultiProject } from "../workspace.js";
6
11
  import { isTestFile } from "../settings.js";
7
12
  import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
8
- import { contextOf, declarationAt, filterSuffix, lineOf, matchRows, narrowHints, open, positionStartDir, readFilters, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
13
+ import { contextOf, declarationAt, filterSuffix, lineOf, MATCH_ROWS, matchRows, narrowHints, open, positionStartDir, projectFields, readFilters, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
9
14
  const DEFAULT_LIMIT = 50;
10
15
  const MAX_LIMIT = 500;
11
16
  const DEPTH_ROWS = 50;
@@ -13,16 +18,21 @@ const SHARED_FLAGS = `--kind <function|class|method|property|variable|interface|
13
18
  const VIA_DOC = `via_interface: n (Iface.member) = References through an interface or base member the Symbol implements or overrides - they MAY reach it; not in refs_total
14
19
  via_implementations: n (Impl.member) = References to implementations or overrides of the Symbol; not in refs_total
15
20
  implementations: n = members that implement or override the Symbol (their Declarations are never refs)
16
- matches: 0 under -P lists found_outside_project[n]{file,line} when the name is declared outside that Project`;
21
+ matches: 0 under -P lists found_outside_project[n]{file,line} when the name is declared outside that Project
22
+ refs_total: 0 under -P in a workspace lists found_outside_project[n]{file,line} = importers outside that Project
23
+ scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
24
+ tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references`;
17
25
  export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
18
- flags[11]:
26
+ flags[12]:
19
27
  ${SHARED_FLAGS}
20
28
  --implementations (lists implementations[n]{file,line,symbol} instead of the count)
29
+ --grep (adds unresolved[n]{file,line,context} - textual matches of the name that are not References; also searches .astro/.vue/.svelte/.mdx/.html)
21
30
  --kind calls = same as \`ts-axi callers\`
22
31
  output:
23
32
  symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,context} (Declarations excluded), elapsed_ms
24
33
  ${VIA_DOC}
25
- an ambiguous name lists matches[n] + the disambiguated forms; nothing is analysed
34
+ note = the name is registered by a string ('Name' in .controller('Name', fn)) - its uses are textual; add --grep
35
+ an ambiguous name lists matches[n] (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms; nothing is analysed
26
36
  examples[4]:
27
37
  ts-axi refs User.save
28
38
  ts-axi refs src/models/user.ts:12
@@ -55,7 +65,7 @@ async function run(args, ctx, invoked) {
55
65
  { name: "--limit" },
56
66
  { name: "--test-glob", repeatable: true },
57
67
  { name: "--via", boolean: true },
58
- ...(invoked === "calls" ? [{ name: "--depth" }] : [{ name: "--implementations", boolean: true }]),
68
+ ...(invoked === "calls" ? [{ name: "--depth" }] : [{ name: "--implementations", boolean: true }, { name: "--grep", boolean: true }]),
59
69
  ];
60
70
  const parsed = parseArgs(args, specs, command);
61
71
  const [raw] = expectPositionals(parsed, 1, `${command} <name | Class.member | path:line[:col]> [flags]`);
@@ -67,7 +77,11 @@ async function run(args, ctx, invoked) {
67
77
  const label = targetLabel(target);
68
78
  const project = flag(parsed, "-P");
69
79
  const name = mode === "calls" ? "callers" : "refs";
70
- const show = { via: bool(parsed, "--via"), implementations: invoked === "refs" && bool(parsed, "--implementations") };
80
+ const show = {
81
+ via: bool(parsed, "--via"),
82
+ implementations: invoked === "refs" && bool(parsed, "--implementations"),
83
+ grep: invoked === "refs" && bool(parsed, "--grep"),
84
+ };
71
85
  const started = performance.now();
72
86
  const opened = open(ctx.cwd, parsed, positionStartDir(ctx.cwd, target));
73
87
  const warning = opened.warning ? { warning: opened.warning } : {};
@@ -100,10 +114,10 @@ async function run(args, ctx, invoked) {
100
114
  ]));
101
115
  }
102
116
  if (groups.length > 1) {
103
- return out(block({ ...warning, symbol: label, ambiguous: `${groups.length} Symbols - pick one`, matches: matchRows(groups) }), helpBlock(narrowHints(name, groups, f, `${label}${filterSuffix(f, project)}`)));
117
+ return out(block({ ...warning, symbol: label, ambiguous: `${groups.length} Symbols - pick one`, ...matchRows(groups) }), helpBlock(narrowHints(opened, name, groups, f, `${label}${filterSuffix(f, project)}`, target)));
104
118
  }
105
119
  const decl = groups[0].decls[0];
106
- resolved = { absFile: opened.index.abs(decl.file), pos: decl.namePos, decl };
120
+ resolved = { absFile: opened.index.abs(decl.file), pos: decl.namePos, decl, seeds: [...new Set(groups[0].decls.map((d) => opened.index.abs(d.file)))] };
107
121
  }
108
122
  else {
109
123
  const at = resolvePosition(opened.index, ctx.cwd, target);
@@ -115,17 +129,62 @@ async function run(args, ctx, invoked) {
115
129
  }
116
130
  resolved = at.kind === "decl" ? { absFile: at.abs, pos: at.decl.namePos, decl: at.decl } : { absFile: at.abs, pos: at.pos };
117
131
  }
118
- const semantic = createSemanticProject(opened.index);
132
+ let built = semanticFor(opened, resolved.seeds ?? [resolved.absFile], resolved.decl ? REFS_HOPS : 0);
133
+ if (!resolved.decl && built.scope) {
134
+ // A use position: find its Declaration in a small program, then scope around the declaring file.
135
+ const at = built.semantic.definitionAt(resolved.absFile, resolved.pos);
136
+ if (at && at.line > 0)
137
+ resolved = { absFile: at.absFile, pos: at.pos };
138
+ built = semanticFor(opened, [resolved.absFile], REFS_HOPS);
139
+ }
140
+ const { semantic, scope } = built;
119
141
  const def = semantic.definitionAt(resolved.absFile, resolved.pos);
120
142
  const decl = resolved.decl ??
121
143
  (def && def.line > 0 ? declarationAt(opened.index, def.absFile, def.pos, kindFromScriptElement(def.kind)) : undefined);
122
144
  const kind = decl?.kind ?? kindFromScriptElement(def?.kind ?? semantic.quickInfo(resolved.absFile, resolved.pos)?.kind ?? "");
123
145
  const symbol = decl?.qualifiedName ?? def?.name ?? label;
124
146
  const declaredAt = decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
125
- const header = { ...warning, symbol, kind, declared_at: declaredAt };
147
+ const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
148
+ const explicit = Boolean(project || opened.settings.project);
149
+ const o = { started, symbol, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile, ...(decl ? { decl } : {}) };
126
150
  return mode === "calls"
127
- ? renderCallers(semantic, opened, resolved, header, { limit, depth, started, symbol, declaredAt, show })
128
- : renderRefs(semantic, opened, resolved, header, { limit, started, symbol, declaredAt, kind, show });
151
+ ? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
152
+ : renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
153
+ }
154
+ const OUTSIDE_ROWS = 5;
155
+ /**
156
+ * No References under an explicit Project: importers of the declaring file (or of a barrel
157
+ * re-exporting it) elsewhere in the workspace, from the syntactic importer index.
158
+ */
159
+ function outsideImporters(opened, o) {
160
+ if (!o.explicit)
161
+ return { fields: {}, help: [] };
162
+ const workspace = detectWorkspace(opened.root);
163
+ if (!isMultiProject(workspace.kind))
164
+ return { fields: {}, help: [] };
165
+ const index = importerIndexFor(opened.index, workspace);
166
+ const closure = importerClosure(index, [o.absFile], REFS_HOPS);
167
+ const inProject = new Set(opened.index.files);
168
+ const barrels = new Set(closure.barrels);
169
+ const targets = [o.absFile, ...closure.barrels];
170
+ const needle = o.symbol.split(".").pop();
171
+ const rows = closure.importers
172
+ .filter((f) => !inProject.has(f) && !barrels.has(f) && (opened.index.text(f) ?? "").includes(needle))
173
+ .map((f) => {
174
+ const line = targets.flatMap((t) => index.importersOf(t)).find((i) => i.file === f)?.line ?? 1;
175
+ return { file: opened.index.rel(f), line };
176
+ })
177
+ .sort((a, b) => byDepthThenPath(a.file, b.file));
178
+ if (rows.length === 0)
179
+ return { fields: {}, help: [] };
180
+ return {
181
+ fields: {
182
+ found_outside_project: rows.slice(0, OUTSIDE_ROWS),
183
+ ...(rows.length > OUTSIDE_ROWS ? { found_outside_total: rows.length } : {}),
184
+ hint: `${rows.length} files outside the selected project import it - run without -P for workspace scope`,
185
+ },
186
+ help: [`Run \`ts-axi ${o.name} ${o.label}\` without -P to search the whole workspace`],
187
+ };
129
188
  }
130
189
  function sortRows(rows) {
131
190
  return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col);
@@ -141,6 +200,7 @@ function footer(semantic, started, touched) {
141
200
  const diagnostics = semantic.diagnosticsCount(touched);
142
201
  return { elapsed_ms: Math.round(performance.now() - started), ...(diagnostics > 0 ? { diagnostics } : {}) };
143
202
  }
203
+ const NAMELESS_NOTE = "registered by a string - References to it are textual (templates; DI arrays; route configs)";
144
204
  /** `via_interface` / `via_implementations` counts, plus the hint to see their rows. */
145
205
  function viaFields(via, bases) {
146
206
  const base = via.filter((v) => v.through === "base");
@@ -173,11 +233,21 @@ function renderRefs(semantic, opened, resolved, header, o) {
173
233
  help.push(`Run \`ts-axi refs ${o.declaredAt} --via\` for the ${via.length} via References (${viaNames(via)})`);
174
234
  if (impls.length > 0 && !o.show.implementations)
175
235
  help.push(`Run \`ts-axi refs ${o.declaredAt} --implementations\` to list the ${impls.length} implementations`);
236
+ const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
237
+ help.unshift(...outside.help);
238
+ const word = o.symbol.split(".").pop();
239
+ const unresolved = o.show.grep
240
+ ? textualMatches(opened, semantic, [word], new Set([...rows, ...via, ...impls].map((r) => `${r.file}:${r.line}`)), new Set(o.decl ? [`${o.decl.file}:${o.decl.line}`, `${o.decl.file}:${o.decl.nameLine}`] : []), new Map())
241
+ : [];
242
+ if (o.decl?.nameless && !o.show.grep)
243
+ help.unshift(`Run \`ts-axi refs ${o.declaredAt} --grep\` for textual matches of '${word}'`);
176
244
  if (isFunctionLike(o.kind))
177
245
  help.push(`Run \`ts-axi callers ${o.declaredAt}\` for call sites with their enclosing function`);
178
246
  help.push(`Run \`ts-axi def ${o.declaredAt}\` for the declaration body`);
247
+ help.push(...scopeHelp(o.scope));
179
248
  return out(block({
180
249
  ...header,
250
+ ...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
181
251
  refs_total: rows.length,
182
252
  files,
183
253
  tests,
@@ -196,6 +266,10 @@ function renderRefs(semantic, opened, resolved, header, o) {
196
266
  })),
197
267
  }),
198
268
  ...(truncated ? { truncated: true } : {}),
269
+ ...(o.show.grep ? { unresolved: unresolved.length === 0 ? 0 : unresolved.slice(0, o.limit) } : {}),
270
+ ...(unresolved.length > o.limit ? { unresolved_total: unresolved.length } : {}),
271
+ ...outside.fields,
272
+ ...scopeFields(o.scope),
199
273
  ...footer(semantic, o.started, fileList),
200
274
  }), helpBlock(help));
201
275
  }
@@ -233,7 +307,7 @@ function depthRows(sites) {
233
307
  function renderCallers(semantic, opened, resolved, header, o) {
234
308
  const sites = semantic.incomingCalls(resolved.absFile, resolved.pos);
235
309
  if (sites === undefined) {
236
- return out(block({ ...header, refs_total: 0, note: "not callable - no call hierarchy for this Symbol", ...footer(semantic, o.started, []) }), helpBlock([`Run \`ts-axi refs ${o.declaredAt}\` for every Reference`]));
310
+ return out(block({ ...header, refs_total: 0, note: "not callable - no call hierarchy for this Symbol", ...scopeFields(o.scope), ...footer(semantic, o.started, []) }), helpBlock([`Run \`ts-axi refs ${o.declaredAt}\` for every Reference`]));
237
311
  }
238
312
  const rows = sortRows(sites.filter((s) => s.via === undefined));
239
313
  const via = sortRows(sites.filter((s) => s.via !== undefined));
@@ -262,9 +336,13 @@ function renderCallers(semantic, opened, resolved, header, o) {
262
336
  const top = shown.find((r) => r.caller !== "(top-level)");
263
337
  if (top && o.depth === 1)
264
338
  help.push(`Run \`ts-axi callers ${o.declaredAt} --depth 2\` for who calls the callers`);
265
- help.push(`Run \`ts-axi refs ${o.declaredAt}\` for every Reference (not only calls)`);
339
+ const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
340
+ help.unshift(...outside.help);
341
+ help.push(`Run \`ts-axi refs ${o.declaredAt}${o.decl?.nameless ? " --grep" : ""}\` for every Reference (not only calls)`);
342
+ help.push(...scopeHelp(o.scope));
266
343
  return out(block({
267
344
  ...header,
345
+ ...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
268
346
  refs_total: rows.length,
269
347
  files,
270
348
  tests,
@@ -282,6 +360,8 @@ function renderCallers(semantic, opened, resolved, header, o) {
282
360
  }),
283
361
  ...(truncated ? { truncated: true } : {}),
284
362
  ...levels,
363
+ ...outside.fields,
364
+ ...scopeFields(o.scope),
285
365
  ...footer(semantic, o.started, fileList),
286
366
  }), helpBlock(help));
287
367
  }
@@ -1,8 +1,5 @@
1
- // Pure `impact` logic: changed ranges -> Declarations -> Change classes, ordering
2
- // and row budgets. No git, no LanguageService: everything here is unit-testable.
3
- import ts from "typescript";
4
1
  import { parseSource } from "./project.js";
5
- import { collectDeclarations, fullSignatureOf, kindOf } from "./symbols.js";
2
+ import { collectDeclarations } from "./symbols.js";
6
3
  const TYPE_LEVEL = new Set(["interface", "type", "enum"]);
7
4
  export function matchKey(decl) {
8
5
  return `${decl.qualifiedName}#${decl.kind}`;
@@ -64,30 +61,11 @@ export function toRanges(lines) {
64
61
  export function formatRanges(ranges) {
65
62
  return ranges.map(([a, b]) => (a === b ? String(a) : `${a}-${b}`)).join(" ");
66
63
  }
67
- function nameStart(node, sf) {
68
- if (ts.isConstructorDeclaration(node)) {
69
- return node.getChildren(sf).find((c) => c.kind === ts.SyntaxKind.ConstructorKeyword)?.getStart(sf) ?? node.getStart(sf);
70
- }
71
- const name = node.name;
72
- return name ? name.getStart(sf) : node.getStart(sf);
73
- }
74
64
  /** Full Signature per match key; overloads are joined so adding one is a `signature` change. */
75
- export function signatures(sf, decls) {
76
- const nodes = new Map();
77
- const visit = (node) => {
78
- if (kindOf(node) !== undefined) {
79
- const at = nameStart(node, sf);
80
- // A variable statement starts at `export const`; collectDeclarations keys the declarator name.
81
- if (!nodes.has(at))
82
- nodes.set(at, node);
83
- }
84
- ts.forEachChild(node, visit);
85
- };
86
- visit(sf);
65
+ export function signatures(decls) {
87
66
  const result = new Map();
88
67
  for (const decl of decls) {
89
- const node = nodes.get(decl.namePos);
90
- const text = node ? fullSignatureOf(node, sf) : decl.signature;
68
+ const text = decl.fullSignature ?? decl.signature;
91
69
  const key = matchKey(decl);
92
70
  const prev = result.get(key);
93
71
  result.set(key, prev === undefined ? text : `${prev}\n${text}`);
@@ -110,8 +88,8 @@ export function classifyFile(input) {
110
88
  const beforeMap = beforeSide ? mapRanges(beforeDecls, input.before) : { touched: new Map(), outside: [] };
111
89
  const afterByKey = new Map(afterDecls.map((d) => [matchKey(d), d]));
112
90
  const beforeByKey = new Map(beforeDecls.map((d) => [matchKey(d), d]));
113
- const afterSigs = afterSide ? signatures(afterSide.sf, afterDecls) : new Map();
114
- const beforeSigs = beforeSide ? signatures(beforeSide.sf, beforeDecls) : new Map();
91
+ const afterSigs = afterSide ? signatures(afterDecls) : new Map();
92
+ const beforeSigs = beforeSide ? signatures(beforeDecls) : new Map();
115
93
  const keys = [...new Set([...afterMap.touched.keys(), ...beforeMap.touched.keys()])];
116
94
  const changes = [];
117
95
  for (const key of keys) {
@@ -0,0 +1,182 @@
1
+ // Syntactic reverse-import graph (regex over file text, no AST): which workspace files import
2
+ // a given file, through relative paths, `paths` aliases, workspace package names and barrels.
3
+ import { dirname, resolve } from "node:path";
4
+ const PATTERNS = [
5
+ { re: /\bexport\s+(?:type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s*from\s*(['"])([^'"\n]+)\1/g, reexport: true },
6
+ { re: /\bimport\s+(?:type\s+)?[^'";]*?\bfrom\s*(['"])([^'"\n]+)\1/g, reexport: false },
7
+ { re: /\bimport\s*(['"])([^'"\n]+)\1/g, reexport: false },
8
+ { re: /\b(?:require|import|(?:vi|jest)\.(?:mock|doMock|requireActual|importActual))\s*\(\s*(['"])([^'"\n]+)\1/g, reexport: false },
9
+ ];
10
+ /** Static specifiers of `import`/`export ... from`, side-effect imports, `require()`, `import()`, `vi.mock()`. */
11
+ export function extractImports(text) {
12
+ if (!/\b(?:import|export|require|mock)\b/.test(text))
13
+ return [];
14
+ const edges = new Map();
15
+ let lineStarts;
16
+ const lineAt = (offset) => {
17
+ lineStarts ??= [0, ...[...text.matchAll(/\n/g)].map((m) => m.index + 1)];
18
+ let lo = 0;
19
+ let hi = lineStarts.length - 1;
20
+ while (lo < hi) {
21
+ const mid = (lo + hi + 1) >> 1;
22
+ if (lineStarts[mid] <= offset)
23
+ lo = mid;
24
+ else
25
+ hi = mid - 1;
26
+ }
27
+ return lo + 1;
28
+ };
29
+ for (const { re, reexport } of PATTERNS) {
30
+ for (const m of text.matchAll(re)) {
31
+ const specOffset = m.index + m[0].lastIndexOf(m[2]);
32
+ // A position matched by an earlier (re-export) pattern keeps that classification.
33
+ if (!edges.has(specOffset))
34
+ edges.set(specOffset, { spec: m[2], reexport, line: lineAt(specOffset) });
35
+ }
36
+ }
37
+ return [...edges.entries()].sort((a, b) => a[0] - b[0]).map(([, e]) => e);
38
+ }
39
+ const EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
40
+ /** Resolves a specifier to a file of `files` (absolute paths); anything else (node_modules, builtins) is undefined. */
41
+ export function createResolver(input, files) {
42
+ const tryPath = (p, depth = 0) => {
43
+ if (files.has(p))
44
+ return p;
45
+ const stem = p.replace(/\.(?:d\.)?[mc]?[jt]sx?$/, "");
46
+ for (const ext of EXTENSIONS)
47
+ if (files.has(stem + ext))
48
+ return stem + ext;
49
+ for (const ext of EXTENSIONS)
50
+ if (files.has(`${p}/index${ext}`))
51
+ return `${p}/index${ext}`;
52
+ // Build output in a package manifest: look for the source next to it.
53
+ if (depth === 0 && /[\\/](?:dist|build|lib|out)[\\/]/.test(p))
54
+ return tryPath(p.replace(/([\\/])(?:dist|build|lib|out)([\\/])/, "$1src$2"), 1);
55
+ return undefined;
56
+ };
57
+ const aliases = Object.entries(input.paths?.paths ?? {})
58
+ .map(([pattern, targets]) => {
59
+ const star = pattern.indexOf("*");
60
+ return { pattern, targets, prefix: star < 0 ? pattern : pattern.slice(0, star), suffix: star < 0 ? "" : pattern.slice(star + 1), wildcard: star >= 0 };
61
+ })
62
+ // Exact patterns first, then the longest prefix (TypeScript's order).
63
+ .sort((a, b) => Number(a.wildcard) - Number(b.wildcard) || b.prefix.length - a.prefix.length);
64
+ const aliasBase = input.paths?.base ?? "";
65
+ const fromAlias = (spec) => {
66
+ for (const a of aliases) {
67
+ let captured;
68
+ if (!a.wildcard) {
69
+ if (spec !== a.pattern)
70
+ continue;
71
+ captured = "";
72
+ }
73
+ else {
74
+ if (!spec.startsWith(a.prefix) || !spec.endsWith(a.suffix) || spec.length < a.prefix.length + a.suffix.length)
75
+ continue;
76
+ captured = spec.slice(a.prefix.length, spec.length - a.suffix.length);
77
+ }
78
+ for (const target of a.targets) {
79
+ const hit = tryPath(resolve(aliasBase, target.replace("*", captured)));
80
+ if (hit)
81
+ return hit;
82
+ }
83
+ }
84
+ return undefined;
85
+ };
86
+ const packages = [...input.packages].sort((a, b) => b.name.length - a.name.length);
87
+ const fromPackage = (spec) => {
88
+ const pkg = packages.find((p) => spec === p.name || spec.startsWith(`${p.name}/`));
89
+ if (!pkg)
90
+ return undefined;
91
+ const sub = spec === pkg.name ? "." : `./${spec.slice(pkg.name.length + 1)}`;
92
+ const candidates = [...(pkg.exports[sub] ?? [])];
93
+ for (const [key, targets] of Object.entries(pkg.exports)) {
94
+ const star = key.indexOf("*");
95
+ if (star < 0 || !sub.startsWith(key.slice(0, star)) || !sub.endsWith(key.slice(star + 1)))
96
+ continue;
97
+ const captured = sub.slice(star, sub.length - (key.length - star - 1));
98
+ candidates.push(...targets.map((t) => t.replace("*", captured)));
99
+ }
100
+ if (sub !== ".")
101
+ candidates.push(resolve(pkg.dir, sub), resolve(pkg.dir, "src", sub));
102
+ for (const c of candidates) {
103
+ const hit = tryPath(c);
104
+ if (hit)
105
+ return hit;
106
+ }
107
+ return undefined;
108
+ };
109
+ return (spec, fromFile) => {
110
+ if (spec.startsWith(".") || spec.startsWith("/"))
111
+ return tryPath(resolve(dirname(fromFile), spec));
112
+ return fromAlias(spec) ?? fromPackage(spec);
113
+ };
114
+ }
115
+ export const IMPORTER_MAX_FILES = 50_000;
116
+ export const IMPORTER_MAX_MS = 3_000;
117
+ /** Reads every candidate once (bounded) and inverts its resolved imports. */
118
+ export function buildImporterIndex(files, read, resolveSpec, options = {}) {
119
+ const maxFiles = options.maxFiles ?? IMPORTER_MAX_FILES;
120
+ const deadline = performance.now() + (options.maxMs ?? IMPORTER_MAX_MS);
121
+ const reverse = new Map();
122
+ let scanned = 0;
123
+ let partial = false;
124
+ for (const file of files) {
125
+ if (scanned >= maxFiles || performance.now() > deadline) {
126
+ partial = true;
127
+ break;
128
+ }
129
+ scanned++;
130
+ const text = read(file);
131
+ if (text === undefined)
132
+ continue;
133
+ const seen = new Set();
134
+ for (const edge of extractImports(text)) {
135
+ const target = resolveSpec(edge.spec, file);
136
+ if (!target || target === file)
137
+ continue;
138
+ const key = `${target}#${edge.reexport}`;
139
+ if (seen.has(key))
140
+ continue;
141
+ seen.add(key);
142
+ const list = reverse.get(target) ?? [];
143
+ list.push({ file, reexport: edge.reexport, line: edge.line });
144
+ reverse.set(target, list);
145
+ }
146
+ }
147
+ return { scanned, total: files.length, partial, importersOf: (file) => reverse.get(file) ?? [] };
148
+ }
149
+ /**
150
+ * Importers of `seeds` up to `hops` levels; a re-export (barrel) does not use a hop, so
151
+ * importers of a barrel that re-exports a seed count as direct importers.
152
+ */
153
+ export function importerClosure(index, seeds, hops) {
154
+ const seedSet = new Set(seeds);
155
+ const importers = new Set();
156
+ const barrels = new Set();
157
+ let frontier = [...seeds];
158
+ const expanded = new Set(seeds);
159
+ for (let hop = 0; hop < hops && frontier.length > 0; hop++) {
160
+ const next = [];
161
+ const queue = [...frontier];
162
+ while (queue.length > 0) {
163
+ const file = queue.pop();
164
+ for (const imp of index.importersOf(file)) {
165
+ if (seedSet.has(imp.file))
166
+ continue;
167
+ importers.add(imp.file);
168
+ if (imp.reexport)
169
+ barrels.add(imp.file);
170
+ if (expanded.has(imp.file))
171
+ continue;
172
+ expanded.add(imp.file);
173
+ if (imp.reexport)
174
+ queue.push(imp.file);
175
+ else
176
+ next.push(imp.file);
177
+ }
178
+ }
179
+ frontier = next;
180
+ }
181
+ return { importers: [...importers].sort(), barrels: [...barrels].sort() };
182
+ }