ts-axi 0.1.2 → 0.1.3

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 CHANGED
@@ -75,6 +75,20 @@ npx skills add cmosgh/ts-axi --skill ts-axi
75
75
 
76
76
  <!-- TODO(phase 4): paste real examples for def / refs / outline / config from the fixture project. -->
77
77
 
78
+ ## Monorepos
79
+
80
+ No configuration and no new files in your repo: ts-axi detects the layout from what is already there - `nx.json` (projects = `project.json` dirs, or a legacy `workspace.json`), `pnpm-workspace.yaml`, `package.json` `workspaces`, or a root `tsconfig.json` that only holds `references`. The home view prints `workspace: nx (14 projects)`.
81
+
82
+ Without `-P`, `refs`, `callers` and `impact` do not type-check the whole repo. They build the program from a subgraph: the declaring file (for `impact`, the touched files) plus every file that imports it, found by a fast text scan of import specifiers that follows `tsconfig.base.json` `paths`, workspace package names and `export * from` barrels. A caller in another project is found; the cost tracks the subgraph, not the repo. Each result says what was analysed:
83
+
84
+ ```
85
+ scope: nx workspace - 30 of 5433 files (seeds 1; importers 29)
86
+ ```
87
+
88
+ `scope_partial: true` means the importer scan stopped at its cap (50k files or 3 s); the help line says what is missing. `-P <tsconfig>` still analyses exactly one project; when it finds nothing, `found_outside_project` lists the importers outside it. A `-P` tsconfig that only holds `references` (an Nx project's `tsconfig.json`) is expanded to the configs it references: `tsconfig: libs/shared/tsconfig.json (2 referenced configs)`.
89
+
90
+ Files are listed with `git ls-files` (tracked plus untracked, gitignore applied), so nested worktrees and ignored copies are never analysed. Outside git, a directory walk skips `node_modules`, build output, `.claude`, `.cursor`, `.idea`, `.vscode` and any nested checkout.
91
+
78
92
  ## Test files
79
93
 
80
94
  A file counts as a test by path pattern. Defaults: `**/*.test.*`, `**/*.spec.*`, `**/__tests__/**`, `**/test/**`, `**/tests/**`, `**/e2e/**`. The home view shows `test_globs` and `test_files` so a wrong rule is visible.
@@ -100,7 +114,7 @@ Any other key is a `VALIDATION_ERROR` naming the valid ones. `--test-glob <glob>
100
114
  One dependency: the `typescript` compiler API (ADR in `docs/adr/0001-typescript-language-service-engine.md`).
101
115
 
102
116
  - `outline`, `def` and the home view only parse files (`ts.createSourceFile`): no tsconfig needed, well under a second.
103
- - `refs`, `callers` and `impact` build a `LanguageService` over the nearest `tsconfig.json` (or an implicit project of every source file when none exists, with a warning) and ask it for references and call hierarchy. Symbol identity comes from the checker, so `user.save` resolves to `User.save` through imports and re-exports.
117
+ - `refs`, `callers` and `impact` build a `LanguageService` over the nearest `tsconfig.json` (or an implicit project of every source file when none exists, with a warning; in a monorepo or an implicit project of more than 3000 files, over the importer subgraph described above) and ask it for references and call hierarchy. Symbol identity comes from the checker, so `user.save` resolves to `User.save` through imports and re-exports.
104
118
  - `impact` reads the "before" version of touched files with `git show` and compares signatures to classify each change (`added`, `removed`, `signature`, `body`, `type`, `modified`). Uses it cannot follow (access through `any`, dynamic string keys) are reported as `blind_spots`, never silently dropped.
105
119
  - Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
106
120
 
@@ -8,6 +8,7 @@ import { usageError } from "../errors.js";
8
8
  import { hasGlobMagic, matchesGlob } from "../glob.js";
9
9
  import { openProject } from "../project.js";
10
10
  import { loadSettings } from "../settings.js";
11
+ import { byDepthThenPath } from "../walk.js";
11
12
  import { clip, didYouMean, groupSymbols, KINDS, kindOf, signatureOf, } from "../symbols.js";
12
13
  export const BODY_LINES = 60;
13
14
  export const CONTEXT_MAX = 100;
@@ -71,7 +72,15 @@ export function resolveNameTarget(ctx, target, f) {
71
72
  ...(target.member !== undefined ? { member: target.member } : {}),
72
73
  fuzzy: f.fuzzy,
73
74
  });
74
- return groupSymbols(applyFilters(ctx, found, f));
75
+ // Shallowest path first: the canonical copy before nested duplicates.
76
+ return groupSymbols(applyFilters(ctx, found, f)).sort((a, b) => byDepthThenPath(a.primary.file, b.primary.file) || a.decls[0].nameLine - b.decls[0].nameLine);
77
+ }
78
+ /** `tsconfig: x (n referenced configs)` when a references-only tsconfig was expanded. */
79
+ export function projectFields(ctx) {
80
+ const { tsconfig, referenced } = ctx.index.project;
81
+ if (!tsconfig || referenced === undefined)
82
+ return {};
83
+ return { tsconfig: `${relative(ctx.cwd, tsconfig).split(sep).join("/")} (${referenced} referenced configs)` };
75
84
  }
76
85
  export function suggestions(ctx, target) {
77
86
  const names = [...ctx.index.allNames()].filter((n) => (target.member === undefined ? !n.includes(".") : n.includes(".")));
@@ -1,5 +1,5 @@
1
1
  import { bool, expectPositionals, flag, parseArgs } from "../args.js";
2
- import { createSemanticProject } from "../project.js";
2
+ import { semanticFor } from "../scope.js";
3
3
  import { block, fenced, helpBlock, out } from "../render.js";
4
4
  import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
5
5
  import { BODY_LINES, declarationAt, filterSuffix, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, } from "./common.js";
@@ -63,7 +63,7 @@ export async function defCommand(args, ctx) {
63
63
  return out(block({ position: label, identifiers: at.candidates }), helpBlock(at.candidates.slice(0, 3).map((c) => `Run \`ts-axi def ${at.file}:${c.line}:${c.col}\` for ${c.name}`)));
64
64
  }
65
65
  // A reference, not a Declaration: ask the checker where it is declared.
66
- const semantic = createSemanticProject(opened.index);
66
+ const { semantic } = semanticFor(opened, [at.abs], 0);
67
67
  const def = semantic.definitionAt(at.abs, at.pos);
68
68
  const decl = def && def.line > 0 ? declarationAt(opened.index, def.absFile, def.pos, kindFromScriptElement(def.kind)) : undefined;
69
69
  if (!decl) {
@@ -5,8 +5,9 @@ import { usageError } from "../errors.js";
5
5
  import { currentBranch, dirtyFileCount, isGitRepo } from "../git.js";
6
6
  import { block, helpBlock, out } from "../render.js";
7
7
  import { SETTINGS_FILE, isTestFile, loadSettings, readSettingsFile } from "../settings.js";
8
+ import { listFiles, skipDir } from "../walk.js";
9
+ import { detectWorkspace, hasMultiProjectSignal, isMultiProject } from "../workspace.js";
8
10
  const SOURCE_EXT = /\.(?:[cm]?[jt]s|[jt]sx)$/;
9
- const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git"]);
10
11
  /** The view runs at session start: bound the file walk by count and time. */
11
12
  const WALK_CAP = 20_000;
12
13
  const WALK_DEADLINE_MS = 600;
@@ -15,8 +16,8 @@ const HOME_SPECS = [
15
16
  { name: "--test-glob", repeatable: true },
16
17
  { name: "--project", alias: "-P" },
17
18
  ];
18
- /** Does `dir` hold a TS/JS source within `depth` levels (node_modules skipped)? */
19
- function hasSources(dir, depth) {
19
+ /** Does `dir` hold a TS/JS source within `depth` levels (shared skip rule)? */
20
+ function hasSources(dir, depth, root = dir) {
20
21
  let entries;
21
22
  try {
22
23
  entries = readdirSync(dir, { withFileTypes: true });
@@ -30,36 +31,11 @@ function hasSources(dir, depth) {
30
31
  }
31
32
  if (depth === 0)
32
33
  return false;
33
- return entries.some((e) => e.isDirectory() && e.name !== "node_modules" && e.name !== ".git" && hasSources(join(dir, e.name), depth - 1));
34
+ return entries.some((e) => e.isDirectory() && !skipDir(e.name, join(dir, e.name), root) && hasSources(join(dir, e.name), depth - 1, root));
34
35
  }
35
- /** Implicit project: every source below `root`, minus node_modules, dist, build, .git. */
36
+ /** Implicit project: every source below `root` from the shared listing (git, else a bounded fs walk). */
36
37
  function walkSources(root) {
37
- const files = [];
38
- const deadline = Date.now() + WALK_DEADLINE_MS;
39
- let capped = false;
40
- const stack = [root];
41
- while (stack.length > 0 && !capped) {
42
- const dir = stack.pop();
43
- let entries;
44
- try {
45
- entries = readdirSync(dir, { withFileTypes: true });
46
- }
47
- catch {
48
- continue;
49
- }
50
- for (const entry of entries) {
51
- if (entry.isDirectory()) {
52
- if (!SKIP_DIRS.has(entry.name))
53
- stack.push(join(dir, entry.name));
54
- }
55
- else if (entry.isFile() && SOURCE_EXT.test(entry.name)) {
56
- files.push(join(dir, entry.name));
57
- }
58
- }
59
- if (files.length >= WALK_CAP || Date.now() > deadline)
60
- capped = true;
61
- }
62
- return { files, capped };
38
+ return listFiles(root, (name) => SOURCE_EXT.test(name) && !/\.d\.[mc]?ts$/.test(name), { maxFiles: WALK_CAP, deadline: Date.now() + WALK_DEADLINE_MS });
63
39
  }
64
40
  function findTsconfig(cwd) {
65
41
  let dir = cwd;
@@ -73,14 +49,16 @@ function findTsconfig(cwd) {
73
49
  dir = parent;
74
50
  }
75
51
  }
76
- async function tsconfigFiles(tsconfig) {
77
- const ts = (await import("typescript")).default;
78
- const read = ts.readConfigFile(tsconfig, ts.sys.readFile);
79
- if (read.error)
52
+ async function tsconfigFiles(tsconfig, root) {
53
+ // Loaded lazily: the TypeScript module is only needed when a tsconfig exists.
54
+ const { loadProject } = await import("../project.js");
55
+ try {
56
+ const project = loadProject(root, { path: tsconfig, implicit: false });
57
+ return { files: project.fileNames, capped: false, ...(project.referenced !== undefined ? { referenced: project.referenced } : {}) };
58
+ }
59
+ catch {
80
60
  return { files: [], capped: false };
81
- const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(tsconfig), undefined, tsconfig);
82
- const files = parsed.fileNames.filter((f) => !f.split(/[\\/]/).includes("node_modules"));
83
- return { files, capped: false };
61
+ }
84
62
  }
85
63
  function rel(root, path) {
86
64
  return relative(root, path).split(sep).join("/") || ".";
@@ -112,17 +90,24 @@ function resolveTsconfig(cwd, settings) {
112
90
  }
113
91
  return findTsconfig(cwd);
114
92
  }
115
- export async function homeCommand(args) {
93
+ export async function homeCommand(args, ctx) {
116
94
  const parsed = parseArgs(args, HOME_SPECS, "ts-axi");
117
95
  expectPositionals(parsed, 0, "ts-axi [-P <tsconfig>] [--test-glob <glob>]");
118
- const cwd = process.cwd();
96
+ const cwd = ctx?.cwd ?? process.cwd();
119
97
  const project = flag(parsed, "--project");
120
98
  const settings = loadSettings(cwd, {
121
99
  testGlobs: flags(parsed, "--test-glob"),
122
100
  ...(project !== undefined ? { project } : {}),
123
101
  });
124
102
  const tsconfig = resolveTsconfig(cwd, settings);
125
- if (tsconfig === undefined && !hasSources(cwd, PROBE_DEPTH)) {
103
+ // Same rule as the semantic commands: no explicit project + a multi-project workspace = workspace scope.
104
+ let workspace;
105
+ if (settings.project === undefined && (tsconfig === undefined || hasMultiProjectSignal(settings.root))) {
106
+ const detected = detectWorkspace(settings.root);
107
+ if (isMultiProject(detected.kind))
108
+ workspace = detected;
109
+ }
110
+ if (tsconfig === undefined && workspace === undefined && !hasSources(cwd, PROBE_DEPTH)) {
126
111
  return out(block({ project: "none" }), helpBlock([
127
112
  "Run `ts-axi outline <file>` on any .ts/.js file; it needs no project",
128
113
  "Run `ts-axi --help` for all commands",
@@ -130,7 +115,9 @@ export async function homeCommand(args) {
130
115
  }
131
116
  const [git, counted] = await Promise.all([
132
117
  gitSummary(cwd),
133
- tsconfig !== undefined ? tsconfigFiles(tsconfig) : Promise.resolve(walkSources(settings.root)),
118
+ workspace
119
+ ? Promise.resolve({ files: workspace.sourceFiles, capped: false })
120
+ : tsconfig !== undefined ? tsconfigFiles(tsconfig, settings.root) : Promise.resolve(walkSources(settings.root)),
134
121
  ]);
135
122
  const testFiles = counted.files.filter((f) => isTestFile(rel(settings.root, f), settings)).length;
136
123
  const plus = counted.capped ? "+" : "";
@@ -143,7 +130,12 @@ export async function homeCommand(args) {
143
130
  help.push("Run `ts-axi outline <file>` to list the declarations in a file", "Run `ts-axi def <name>` to find where a symbol is declared", "Run `ts-axi config` to see or change the test-file globs", "Run `ts-axi --help` for all commands");
144
131
  return out(block({
145
132
  root: settings.root,
146
- tsconfig: tsconfig === undefined ? "implicit" : rel(settings.root, tsconfig),
133
+ ...(workspace ? { workspace: `${workspace.kind} (${workspace.projects.length} projects)` } : {}),
134
+ tsconfig: workspace
135
+ ? "nearest per file - workspace scope"
136
+ : tsconfig === undefined
137
+ ? "implicit"
138
+ : `${rel(settings.root, tsconfig)}${counted.referenced !== undefined ? ` (${counted.referenced} referenced configs)` : ""}`,
147
139
  source_files: counted.capped ? `${counted.files.length}${plus}` : counted.files.length,
148
140
  test_globs: settings.testGlobs.length,
149
141
  test_files: counted.capped ? `${testFiles}${plus}` : testFiles,
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, sep } from "node:path";
3
3
  import ts from "typescript";
4
4
  import { bool, intFlag, parseArgs, flag } from "../args.js";
@@ -6,17 +6,18 @@ import { changedRanges, parseUnifiedDiff } from "../diff.js";
6
6
  import { AxiError, usageError } from "../errors.js";
7
7
  import { diffNameStatus, diffUnified, isGitRepo, resolveBaseRev, showFile, untrackedFiles } from "../git.js";
8
8
  import { allocateBudget, classifyFile, compareChanges, formatRanges, inRanges, localOnlyLine, matchKey, reverseApply, wordMatches, } from "../impact.js";
9
- import { createSemanticProject, loadProject, SOURCE_EXTENSIONS } from "../project.js";
9
+ import { loadProject, SOURCE_EXTENSIONS } from "../project.js";
10
10
  import { block, helpBlock, out } from "../render.js";
11
11
  import { isTestFile } from "../settings.js";
12
12
  import { isFunctionLike } from "../symbols.js";
13
- import { contextOf, lineOf, open } from "./common.js";
13
+ import { IMPACT_HOPS, scopeFields, scopeHelp, semanticFor } from "../scope.js";
14
+ import { walkFiles } from "../walk.js";
15
+ import { contextOf, lineOf, open, projectFields } from "./common.js";
14
16
  const DEFAULT_LIMIT = 50;
15
17
  const MAX_LIMIT = 500;
16
18
  const SIDE_ROWS = 20;
17
19
  /** Non-TS files `--grep` also searches: templates that use TS exports the checker never sees. */
18
20
  const GREP_EXTRA = [".astro", ".vue", ".svelte", ".mdx", ".html"];
19
- const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage"]);
20
21
  export const IMPACT_HELP = `usage: ts-axi impact [<git-range> | -] [flags]
21
22
  input:
22
23
  (none) = working tree vs HEAD (staged + unstaged + untracked); --staged = index vs HEAD
@@ -34,6 +35,7 @@ output:
34
35
  blind_spots[n]{symbol,file,line,reason} = uses the checker cannot follow (any; string keys; unresolved export *)
35
36
  skipped[n]{file,reason} = files in the diff that were not analysed (.astro, .json, outside the Project)
36
37
  tests: 0 on an exported signature or body change = no Test file references it
38
+ scope = in a monorepo without -P the program is the touched files + two levels of importers (scope_partial: true when capped)
37
39
  examples[5]:
38
40
  ts-axi impact
39
41
  ts-axi impact --staged
@@ -223,22 +225,7 @@ function blindSpots(semantic, names, changeSet) {
223
225
  return spots;
224
226
  }
225
227
  function walkExtra(dir, outFiles) {
226
- let entries;
227
- try {
228
- entries = readdirSync(dir, { withFileTypes: true });
229
- }
230
- catch {
231
- return;
232
- }
233
- for (const entry of entries) {
234
- if (entry.isDirectory()) {
235
- if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith("."))
236
- walkExtra(join(dir, entry.name), outFiles);
237
- }
238
- else if (GREP_EXTRA.some((ext) => entry.name.endsWith(ext))) {
239
- outFiles.push(join(dir, entry.name));
240
- }
241
- }
228
+ outFiles.push(...walkFiles(dir, { accept: (name) => GREP_EXTRA.some((ext) => name.endsWith(ext)) }).files);
242
229
  }
243
230
  function textualMatches(opened, semantic, names, known, skipLines, changeSet) {
244
231
  const files = [...semantic.projectFiles];
@@ -324,7 +311,7 @@ function rerun(arg, parsed, extra) {
324
311
  * them): name the sibling tsconfig that includes them so tests get counted.
325
312
  */
326
313
  function specFilesHint(cwd, opened, skipped, noTests) {
327
- const tsconfig = opened.location.path;
314
+ const tsconfig = opened.index.project.tsconfig;
328
315
  if (!tsconfig)
329
316
  return undefined;
330
317
  const outside = skipped.filter((s) => s.reason === "not in the Project");
@@ -410,6 +397,7 @@ export async function impactCommand(args, ctx) {
410
397
  const total = declChanges.length + fileLevel.length;
411
398
  const header = {
412
399
  ...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}),
400
+ ...projectFields(opened),
413
401
  base: input.base,
414
402
  head: input.head,
415
403
  changes: total,
@@ -432,7 +420,8 @@ export async function impactCommand(args, ctx) {
432
420
  }), helpBlock(help));
433
421
  }
434
422
  // --- References (semantic, on the checkout) ---
435
- const semantic = createSemanticProject(opened.index);
423
+ // Scoped workspace: touched files + two levels of importers; deleted or renamed-away paths still resolve so their importers count.
424
+ const { semantic, scope } = semanticFor(opened, analysed.filter((f) => f.status !== "deleted").map((f) => opened.index.abs(f.path)), IMPACT_HOPS, analysed.flatMap((f) => (f.status === "deleted" ? [f.path] : f.oldPath ? [f.oldPath] : [])).map((p) => opened.index.abs(p)));
436
425
  const rows = declChanges.map((change) => {
437
426
  const source = change.after ?? change.before;
438
427
  const live = change.after ? opened.index.declarations(opened.index.abs(change.file)).find((d) => matchKey(d) === change.key) : undefined;
@@ -536,6 +525,7 @@ export async function impactCommand(args, ctx) {
536
525
  const specHint = specFilesHint(ctx.cwd, opened, skipped, listed.every((r) => fileCounts(r).tests === 0));
537
526
  if (specHint)
538
527
  help.push(specHint);
528
+ help.push(...scopeHelp(scope));
539
529
  const notes = rows
540
530
  .filter((r) => r.change.change === "signature" && r.bases.length > 0)
541
531
  .map((r) => ({ symbol: r.symbol, note: `implements ${r.bases.join(" and ")}` }));
@@ -565,6 +555,7 @@ export async function impactCommand(args, ctx) {
565
555
  ...(grep ? capped("unresolved", unresolved) : {}),
566
556
  ...depthLevels(semantic, rows, depth),
567
557
  ...skippedBlock,
558
+ ...scopeFields(scope),
568
559
  elapsed_ms: Math.round(performance.now() - started),
569
560
  ...(diagnostics > 0 ? { diagnostics } : {}),
570
561
  }), helpBlock(help));
@@ -1,11 +1,15 @@
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 { findOutsideProject, nearestTsconfig } from "../project.js";
4
5
  import { viaLine } from "../references.js";
5
6
  import { block, helpBlock, out } from "../render.js";
7
+ import { importerIndexFor, REFS_HOPS, scopeFields, scopeHelp, semanticFor } from "../scope.js";
8
+ import { byDepthThenPath } from "../walk.js";
9
+ import { detectWorkspace, isMultiProject } from "../workspace.js";
6
10
  import { isTestFile } from "../settings.js";
7
11
  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";
12
+ import { contextOf, declarationAt, filterSuffix, lineOf, matchRows, narrowHints, open, positionStartDir, projectFields, readFilters, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
9
13
  const DEFAULT_LIMIT = 50;
10
14
  const MAX_LIMIT = 500;
11
15
  const DEPTH_ROWS = 50;
@@ -13,7 +17,10 @@ const SHARED_FLAGS = `--kind <function|class|method|property|variable|interface|
13
17
  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
18
  via_implementations: n (Impl.member) = References to implementations or overrides of the Symbol; not in refs_total
15
19
  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`;
20
+ matches: 0 under -P lists found_outside_project[n]{file,line} when the name is declared outside that Project
21
+ refs_total: 0 under -P in a workspace lists found_outside_project[n]{file,line} = importers outside that Project
22
+ scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
23
+ tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references`;
17
24
  export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
18
25
  flags[11]:
19
26
  ${SHARED_FLAGS}
@@ -103,7 +110,7 @@ async function run(args, ctx, invoked) {
103
110
  return out(block({ ...warning, symbol: label, ambiguous: `${groups.length} Symbols - pick one`, matches: matchRows(groups) }), helpBlock(narrowHints(name, groups, f, `${label}${filterSuffix(f, project)}`)));
104
111
  }
105
112
  const decl = groups[0].decls[0];
106
- resolved = { absFile: opened.index.abs(decl.file), pos: decl.namePos, decl };
113
+ 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
114
  }
108
115
  else {
109
116
  const at = resolvePosition(opened.index, ctx.cwd, target);
@@ -115,17 +122,62 @@ async function run(args, ctx, invoked) {
115
122
  }
116
123
  resolved = at.kind === "decl" ? { absFile: at.abs, pos: at.decl.namePos, decl: at.decl } : { absFile: at.abs, pos: at.pos };
117
124
  }
118
- const semantic = createSemanticProject(opened.index);
125
+ let built = semanticFor(opened, resolved.seeds ?? [resolved.absFile], resolved.decl ? REFS_HOPS : 0);
126
+ if (!resolved.decl && built.scope) {
127
+ // A use position: find its Declaration in a small program, then scope around the declaring file.
128
+ const at = built.semantic.definitionAt(resolved.absFile, resolved.pos);
129
+ if (at && at.line > 0)
130
+ resolved = { absFile: at.absFile, pos: at.pos };
131
+ built = semanticFor(opened, [resolved.absFile], REFS_HOPS);
132
+ }
133
+ const { semantic, scope } = built;
119
134
  const def = semantic.definitionAt(resolved.absFile, resolved.pos);
120
135
  const decl = resolved.decl ??
121
136
  (def && def.line > 0 ? declarationAt(opened.index, def.absFile, def.pos, kindFromScriptElement(def.kind)) : undefined);
122
137
  const kind = decl?.kind ?? kindFromScriptElement(def?.kind ?? semantic.quickInfo(resolved.absFile, resolved.pos)?.kind ?? "");
123
138
  const symbol = decl?.qualifiedName ?? def?.name ?? label;
124
139
  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 };
140
+ const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
141
+ const explicit = Boolean(project || opened.settings.project);
142
+ const o = { started, symbol, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile };
126
143
  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 });
144
+ ? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
145
+ : renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
146
+ }
147
+ const OUTSIDE_ROWS = 5;
148
+ /**
149
+ * No References under an explicit Project: importers of the declaring file (or of a barrel
150
+ * re-exporting it) elsewhere in the workspace, from the syntactic importer index.
151
+ */
152
+ function outsideImporters(opened, o) {
153
+ if (!o.explicit)
154
+ return { fields: {}, help: [] };
155
+ const workspace = detectWorkspace(opened.root);
156
+ if (!isMultiProject(workspace.kind))
157
+ return { fields: {}, help: [] };
158
+ const index = importerIndexFor(opened.index, workspace);
159
+ const closure = importerClosure(index, [o.absFile], REFS_HOPS);
160
+ const inProject = new Set(opened.index.files);
161
+ const barrels = new Set(closure.barrels);
162
+ const targets = [o.absFile, ...closure.barrels];
163
+ const needle = o.symbol.split(".").pop();
164
+ const rows = closure.importers
165
+ .filter((f) => !inProject.has(f) && !barrels.has(f) && (opened.index.text(f) ?? "").includes(needle))
166
+ .map((f) => {
167
+ const line = targets.flatMap((t) => index.importersOf(t)).find((i) => i.file === f)?.line ?? 1;
168
+ return { file: opened.index.rel(f), line };
169
+ })
170
+ .sort((a, b) => byDepthThenPath(a.file, b.file));
171
+ if (rows.length === 0)
172
+ return { fields: {}, help: [] };
173
+ return {
174
+ fields: {
175
+ found_outside_project: rows.slice(0, OUTSIDE_ROWS),
176
+ ...(rows.length > OUTSIDE_ROWS ? { found_outside_total: rows.length } : {}),
177
+ hint: `${rows.length} files outside the selected project import it - run without -P for workspace scope`,
178
+ },
179
+ help: [`Run \`ts-axi ${o.name} ${o.label}\` without -P to search the whole workspace`],
180
+ };
129
181
  }
130
182
  function sortRows(rows) {
131
183
  return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col);
@@ -173,9 +225,12 @@ function renderRefs(semantic, opened, resolved, header, o) {
173
225
  help.push(`Run \`ts-axi refs ${o.declaredAt} --via\` for the ${via.length} via References (${viaNames(via)})`);
174
226
  if (impls.length > 0 && !o.show.implementations)
175
227
  help.push(`Run \`ts-axi refs ${o.declaredAt} --implementations\` to list the ${impls.length} implementations`);
228
+ const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
229
+ help.unshift(...outside.help);
176
230
  if (isFunctionLike(o.kind))
177
231
  help.push(`Run \`ts-axi callers ${o.declaredAt}\` for call sites with their enclosing function`);
178
232
  help.push(`Run \`ts-axi def ${o.declaredAt}\` for the declaration body`);
233
+ help.push(...scopeHelp(o.scope));
179
234
  return out(block({
180
235
  ...header,
181
236
  refs_total: rows.length,
@@ -196,6 +251,8 @@ function renderRefs(semantic, opened, resolved, header, o) {
196
251
  })),
197
252
  }),
198
253
  ...(truncated ? { truncated: true } : {}),
254
+ ...outside.fields,
255
+ ...scopeFields(o.scope),
199
256
  ...footer(semantic, o.started, fileList),
200
257
  }), helpBlock(help));
201
258
  }
@@ -233,7 +290,7 @@ function depthRows(sites) {
233
290
  function renderCallers(semantic, opened, resolved, header, o) {
234
291
  const sites = semantic.incomingCalls(resolved.absFile, resolved.pos);
235
292
  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`]));
293
+ 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
294
  }
238
295
  const rows = sortRows(sites.filter((s) => s.via === undefined));
239
296
  const via = sortRows(sites.filter((s) => s.via !== undefined));
@@ -262,7 +319,10 @@ function renderCallers(semantic, opened, resolved, header, o) {
262
319
  const top = shown.find((r) => r.caller !== "(top-level)");
263
320
  if (top && o.depth === 1)
264
321
  help.push(`Run \`ts-axi callers ${o.declaredAt} --depth 2\` for who calls the callers`);
322
+ const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
323
+ help.unshift(...outside.help);
265
324
  help.push(`Run \`ts-axi refs ${o.declaredAt}\` for every Reference (not only calls)`);
325
+ help.push(...scopeHelp(o.scope));
266
326
  return out(block({
267
327
  ...header,
268
328
  refs_total: rows.length,
@@ -282,6 +342,8 @@ function renderCallers(semantic, opened, resolved, header, o) {
282
342
  }),
283
343
  ...(truncated ? { truncated: true } : {}),
284
344
  ...levels,
345
+ ...outside.fields,
346
+ ...scopeFields(o.scope),
285
347
  ...footer(semantic, o.started, fileList),
286
348
  }), helpBlock(help));
287
349
  }
@@ -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
+ }