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 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,8 +114,9 @@ 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.
119
+ - Script-style JavaScript (AngularJS registrations, Meteor methods and helpers, `module.exports` objects, prototype methods, IIFE helpers) gets nested declarations with qualified names, so `outline` lists them and `impact` names the function an edit touches instead of a file-level change.
105
120
  - Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
106
121
 
107
122
  Every semantic command starts cold and prints `elapsed_ms`.
@@ -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(".")));
@@ -87,35 +96,89 @@ export function positionOf(group) {
87
96
  const d = group.decls[0];
88
97
  return `${d.file}:${d.nameLine}`;
89
98
  }
99
+ /** Ambiguity lists stop here; the narrowing hints cover the rest. */
100
+ export const MATCH_ROWS = 20;
101
+ /** `matches` (first MATCH_ROWS, shallowest first) plus `matches_total` / `truncated` past that. */
90
102
  export function matchRows(groups) {
91
- return groups.map((g) => ({
103
+ const rows = groups.slice(0, MATCH_ROWS).map((g) => ({
92
104
  file: g.primary.file,
93
105
  line: g.decls[0].nameLine,
94
106
  kind: g.primary.kind,
95
107
  exported: g.primary.exported,
96
108
  signature: g.decls[0].signature,
97
109
  }));
110
+ return { matches: rows, ...(groups.length > MATCH_ROWS ? { matches_total: groups.length, truncated: true } : {}) };
111
+ }
112
+ function countBy(items, key) {
113
+ const counts = new Map();
114
+ for (const item of items) {
115
+ const k = key(item);
116
+ if (k !== undefined)
117
+ counts.set(k, (counts.get(k) ?? 0) + 1);
118
+ }
119
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
120
+ }
121
+ /** Longest common directory of `files` ('' when they share none). */
122
+ function commonDir(files) {
123
+ const split = files.map((f) => posix.dirname(f).split("/").filter((p) => p !== "" && p !== "."));
124
+ const first = split[0] ?? [];
125
+ let n = 0;
126
+ while (n < first.length && split.every((parts) => parts[n] === first[n]))
127
+ n++;
128
+ return first.slice(0, n).join("/");
129
+ }
130
+ const IDENT_PART = /^[A-Za-z_$][\w$]*$/;
131
+ /** Root-relative path → the form `--in` expects (relative to cwd). */
132
+ function cwdGlob(ctx, rootRel) {
133
+ const prefix = relative(ctx.root, ctx.cwd).split(sep).join("/");
134
+ if (!prefix || prefix.startsWith(".."))
135
+ return rootRel;
136
+ return posix.relative(prefix, rootRel) || ".";
98
137
  }
99
- /** Concrete hints that narrow an ambiguous name to one Symbol. */
100
- export function narrowHints(command, groups, f, label) {
138
+ /** Concrete hints that narrow an ambiguous name, computed from the matches themselves. */
139
+ export function narrowHints(ctx, command, groups, f, label, target) {
140
+ const total = groups.length;
101
141
  const hints = groups.slice(0, 2).map((g) => `Run \`ts-axi ${command} ${positionOf(g)}\` for the one in ${g.primary.file}`);
102
- const first = groups[0].primary;
103
- const dirs = new Set(groups.map((g) => posix.dirname(g.primary.file)));
104
- const dir = posix.dirname(first.file);
105
- if (dirs.size > 1 && [...dirs].filter((d) => d === dir || d.startsWith(`${dir}/`)).length === 1) {
106
- hints.push(`Run \`ts-axi ${command} ${label} --in ${dir}\` to keep matches below ${dir}`);
142
+ const files = groups.map((g) => g.primary.file);
143
+ // --in: the subtrees right below the common directory, largest first.
144
+ const prefix = commonDir(files);
145
+ const below = countBy(files, (file) => {
146
+ const rest = (prefix ? file.slice(prefix.length + 1) : file).split("/");
147
+ return rest.length > 1 ? posix.join(prefix, rest[0]) : file;
148
+ });
149
+ if (below.length > 1) {
150
+ for (const [dir, n] of below.slice(0, 2))
151
+ hints.push(`Run \`ts-axi ${command} ${label} --in ${cwdGlob(ctx, dir)}\` to keep ${n} of ${total}`);
152
+ }
153
+ else if (prefix) {
154
+ hints.push(`Run \`ts-axi ${command} ${label} --in ${cwdGlob(ctx, prefix)}\` - all ${total} are below ${prefix}`);
107
155
  }
108
- else {
109
- hints.push(`Run \`ts-axi ${command} ${label} --in ${first.file}\` to keep matches in one file`);
156
+ if (!f.kind) {
157
+ const kinds = countBy(groups, (g) => g.primary.kind);
158
+ if (kinds.length > 1)
159
+ for (const [kind, n] of kinds.slice(0, 3))
160
+ hints.push(`Run \`ts-axi ${command} ${label} --kind ${kind}\` to keep ${n} of ${total}`);
110
161
  }
111
- const kinds = new Set(groups.map((g) => g.primary.kind));
112
- if (kinds.size > 1 && !f.kind)
113
- hints.push(`Run \`ts-axi ${command} ${label} --kind ${first.kind}\` to keep one kind`);
114
- if (!f.exported && groups.some((g) => g.primary.exported) && groups.some((g) => !g.primary.exported)) {
115
- hints.push(`Run \`ts-axi ${command} ${label} --exported\` to keep exported Declarations`);
162
+ if (target && target.member === undefined) {
163
+ const parents = countBy(groups, (g) => g.primary.parentName?.split(".").pop());
164
+ const distinct = parents.length > 1 || (parents.length === 1 && groups.some((g) => g.primary.parentName === undefined));
165
+ if (distinct) {
166
+ for (const [parent, n] of parents.filter(([p]) => IDENT_PART.test(p)).slice(0, 2)) {
167
+ hints.push(`Run \`ts-axi ${command} ${parent}.${target.name}${filterSuffixOf(label, target)}\` to keep ${n} of ${total}`);
168
+ }
169
+ }
170
+ }
171
+ const exported = groups.filter((g) => g.primary.exported).length;
172
+ if (!f.exported && exported > 0 && exported < total) {
173
+ hints.push(`Run \`ts-axi ${command} ${label} --exported\` to keep ${exported} of ${total}`);
116
174
  }
117
175
  return hints;
118
176
  }
177
+ /** The flags part of a hint label (`label` = target + filter suffix). */
178
+ function filterSuffixOf(label, target) {
179
+ const bare = target.member ? `${target.name}.${target.member}` : target.name;
180
+ return label.startsWith(bare) ? label.slice(bare.length) : "";
181
+ }
119
182
  export function resolvePositionFile(cwd, file) {
120
183
  const abs = resolve(cwd, file);
121
184
  if (!existsSync(abs)) {
@@ -167,6 +230,10 @@ export function resolvePosition(index, cwd, target) {
167
230
  const hit = onLine.find((id) => id.getStart(sf) <= offset && offset <= id.end) ?? onLine.find((id) => id.getStart(sf) >= offset);
168
231
  return hit ? pick(hit) : { kind: "none", abs, file, reason: `no identifier at ${file}:${target.line}:${target.col}` };
169
232
  }
233
+ // A string-registered name has no identifier: its line stands for it.
234
+ const nameless = decls.find((d) => d.nameless && d.nameLine === target.line);
235
+ if (nameless)
236
+ return { kind: "decl", decl: nameless, abs };
170
237
  if (onLine.length === 0)
171
238
  return { kind: "none", abs, file, reason: `no identifier on ${file}:${target.line}` };
172
239
  const constructorDecl = decls.find((d) => d.nameLine === target.line && d.name === "constructor");
@@ -1,14 +1,14 @@
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
- import { BODY_LINES, declarationAt, filterSuffix, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, } from "./common.js";
5
+ import { BODY_LINES, declarationAt, filterSuffix, MATCH_ROWS, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, } from "./common.js";
6
6
  export const DEF_HELP = `usage: ts-axi def <name | Class.member | path:line[:col]> [flags]
7
7
  flags[7]:
8
8
  --kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace>, --in <dir|glob> (repeatable; restrict to files), --exported, --fuzzy (substring, case-insensitive), --full (whole body; default first ${BODY_LINES} lines), -P <tsconfig>, --json
9
9
  output:
10
10
  1 match = file, line, kind, exported, signature + body fenced with file line numbers
11
- several = matches[n]{file,line,kind,exported,signature} + how to narrow
11
+ several = matches[n]{file,line,kind,exported,signature} (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms (--in, --kind, Class.member, --exported) with how many each keeps
12
12
  none = matches: 0 + did_you_mean
13
13
  examples[5]:
14
14
  ts-axi def User
@@ -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) {
@@ -91,6 +91,6 @@ export async function defCommand(args, ctx) {
91
91
  return out(block({
92
92
  ...(opened.warning ? { warning: opened.warning } : {}),
93
93
  symbol: label,
94
- matches: matchRows(groups),
95
- }), helpBlock(narrowHints("def", groups, f, `${label}${filterSuffix(f, project)}`)));
94
+ ...matchRows(groups),
95
+ }), helpBlock(narrowHints(opened, "def", groups, f, `${label}${filterSuffix(f, project)}`, target)));
96
96
  }
@@ -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,24 +225,10 @@ 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
- function textualMatches(opened, semantic, names, known, skipLines, changeSet) {
230
+ /** Whole-word matches of `names` in Project files and templates that are not `known` References. */
231
+ export function textualMatches(opened, semantic, names, known, skipLines, changeSet) {
244
232
  const files = [...semantic.projectFiles];
245
233
  const projectSet = new Set(files);
246
234
  walkExtra(opened.root, files);
@@ -324,7 +312,7 @@ function rerun(arg, parsed, extra) {
324
312
  * them): name the sibling tsconfig that includes them so tests get counted.
325
313
  */
326
314
  function specFilesHint(cwd, opened, skipped, noTests) {
327
- const tsconfig = opened.location.path;
315
+ const tsconfig = opened.index.project.tsconfig;
328
316
  if (!tsconfig)
329
317
  return undefined;
330
318
  const outside = skipped.filter((s) => s.reason === "not in the Project");
@@ -410,6 +398,7 @@ export async function impactCommand(args, ctx) {
410
398
  const total = declChanges.length + fileLevel.length;
411
399
  const header = {
412
400
  ...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}),
401
+ ...projectFields(opened),
413
402
  base: input.base,
414
403
  head: input.head,
415
404
  changes: total,
@@ -432,7 +421,8 @@ export async function impactCommand(args, ctx) {
432
421
  }), helpBlock(help));
433
422
  }
434
423
  // --- References (semantic, on the checkout) ---
435
- const semantic = createSemanticProject(opened.index);
424
+ // Scoped workspace: touched files + two levels of importers; deleted or renamed-away paths still resolve so their importers count.
425
+ 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
426
  const rows = declChanges.map((change) => {
437
427
  const source = change.after ?? change.before;
438
428
  const live = change.after ? opened.index.declarations(opened.index.abs(change.file)).find((d) => matchKey(d) === change.key) : undefined;
@@ -536,6 +526,7 @@ export async function impactCommand(args, ctx) {
536
526
  const specHint = specFilesHint(ctx.cwd, opened, skipped, listed.every((r) => fileCounts(r).tests === 0));
537
527
  if (specHint)
538
528
  help.push(specHint);
529
+ help.push(...scopeHelp(scope));
539
530
  const notes = rows
540
531
  .filter((r) => r.change.change === "signature" && r.bases.length > 0)
541
532
  .map((r) => ({ symbol: r.symbol, note: `implements ${r.bases.join(" and ")}` }));
@@ -565,6 +556,7 @@ export async function impactCommand(args, ctx) {
565
556
  ...(grep ? capped("unresolved", unresolved) : {}),
566
557
  ...depthLevels(semantic, rows, depth),
567
558
  ...skippedBlock,
559
+ ...scopeFields(scope),
568
560
  elapsed_ms: Math.round(performance.now() - started),
569
561
  ...(diagnostics > 0 ? { diagnostics } : {}),
570
562
  }), helpBlock(help));