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 +21 -7
- package/dist/src/commands/common.js +10 -1
- package/dist/src/commands/def.js +2 -2
- package/dist/src/commands/home.js +35 -43
- package/dist/src/commands/impact.js +89 -38
- package/dist/src/commands/refs.js +165 -27
- package/dist/src/importers.js +182 -0
- package/dist/src/project.js +301 -69
- package/dist/src/references.js +69 -0
- package/dist/src/scope.js +119 -0
- package/dist/src/walk.js +96 -0
- package/dist/src/workspace.js +297 -0
- package/package.json +1 -1
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
|
-
|
|
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(".")));
|
package/dist/src/commands/def.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { bool, expectPositionals, flag, parseArgs } from "../args.js";
|
|
2
|
-
import {
|
|
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 =
|
|
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 (
|
|
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
|
|
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
|
|
36
|
+
/** Implicit project: every source below `root` from the shared listing (git, else a bounded fs walk). */
|
|
36
37
|
function walkSources(root) {
|
|
37
|
-
|
|
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
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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(
|
|
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
|
|
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.
|
|
438
|
-
const refRows = listed.flatMap((r, i) => r.
|
|
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.
|
|
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));
|