ts-axi 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/dist/src/commands/common.js +83 -16
- package/dist/src/commands/def.js +6 -6
- package/dist/src/commands/home.js +35 -43
- package/dist/src/commands/impact.js +15 -23
- package/dist/src/commands/refs.js +95 -15
- package/dist/src/impact.js +5 -27
- package/dist/src/importers.js +182 -0
- package/dist/src/project.js +110 -73
- package/dist/src/scope.js +119 -0
- package/dist/src/symbols.js +324 -20
- package/dist/src/walk.js +96 -0
- package/dist/src/workspace.js +297 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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");
|
package/dist/src/commands/def.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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
|
-
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
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import { existsSync,
|
|
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 {
|
|
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
|
|
@@ -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
|
-
|
|
227
|
-
try {
|
|
228
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
for (const entry of entries) {
|
|
234
|
-
if (entry.isDirectory()) {
|
|
235
|
-
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith("."))
|
|
236
|
-
walkExtra(join(dir, entry.name), outFiles);
|
|
237
|
-
}
|
|
238
|
-
else if (GREP_EXTRA.some((ext) => entry.name.endsWith(ext))) {
|
|
239
|
-
outFiles.push(join(dir, entry.name));
|
|
240
|
-
}
|
|
241
|
-
}
|
|
228
|
+
outFiles.push(...walkFiles(dir, { accept: (name) => GREP_EXTRA.some((ext) => name.endsWith(ext)) }).files);
|
|
242
229
|
}
|
|
243
|
-
|
|
230
|
+
/** 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.
|
|
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
|
-
|
|
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));
|