ts-axi 0.1.3 → 0.1.5
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 +118 -4
- package/dist/src/commands/common.js +73 -15
- package/dist/src/commands/def.js +4 -4
- package/dist/src/commands/impact.js +46 -4
- package/dist/src/commands/refs.js +61 -10
- package/dist/src/impact.js +5 -27
- package/dist/src/project.js +115 -2
- package/dist/src/settings.js +3 -0
- package/dist/src/symbols.js +324 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,17 @@ help[5]:
|
|
|
37
37
|
|
|
38
38
|
Read-only: no lint, no refactor, no rename.
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
## Status (v0.1.4)
|
|
41
|
+
|
|
42
|
+
Early, but already used in real reviews. What the numbers say so far:
|
|
43
|
+
|
|
44
|
+
- **Correctness on the test corpus.** 152 unit and command tests over five synthetic fixtures (a TS project with an interface and three implementations, an Nx-style workspace with `tsconfig.base.json` paths and a pnpm workspace, a script-style JavaScript tree, a CommonJS package and an ESM package without tsconfig), plus 39 integration tests that run the built binary against a vendored Astro + React one-pager (`test/integration/sample`: barrel re-exports, JSX islands, custom hooks, a JS consumer of a TS export, a `.ts-axi.json` test glob). They pin exact line numbers, reference counts, test counts, change classes and the interface split.
|
|
45
|
+
- **A real monorepo** (Nx, 142 projects, ~5.4k source files after gitignore): `refs` on a service method finds its two callers in *another* project in 3-5 s without any flag; `impact` on a 7-file commit reports 15 changes with cross-project references in ~5 s; `callers` on a method that implements an interface shared by seven classes reports the 1 direct call plus the spec calls, where the raw compiler API returned 8 unrelated implementations. Before the workspace scope, the same `impact` ran out of memory.
|
|
46
|
+
- **Against grep, in characters an agent has to read** (same monorepo, measured by a reviewer session): `callers` 720 vs ~3.5k for the two greps a reviewer needs - and grep still leaves the interface ambiguity to the human; `impact` on a commit 4.7k vs ~7.3k for per-symbol greps plus the `git show --stat` needed to learn the symbol list. `def` on a very common name was *worse* than grep until nested git worktrees were excluded (96 matches, 92 of them copies); it is 4 now.
|
|
47
|
+
|
|
48
|
+
- **Plain JavaScript** (no tsconfig, measured on two public packages): on p-limit (ESM, `index.js` beside `index.d.ts`) `refs pLimit` went from 1 to 48 References, 35 of them in `test.js`, which now counts as a Test file; on express (CommonJS, `index.js` = `module.exports = require('./lib/express')`) `callers createApplication` went from 2 call sites in 2 files to 678 in 94 files, 67 of them Test files (every one of the 22 test files that does `require('..')` included). The rule: References are semantic wherever the checker can follow them, and every Symbol declared in JavaScript (or in an implicit project) also prints `textual: <n> matches in <m> files`; when the text names it more often than the checker found, `help` points at `--grep` for the rest. `refs app.render` on express is the honest case: the method sits on an untyped expando object, the checker finds 0 References, and the output says `textual: 132 matches in 21 files` with the `--grep` hint instead of a bare 0; `impact` adds a `textual` column for such a Change rather than calling it local-only.
|
|
49
|
+
|
|
50
|
+
Known limits: `.astro` / `.vue` / `.svelte` files are not analysed (they appear under `skipped`, and `--grep` searches them textually); every semantic command starts cold (0.3 s on a small project, 2-5 s on the monorepo, dominated by the library `.d.ts` files that barrels pull in); import specifiers are extracted textually, so computed specifiers and bundler-only aliases are missed.
|
|
41
51
|
|
|
42
52
|
## Install
|
|
43
53
|
|
|
@@ -73,7 +83,99 @@ npx skills add cmosgh/ts-axi --skill ts-axi
|
|
|
73
83
|
|
|
74
84
|
`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
85
|
|
|
76
|
-
|
|
86
|
+
### A review, end to end
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
ts-axi # which project, how many files, how many are tests
|
|
90
|
+
ts-axi impact main...HEAD # what the branch changes and who depends on it
|
|
91
|
+
ts-axi callers src/lib/format.ts:5 # the risky symbol: every call site with its enclosing function
|
|
92
|
+
ts-axi def src/lib/cart.ts:32 # read one declaration, numbered for inline comments
|
|
93
|
+
ts-axi outline src/lib/cart.ts # the rest of the file, without reading it
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Real output from the sample project (`test/integration/sample`):
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
$ ts-axi callers formatPrice
|
|
100
|
+
symbol: formatPrice
|
|
101
|
+
kind: function
|
|
102
|
+
declared_at: "src/lib/format.ts:5"
|
|
103
|
+
refs_total: 7
|
|
104
|
+
files: 6
|
|
105
|
+
tests: 2
|
|
106
|
+
refs[7]{file,line,caller,context}:
|
|
107
|
+
src/components/Counter.tsx,12,Counter,"{cups} cups = {formatPrice(cups * CUP_CENTS)}"
|
|
108
|
+
src/components/ProductCard.tsx,13,ProductCard,"<p>{formatPrice(item.priceCents)}</p>"
|
|
109
|
+
src/lib/analytics.js,4,trackPurchase,"return { event: 'purchase', value: formatPrice(cents, 'EUR') };"
|
|
110
|
+
src/lib/cart.it.ts,8,(top-level),expect(cart.total()).toBe(formatPrice(3700));
|
|
111
|
+
src/lib/cart.ts,34,Cart.total,return formatPrice(cents);
|
|
112
|
+
src/lib/format.test.ts,6,(top-level),expect(formatPrice(1850)).toBe('18.50 USD');
|
|
113
|
+
src/lib/format.test.ts,9,(top-level),"expect(formatPrice(305, 'EUR')).toBe('3.05 EUR');"
|
|
114
|
+
elapsed_ms: 549
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
$ ts-axi def Cart.total
|
|
119
|
+
file: src/lib/cart.ts
|
|
120
|
+
line: 32
|
|
121
|
+
kind: method
|
|
122
|
+
exported: true
|
|
123
|
+
signature: "total(): string"
|
|
124
|
+
body:
|
|
125
|
+
---
|
|
126
|
+
32 | total(): string {
|
|
127
|
+
33 | const cents = this.items.reduce((sum, i) => sum + i.priceCents * i.qty, 0);
|
|
128
|
+
34 | return formatPrice(cents);
|
|
129
|
+
35 | }
|
|
130
|
+
---
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The line numbers in front of the body are file lines, so an agent can cite or comment on them directly. An ambiguous name lists the candidates and the exact commands that narrow them:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
$ ts-axi def parse
|
|
137
|
+
symbol: parse
|
|
138
|
+
matches[2]{file,line,kind,exported,signature}:
|
|
139
|
+
src/lib/parse.ts,1,function,true,"export function parse(input: string): number"
|
|
140
|
+
src/lib/query.ts,1,function,true,"export function parse(q: string): Record<string, string>"
|
|
141
|
+
help[4]:
|
|
142
|
+
- "Run `ts-axi def src/lib/parse.ts:1` for the one in src/lib/parse.ts"
|
|
143
|
+
- "Run `ts-axi def src/lib/query.ts:1` for the one in src/lib/query.ts"
|
|
144
|
+
...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Interfaces are not conflated
|
|
148
|
+
|
|
149
|
+
`findReferences` in the compiler resolves a call like `parser.parse(raw)` (with `parser: FieldParser`) to every implementation of `parse`, and lists the other implementations' declarations as references. For a review that inflates the blast radius badly. ts-axi keeps the split:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
$ ts-axi callers DateParser.parse
|
|
153
|
+
symbol: DateParser.parse
|
|
154
|
+
refs_total: 1
|
|
155
|
+
via_interface: 1 (FieldParser.parse)
|
|
156
|
+
refs[1]{file,line,caller,context}:
|
|
157
|
+
src/services/dates.ts,4,dayOf,return new DateParser().parse(x);
|
|
158
|
+
|
|
159
|
+
$ ts-axi refs FieldParser.parse
|
|
160
|
+
refs_total: 1
|
|
161
|
+
via_implementations: 1 (DateParser.parse)
|
|
162
|
+
implementations: 3
|
|
163
|
+
refs[1]{file,line,context}:
|
|
164
|
+
src/parsers/registry.ts,5,return parser.parse(raw);
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Script-style JavaScript
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
$ ts-axi outline client/app.js
|
|
171
|
+
decls[4]{line,kind,name,exported,signature}:
|
|
172
|
+
6,function,CartCtrl,true,"controller CartCtrl($scope, priceService)"
|
|
173
|
+
12,function,priceService,true,service priceService()
|
|
174
|
+
13,method,priceService.sum,true,this.sum = function (items)
|
|
175
|
+
17,function,cartBadge,true,directive cartBadge()
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
An edit inside `CartCtrl` is reported by `impact` as `CartCtrl body`, not as a file-level change. Names registered by a string have no identifier the checker can follow; `refs` says so and offers `--grep`.
|
|
77
179
|
|
|
78
180
|
## Monorepos
|
|
79
181
|
|
|
@@ -91,7 +193,7 @@ Files are listed with `git ls-files` (tracked plus untracked, gitignore applied)
|
|
|
91
193
|
|
|
92
194
|
## Test files
|
|
93
195
|
|
|
94
|
-
A file counts as a test by path pattern. Defaults: `**/*.test.*`, `**/*.spec.*`, `**/__tests__/**`, `**/test/**`, `**/tests/**`, `**/e2e
|
|
196
|
+
A file counts as a test by path pattern. Defaults: `**/*.test.*`, `**/*.spec.*`, `**/__tests__/**`, `**/test/**`, `**/tests/**`, `**/e2e/**`, `**/spec/**`, `**/test.*`, `**/*_test.*`. The home view shows `test_globs` and `test_files` so a wrong rule is visible.
|
|
95
197
|
|
|
96
198
|
## `.ts-axi.json`
|
|
97
199
|
|
|
@@ -116,6 +218,8 @@ One dependency: the `typescript` compiler API (ADR in `docs/adr/0001-typescript-
|
|
|
116
218
|
- `outline`, `def` and the home view only parse files (`ts.createSourceFile`): no tsconfig needed, well under a second.
|
|
117
219
|
- `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.
|
|
118
220
|
- `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.
|
|
221
|
+
- Module resolution keeps TypeScript's rules with two substitutions so References reach implementations: a `.d.ts` that has a JavaScript twin in the Project (`index.d.ts` beside `index.js`) resolves to the twin, and a CommonJS shim whose only statement is `module.exports = require('./x')` resolves to `./x`, so `require('..')` reaches the real function. A `.d.ts` twin that still ends up in the program is queried too and its References merged.
|
|
222
|
+
- 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.
|
|
119
223
|
- Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
|
|
120
224
|
|
|
121
225
|
Every semantic command starts cold and prints `elapsed_ms`.
|
|
@@ -127,7 +231,17 @@ corepack pnpm install
|
|
|
127
231
|
corepack pnpm run check # typecheck, tests, build, skill freshness
|
|
128
232
|
```
|
|
129
233
|
|
|
130
|
-
`skills/ts-axi/SKILL.md` is generated: `corepack pnpm run build:skill`. See `PLAN.md` for the spec
|
|
234
|
+
`skills/ts-axi/SKILL.md` is generated: `corepack pnpm run build:skill`. See `PLAN.md` for the spec, `GLOSSARY.md` for the vocabulary and `docs/adr/` for the two engine decisions (TypeScript LanguageService over ts-morph / tree-sitter; importer subgraph over a configured project list).
|
|
235
|
+
|
|
236
|
+
### Tests
|
|
237
|
+
|
|
238
|
+
| Suite | Runs | Covers |
|
|
239
|
+
| --- | --- | --- |
|
|
240
|
+
| `test/unit.test.ts`, `test/workspace.test.ts` | `pnpm test` | target parsing, signatures, change classification, diff parsing, settings, glob matching, reference classification, workspace detection, import resolution |
|
|
241
|
+
| `test/commands.test.ts` | `pnpm test` | the whole CLI through `main()` over five fixtures copied to temp git repos: `test/fixtures/project` (TS, interface + implementations, barrel, JS consumer, `any` access), `test/fixtures/workspace` (Nx + pnpm workspace, `paths`, references-only tsconfigs), `test/fixtures/legacy-js` (AngularJS, Meteor, prototype, IIFE, CommonJS), `test/fixtures/commonjs` (`require('..')` shim, expando methods, `.d.ts` twin), `test/fixtures/esm-js` (`"type": "module"`, `index.js` + `index.d.ts`) |
|
|
242
|
+
| `test/integration/*.test.ts` | `pnpm run test:integration` | the built binary against the vendored Astro + React sample in `test/integration/sample` (installs its own dependencies once; copied to a temp git repo per run, see `test/integration/README.md`) |
|
|
243
|
+
|
|
244
|
+
CI runs the first two on Node 22 and 24, the built CLI on Node 20, and the integration suite on Node 24.
|
|
131
245
|
|
|
132
246
|
## License
|
|
133
247
|
|
|
@@ -96,35 +96,89 @@ export function positionOf(group) {
|
|
|
96
96
|
const d = group.decls[0];
|
|
97
97
|
return `${d.file}:${d.nameLine}`;
|
|
98
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. */
|
|
99
102
|
export function matchRows(groups) {
|
|
100
|
-
|
|
103
|
+
const rows = groups.slice(0, MATCH_ROWS).map((g) => ({
|
|
101
104
|
file: g.primary.file,
|
|
102
105
|
line: g.decls[0].nameLine,
|
|
103
106
|
kind: g.primary.kind,
|
|
104
107
|
exported: g.primary.exported,
|
|
105
108
|
signature: g.decls[0].signature,
|
|
106
109
|
}));
|
|
110
|
+
return { matches: rows, ...(groups.length > MATCH_ROWS ? { matches_total: groups.length, truncated: true } : {}) };
|
|
107
111
|
}
|
|
108
|
-
|
|
109
|
-
|
|
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) || ".";
|
|
137
|
+
}
|
|
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;
|
|
110
141
|
const hints = groups.slice(0, 2).map((g) => `Run \`ts-axi ${command} ${positionOf(g)}\` for the one in ${g.primary.file}`);
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
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}`);
|
|
116
155
|
}
|
|
117
|
-
|
|
118
|
-
|
|
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}`);
|
|
119
161
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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}`);
|
|
125
174
|
}
|
|
126
175
|
return hints;
|
|
127
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
|
+
}
|
|
128
182
|
export function resolvePositionFile(cwd, file) {
|
|
129
183
|
const abs = resolve(cwd, file);
|
|
130
184
|
if (!existsSync(abs)) {
|
|
@@ -176,6 +230,10 @@ export function resolvePosition(index, cwd, target) {
|
|
|
176
230
|
const hit = onLine.find((id) => id.getStart(sf) <= offset && offset <= id.end) ?? onLine.find((id) => id.getStart(sf) >= offset);
|
|
177
231
|
return hit ? pick(hit) : { kind: "none", abs, file, reason: `no identifier at ${file}:${target.line}:${target.col}` };
|
|
178
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 };
|
|
179
237
|
if (onLine.length === 0)
|
|
180
238
|
return { kind: "none", abs, file, reason: `no identifier on ${file}:${target.line}` };
|
|
181
239
|
const constructorDecl = decls.find((d) => d.nameLine === target.line && d.name === "constructor");
|
package/dist/src/commands/def.js
CHANGED
|
@@ -2,13 +2,13 @@ import { bool, expectPositionals, flag, parseArgs } from "../args.js";
|
|
|
2
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
|
|
@@ -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
|
}
|
|
@@ -28,6 +28,7 @@ flags[7]:
|
|
|
28
28
|
output:
|
|
29
29
|
base, head, changes (count), changed[n]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
|
|
30
30
|
change = signature | removed | type | added | body | modified (before unavailable) - riskiest first
|
|
31
|
+
textual = column added only when an untyped (JavaScript; implicit project) Symbol has more whole-word matches outside the change set than refs_outside - those are uses the checker cannot resolve; - for typed rows; such a Change is never local_only
|
|
31
32
|
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
|
|
32
33
|
refs[n]{symbol,file,line,context} = References outside the change set; truncated + refs_shown + refs_outside_total when cut
|
|
33
34
|
notes[n]{symbol,note} = a signature change of a member that implements an interface or base member (the contract may break)
|
|
@@ -227,7 +228,8 @@ function blindSpots(semantic, names, changeSet) {
|
|
|
227
228
|
function walkExtra(dir, outFiles) {
|
|
228
229
|
outFiles.push(...walkFiles(dir, { accept: (name) => GREP_EXTRA.some((ext) => name.endsWith(ext)) }).files);
|
|
229
230
|
}
|
|
230
|
-
|
|
231
|
+
/** Whole-word matches of `names` in Project files and templates that are not `known` References. */
|
|
232
|
+
export function textualMatches(opened, semantic, names, known, skipLines, changeSet) {
|
|
231
233
|
const files = [...semantic.projectFiles];
|
|
232
234
|
const projectSet = new Set(files);
|
|
233
235
|
walkExtra(opened.root, files);
|
|
@@ -253,6 +255,33 @@ function textualMatches(opened, semantic, names, known, skipLines, changeSet) {
|
|
|
253
255
|
}
|
|
254
256
|
return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
255
257
|
}
|
|
258
|
+
/** The checker may miss uses: JS declaring file, a string-registered name, or no tsconfig at all. */
|
|
259
|
+
export function isUntyped(opened, decl, file) {
|
|
260
|
+
return decl?.nameless === true || opened.index.project.implicit || (file !== undefined && /\.[mc]?jsx?$/.test(file));
|
|
261
|
+
}
|
|
262
|
+
/** Whole-word occurrences of `name` in Project files outside `changeSet`; lines declaring a `name` excluded. */
|
|
263
|
+
export function textualCount(opened, name, changeSet = new Map()) {
|
|
264
|
+
let matches = 0;
|
|
265
|
+
let files = 0;
|
|
266
|
+
for (const abs of opened.index.files) {
|
|
267
|
+
const text = opened.index.text(abs);
|
|
268
|
+
if (text === undefined || !text.includes(name))
|
|
269
|
+
continue;
|
|
270
|
+
const rel = opened.index.rel(abs);
|
|
271
|
+
const declared = new Set(opened.index.declarations(abs).filter((d) => d.name === name).map((d) => d.nameLine));
|
|
272
|
+
const n = wordMatches(text, name).filter((m) => !declared.has(m.line) && !inRanges(changeSet.get(rel), m.line)).length;
|
|
273
|
+
matches += n;
|
|
274
|
+
if (n > 0)
|
|
275
|
+
files++;
|
|
276
|
+
}
|
|
277
|
+
return { matches, files };
|
|
278
|
+
}
|
|
279
|
+
export function textualLine(count) {
|
|
280
|
+
return `${count.matches} matches in ${count.files} files`;
|
|
281
|
+
}
|
|
282
|
+
export function textualHint(command, gap) {
|
|
283
|
+
return `Run \`${command}\` for the ${gap} textual matches the checker cannot resolve (untyped JavaScript)`;
|
|
284
|
+
}
|
|
256
285
|
function siteKey(site) {
|
|
257
286
|
return `${site.item.file}#${site.item.selectionSpan.start}`;
|
|
258
287
|
}
|
|
@@ -463,8 +492,17 @@ export async function impactCommand(args, ctx) {
|
|
|
463
492
|
row.all = entries.filter((e) => !e.isDefinition);
|
|
464
493
|
row.outside = row.all.filter((e) => !inRanges(changeSet.get(e.file), e.line)).sort(byPosition);
|
|
465
494
|
}
|
|
495
|
+
for (const row of rows) {
|
|
496
|
+
if (!row.live || !isUntyped(opened, row.live, row.live.file))
|
|
497
|
+
continue;
|
|
498
|
+
row.textual = textualCount(opened, row.live.name, changeSet);
|
|
499
|
+
if (row.textual.matches > 0)
|
|
500
|
+
row.unresolved = textualMatches(opened, semantic, [row.live.name], known, new Set(), changeSet).length;
|
|
501
|
+
}
|
|
502
|
+
// Textual matches the checker did not resolve: such a Change is never local_only.
|
|
503
|
+
const textualGap = (r) => (r.textual && r.textual.matches > r.outside.length ? r.unresolved ?? 0 : 0);
|
|
466
504
|
const shownRefs = (r) => (showVia ? [...r.outside, ...r.via].sort(byPosition) : r.outside);
|
|
467
|
-
const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0;
|
|
505
|
+
const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0 || textualGap(r) > 0;
|
|
468
506
|
const listed = rows
|
|
469
507
|
.filter(hasOutside)
|
|
470
508
|
.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 }));
|
|
@@ -512,7 +550,10 @@ export async function impactCommand(args, ctx) {
|
|
|
512
550
|
help.push(`${removedTop.symbol} was removed but is still referenced - those files break`);
|
|
513
551
|
if (truncated)
|
|
514
552
|
help.push(`Showing ${refRows.length} of ${outsideTotal} ref rows; run \`${rerun(arg, parsed, `--limit ${Math.min(MAX_LIMIT, outsideTotal)}`)}\` for more`);
|
|
515
|
-
|
|
553
|
+
const gap = listed.reduce((n, r) => n + textualGap(r), 0);
|
|
554
|
+
if (gap > 0 && !grep)
|
|
555
|
+
help.unshift(textualHint(rerun(arg, parsed, "--grep"), gap));
|
|
556
|
+
if (spots.length > 0 && !grep && gap === 0)
|
|
516
557
|
help.push(`Run \`${rerun(arg, parsed, "--grep")}\` for textual matches the checker cannot see`);
|
|
517
558
|
if (depth === 1 && rows.some((r) => r.live && isFunctionLike(r.kind) && r.outside.length > 0)) {
|
|
518
559
|
help.push(`Run \`${rerun(arg, parsed, "--depth 2")}\` for who calls the callers`);
|
|
@@ -542,9 +583,10 @@ export async function impactCommand(args, ctx) {
|
|
|
542
583
|
refs_total: r.all.length,
|
|
543
584
|
refs_outside: r.outside.length,
|
|
544
585
|
via: r.via.length,
|
|
586
|
+
...(gap > 0 ? { textual: r.textual ? r.textual.matches : "-" } : {}),
|
|
545
587
|
...fileCounts(r),
|
|
546
588
|
})),
|
|
547
|
-
refs: refRows,
|
|
589
|
+
...(refRows.length > 0 ? { refs: refRows } : {}),
|
|
548
590
|
}
|
|
549
591
|
: {}),
|
|
550
592
|
...(truncated ? { truncated: true, refs_shown: refRows.length, refs_outside_total: outsideTotal } : {}),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { relative, sep } from "node:path";
|
|
2
2
|
import { bool, expectPositionals, flag, intFlag, parseArgs } from "../args.js";
|
|
3
3
|
import { importerClosure } from "../importers.js";
|
|
4
|
+
import { isUntyped, textualCount, textualHint, textualLine, textualMatches } from "./impact.js";
|
|
4
5
|
import { findOutsideProject, nearestTsconfig } from "../project.js";
|
|
5
6
|
import { viaLine } from "../references.js";
|
|
6
7
|
import { block, helpBlock, out } from "../render.js";
|
|
@@ -9,7 +10,7 @@ import { byDepthThenPath } from "../walk.js";
|
|
|
9
10
|
import { detectWorkspace, isMultiProject } from "../workspace.js";
|
|
10
11
|
import { isTestFile } from "../settings.js";
|
|
11
12
|
import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
|
|
12
|
-
import { contextOf, declarationAt, filterSuffix, lineOf, matchRows, narrowHints, open, positionStartDir, projectFields, readFilters, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
|
|
13
|
+
import { contextOf, declarationAt, filterSuffix, lineOf, MATCH_ROWS, matchRows, narrowHints, open, positionStartDir, projectFields, readFilters, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
|
|
13
14
|
const DEFAULT_LIMIT = 50;
|
|
14
15
|
const MAX_LIMIT = 500;
|
|
15
16
|
const DEPTH_ROWS = 50;
|
|
@@ -20,16 +21,19 @@ const VIA_DOC = `via_interface: n (Iface.member) = References through an interfa
|
|
|
20
21
|
matches: 0 under -P lists found_outside_project[n]{file,line} when the name is declared outside that Project
|
|
21
22
|
refs_total: 0 under -P in a workspace lists found_outside_project[n]{file,line} = importers outside that Project
|
|
22
23
|
scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
|
|
23
|
-
tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references
|
|
24
|
+
tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references
|
|
25
|
+
textual: n matches in m files = whole-word count of the name in Project files (JavaScript declarations and implicit projects); above refs_total = uses the checker cannot resolve - see refs --grep`;
|
|
24
26
|
export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
|
|
25
|
-
flags[
|
|
27
|
+
flags[12]:
|
|
26
28
|
${SHARED_FLAGS}
|
|
27
29
|
--implementations (lists implementations[n]{file,line,symbol} instead of the count)
|
|
30
|
+
--grep (adds unresolved[n]{file,line,context} - textual matches of the name that are not References; also searches .astro/.vue/.svelte/.mdx/.html)
|
|
28
31
|
--kind calls = same as \`ts-axi callers\`
|
|
29
32
|
output:
|
|
30
33
|
symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,context} (Declarations excluded), elapsed_ms
|
|
31
34
|
${VIA_DOC}
|
|
32
|
-
|
|
35
|
+
note = the name is registered by a string ('Name' in .controller('Name', fn)) - its uses are textual; add --grep
|
|
36
|
+
an ambiguous name lists matches[n] (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms; nothing is analysed
|
|
33
37
|
examples[4]:
|
|
34
38
|
ts-axi refs User.save
|
|
35
39
|
ts-axi refs src/models/user.ts:12
|
|
@@ -62,7 +66,7 @@ async function run(args, ctx, invoked) {
|
|
|
62
66
|
{ name: "--limit" },
|
|
63
67
|
{ name: "--test-glob", repeatable: true },
|
|
64
68
|
{ name: "--via", boolean: true },
|
|
65
|
-
...(invoked === "calls" ? [{ name: "--depth" }] : [{ name: "--implementations", boolean: true }]),
|
|
69
|
+
...(invoked === "calls" ? [{ name: "--depth" }] : [{ name: "--implementations", boolean: true }, { name: "--grep", boolean: true }]),
|
|
66
70
|
];
|
|
67
71
|
const parsed = parseArgs(args, specs, command);
|
|
68
72
|
const [raw] = expectPositionals(parsed, 1, `${command} <name | Class.member | path:line[:col]> [flags]`);
|
|
@@ -74,7 +78,11 @@ async function run(args, ctx, invoked) {
|
|
|
74
78
|
const label = targetLabel(target);
|
|
75
79
|
const project = flag(parsed, "-P");
|
|
76
80
|
const name = mode === "calls" ? "callers" : "refs";
|
|
77
|
-
const show = {
|
|
81
|
+
const show = {
|
|
82
|
+
via: bool(parsed, "--via"),
|
|
83
|
+
implementations: invoked === "refs" && bool(parsed, "--implementations"),
|
|
84
|
+
grep: invoked === "refs" && bool(parsed, "--grep"),
|
|
85
|
+
};
|
|
78
86
|
const started = performance.now();
|
|
79
87
|
const opened = open(ctx.cwd, parsed, positionStartDir(ctx.cwd, target));
|
|
80
88
|
const warning = opened.warning ? { warning: opened.warning } : {};
|
|
@@ -107,7 +115,7 @@ async function run(args, ctx, invoked) {
|
|
|
107
115
|
]));
|
|
108
116
|
}
|
|
109
117
|
if (groups.length > 1) {
|
|
110
|
-
return out(block({ ...warning, symbol: label, ambiguous: `${groups.length} Symbols - pick one`,
|
|
118
|
+
return out(block({ ...warning, symbol: label, ambiguous: `${groups.length} Symbols - pick one`, ...matchRows(groups) }), helpBlock(narrowHints(opened, name, groups, f, `${label}${filterSuffix(f, project)}`, target)));
|
|
111
119
|
}
|
|
112
120
|
const decl = groups[0].decls[0];
|
|
113
121
|
resolved = { absFile: opened.index.abs(decl.file), pos: decl.namePos, decl, seeds: [...new Set(groups[0].decls.map((d) => opened.index.abs(d.file)))] };
|
|
@@ -139,7 +147,9 @@ async function run(args, ctx, invoked) {
|
|
|
139
147
|
const declaredAt = decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
|
|
140
148
|
const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
|
|
141
149
|
const explicit = Boolean(project || opened.settings.project);
|
|
142
|
-
const
|
|
150
|
+
const declaredFile = decl?.file ?? (def && def.line > 0 ? def.file : undefined);
|
|
151
|
+
const textual = declaredFile !== undefined && isUntyped(opened, decl, declaredFile) ? textualCount(opened, symbol.split(".").pop()) : undefined;
|
|
152
|
+
const o = { started, symbol, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile, ...(decl ? { decl } : {}), ...(textual ? { textual } : {}) };
|
|
143
153
|
return mode === "calls"
|
|
144
154
|
? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
|
|
145
155
|
: renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
|
|
@@ -193,6 +203,7 @@ function footer(semantic, started, touched) {
|
|
|
193
203
|
const diagnostics = semantic.diagnosticsCount(touched);
|
|
194
204
|
return { elapsed_ms: Math.round(performance.now() - started), ...(diagnostics > 0 ? { diagnostics } : {}) };
|
|
195
205
|
}
|
|
206
|
+
const NAMELESS_NOTE = "registered by a string - References to it are textual (templates; DI arrays; route configs)";
|
|
196
207
|
/** `via_interface` / `via_implementations` counts, plus the hint to see their rows. */
|
|
197
208
|
function viaFields(via, bases) {
|
|
198
209
|
const base = via.filter((v) => v.through === "base");
|
|
@@ -202,6 +213,23 @@ function viaFields(via, bases) {
|
|
|
202
213
|
...(impl.length > 0 ? { via_implementations: viaLine(impl.length, impl.map((v) => v.via)) } : {}),
|
|
203
214
|
};
|
|
204
215
|
}
|
|
216
|
+
/** Whole-word matches of the name on lines that hold no known position (what `--grep` lists). */
|
|
217
|
+
function unresolvedOf(opened, semantic, o, known) {
|
|
218
|
+
return textualMatches(opened, semantic, [o.symbol.split(".").pop()], new Set(known.map((r) => `${r.file}:${r.line}`)), new Set(o.decl ? [`${o.decl.file}:${o.decl.line}`, `${o.decl.file}:${o.decl.nameLine}`] : []), new Map());
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* `textual` field and, when the name occurs more often than refs_total and some matches sit on no
|
|
222
|
+
* Reference line (`unresolved` = the `--grep` rows), the `--grep` hint.
|
|
223
|
+
*/
|
|
224
|
+
function textualFields(o, refsTotal, unresolved, grepShown) {
|
|
225
|
+
if (!o.textual)
|
|
226
|
+
return { fields: {}, help: [] };
|
|
227
|
+
const gap = o.textual.matches > refsTotal ? unresolved : 0;
|
|
228
|
+
return {
|
|
229
|
+
fields: { textual: textualLine(o.textual) },
|
|
230
|
+
help: gap > 0 && !grepShown ? [textualHint(`ts-axi refs ${o.declaredAt} --grep`, gap)] : [],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
205
233
|
/** Direct rows, plus via rows (with a `via` column) under `--via`. */
|
|
206
234
|
function mergedRows(direct, via, show) {
|
|
207
235
|
return show.via ? sortRows([...direct, ...via]) : direct;
|
|
@@ -227,13 +255,22 @@ function renderRefs(semantic, opened, resolved, header, o) {
|
|
|
227
255
|
help.push(`Run \`ts-axi refs ${o.declaredAt} --implementations\` to list the ${impls.length} implementations`);
|
|
228
256
|
const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
|
|
229
257
|
help.unshift(...outside.help);
|
|
258
|
+
const word = o.symbol.split(".").pop();
|
|
259
|
+
const unresolved = o.show.grep || o.textual ? unresolvedOf(opened, semantic, o, [...rows, ...via, ...impls]) : [];
|
|
260
|
+
const text = textualFields(o, rows.length, unresolved.length, o.show.grep);
|
|
261
|
+
if (text.help.length > 0)
|
|
262
|
+
help.unshift(...text.help);
|
|
263
|
+
else if (o.decl?.nameless && !o.show.grep)
|
|
264
|
+
help.unshift(`Run \`ts-axi refs ${o.declaredAt} --grep\` for textual matches of '${word}'`);
|
|
230
265
|
if (isFunctionLike(o.kind))
|
|
231
266
|
help.push(`Run \`ts-axi callers ${o.declaredAt}\` for call sites with their enclosing function`);
|
|
232
267
|
help.push(`Run \`ts-axi def ${o.declaredAt}\` for the declaration body`);
|
|
233
268
|
help.push(...scopeHelp(o.scope));
|
|
234
269
|
return out(block({
|
|
235
270
|
...header,
|
|
271
|
+
...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
|
|
236
272
|
refs_total: rows.length,
|
|
273
|
+
...text.fields,
|
|
237
274
|
files,
|
|
238
275
|
tests,
|
|
239
276
|
...viaFields(via, split.bases),
|
|
@@ -251,6 +288,8 @@ function renderRefs(semantic, opened, resolved, header, o) {
|
|
|
251
288
|
})),
|
|
252
289
|
}),
|
|
253
290
|
...(truncated ? { truncated: true } : {}),
|
|
291
|
+
...(o.show.grep ? { unresolved: unresolved.length === 0 ? 0 : unresolved.slice(0, o.limit) } : {}),
|
|
292
|
+
...(unresolved.length > o.limit ? { unresolved_total: unresolved.length } : {}),
|
|
254
293
|
...outside.fields,
|
|
255
294
|
...scopeFields(o.scope),
|
|
256
295
|
...footer(semantic, o.started, fileList),
|
|
@@ -289,8 +328,17 @@ function depthRows(sites) {
|
|
|
289
328
|
}
|
|
290
329
|
function renderCallers(semantic, opened, resolved, header, o) {
|
|
291
330
|
const sites = semantic.incomingCalls(resolved.absFile, resolved.pos);
|
|
331
|
+
// Every Reference line counts as known: the gap is what `refs --grep` would list.
|
|
332
|
+
const callerText = o.textual
|
|
333
|
+
? (() => {
|
|
334
|
+
const split = semantic.referenceSplit(resolved.absFile, resolved.pos);
|
|
335
|
+
const known = [...split.direct, ...split.declarations, ...split.via, ...split.implementations, ...split.dropped, ...(sites ?? [])];
|
|
336
|
+
const calls = (sites ?? []).filter((s) => s.via === undefined).length;
|
|
337
|
+
return textualFields(o, calls, unresolvedOf(opened, semantic, o, known).length, false);
|
|
338
|
+
})()
|
|
339
|
+
: { fields: {}, help: [] };
|
|
292
340
|
if (sites === undefined) {
|
|
293
|
-
return out(block({ ...header, refs_total: 0, note: "not callable - no call hierarchy for this Symbol", ...scopeFields(o.scope), ...footer(semantic, o.started, []) }), helpBlock([`Run \`ts-axi refs ${o.declaredAt}\` for every Reference`]));
|
|
341
|
+
return out(block({ ...header, refs_total: 0, ...callerText.fields, note: "not callable - no call hierarchy for this Symbol", ...scopeFields(o.scope), ...footer(semantic, o.started, []) }), helpBlock([...callerText.help, `Run \`ts-axi refs ${o.declaredAt}\` for every Reference`]));
|
|
294
342
|
}
|
|
295
343
|
const rows = sortRows(sites.filter((s) => s.via === undefined));
|
|
296
344
|
const via = sortRows(sites.filter((s) => s.via !== undefined));
|
|
@@ -321,11 +369,14 @@ function renderCallers(semantic, opened, resolved, header, o) {
|
|
|
321
369
|
help.push(`Run \`ts-axi callers ${o.declaredAt} --depth 2\` for who calls the callers`);
|
|
322
370
|
const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
|
|
323
371
|
help.unshift(...outside.help);
|
|
324
|
-
help.
|
|
372
|
+
help.unshift(...callerText.help);
|
|
373
|
+
help.push(`Run \`ts-axi refs ${o.declaredAt}${o.decl?.nameless ? " --grep" : ""}\` for every Reference (not only calls)`);
|
|
325
374
|
help.push(...scopeHelp(o.scope));
|
|
326
375
|
return out(block({
|
|
327
376
|
...header,
|
|
377
|
+
...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
|
|
328
378
|
refs_total: rows.length,
|
|
379
|
+
...callerText.fields,
|
|
329
380
|
files,
|
|
330
381
|
tests,
|
|
331
382
|
...viaFields(via, []),
|
package/dist/src/impact.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
// Pure `impact` logic: changed ranges -> Declarations -> Change classes, ordering
|
|
2
|
-
// and row budgets. No git, no LanguageService: everything here is unit-testable.
|
|
3
|
-
import ts from "typescript";
|
|
4
1
|
import { parseSource } from "./project.js";
|
|
5
|
-
import { collectDeclarations
|
|
2
|
+
import { collectDeclarations } from "./symbols.js";
|
|
6
3
|
const TYPE_LEVEL = new Set(["interface", "type", "enum"]);
|
|
7
4
|
export function matchKey(decl) {
|
|
8
5
|
return `${decl.qualifiedName}#${decl.kind}`;
|
|
@@ -64,30 +61,11 @@ export function toRanges(lines) {
|
|
|
64
61
|
export function formatRanges(ranges) {
|
|
65
62
|
return ranges.map(([a, b]) => (a === b ? String(a) : `${a}-${b}`)).join(" ");
|
|
66
63
|
}
|
|
67
|
-
function nameStart(node, sf) {
|
|
68
|
-
if (ts.isConstructorDeclaration(node)) {
|
|
69
|
-
return node.getChildren(sf).find((c) => c.kind === ts.SyntaxKind.ConstructorKeyword)?.getStart(sf) ?? node.getStart(sf);
|
|
70
|
-
}
|
|
71
|
-
const name = node.name;
|
|
72
|
-
return name ? name.getStart(sf) : node.getStart(sf);
|
|
73
|
-
}
|
|
74
64
|
/** Full Signature per match key; overloads are joined so adding one is a `signature` change. */
|
|
75
|
-
export function signatures(
|
|
76
|
-
const nodes = new Map();
|
|
77
|
-
const visit = (node) => {
|
|
78
|
-
if (kindOf(node) !== undefined) {
|
|
79
|
-
const at = nameStart(node, sf);
|
|
80
|
-
// A variable statement starts at `export const`; collectDeclarations keys the declarator name.
|
|
81
|
-
if (!nodes.has(at))
|
|
82
|
-
nodes.set(at, node);
|
|
83
|
-
}
|
|
84
|
-
ts.forEachChild(node, visit);
|
|
85
|
-
};
|
|
86
|
-
visit(sf);
|
|
65
|
+
export function signatures(decls) {
|
|
87
66
|
const result = new Map();
|
|
88
67
|
for (const decl of decls) {
|
|
89
|
-
const
|
|
90
|
-
const text = node ? fullSignatureOf(node, sf) : decl.signature;
|
|
68
|
+
const text = decl.fullSignature ?? decl.signature;
|
|
91
69
|
const key = matchKey(decl);
|
|
92
70
|
const prev = result.get(key);
|
|
93
71
|
result.set(key, prev === undefined ? text : `${prev}\n${text}`);
|
|
@@ -110,8 +88,8 @@ export function classifyFile(input) {
|
|
|
110
88
|
const beforeMap = beforeSide ? mapRanges(beforeDecls, input.before) : { touched: new Map(), outside: [] };
|
|
111
89
|
const afterByKey = new Map(afterDecls.map((d) => [matchKey(d), d]));
|
|
112
90
|
const beforeByKey = new Map(beforeDecls.map((d) => [matchKey(d), d]));
|
|
113
|
-
const afterSigs = afterSide ? signatures(
|
|
114
|
-
const beforeSigs = beforeSide ? signatures(
|
|
91
|
+
const afterSigs = afterSide ? signatures(afterDecls) : new Map();
|
|
92
|
+
const beforeSigs = beforeSide ? signatures(beforeDecls) : new Map();
|
|
115
93
|
const keys = [...new Set([...afterMap.touched.keys(), ...beforeMap.touched.keys()])];
|
|
116
94
|
const changes = [];
|
|
117
95
|
for (const key of keys) {
|
package/dist/src/project.js
CHANGED
|
@@ -72,6 +72,10 @@ function isSourceFile(name) {
|
|
|
72
72
|
export function walkSourceFiles(root) {
|
|
73
73
|
return listFiles(root, isSourceFile).files;
|
|
74
74
|
}
|
|
75
|
+
// `Bundler` resolves `require('..')` (directory `main`/`index.js`), ESM `./x.js` specifiers and
|
|
76
|
+
// `"type": "module"` packages alike; NodeNext/Node10 gave the same References on CommonJS and ESM
|
|
77
|
+
// samples. What breaks plain JavaScript is not the option set but `.d.ts` twins and
|
|
78
|
+
// `module.exports = require()` shims, handled by `resolveModuleNameLiterals` in the host.
|
|
75
79
|
export const IMPLICIT_COMPILER_OPTIONS = {
|
|
76
80
|
allowJs: true,
|
|
77
81
|
checkJs: false,
|
|
@@ -278,6 +282,80 @@ export function createSyntacticIndex(root, project) {
|
|
|
278
282
|
return index;
|
|
279
283
|
}
|
|
280
284
|
const FULL_DIAGNOSTICS_LIMIT = 300;
|
|
285
|
+
const DECLARATION_TWINS = [
|
|
286
|
+
[/\.d\.ts$/, [[".js", ts.Extension.Js], [".jsx", ts.Extension.Jsx], [".mjs", ts.Extension.Mjs], [".cjs", ts.Extension.Cjs]]],
|
|
287
|
+
[/\.d\.mts$/, [[".mjs", ts.Extension.Mjs]]],
|
|
288
|
+
[/\.d\.cts$/, [[".cjs", ts.Extension.Cjs]]],
|
|
289
|
+
];
|
|
290
|
+
/** The implementation beside a `.d.ts` (`index.d.ts` → `index.js`) when it is a Project source file. */
|
|
291
|
+
function implementationTwin(file, isSource) {
|
|
292
|
+
for (const [re, impls] of DECLARATION_TWINS) {
|
|
293
|
+
if (!re.test(file))
|
|
294
|
+
continue;
|
|
295
|
+
const base = file.replace(re, "");
|
|
296
|
+
for (const [ext, extension] of impls)
|
|
297
|
+
if (isSource(base + ext))
|
|
298
|
+
return { file: base + ext, extension };
|
|
299
|
+
}
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
/** `'x'` of a module whose only statement is `module.exports = require('x')` (`exports = module.exports = ...` too). */
|
|
303
|
+
export function passThroughRequire(sf) {
|
|
304
|
+
const statements = sf.statements.filter((s) => !(ts.isExpressionStatement(s) && ts.isStringLiteral(s.expression)));
|
|
305
|
+
const only = statements.length === 1 ? statements[0] : undefined;
|
|
306
|
+
if (!only || !ts.isExpressionStatement(only))
|
|
307
|
+
return undefined;
|
|
308
|
+
let expr = only.expression;
|
|
309
|
+
let assignsModuleExports = false;
|
|
310
|
+
while (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
311
|
+
const left = expr.left;
|
|
312
|
+
if (ts.isPropertyAccessExpression(left) && ts.isIdentifier(left.expression) && left.expression.text === "module" && left.name.text === "exports") {
|
|
313
|
+
assignsModuleExports = true;
|
|
314
|
+
}
|
|
315
|
+
else if (!(ts.isIdentifier(left) && left.text === "exports"))
|
|
316
|
+
return undefined;
|
|
317
|
+
expr = expr.right;
|
|
318
|
+
}
|
|
319
|
+
if (!assignsModuleExports || !ts.isCallExpression(expr) || !ts.isIdentifier(expr.expression) || expr.expression.text !== "require")
|
|
320
|
+
return undefined;
|
|
321
|
+
const [arg] = expr.arguments;
|
|
322
|
+
return expr.arguments.length === 1 && arg && ts.isStringLiteralLike(arg) ? arg.text : undefined;
|
|
323
|
+
}
|
|
324
|
+
const MAX_SHIM_HOPS = 3;
|
|
325
|
+
/**
|
|
326
|
+
* TypeScript's resolution, then two substitutions so References reach implementations:
|
|
327
|
+
* a `.d.ts` with a JS twin in the Project resolves to the twin (TS tries `.d.ts` before `.js`),
|
|
328
|
+
* and a `module.exports = require('x')` shim resolves to `x` (the binder makes no alias of it).
|
|
329
|
+
*/
|
|
330
|
+
function createModuleResolver(options, currentDirectory, isSource, sourceOf) {
|
|
331
|
+
const cache = ts.createModuleResolutionCache(currentDirectory, (s) => (ts.sys.useCaseSensitiveFileNames ? s : s.toLowerCase()), options);
|
|
332
|
+
const resolveOne = (name, containingFile, mode, redirected, hops) => {
|
|
333
|
+
const result = ts.resolveModuleName(name, containingFile, options, ts.sys, cache, redirected, mode);
|
|
334
|
+
const found = result.resolvedModule;
|
|
335
|
+
if (!found)
|
|
336
|
+
return result;
|
|
337
|
+
let resolvedFileName = resolve(found.resolvedFileName);
|
|
338
|
+
let extension = found.extension;
|
|
339
|
+
const twin = implementationTwin(resolvedFileName, isSource);
|
|
340
|
+
if (twin)
|
|
341
|
+
({ file: resolvedFileName, extension } = twin);
|
|
342
|
+
else if (!isSource(resolvedFileName))
|
|
343
|
+
return result;
|
|
344
|
+
if (hops < MAX_SHIM_HOPS && /\.[mc]?jsx?$/.test(resolvedFileName)) {
|
|
345
|
+
const sf = sourceOf(resolvedFileName);
|
|
346
|
+
const inner = sf ? passThroughRequire(sf) : undefined;
|
|
347
|
+
if (inner !== undefined) {
|
|
348
|
+
const next = resolveOne(inner, resolvedFileName, ts.ModuleKind.CommonJS, redirected, hops + 1);
|
|
349
|
+
if (next.resolvedModule && isSource(resolve(next.resolvedModule.resolvedFileName)))
|
|
350
|
+
return next;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (!twin)
|
|
354
|
+
return result;
|
|
355
|
+
return { ...result, resolvedModule: { ...found, resolvedFileName, extension, isExternalLibraryImport: false } };
|
|
356
|
+
};
|
|
357
|
+
return (literals, containingFile, redirected, opts, containingSourceFile) => literals.map((literal) => resolveOne(literal.text, containingFile, ts.getModeForUsageLocation(containingSourceFile, literal, opts), redirected, 0));
|
|
358
|
+
}
|
|
281
359
|
export function createSemanticProject(index, scope) {
|
|
282
360
|
const started = performance.now();
|
|
283
361
|
const { project } = index;
|
|
@@ -306,6 +384,7 @@ export function createSemanticProject(index, scope) {
|
|
|
306
384
|
getDirectories: ts.sys.getDirectories,
|
|
307
385
|
realpath: ts.sys.realpath,
|
|
308
386
|
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
|
|
387
|
+
resolveModuleNameLiterals: createModuleResolver(options, currentDirectory, (f) => indexed.has(f), (f) => index.sourceFile(f)),
|
|
309
388
|
};
|
|
310
389
|
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
311
390
|
const program = service.getProgram();
|
|
@@ -406,8 +485,34 @@ export function createSemanticProject(index, scope) {
|
|
|
406
485
|
}
|
|
407
486
|
return [...keys];
|
|
408
487
|
};
|
|
488
|
+
/**
|
|
489
|
+
* The same-named top-level Declaration in the `.d.ts` beside a JS file, when that `.d.ts` is in the
|
|
490
|
+
* program: importers the resolver did not redirect (a package.json `types` field) bind to it.
|
|
491
|
+
*/
|
|
492
|
+
const declarationTwin = (absFile, pos) => {
|
|
493
|
+
if (!/\.[mc]?jsx?$/.test(absFile))
|
|
494
|
+
return undefined;
|
|
495
|
+
const sf = program.getSourceFile(absFile);
|
|
496
|
+
const id = sf ? nodeAt(sf, pos) : undefined;
|
|
497
|
+
if (!id || !ts.isIdentifier(id))
|
|
498
|
+
return undefined;
|
|
499
|
+
const base = absFile.replace(/\.([mc]?)jsx?$/, "");
|
|
500
|
+
const twin = [".d.ts", ".d.mts", ".d.cts"].map((ext) => program.getSourceFile(base + ext)).find((t) => t !== undefined);
|
|
501
|
+
if (!twin)
|
|
502
|
+
return undefined;
|
|
503
|
+
for (const statement of twin.statements) {
|
|
504
|
+
const names = ts.isVariableStatement(statement)
|
|
505
|
+
? statement.declarationList.declarations.map((d) => d.name)
|
|
506
|
+
: "name" in statement && statement.name ? [statement.name] : [];
|
|
507
|
+
const hit = names.find((n) => ts.isIdentifier(n) && n.text === id.text);
|
|
508
|
+
if (hit)
|
|
509
|
+
return { file: twin.fileName, pos: hit.getStart(twin) };
|
|
510
|
+
}
|
|
511
|
+
return undefined;
|
|
512
|
+
};
|
|
409
513
|
const split = (absFile, pos) => {
|
|
410
|
-
const
|
|
514
|
+
const twin = declarationTwin(absFile, pos);
|
|
515
|
+
const groups = [...(service.findReferences(absFile, pos) ?? []), ...(twin ? service.findReferences(twin.file, twin.pos) ?? [] : [])];
|
|
411
516
|
const descriptors = [];
|
|
412
517
|
let target = -1;
|
|
413
518
|
let flagged = -1;
|
|
@@ -514,7 +619,15 @@ export function createSemanticProject(index, scope) {
|
|
|
514
619
|
const items = prepared === undefined ? [] : Array.isArray(prepared) ? prepared : [prepared];
|
|
515
620
|
if (items.length === 0)
|
|
516
621
|
return undefined;
|
|
517
|
-
|
|
622
|
+
const twin = declarationTwin(absFile, pos);
|
|
623
|
+
const twinCalls = twin ? service.provideCallHierarchyIncomingCalls(twin.file, twin.pos) : [];
|
|
624
|
+
const seen = new Set();
|
|
625
|
+
return items
|
|
626
|
+
.flatMap((item) => splitSites(item.file, item.selectionSpan.start, [...service.provideCallHierarchyIncomingCalls(item.file, item.selectionSpan.start), ...twinCalls]))
|
|
627
|
+
.filter((site) => {
|
|
628
|
+
const key = `${site.file}:${site.line}:${site.col}`;
|
|
629
|
+
return !seen.has(key) && Boolean(seen.add(key));
|
|
630
|
+
});
|
|
518
631
|
},
|
|
519
632
|
incomingCallsOf(item) {
|
|
520
633
|
if (item.kind === ts.ScriptElementKind.scriptElement)
|
package/dist/src/settings.js
CHANGED
|
@@ -11,6 +11,9 @@ export const DEFAULT_TEST_GLOBS = [
|
|
|
11
11
|
"**/test/**",
|
|
12
12
|
"**/tests/**",
|
|
13
13
|
"**/e2e/**",
|
|
14
|
+
"**/spec/**",
|
|
15
|
+
"**/test.*",
|
|
16
|
+
"**/*_test.*",
|
|
14
17
|
];
|
|
15
18
|
export const SETTINGS_FILE = ".ts-axi.json";
|
|
16
19
|
export const SETTINGS_KEYS = ["testGlobs", "testGlobsReplace", "project"];
|
package/dist/src/symbols.js
CHANGED
|
@@ -155,6 +155,14 @@ function bodyOf(node) {
|
|
|
155
155
|
function openBrace(node, sf) {
|
|
156
156
|
return node.getChildren(sf).find((c) => c.kind === ts.SyntaxKind.OpenBraceToken)?.getStart(sf);
|
|
157
157
|
}
|
|
158
|
+
/** Text up to the body of a function initializer, or up to the end of the type annotation (the initializer is the body). */
|
|
159
|
+
function headText(node, sf, init, typeEnd) {
|
|
160
|
+
if (isFunctionInitializer(init)) {
|
|
161
|
+
const text = tokenText(node, sf, node.getStart(sf), init.body.getStart(sf));
|
|
162
|
+
return ts.isArrowFunction(init) ? text.replace(/\s*=>$/, " =>") : text;
|
|
163
|
+
}
|
|
164
|
+
return tokenText(node, sf, node.getStart(sf), typeEnd);
|
|
165
|
+
}
|
|
158
166
|
/** Uncapped Signature text; `signatureOf` clips it for display. */
|
|
159
167
|
export function fullSignatureOf(node, sf) {
|
|
160
168
|
if (ts.isVariableDeclaration(node)) {
|
|
@@ -163,13 +171,13 @@ export function fullSignatureOf(node, sf) {
|
|
|
163
171
|
const head = ts.isVariableDeclarationList(list) && ts.isVariableStatement(statement)
|
|
164
172
|
? tokenText(statement, sf, statement.getStart(sf), list.declarations[0].getStart(sf))
|
|
165
173
|
: "";
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
text = text.replace(/\s*=>$/, " =>");
|
|
171
|
-
return `${head} ${text}`.trim();
|
|
174
|
+
return `${head} ${headText(node, sf, node.initializer, (node.type ?? node.exclamationToken ?? node.name).end)}`.trim();
|
|
175
|
+
}
|
|
176
|
+
if (ts.isPropertyDeclaration(node)) {
|
|
177
|
+
return headText(node, sf, node.initializer, (node.type ?? node.exclamationToken ?? node.questionToken ?? node.name).end).replace(/;$/, "");
|
|
172
178
|
}
|
|
179
|
+
if (ts.isEnumMember(node))
|
|
180
|
+
return tokenText(node, sf, node.getStart(sf), node.name.end);
|
|
173
181
|
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
|
|
174
182
|
return tokenText(node, sf, node.getStart(sf), openBrace(node, sf) ?? node.end);
|
|
175
183
|
}
|
|
@@ -216,12 +224,76 @@ function nameText(name, sf) {
|
|
|
216
224
|
return name.text;
|
|
217
225
|
return name.getText(sf);
|
|
218
226
|
}
|
|
227
|
+
/** Registration verbs whose `('Name', fn)` call introduces a nested Declaration (AngularJS style). */
|
|
228
|
+
const REGISTRATION_VERBS = new Set(["controller", "service", "factory", "directive", "filter", "component", "run", "config", "provider"]);
|
|
229
|
+
/** Verbs whose callback is a constructor: its `this.x = function` statements are members. */
|
|
230
|
+
const CONSTRUCTOR_VERBS = new Set(["controller", "service", "provider"]);
|
|
231
|
+
/** `Template.x.<hook>(fn)` lifecycle callbacks. */
|
|
232
|
+
const TEMPLATE_HOOKS = new Set(["onCreated", "onRendered", "onDestroyed"]);
|
|
233
|
+
function unwrap(expr) {
|
|
234
|
+
let e = expr;
|
|
235
|
+
for (;;) {
|
|
236
|
+
if (ts.isParenthesizedExpression(e))
|
|
237
|
+
e = e.expression;
|
|
238
|
+
else if (ts.isPrefixUnaryExpression(e))
|
|
239
|
+
e = e.operand;
|
|
240
|
+
else if (ts.isVoidExpression(e))
|
|
241
|
+
e = e.expression;
|
|
242
|
+
else
|
|
243
|
+
return e;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function asFunction(expr) {
|
|
247
|
+
if (!expr)
|
|
248
|
+
return undefined;
|
|
249
|
+
const e = unwrap(expr);
|
|
250
|
+
return ts.isFunctionExpression(e) || ts.isArrowFunction(e) ? e : undefined;
|
|
251
|
+
}
|
|
252
|
+
/** Statements of an IIFE body: `(function () {...})()`, `!function () {...}()`, `(function () {...}).call(this)`. */
|
|
253
|
+
function iifeBody(expr) {
|
|
254
|
+
if (!expr)
|
|
255
|
+
return undefined;
|
|
256
|
+
const call = unwrap(expr);
|
|
257
|
+
if (!ts.isCallExpression(call))
|
|
258
|
+
return undefined;
|
|
259
|
+
let callee = unwrap(call.expression);
|
|
260
|
+
if (ts.isPropertyAccessExpression(callee) && (callee.name.text === "call" || callee.name.text === "apply"))
|
|
261
|
+
callee = unwrap(callee.expression);
|
|
262
|
+
const fn = asFunction(callee);
|
|
263
|
+
return fn && ts.isBlock(fn.body) ? fn.body.statements : undefined;
|
|
264
|
+
}
|
|
265
|
+
/** The callback of a registration: a function, or the last element of a DI array. */
|
|
266
|
+
function registeredFunction(arg) {
|
|
267
|
+
if (!arg)
|
|
268
|
+
return undefined;
|
|
269
|
+
if (ts.isArrayLiteralExpression(arg))
|
|
270
|
+
return asFunction(arg.elements[arg.elements.length - 1]);
|
|
271
|
+
return asFunction(arg);
|
|
272
|
+
}
|
|
273
|
+
function isStringName(node) {
|
|
274
|
+
return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node));
|
|
275
|
+
}
|
|
276
|
+
function compact(node, sf) {
|
|
277
|
+
return node.getText(sf).replace(/\s+/g, "");
|
|
278
|
+
}
|
|
279
|
+
/** Parameter list (and return type) of a function as `(a, b): T`. */
|
|
280
|
+
function paramsText(fn, sf) {
|
|
281
|
+
const params = fn.parameters.map((p) => tokenText(p, sf, p.getStart(sf), p.end)).join(", ");
|
|
282
|
+
return `(${params})${fn.type ? `: ${tokenText(fn.type, sf, fn.type.getStart(sf), fn.type.end)}` : ""}`;
|
|
283
|
+
}
|
|
284
|
+
/** `node` text up to the body of its function value; `=>` kept for arrows. */
|
|
285
|
+
function headUntil(node, fn, sf) {
|
|
286
|
+
const text = tokenText(node, sf, node.getStart(sf), fn.body.getStart(sf));
|
|
287
|
+
return ts.isArrowFunction(fn) ? text.replace(/\s*=>$/, " =>") : text;
|
|
288
|
+
}
|
|
219
289
|
/**
|
|
220
|
-
*
|
|
221
|
-
* enum members and namespace members following their parent
|
|
290
|
+
* Declarations of a file in source order: top-level ones with class, interface
|
|
291
|
+
* and enum members and namespace members following their parent, plus nested
|
|
292
|
+
* declarations of script-style code (registrations, IIFE helpers, prototype and
|
|
293
|
+
* object-literal methods; see GLOSSARY.md Declaration).
|
|
222
294
|
*/
|
|
223
295
|
export function collectDeclarations(sf, file) {
|
|
224
|
-
const
|
|
296
|
+
const raws = [];
|
|
225
297
|
const local = localExportNames(sf);
|
|
226
298
|
const add = (node, nameNode, name, exported, parent) => {
|
|
227
299
|
const kind = kindOf(node);
|
|
@@ -231,26 +303,228 @@ export function collectDeclarations(sf, file) {
|
|
|
231
303
|
? node.parent.parent
|
|
232
304
|
: node;
|
|
233
305
|
const start = startNode.getStart(sf);
|
|
234
|
-
const startLc = sf.getLineAndCharacterOfPosition(start);
|
|
235
|
-
const namePos = nameNode ? nameNode.getStart(sf) : start;
|
|
236
306
|
const body = bodyOf(ts.isVariableDeclaration(node) && isFunctionInitializer(node.initializer) ? node.initializer : node);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
col: startLc.character + 1,
|
|
241
|
-
endLine: sf.getLineAndCharacterOfPosition(node.end).line + 1,
|
|
307
|
+
raws.push({
|
|
308
|
+
start,
|
|
309
|
+
end: node.end,
|
|
242
310
|
kind,
|
|
243
311
|
name,
|
|
244
312
|
qualifiedName: parent ? `${parent}.${name}` : name,
|
|
245
313
|
exported,
|
|
246
|
-
signature:
|
|
314
|
+
signature: fullSignatureOf(node, sf),
|
|
247
315
|
...(parent ? { parentName: parent } : {}),
|
|
248
|
-
namePos,
|
|
249
|
-
nameLine: sf.getLineAndCharacterOfPosition(namePos).line + 1,
|
|
316
|
+
namePos: nameNode ? nameNode.getStart(sf) : start,
|
|
250
317
|
hasBody: body !== undefined ||
|
|
251
318
|
!(ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node)),
|
|
252
319
|
});
|
|
253
320
|
};
|
|
321
|
+
const nested = (raw) => {
|
|
322
|
+
raws.push({ ...raw, qualifiedName: raw.qualifiedName ?? (raw.parentName ? `${raw.parentName}.${raw.name}` : raw.name), hasBody: true });
|
|
323
|
+
};
|
|
324
|
+
/** Method and function-valued properties of an object literal as `<holder>.<name>`. */
|
|
325
|
+
const objectMembers = (obj, holder, exported) => {
|
|
326
|
+
for (const prop of obj.properties) {
|
|
327
|
+
const name = nameText(prop.name, sf);
|
|
328
|
+
if (!name || !prop.name)
|
|
329
|
+
continue;
|
|
330
|
+
if (ts.isMethodDeclaration(prop)) {
|
|
331
|
+
nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported, signature: fullSignatureOf(prop, sf), namePos: prop.name.getStart(sf) });
|
|
332
|
+
}
|
|
333
|
+
else if (ts.isPropertyAssignment(prop)) {
|
|
334
|
+
const fn = asFunction(prop.initializer);
|
|
335
|
+
if (fn)
|
|
336
|
+
nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported, signature: headUntil(prop, fn, sf), namePos: prop.name.getStart(sf) });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
/** `this.x = function` statements at the top level of a constructor-like body. */
|
|
341
|
+
const thisMembers = (body, owner, exported) => {
|
|
342
|
+
if (!body || !ts.isBlock(body))
|
|
343
|
+
return;
|
|
344
|
+
for (const statement of body.statements) {
|
|
345
|
+
if (!ts.isExpressionStatement(statement) || !ts.isBinaryExpression(statement.expression))
|
|
346
|
+
continue;
|
|
347
|
+
const { left, operatorToken, right } = statement.expression;
|
|
348
|
+
const fn = asFunction(right);
|
|
349
|
+
if (operatorToken.kind !== ts.SyntaxKind.EqualsToken || !fn || !ts.isPropertyAccessExpression(left) || left.expression.kind !== ts.SyntaxKind.ThisKeyword)
|
|
350
|
+
continue;
|
|
351
|
+
nested({ start: statement.getStart(sf), end: statement.end, kind: "method", name: left.name.text, parentName: owner, exported, signature: headUntil(statement.expression, fn, sf), namePos: left.name.getStart(sf) });
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
/** Top-level object literals returned by a factory: its API. */
|
|
355
|
+
const returnedMembers = (body, owner) => {
|
|
356
|
+
if (ts.isObjectLiteralExpression(unwrap(body)))
|
|
357
|
+
return objectMembers(unwrap(body), owner, true);
|
|
358
|
+
if (!ts.isBlock(body))
|
|
359
|
+
return;
|
|
360
|
+
for (const statement of body.statements) {
|
|
361
|
+
if (ts.isReturnStatement(statement) && statement.expression && ts.isObjectLiteralExpression(unwrap(statement.expression))) {
|
|
362
|
+
objectMembers(unwrap(statement.expression), owner, true);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const topLevelExported = (name) => raws.find((r) => r.qualifiedName === name && r.parentName === undefined)?.exported ?? false;
|
|
367
|
+
const isConstructorName = (name) => /^[A-Z]/.test(name);
|
|
368
|
+
/** `.controller('Name', fn)` and friends; `.run(fn)` / `.config(fn)` take the verb as name. */
|
|
369
|
+
const registration = (call, verb) => {
|
|
370
|
+
const [first, second] = call.arguments;
|
|
371
|
+
const named = isStringName(first);
|
|
372
|
+
if (!named && verb !== "run" && verb !== "config")
|
|
373
|
+
return;
|
|
374
|
+
const name = named ? first.text : verb;
|
|
375
|
+
const arg = named ? second : first;
|
|
376
|
+
const callee = call.expression;
|
|
377
|
+
const start = named ? first.getStart(sf) : callee.name.getStart(sf);
|
|
378
|
+
if (verb === "component" && arg && ts.isObjectLiteralExpression(arg)) {
|
|
379
|
+
nested({ start, end: arg.end, kind: "variable", name, exported: true, signature: `component ${name}`, namePos: arg.getStart(sf), nameless: true });
|
|
380
|
+
objectMembers(arg, name, true);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const fn = registeredFunction(arg);
|
|
384
|
+
if (!fn)
|
|
385
|
+
return;
|
|
386
|
+
const inject = arg && ts.isArrayLiteralExpression(arg) ? ` [${arg.elements.slice(0, -1).map((e) => e.getText(sf)).join(", ")}]` : "";
|
|
387
|
+
nested({
|
|
388
|
+
start,
|
|
389
|
+
end: arg.end,
|
|
390
|
+
kind: "function",
|
|
391
|
+
name,
|
|
392
|
+
qualifiedName: name,
|
|
393
|
+
parentName: verb,
|
|
394
|
+
exported: true,
|
|
395
|
+
signature: `${verb} ${name}${paramsText(fn, sf)}`,
|
|
396
|
+
namePos: (ts.isFunctionExpression(fn) && fn.name ? fn.name : fn).getStart(sf),
|
|
397
|
+
nameless: true,
|
|
398
|
+
});
|
|
399
|
+
// The DI array is compared, not displayed: reordering injections is a contract change.
|
|
400
|
+
if (inject)
|
|
401
|
+
raws[raws.length - 1].signature += `\n${inject.trim()}`;
|
|
402
|
+
if (CONSTRUCTOR_VERBS.has(verb))
|
|
403
|
+
thisMembers(fn.body, name, true);
|
|
404
|
+
if (verb === "factory" || verb === "service")
|
|
405
|
+
returnedMembers(fn.body, name);
|
|
406
|
+
};
|
|
407
|
+
/** Registrations along a call chain (`angular.module('x').controller(...).service(...)`), in source order. */
|
|
408
|
+
const callChain = (expr) => {
|
|
409
|
+
const calls = [];
|
|
410
|
+
let e = unwrap(expr);
|
|
411
|
+
while (ts.isCallExpression(e)) {
|
|
412
|
+
const callee = e.expression;
|
|
413
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
414
|
+
calls.push({ call: e, name: callee.name.text });
|
|
415
|
+
e = callee.expression;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
for (const { call, name } of calls.reverse()) {
|
|
422
|
+
const callee = call.expression;
|
|
423
|
+
const receiver = compact(callee.expression, sf);
|
|
424
|
+
const first = call.arguments[0];
|
|
425
|
+
if (receiver === "Meteor" && name === "methods" && first && ts.isObjectLiteralExpression(first)) {
|
|
426
|
+
objectMembers(first, "methods", true);
|
|
427
|
+
}
|
|
428
|
+
else if (receiver.startsWith("Template") && (name === "helpers" || name === "events") && first && ts.isObjectLiteralExpression(first)) {
|
|
429
|
+
objectMembers(first, `${receiver}.${name}`, true);
|
|
430
|
+
}
|
|
431
|
+
else if (receiver.startsWith("Template") && TEMPLATE_HOOKS.has(name) && asFunction(first)) {
|
|
432
|
+
nested({ start: call.getStart(sf), end: call.end, kind: "method", name, parentName: receiver, exported: true, signature: `${receiver}.${headUntil(first, asFunction(first), sf)}`, namePos: callee.name.getStart(sf) });
|
|
433
|
+
}
|
|
434
|
+
else if (REGISTRATION_VERBS.has(name)) {
|
|
435
|
+
registration(call, name);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
/** `exports.x = fn`, `module.exports = {...}`, `Foo.prototype.bar = fn`, `Foo.bar = fn`. */
|
|
440
|
+
const assignment = (statement, expr) => {
|
|
441
|
+
const left = expr.left;
|
|
442
|
+
const right = unwrap(expr.right);
|
|
443
|
+
if (!ts.isPropertyAccessExpression(left))
|
|
444
|
+
return;
|
|
445
|
+
const target = compact(left, sf);
|
|
446
|
+
if (target === "module.exports") {
|
|
447
|
+
if (ts.isObjectLiteralExpression(right))
|
|
448
|
+
objectMembers(right, "exports", true);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const owner = left.expression;
|
|
452
|
+
if (ts.isPropertyAccessExpression(owner) && owner.name.text === "prototype" && ts.isIdentifier(owner.expression)) {
|
|
453
|
+
const cls = owner.expression.text;
|
|
454
|
+
const fn = asFunction(right);
|
|
455
|
+
if (fn)
|
|
456
|
+
nested({ start: statement.getStart(sf), end: statement.end, kind: "method", name: left.name.text, parentName: cls, exported: topLevelExported(cls), signature: headUntil(expr, fn, sf), namePos: left.name.getStart(sf) });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (ts.isIdentifier(left.name) && left.name.text === "prototype" && ts.isIdentifier(owner) && ts.isObjectLiteralExpression(right)) {
|
|
460
|
+
objectMembers(right, owner.text, topLevelExported(owner.text));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const fn = asFunction(right);
|
|
464
|
+
if (!fn)
|
|
465
|
+
return;
|
|
466
|
+
const holder = target === `module.exports.${left.name.text}` ? "exports" : ts.isIdentifier(owner) ? owner.text : undefined;
|
|
467
|
+
if (holder === undefined)
|
|
468
|
+
return;
|
|
469
|
+
const exported = holder === "exports" || topLevelExported(holder);
|
|
470
|
+
nested({ start: statement.getStart(sf), end: statement.end, kind: "method", name: left.name.text, parentName: holder, exported, signature: headUntil(expr, fn, sf), namePos: left.name.getStart(sf) });
|
|
471
|
+
};
|
|
472
|
+
/** Script-style patterns of one module-level statement; `inIife` adds the IIFE's own functions. */
|
|
473
|
+
const scriptStatement = (statement, inIife) => {
|
|
474
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
475
|
+
if (!statement.name || !statement.body)
|
|
476
|
+
return;
|
|
477
|
+
if (inIife)
|
|
478
|
+
add(statement, statement.name, statement.name.text, false);
|
|
479
|
+
if (isConstructorName(statement.name.text))
|
|
480
|
+
thisMembers(statement.body, statement.name.text, inIife ? false : topLevelExported(statement.name.text));
|
|
481
|
+
}
|
|
482
|
+
else if (ts.isVariableStatement(statement)) {
|
|
483
|
+
for (const decl of statement.declarationList.declarations) {
|
|
484
|
+
if (!ts.isIdentifier(decl.name) || !decl.initializer)
|
|
485
|
+
continue;
|
|
486
|
+
const name = decl.name.text;
|
|
487
|
+
const fn = asFunction(decl.initializer);
|
|
488
|
+
if (inIife && fn)
|
|
489
|
+
add(decl, decl.name, name, false);
|
|
490
|
+
const exported = inIife ? false : topLevelExported(name);
|
|
491
|
+
const init = unwrap(decl.initializer);
|
|
492
|
+
if (ts.isObjectLiteralExpression(init))
|
|
493
|
+
objectMembers(init, name, exported);
|
|
494
|
+
else if (fn && isConstructorName(name))
|
|
495
|
+
thisMembers(fn.body, name, exported);
|
|
496
|
+
else {
|
|
497
|
+
const body = iifeBody(decl.initializer);
|
|
498
|
+
if (body)
|
|
499
|
+
scriptStatements(body, true);
|
|
500
|
+
else
|
|
501
|
+
callChain(decl.initializer);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else if (ts.isExpressionStatement(statement)) {
|
|
506
|
+
const expr = unwrap(statement.expression);
|
|
507
|
+
const body = iifeBody(expr);
|
|
508
|
+
if (body)
|
|
509
|
+
scriptStatements(body, true);
|
|
510
|
+
else if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.EqualsToken)
|
|
511
|
+
assignment(statement, expr);
|
|
512
|
+
else
|
|
513
|
+
callChain(expr);
|
|
514
|
+
}
|
|
515
|
+
else if (ts.isIfStatement(statement)) {
|
|
516
|
+
scriptStatement(statement.thenStatement, inIife);
|
|
517
|
+
if (statement.elseStatement)
|
|
518
|
+
scriptStatement(statement.elseStatement, inIife);
|
|
519
|
+
}
|
|
520
|
+
else if (ts.isBlock(statement)) {
|
|
521
|
+
scriptStatements(statement.statements, inIife);
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
const scriptStatements = (statements, inIife) => {
|
|
525
|
+
for (const statement of statements)
|
|
526
|
+
scriptStatement(statement, inIife);
|
|
527
|
+
};
|
|
254
528
|
const visitStatements = (statements, parent, parentExported) => {
|
|
255
529
|
for (const statement of statements) {
|
|
256
530
|
if (ts.isVariableStatement(statement)) {
|
|
@@ -260,6 +534,8 @@ export function collectDeclarations(sf, file) {
|
|
|
260
534
|
const exported = parent ? parentExported && hasModifier(statement, ts.SyntaxKind.ExportKeyword) : isTopLevelExported(statement, decl.name.text, local);
|
|
261
535
|
add(decl, decl.name, decl.name.text, exported, parent);
|
|
262
536
|
}
|
|
537
|
+
if (!parent)
|
|
538
|
+
scriptStatement(statement, false);
|
|
263
539
|
continue;
|
|
264
540
|
}
|
|
265
541
|
if (ts.isFunctionDeclaration(statement) ||
|
|
@@ -307,11 +583,39 @@ export function collectDeclarations(sf, file) {
|
|
|
307
583
|
else if (ts.isModuleDeclaration(statement) && statement.body && ts.isModuleBlock(statement.body)) {
|
|
308
584
|
visitStatements(statement.body.statements, qualified, exported);
|
|
309
585
|
}
|
|
586
|
+
else if (ts.isFunctionDeclaration(statement) && !parent) {
|
|
587
|
+
scriptStatement(statement, false);
|
|
588
|
+
}
|
|
589
|
+
continue;
|
|
310
590
|
}
|
|
591
|
+
if (!parent)
|
|
592
|
+
scriptStatement(statement, false);
|
|
311
593
|
}
|
|
312
594
|
};
|
|
313
595
|
visitStatements(sf.statements, undefined, true);
|
|
314
|
-
|
|
596
|
+
// Nested declarations are found after their holder statement; source order is start order.
|
|
597
|
+
const ordered = raws.map((r, i) => ({ r, i })).sort((a, b) => a.r.start - b.r.start || a.i - b.i);
|
|
598
|
+
return ordered.map(({ r }) => {
|
|
599
|
+
const startLc = sf.getLineAndCharacterOfPosition(r.start);
|
|
600
|
+
const full = r.signature;
|
|
601
|
+
return {
|
|
602
|
+
file,
|
|
603
|
+
line: startLc.line + 1,
|
|
604
|
+
col: startLc.character + 1,
|
|
605
|
+
endLine: sf.getLineAndCharacterOfPosition(r.end).line + 1,
|
|
606
|
+
kind: r.kind,
|
|
607
|
+
name: r.name,
|
|
608
|
+
qualifiedName: r.qualifiedName,
|
|
609
|
+
exported: r.exported,
|
|
610
|
+
signature: clip(full.split("\n")[0]),
|
|
611
|
+
...(r.parentName !== undefined ? { parentName: r.parentName } : {}),
|
|
612
|
+
namePos: r.namePos,
|
|
613
|
+
nameLine: sf.getLineAndCharacterOfPosition(r.namePos).line + 1,
|
|
614
|
+
hasBody: r.hasBody,
|
|
615
|
+
fullSignature: full,
|
|
616
|
+
...(r.nameless ? { nameless: true } : {}),
|
|
617
|
+
};
|
|
618
|
+
});
|
|
315
619
|
}
|
|
316
620
|
/** Declarations sharing file and qualified name (overloads, merging) are one Symbol. */
|
|
317
621
|
export function symbolKey(decl) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-axi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "AXI-compliant TypeScript symbol CLI for agents - definitions, references, callers, file outlines and change impact (blast radius) with token-efficient TOON output",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|