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.
- package/README.md +16 -1
- package/dist/src/commands/common.js +83 -16
- package/dist/src/commands/def.js +6 -6
- package/dist/src/commands/home.js +35 -43
- package/dist/src/commands/impact.js +15 -23
- package/dist/src/commands/refs.js +95 -15
- package/dist/src/impact.js +5 -27
- package/dist/src/importers.js +182 -0
- package/dist/src/project.js +110 -73
- package/dist/src/scope.js +119 -0
- package/dist/src/symbols.js +324 -20
- package/dist/src/walk.js +96 -0
- package/dist/src/workspace.js +297 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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[
|
|
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
|
-
|
|
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 = {
|
|
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`,
|
|
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
|
-
|
|
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
|
|
128
|
-
: renderRefs(semantic, opened, resolved, header, {
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/impact.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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(
|
|
114
|
-
const beforeSigs = beforeSide ? signatures(
|
|
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
|
+
}
|