ts-axi 0.1.1 → 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
@@ -11,9 +11,9 @@ $ ts-axi impact main...HEAD
11
11
  base: 923fb4d7ddc8f4e5c4df9029be388f8cb29ce095
12
12
  head: HEAD
13
13
  changes: 2
14
- changed[2]{symbol,kind,change,exported,at,refs_total,refs_outside,files,tests}:
15
- formatPrice,function,signature,true,"src/lib/format.ts:5",13,12,6,2
16
- Cart.total,method,body,true,"src/lib/cart.ts:32",8,8,5,3
14
+ changed[2]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}:
15
+ formatPrice,function,signature,true,"src/lib/format.ts:5",13,12,0,6,2
16
+ Cart.total,method,body,true,"src/lib/cart.ts:32",8,8,0,5,3
17
17
  refs[20]{symbol,file,line,context}:
18
18
  formatPrice,src/components/Counter.tsx,2,"import { formatPrice } from '../lib';"
19
19
  formatPrice,src/components/Counter.tsx,12,"{cups} cups = {formatPrice(cups * CUP_CENTS)}"
@@ -65,16 +65,30 @@ npx skills add cmosgh/ts-axi --skill ts-axi
65
65
  | --- | --- |
66
66
  | `ts-axi` | Home view: project root, tsconfig used (or implicit), source file count, test-file glob and match counts, git branch and dirty count, next steps. Under a second, exit 0 everywhere |
67
67
  | `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` |
68
- | `refs <target>` | References of one symbol, grouped by file, with test counts. `--limit`, `--kind calls` |
69
- | `callers <target>` | Incoming calls of a function-like symbol. `--depth 1..3` |
68
+ | `refs <target>` | References of one symbol, grouped by file, with test counts. A class member that implements an interface (or overrides a base) member counts only its direct References; calls through the interface are `via_interface: n (Iface.member)`, sibling implementations are dropped, and an interface member reports `implementations: n`. `--limit`, `--kind calls`, `--via` (via rows with a `via` column), `--implementations` |
69
+ | `callers <target>` | Incoming calls of a function-like symbol, split the same way (`via_interface`, `--via`). `--depth 1..3` |
70
70
  | `outline <file>` | Top-level declarations in source order, class members nested. `--exports` |
71
- | `impact [<git-range> \| -]` | Declarations changed by the working tree (untracked files included; `--staged`), a git range (`main...HEAD`) or a unified diff on stdin (`bb-axi pr diff 42 --raw \| ts-axi impact -`), riskiest first: change class (`signature`, `removed`, `type`, `added`, `body`, `modified`), References outside the change set, test-file counts, `blind_spots`, `local_only`, `file_level` and `skipped` (non-TS files in the diff). `--limit`, `--depth 2..3`, `--grep`. Analyses the current checkout |
71
+ | `impact [<git-range> \| -]` | Declarations changed by the working tree (untracked files included; `--staged`), a git range (`main...HEAD`) or a unified diff on stdin (`bb-axi pr diff 42 --raw \| ts-axi impact -`), riskiest first: change class (`signature`, `removed`, `type`, `added`, `body`, `modified`), References outside the change set (direct only), a `via` count of calls through an interface or base member (`--via` lists them), test-file counts, `notes` (a signature change of a member that implements an interface member), `blind_spots`, `local_only`, `file_level` and `skipped` (non-TS files in the diff). `--limit`, `--depth 2..3`, `--grep`. Analyses the current checkout |
72
72
  | `config` | Show effective settings with their source; `config set <key> <value>` and `config unset <key>` write `.ts-axi.json` |
73
73
 
74
74
  `ts-axi <command> --help` prints a concise, complete flag reference. `-P <tsconfig>` selects a project on any command that may analyse types; `--json` prints the decoded payload.
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,37 +1,41 @@
1
- import { readdirSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
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";
5
5
  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, 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
23
24
  <git-range> = any git diff range (main...HEAD, abc123, v1..v2); - = unified diff on stdin (needs the after side checked out)
24
25
  analysis is of the current checkout - for a range whose head is not the working tree, check that head out first
25
- flags[6]:
26
- --staged, --depth <1-3> (callers_depthN[n]{file,count} for N > 1), --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} ref rows across the report), --grep (adds unresolved[n]{file,line,context} - textual matches that are not References; also searches .astro/.vue/.svelte/.mdx/.html), --test-glob <glob> (repeatable), -P <tsconfig>, --json
26
+ flags[7]:
27
+ --staged, --via (adds References through an interface or base member to refs[n] with a via column), --depth <1-3> (callers_depthN[n]{file,count} for N > 1), --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} ref rows across the report), --grep (adds unresolved[n]{file,line,context} - textual matches that are not References; also searches .astro/.vue/.svelte/.mdx/.html), --test-glob <glob> (repeatable), -P <tsconfig>, --json
27
28
  output:
28
- base, head, changes (count), changed[n]{symbol,kind,change,exported,at,refs_total,refs_outside,files,tests}
29
+ base, head, changes (count), changed[n]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
29
30
  change = signature | removed | type | added | body | modified (before unavailable) - riskiest first
31
+ refs_* / files / tests count direct References only; via = References outside the change set that reach the Symbol through an interface or base member it implements (or name an override of it) - they MAY call it
30
32
  refs[n]{symbol,file,line,context} = References outside the change set; truncated + refs_shown + refs_outside_total when cut
33
+ notes[n]{symbol,note} = a signature change of a member that implements an interface or base member (the contract may break)
31
34
  local_only = Changes with no Reference outside the change set; file_level[n]{file,lines} = changed lines outside any Declaration
32
35
  blind_spots[n]{symbol,file,line,reason} = uses the checker cannot follow (any; string keys; unresolved export *)
33
36
  skipped[n]{file,reason} = files in the diff that were not analysed (.astro, .json, outside the Project)
34
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)
35
39
  examples[5]:
36
40
  ts-axi impact
37
41
  ts-axi impact --staged
@@ -41,6 +45,7 @@ examples[5]:
41
45
  `;
42
46
  const SPECS = [
43
47
  { name: "--staged", boolean: true },
48
+ { name: "--via", boolean: true },
44
49
  { name: "--depth" },
45
50
  { name: "--limit" },
46
51
  { name: "--grep", boolean: true },
@@ -220,22 +225,7 @@ function blindSpots(semantic, names, changeSet) {
220
225
  return spots;
221
226
  }
222
227
  function walkExtra(dir, outFiles) {
223
- let entries;
224
- try {
225
- entries = readdirSync(dir, { withFileTypes: true });
226
- }
227
- catch {
228
- return;
229
- }
230
- for (const entry of entries) {
231
- if (entry.isDirectory()) {
232
- if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith("."))
233
- walkExtra(join(dir, entry.name), outFiles);
234
- }
235
- else if (GREP_EXTRA.some((ext) => entry.name.endsWith(ext))) {
236
- outFiles.push(join(dir, entry.name));
237
- }
238
- }
228
+ outFiles.push(...walkFiles(dir, { accept: (name) => GREP_EXTRA.some((ext) => name.endsWith(ext)) }).files);
239
229
  }
240
230
  function textualMatches(opened, semantic, names, known, skipLines, changeSet) {
241
231
  const files = [...semantic.projectFiles];
@@ -316,6 +306,36 @@ function rerun(arg, parsed, extra) {
316
306
  parts.push(extra);
317
307
  return `${arg === "-" ? "<diff> | " : ""}${parts.join(" ")}`;
318
308
  }
309
+ /**
310
+ * Spec files skipped as "not in the Project" (a tsconfig.lib.json / tsconfig.app.json that excludes
311
+ * them): name the sibling tsconfig that includes them so tests get counted.
312
+ */
313
+ function specFilesHint(cwd, opened, skipped, noTests) {
314
+ const tsconfig = opened.index.project.tsconfig;
315
+ if (!tsconfig)
316
+ return undefined;
317
+ const outside = skipped.filter((s) => s.reason === "not in the Project");
318
+ const tests = outside.filter((s) => isTestFile(s.file, opened.settings));
319
+ if (tests.length === 0 || !(tests.length === outside.length || noTests))
320
+ return undefined;
321
+ const dir = dirname(tsconfig);
322
+ const name = basename(tsconfig);
323
+ const order = name === "tsconfig.lib.json" || name === "tsconfig.app.json" ? ["tsconfig.json", "tsconfig.spec.json"] : ["tsconfig.spec.json", "tsconfig.json"];
324
+ const candidates = order.map((n) => join(dir, n)).filter((c) => c !== tsconfig && existsSync(c));
325
+ // Prefer the one that really includes a skipped spec file (an Nx tsconfig.json may only hold references).
326
+ const includes = (c) => {
327
+ try {
328
+ const files = new Set(loadProject(opened.root, { path: c, implicit: false }).fileNames);
329
+ return tests.some((t) => files.has(opened.index.abs(t.file)));
330
+ }
331
+ catch {
332
+ return false;
333
+ }
334
+ };
335
+ const pick = candidates.find(includes) ?? candidates[0];
336
+ const rel = (abs) => relative(cwd, abs).split(sep).join("/");
337
+ return `Spec files are outside ${rel(tsconfig)}; run with -P ${pick ? rel(pick) : "<sibling tsconfig.json that includes them>"} to count tests`;
338
+ }
319
339
  export async function impactCommand(args, ctx) {
320
340
  const parsed = parseArgs(args, SPECS, "ts-axi impact");
321
341
  if (parsed.positionals.length > 1) {
@@ -328,6 +348,7 @@ export async function impactCommand(args, ctx) {
328
348
  const limit = intFlag(parsed, "--limit", DEFAULT_LIMIT, { min: 1, max: MAX_LIMIT });
329
349
  const depth = intFlag(parsed, "--depth", 1, { min: 1, max: 3 });
330
350
  const grep = bool(parsed, "--grep");
351
+ const showVia = bool(parsed, "--via");
331
352
  const started = performance.now();
332
353
  const opened = open(ctx.cwd, parsed);
333
354
  const input = arg === "-"
@@ -376,6 +397,7 @@ export async function impactCommand(args, ctx) {
376
397
  const total = declChanges.length + fileLevel.length;
377
398
  const header = {
378
399
  ...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}),
400
+ ...projectFields(opened),
379
401
  base: input.base,
380
402
  head: input.head,
381
403
  changes: total,
@@ -387,6 +409,9 @@ export async function impactCommand(args, ctx) {
387
409
  : ["Changes touch imports or module-level statements only - read those lines directly"];
388
410
  if (skipped.length > 0)
389
411
  help.push("Skipped files are not TS or JS source of the Project - review them by hand");
412
+ const specHint = specFilesHint(ctx.cwd, opened, skipped, true);
413
+ if (specHint)
414
+ help.push(specHint);
390
415
  return out(block({
391
416
  ...header,
392
417
  ...(fileLevel.length > 0 ? { file_level: fileLevel } : {}),
@@ -395,7 +420,8 @@ export async function impactCommand(args, ctx) {
395
420
  }), helpBlock(help));
396
421
  }
397
422
  // --- References (semantic, on the checkout) ---
398
- 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)));
399
425
  const rows = declChanges.map((change) => {
400
426
  const source = change.after ?? change.before;
401
427
  const live = change.after ? opened.index.declarations(opened.index.abs(change.file)).find((d) => matchKey(d) === change.key) : undefined;
@@ -408,6 +434,8 @@ export async function impactCommand(args, ctx) {
408
434
  at: `${change.file}:${(live ?? source).nameLine}`,
409
435
  all: [],
410
436
  outside: [],
437
+ via: [],
438
+ bases: [],
411
439
  };
412
440
  });
413
441
  for (const row of rows) {
@@ -415,33 +443,43 @@ export async function impactCommand(args, ctx) {
415
443
  changeSet.set(row.change.file, [...(changeSet.get(row.change.file) ?? []), [row.live.line, row.live.endLine]]);
416
444
  }
417
445
  const known = new Set();
446
+ const byPosition = (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col;
418
447
  for (const row of rows) {
419
- const entries = row.live
420
- ? semantic.findReferences(opened.index.abs(row.live.file), row.live.namePos)
421
- : row.change.change === "removed"
422
- ? brokenReferences(semantic, row.change.before)
423
- : [];
448
+ let entries = [];
449
+ if (row.live) {
450
+ const split = semantic.referenceSplit(opened.index.abs(row.live.file), row.live.namePos);
451
+ entries = [...split.declarations, ...split.direct];
452
+ // Sibling implementations and via uses are known positions, not textual misses for --grep.
453
+ for (const e of [...split.via, ...split.implementations, ...split.dropped])
454
+ known.add(`${e.file}:${e.line}`);
455
+ row.via = split.via.filter((e) => !inRanges(changeSet.get(e.file), e.line)).sort(byPosition);
456
+ row.bases = split.bases;
457
+ }
458
+ else if (row.change.change === "removed") {
459
+ entries = brokenReferences(semantic, row.change.before);
460
+ }
424
461
  for (const e of entries)
425
462
  known.add(`${e.file}:${e.line}`);
426
463
  row.all = entries.filter((e) => !e.isDefinition);
427
- row.outside = row.all
428
- .filter((e) => !inRanges(changeSet.get(e.file), e.line))
429
- .sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col);
464
+ row.outside = row.all.filter((e) => !inRanges(changeSet.get(e.file), e.line)).sort(byPosition);
430
465
  }
466
+ const shownRefs = (r) => (showVia ? [...r.outside, ...r.via].sort(byPosition) : r.outside);
467
+ const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0;
431
468
  const listed = rows
432
- .filter((r) => r.outside.length > 0)
469
+ .filter(hasOutside)
433
470
  .sort((a, b) => compareChanges({ change: a.change.change, exported: a.exported, refsOutside: a.outside.length, file: a.change.file, line: (a.live ?? a.change.before).line }, { change: b.change.change, exported: b.exported, refsOutside: b.outside.length, file: b.change.file, line: (b.live ?? b.change.before).line }));
434
471
  const localOnly = rows
435
- .filter((r) => r.outside.length === 0)
472
+ .filter((r) => !hasOutside(r))
436
473
  .sort((a, b) => compareChanges({ change: a.change.change, exported: a.exported, refsOutside: 0, file: a.change.file, line: (a.live ?? a.change.before).line }, { change: b.change.change, exported: b.exported, refsOutside: 0, file: b.change.file, line: (b.live ?? b.change.before).line }));
437
- const quotas = allocateBudget(listed.map((r) => r.outside.length), limit);
438
- const refRows = listed.flatMap((r, i) => r.outside.slice(0, quotas[i]).map((e) => ({
474
+ const quotas = allocateBudget(listed.map((r) => shownRefs(r).length), limit);
475
+ const refRows = listed.flatMap((r, i) => shownRefs(r).slice(0, quotas[i]).map((e) => ({
439
476
  symbol: r.symbol,
440
477
  file: e.file,
441
478
  line: e.line,
479
+ ...(showVia ? { via: e.via ?? "-" } : {}),
442
480
  context: contextOf(lineOf(opened.index.text(opened.index.abs(e.file)), e.line), e.col),
443
481
  })));
444
- const outsideTotal = listed.reduce((n, r) => n + r.outside.length, 0);
482
+ const outsideTotal = listed.reduce((n, r) => n + shownRefs(r).length, 0);
445
483
  const truncated = refRows.length < outsideTotal;
446
484
  const fileCounts = (r) => {
447
485
  const files = [...new Set(r.outside.map((e) => e.file))];
@@ -479,8 +517,18 @@ export async function impactCommand(args, ctx) {
479
517
  if (depth === 1 && rows.some((r) => r.live && isFunctionLike(r.kind) && r.outside.length > 0)) {
480
518
  help.push(`Run \`${rerun(arg, parsed, "--depth 2")}\` for who calls the callers`);
481
519
  }
520
+ if (!showVia && listed.some((r) => r.via.length > 0)) {
521
+ help.push(`Run \`${rerun(arg, parsed, "--via")}\` for References through an interface or base member - they may reach the changed code`);
522
+ }
482
523
  if (skipped.length > 0)
483
524
  help.push("Skipped files are not TS or JS source of the Project - review them by hand");
525
+ const specHint = specFilesHint(ctx.cwd, opened, skipped, listed.every((r) => fileCounts(r).tests === 0));
526
+ if (specHint)
527
+ help.push(specHint);
528
+ help.push(...scopeHelp(scope));
529
+ const notes = rows
530
+ .filter((r) => r.change.change === "signature" && r.bases.length > 0)
531
+ .map((r) => ({ symbol: r.symbol, note: `implements ${r.bases.join(" and ")}` }));
484
532
  return out(block({
485
533
  ...header,
486
534
  ...(listed.length > 0
@@ -493,18 +541,21 @@ export async function impactCommand(args, ctx) {
493
541
  at: r.at,
494
542
  refs_total: r.all.length,
495
543
  refs_outside: r.outside.length,
544
+ via: r.via.length,
496
545
  ...fileCounts(r),
497
546
  })),
498
547
  refs: refRows,
499
548
  }
500
549
  : {}),
501
550
  ...(truncated ? { truncated: true, refs_shown: refRows.length, refs_outside_total: outsideTotal } : {}),
551
+ ...(notes.length > 0 ? { notes } : {}),
502
552
  ...(localOnly.length > 0 ? { local_only: localOnlyLine(localOnly.map((r) => `${r.symbol} ${r.change.change}`)) } : {}),
503
553
  ...(fileLevel.length > 0 ? { file_level: fileLevel } : {}),
504
554
  ...capped("blind_spots", spots),
505
555
  ...(grep ? capped("unresolved", unresolved) : {}),
506
556
  ...depthLevels(semantic, rows, depth),
507
557
  ...skippedBlock,
558
+ ...scopeFields(scope),
508
559
  elapsed_ms: Math.round(performance.now() - started),
509
560
  ...(diagnostics > 0 ? { diagnostics } : {}),
510
561
  }), helpBlock(help));