ts-axi 0.1.4 → 0.1.6
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 -5
- package/dist/src/commands/def.js +1 -1
- package/dist/src/commands/impact.js +114 -5
- package/dist/src/commands/outline.js +1 -0
- package/dist/src/commands/refs.js +78 -11
- package/dist/src/project.js +118 -3
- package/dist/src/settings.js +3 -0
- package/dist/src/symbols.js +125 -0
- package/dist/src/templates.js +99 -0
- 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.** 163 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`. ui-router and ngRoute states are Declarations of kind `state` (`states.cart`, with callbacks as `states.cart.resolve.items`), so an edit inside a `resolve` names that callback. Directives, components, their `scope`/`bindings` keys, controllers and filters also get `templates: <n> usages in <m> files` (kebab-case elements and attributes, `ng-controller`, `| filter` in `.html`/`.htm`/`.ejs`/`.hbs`/`.njk`/`.pug`), a `templates` column in `impact`, and a changed template that uses a changed directive or binding is listed under `template_changes` instead of `skipped`.
|
|
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,7 +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.
|
|
119
|
-
-
|
|
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 and ui-router states, 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.
|
|
120
223
|
- Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
|
|
121
224
|
|
|
122
225
|
Every semantic command starts cold and prints `elapsed_ms`.
|
|
@@ -128,7 +231,17 @@ corepack pnpm install
|
|
|
128
231
|
corepack pnpm run check # typecheck, tests, build, skill freshness
|
|
129
232
|
```
|
|
130
233
|
|
|
131
|
-
`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.
|
|
132
245
|
|
|
133
246
|
## License
|
|
134
247
|
|
package/dist/src/commands/def.js
CHANGED
|
@@ -5,7 +5,7 @@ import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.j
|
|
|
5
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
|
-
--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
|
|
8
|
+
--kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace|state>, --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
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
|
|
@@ -11,6 +11,7 @@ import { block, helpBlock, out } from "../render.js";
|
|
|
11
11
|
import { isTestFile } from "../settings.js";
|
|
12
12
|
import { isFunctionLike } from "../symbols.js";
|
|
13
13
|
import { IMPACT_HOPS, scopeFields, scopeHelp, semanticFor } from "../scope.js";
|
|
14
|
+
import { isTemplatePath, loadTemplates, templateTarget, templateUsages, usagesIn } from "../templates.js";
|
|
14
15
|
import { walkFiles } from "../walk.js";
|
|
15
16
|
import { contextOf, lineOf, open, projectFields } from "./common.js";
|
|
16
17
|
const DEFAULT_LIMIT = 50;
|
|
@@ -28,11 +29,14 @@ flags[7]:
|
|
|
28
29
|
output:
|
|
29
30
|
base, head, changes (count), changed[n]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
|
|
30
31
|
change = signature | removed | type | added | body | modified (before unavailable) - riskiest first
|
|
32
|
+
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
33
|
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
34
|
refs[n]{symbol,file,line,context} = References outside the change set; truncated + refs_shown + refs_outside_total when cut
|
|
33
35
|
notes[n]{symbol,note} = a signature change of a member that implements an interface or base member (the contract may break)
|
|
34
36
|
local_only = Changes with no Reference outside the change set; file_level[n]{file,lines} = changed lines outside any Declaration
|
|
35
37
|
blind_spots[n]{symbol,file,line,reason} = uses the checker cannot follow (any; string keys; unresolved export *)
|
|
38
|
+
templates = column added only when a changed Symbol is one templates name (directive, component, binding, controller, filter): its usages in .html/.htm/.ejs/.hbs/.njk/.pug files; - for other rows; such a Change is never local_only
|
|
39
|
+
template_changes[n]{file,symbols} = templates in the diff that use a changed directive, binding, controller or filter - review them with that change
|
|
36
40
|
skipped[n]{file,reason} = files in the diff that were not analysed (.astro, .json, outside the Project)
|
|
37
41
|
tests: 0 on an exported signature or body change = no Test file references it
|
|
38
42
|
scope = in a monorepo without -P the program is the touched files + two levels of importers (scope_partial: true when capped)
|
|
@@ -254,6 +258,54 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
|
|
|
254
258
|
}
|
|
255
259
|
return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
256
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* The checker may miss uses: JS declaring file, a string-registered name, or no tsconfig at all.
|
|
263
|
+
* A state's hooks (`onEnter`, inline `controller`) are called by the router, never by name: no textual count.
|
|
264
|
+
*/
|
|
265
|
+
export function isUntyped(opened, decl, file) {
|
|
266
|
+
if (decl?.parentName !== undefined && /^(states|routes)\./.test(decl.parentName) && !decl.parentName.endsWith(".resolve"))
|
|
267
|
+
return false;
|
|
268
|
+
return decl?.nameless === true || opened.index.project.implicit || (file !== undefined && /\.[mc]?jsx?$/.test(file));
|
|
269
|
+
}
|
|
270
|
+
/** Whole-word occurrences of `name` in Project files outside `changeSet`; lines declaring a `name` excluded. */
|
|
271
|
+
export function textualCount(opened, name, changeSet = new Map()) {
|
|
272
|
+
let matches = 0;
|
|
273
|
+
let files = 0;
|
|
274
|
+
for (const abs of opened.index.files) {
|
|
275
|
+
const text = opened.index.text(abs);
|
|
276
|
+
if (text === undefined || !text.includes(name))
|
|
277
|
+
continue;
|
|
278
|
+
const rel = opened.index.rel(abs);
|
|
279
|
+
const declared = new Set(opened.index.declarations(abs).filter((d) => d.name === name).map((d) => d.nameLine));
|
|
280
|
+
const n = wordMatches(text, name).filter((m) => !declared.has(m.line) && !inRanges(changeSet.get(rel), m.line)).length;
|
|
281
|
+
matches += n;
|
|
282
|
+
if (n > 0)
|
|
283
|
+
files++;
|
|
284
|
+
}
|
|
285
|
+
return { matches, files };
|
|
286
|
+
}
|
|
287
|
+
export function textualLine(count) {
|
|
288
|
+
return `${count.matches} matches in ${count.files} files`;
|
|
289
|
+
}
|
|
290
|
+
export function textualHint(command, gap) {
|
|
291
|
+
return `Run \`${command}\` for the ${gap} textual matches the checker cannot resolve (untyped JavaScript)`;
|
|
292
|
+
}
|
|
293
|
+
/** `--grep` rows plus template usages of the templated rows, one per file:line. */
|
|
294
|
+
function withTemplateRows(textRows, rows, files) {
|
|
295
|
+
const texts = new Map(files.map((f) => [f.rel, f.text]));
|
|
296
|
+
const seen = new Set(textRows.map((u) => `${u.file}:${u.line}`));
|
|
297
|
+
const result = [...textRows];
|
|
298
|
+
for (const row of rows) {
|
|
299
|
+
for (const u of row.templates ?? []) {
|
|
300
|
+
const key = `${u.file}:${u.line}`;
|
|
301
|
+
if (seen.has(key))
|
|
302
|
+
continue;
|
|
303
|
+
seen.add(key);
|
|
304
|
+
result.push({ file: u.file, line: u.line, context: contextOf(lineOf(texts.get(u.file), u.line), u.col) });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return result.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
308
|
+
}
|
|
257
309
|
function siteKey(site) {
|
|
258
310
|
return `${site.item.file}#${site.item.selectionSpan.start}`;
|
|
259
311
|
}
|
|
@@ -362,9 +414,13 @@ export async function impactCommand(args, ctx) {
|
|
|
362
414
|
...input.missing.map((file) => ({ file, reason: "not in the working tree" })),
|
|
363
415
|
];
|
|
364
416
|
const analysed = [];
|
|
417
|
+
// Templates stay skipped unless they use a changed Symbol (then template_changes).
|
|
418
|
+
const templateInputs = [];
|
|
365
419
|
for (const file of input.files) {
|
|
366
420
|
if (/\.d\.[mc]?ts$/.test(file.path))
|
|
367
421
|
skipped.push({ file: file.path, reason: "declaration file" });
|
|
422
|
+
else if (isTemplatePath(file.path))
|
|
423
|
+
templateInputs.push(file);
|
|
368
424
|
else if (!isSourcePath(file.path))
|
|
369
425
|
skipped.push({ file: file.path, reason: "not TS or JS source" });
|
|
370
426
|
else if (file.status !== "deleted" && !projectFiles.has(opened.index.abs(file.path)))
|
|
@@ -403,8 +459,14 @@ export async function impactCommand(args, ctx) {
|
|
|
403
459
|
head: input.head,
|
|
404
460
|
changes: total,
|
|
405
461
|
};
|
|
406
|
-
const
|
|
462
|
+
const skipTemplates = (used) => {
|
|
463
|
+
for (const f of templateInputs)
|
|
464
|
+
if (!used.has(f.path))
|
|
465
|
+
skipped.push({ file: f.path, reason: "not TS or JS source" });
|
|
466
|
+
};
|
|
407
467
|
if (declChanges.length === 0) {
|
|
468
|
+
skipTemplates(new Set());
|
|
469
|
+
const skippedBlock = skipped.length > 0 ? capped("skipped", skipped) : {};
|
|
408
470
|
const help = total === 0
|
|
409
471
|
? [arg === undefined && !staged ? "Run `ts-axi impact main...HEAD` to analyse a branch against main" : "Run `ts-axi impact` for uncommitted changes"]
|
|
410
472
|
: ["Changes touch imports or module-level statements only - read those lines directly"];
|
|
@@ -464,8 +526,43 @@ export async function impactCommand(args, ctx) {
|
|
|
464
526
|
row.all = entries.filter((e) => !e.isDefinition);
|
|
465
527
|
row.outside = row.all.filter((e) => !inRanges(changeSet.get(e.file), e.line)).sort(byPosition);
|
|
466
528
|
}
|
|
529
|
+
for (const row of rows) {
|
|
530
|
+
if (!row.live || !isUntyped(opened, row.live, row.live.file))
|
|
531
|
+
continue;
|
|
532
|
+
row.textual = textualCount(opened, row.live.name, changeSet);
|
|
533
|
+
if (row.textual.matches > 0)
|
|
534
|
+
row.unresolved = textualMatches(opened, semantic, [row.live.name], known, new Set(), changeSet).length;
|
|
535
|
+
}
|
|
536
|
+
// Template usages: directives, components, bindings, controllers and filters named in .html and friends.
|
|
537
|
+
let templateFiles;
|
|
538
|
+
for (const row of rows) {
|
|
539
|
+
const decl = row.live ?? row.change.before;
|
|
540
|
+
const fileDecls = [
|
|
541
|
+
...opened.index.declarations(opened.index.abs(row.change.file)),
|
|
542
|
+
...declChanges.filter((c) => c.file === row.change.file).flatMap((c) => (c.before ? [c.before] : [])),
|
|
543
|
+
];
|
|
544
|
+
const target = templateTarget(decl, fileDecls);
|
|
545
|
+
if (!target)
|
|
546
|
+
continue;
|
|
547
|
+
templateFiles ??= loadTemplates(opened.root);
|
|
548
|
+
row.template = target;
|
|
549
|
+
row.templates = templateUsages(templateFiles, target);
|
|
550
|
+
}
|
|
551
|
+
const templateChanges = [];
|
|
552
|
+
for (const f of templateInputs) {
|
|
553
|
+
const text = f.afterText ?? f.beforeText;
|
|
554
|
+
if (typeof text !== "string")
|
|
555
|
+
continue;
|
|
556
|
+
const symbols = [...new Set(rows.filter((r) => r.template && usagesIn(text, r.template).length > 0).map((r) => r.symbol))];
|
|
557
|
+
if (symbols.length > 0)
|
|
558
|
+
templateChanges.push({ file: f.path, symbols: symbols.join("; ") });
|
|
559
|
+
}
|
|
560
|
+
skipTemplates(new Set(templateChanges.map((t) => t.file)));
|
|
561
|
+
const skippedBlock = skipped.length > 0 ? capped("skipped", skipped) : {};
|
|
562
|
+
// Textual matches the checker did not resolve: such a Change is never local_only.
|
|
563
|
+
const textualGap = (r) => (r.textual && r.textual.matches > r.outside.length ? r.unresolved ?? 0 : 0);
|
|
467
564
|
const shownRefs = (r) => (showVia ? [...r.outside, ...r.via].sort(byPosition) : r.outside);
|
|
468
|
-
const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0;
|
|
565
|
+
const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0 || textualGap(r) > 0 || (r.templates?.length ?? 0) > 0;
|
|
469
566
|
const listed = rows
|
|
470
567
|
.filter(hasOutside)
|
|
471
568
|
.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 }));
|
|
@@ -494,9 +591,10 @@ export async function impactCommand(args, ctx) {
|
|
|
494
591
|
names.set(name, r.symbol);
|
|
495
592
|
}
|
|
496
593
|
const spots = blindSpots(semantic, names, changeSet);
|
|
497
|
-
const
|
|
594
|
+
const textRows = grep
|
|
498
595
|
? textualMatches(opened, semantic, [...names.keys()], known, new Set(spots.map((s) => `${s.file}:${s.line}`)), changeSet)
|
|
499
596
|
: [];
|
|
597
|
+
const unresolved = grep ? withTemplateRows(textRows, rows, templateFiles ?? []) : [];
|
|
500
598
|
const touched = [...new Set([...analysed.filter((f) => f.status !== "deleted").map((f) => f.path), ...listed.flatMap((r) => r.outside.map((e) => e.file))])];
|
|
501
599
|
const diagnostics = semantic.diagnosticsCount(touched);
|
|
502
600
|
// --- Help ---
|
|
@@ -513,7 +611,10 @@ export async function impactCommand(args, ctx) {
|
|
|
513
611
|
help.push(`${removedTop.symbol} was removed but is still referenced - those files break`);
|
|
514
612
|
if (truncated)
|
|
515
613
|
help.push(`Showing ${refRows.length} of ${outsideTotal} ref rows; run \`${rerun(arg, parsed, `--limit ${Math.min(MAX_LIMIT, outsideTotal)}`)}\` for more`);
|
|
516
|
-
|
|
614
|
+
const gap = listed.reduce((n, r) => n + textualGap(r), 0);
|
|
615
|
+
if (gap > 0 && !grep)
|
|
616
|
+
help.unshift(textualHint(rerun(arg, parsed, "--grep"), gap));
|
|
617
|
+
if (spots.length > 0 && !grep && gap === 0)
|
|
517
618
|
help.push(`Run \`${rerun(arg, parsed, "--grep")}\` for textual matches the checker cannot see`);
|
|
518
619
|
if (depth === 1 && rows.some((r) => r.live && isFunctionLike(r.kind) && r.outside.length > 0)) {
|
|
519
620
|
help.push(`Run \`${rerun(arg, parsed, "--depth 2")}\` for who calls the callers`);
|
|
@@ -521,6 +622,11 @@ export async function impactCommand(args, ctx) {
|
|
|
521
622
|
if (!showVia && listed.some((r) => r.via.length > 0)) {
|
|
522
623
|
help.push(`Run \`${rerun(arg, parsed, "--via")}\` for References through an interface or base member - they may reach the changed code`);
|
|
523
624
|
}
|
|
625
|
+
const templated = listed.filter((r) => (r.templates?.length ?? 0) > 0).sort((a, b) => b.templates.length - a.templates.length)[0];
|
|
626
|
+
if (templated && !grep)
|
|
627
|
+
help.push(`Run \`ts-axi refs ${templated.at} --grep\` for the ${templated.templates.length} template usages of ${templated.symbol}`);
|
|
628
|
+
if (templateChanges.length > 0)
|
|
629
|
+
help.push("template_changes use a changed directive or binding - review each against that change");
|
|
524
630
|
if (skipped.length > 0)
|
|
525
631
|
help.push("Skipped files are not TS or JS source of the Project - review them by hand");
|
|
526
632
|
const specHint = specFilesHint(ctx.cwd, opened, skipped, listed.every((r) => fileCounts(r).tests === 0));
|
|
@@ -543,15 +649,18 @@ export async function impactCommand(args, ctx) {
|
|
|
543
649
|
refs_total: r.all.length,
|
|
544
650
|
refs_outside: r.outside.length,
|
|
545
651
|
via: r.via.length,
|
|
652
|
+
...(gap > 0 ? { textual: r.textual ? r.textual.matches : "-" } : {}),
|
|
653
|
+
...(rows.some((x) => x.template) ? { templates: r.templates ? r.templates.length : "-" } : {}),
|
|
546
654
|
...fileCounts(r),
|
|
547
655
|
})),
|
|
548
|
-
refs: refRows,
|
|
656
|
+
...(refRows.length > 0 ? { refs: refRows } : {}),
|
|
549
657
|
}
|
|
550
658
|
: {}),
|
|
551
659
|
...(truncated ? { truncated: true, refs_shown: refRows.length, refs_outside_total: outsideTotal } : {}),
|
|
552
660
|
...(notes.length > 0 ? { notes } : {}),
|
|
553
661
|
...(localOnly.length > 0 ? { local_only: localOnlyLine(localOnly.map((r) => `${r.symbol} ${r.change.change}`)) } : {}),
|
|
554
662
|
...(fileLevel.length > 0 ? { file_level: fileLevel } : {}),
|
|
663
|
+
...(templateChanges.length > 0 ? capped("template_changes", templateChanges) : {}),
|
|
555
664
|
...capped("blind_spots", spots),
|
|
556
665
|
...(grep ? capped("unresolved", unresolved) : {}),
|
|
557
666
|
...depthLevels(semantic, rows, depth),
|
|
@@ -12,6 +12,7 @@ flags[3]:
|
|
|
12
12
|
output:
|
|
13
13
|
one file = file + decls[n]{line,kind,name,exported,signature}; several = decls[n]{file,line,kind,name,exported,signature}
|
|
14
14
|
class members follow their class as Class.member; at most ${MAX_ROWS} rows
|
|
15
|
+
kind state = a ui-router / ngRoute state as states.<name> (routes.<path>); its callbacks follow as states.<name>.resolve.<key> / states.<name>.onEnter
|
|
15
16
|
examples[3]:
|
|
16
17
|
ts-axi outline src/models/user.ts
|
|
17
18
|
ts-axi outline src/models/user.ts --exported
|
|
@@ -1,11 +1,12 @@
|
|
|
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 { textualMatches } from "./impact.js";
|
|
4
|
+
import { isUntyped, textualCount, textualHint, textualLine, textualMatches } from "./impact.js";
|
|
5
5
|
import { findOutsideProject, nearestTsconfig } from "../project.js";
|
|
6
6
|
import { viaLine } from "../references.js";
|
|
7
7
|
import { block, helpBlock, out } from "../render.js";
|
|
8
8
|
import { importerIndexFor, REFS_HOPS, scopeFields, scopeHelp, semanticFor } from "../scope.js";
|
|
9
|
+
import { loadTemplates, templatesLine, templateTarget, templateUsages } from "../templates.js";
|
|
9
10
|
import { byDepthThenPath } from "../walk.js";
|
|
10
11
|
import { detectWorkspace, isMultiProject } from "../workspace.js";
|
|
11
12
|
import { isTestFile } from "../settings.js";
|
|
@@ -14,14 +15,16 @@ import { contextOf, declarationAt, filterSuffix, lineOf, MATCH_ROWS, matchRows,
|
|
|
14
15
|
const DEFAULT_LIMIT = 50;
|
|
15
16
|
const MAX_LIMIT = 500;
|
|
16
17
|
const DEPTH_ROWS = 50;
|
|
17
|
-
const SHARED_FLAGS = `--kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace> (narrows a name), --in <dir|glob> (repeatable; Declarations in these files), --exported, --fuzzy, --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} rows), --test-glob <glob> (repeatable; extra Test file pattern), --via (adds via rows with a via column), -P <tsconfig>, --json`;
|
|
18
|
+
const SHARED_FLAGS = `--kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace|state> (narrows a name), --in <dir|glob> (repeatable; Declarations in these files), --exported, --fuzzy, --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} rows), --test-glob <glob> (repeatable; extra Test file pattern), --via (adds via rows with a via column), -P <tsconfig>, --json`;
|
|
18
19
|
const VIA_DOC = `via_interface: n (Iface.member) = References through an interface or base member the Symbol implements or overrides - they MAY reach it; not in refs_total
|
|
19
20
|
via_implementations: n (Impl.member) = References to implementations or overrides of the Symbol; not in refs_total
|
|
20
21
|
implementations: n = members that implement or override the Symbol (their Declarations are never refs)
|
|
21
22
|
matches: 0 under -P lists found_outside_project[n]{file,line} when the name is declared outside that Project
|
|
22
23
|
refs_total: 0 under -P in a workspace lists found_outside_project[n]{file,line} = importers outside that Project
|
|
23
24
|
scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
|
|
24
|
-
tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references
|
|
25
|
+
tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references
|
|
26
|
+
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
|
|
27
|
+
templates: n usages in m files = template uses (.html .htm .ejs .hbs .njk .pug) of a directive or component (<wap-select / wap-select / data-wap-select), its bindings (fallback-display-property= on that tag), a controller (ng-controller) or a filter (| money); refs --grep lists them in unresolved`;
|
|
25
28
|
export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
|
|
26
29
|
flags[12]:
|
|
27
30
|
${SHARED_FLAGS}
|
|
@@ -146,11 +149,37 @@ async function run(args, ctx, invoked) {
|
|
|
146
149
|
const declaredAt = decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
|
|
147
150
|
const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
|
|
148
151
|
const explicit = Boolean(project || opened.settings.project);
|
|
149
|
-
const
|
|
152
|
+
const declaredFile = decl?.file ?? (def && def.line > 0 ? def.file : undefined);
|
|
153
|
+
const textual = declaredFile !== undefined && isUntyped(opened, decl, declaredFile) ? textualCount(opened, symbol.split(".").pop()) : undefined;
|
|
154
|
+
const tplTarget = decl ? templateTarget(decl, opened.index.declarations(opened.index.abs(decl.file))) : undefined;
|
|
155
|
+
const templates = tplTarget ? templateRows(loadTemplates(opened.root), tplTarget) : undefined;
|
|
156
|
+
const o = { started, symbol, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile, ...(decl ? { decl } : {}), ...(textual ? { textual } : {}), ...(templates ? { templates } : {}) };
|
|
150
157
|
return mode === "calls"
|
|
151
158
|
? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
|
|
152
159
|
: renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
|
|
153
160
|
}
|
|
161
|
+
/** Template usages as `--grep` rows. */
|
|
162
|
+
export function templateRows(files, target) {
|
|
163
|
+
const texts = new Map(files.map((f) => [f.rel, f.text]));
|
|
164
|
+
return templateUsages(files, target).map((u) => ({ file: u.file, line: u.line, context: contextOf(lineOf(texts.get(u.file), u.line), u.col) }));
|
|
165
|
+
}
|
|
166
|
+
/** `templates` field and the hint to list them. */
|
|
167
|
+
function templateFields(o, grepShown) {
|
|
168
|
+
if (!o.templates)
|
|
169
|
+
return { fields: {}, help: [] };
|
|
170
|
+
const n = o.templates.length;
|
|
171
|
+
return {
|
|
172
|
+
fields: { templates: templatesLine(o.templates) },
|
|
173
|
+
help: n > 0 && !grepShown ? [`Run \`ts-axi refs ${o.declaredAt} --grep\` for the ${n} template usages`] : [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** Textual rows plus template rows, one per file:line, sorted. */
|
|
177
|
+
function withTemplates(unresolved, templates) {
|
|
178
|
+
if (!templates || templates.length === 0)
|
|
179
|
+
return unresolved;
|
|
180
|
+
const seen = new Set(unresolved.map((u) => `${u.file}:${u.line}`));
|
|
181
|
+
return [...unresolved, ...templates.filter((t) => !seen.has(`${t.file}:${t.line}`))].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
182
|
+
}
|
|
154
183
|
const OUTSIDE_ROWS = 5;
|
|
155
184
|
/**
|
|
156
185
|
* No References under an explicit Project: importers of the declaring file (or of a barrel
|
|
@@ -210,6 +239,23 @@ function viaFields(via, bases) {
|
|
|
210
239
|
...(impl.length > 0 ? { via_implementations: viaLine(impl.length, impl.map((v) => v.via)) } : {}),
|
|
211
240
|
};
|
|
212
241
|
}
|
|
242
|
+
/** Whole-word matches of the name on lines that hold no known position (what `--grep` lists). */
|
|
243
|
+
function unresolvedOf(opened, semantic, o, known) {
|
|
244
|
+
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());
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* `textual` field and, when the name occurs more often than refs_total and some matches sit on no
|
|
248
|
+
* Reference line (`unresolved` = the `--grep` rows), the `--grep` hint.
|
|
249
|
+
*/
|
|
250
|
+
function textualFields(o, refsTotal, unresolved, grepShown) {
|
|
251
|
+
if (!o.textual)
|
|
252
|
+
return { fields: {}, help: [] };
|
|
253
|
+
const gap = o.textual.matches > refsTotal ? unresolved : 0;
|
|
254
|
+
return {
|
|
255
|
+
fields: { textual: textualLine(o.textual) },
|
|
256
|
+
help: gap > 0 && !grepShown ? [textualHint(`ts-axi refs ${o.declaredAt} --grep`, gap)] : [],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
213
259
|
/** Direct rows, plus via rows (with a `via` column) under `--via`. */
|
|
214
260
|
function mergedRows(direct, via, show) {
|
|
215
261
|
return show.via ? sortRows([...direct, ...via]) : direct;
|
|
@@ -236,11 +282,16 @@ function renderRefs(semantic, opened, resolved, header, o) {
|
|
|
236
282
|
const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
|
|
237
283
|
help.unshift(...outside.help);
|
|
238
284
|
const word = o.symbol.split(".").pop();
|
|
239
|
-
const unresolved = o.show.grep
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
285
|
+
const unresolved = o.show.grep || o.textual || o.templates ? unresolvedOf(opened, semantic, o, [...rows, ...via, ...impls]) : [];
|
|
286
|
+
const grepRows = withTemplates(unresolved, o.templates);
|
|
287
|
+
// The --grep hint counts what --grep lists: textual and template rows together.
|
|
288
|
+
const text = textualFields(o, rows.length, grepRows.length, o.show.grep);
|
|
289
|
+
const tpl = templateFields(o, o.show.grep || text.help.length > 0);
|
|
290
|
+
if (text.help.length > 0)
|
|
291
|
+
help.unshift(...text.help);
|
|
292
|
+
else if (o.decl?.nameless && !o.show.grep && tpl.help.length === 0)
|
|
243
293
|
help.unshift(`Run \`ts-axi refs ${o.declaredAt} --grep\` for textual matches of '${word}'`);
|
|
294
|
+
help.unshift(...tpl.help);
|
|
244
295
|
if (isFunctionLike(o.kind))
|
|
245
296
|
help.push(`Run \`ts-axi callers ${o.declaredAt}\` for call sites with their enclosing function`);
|
|
246
297
|
help.push(`Run \`ts-axi def ${o.declaredAt}\` for the declaration body`);
|
|
@@ -249,6 +300,8 @@ function renderRefs(semantic, opened, resolved, header, o) {
|
|
|
249
300
|
...header,
|
|
250
301
|
...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
|
|
251
302
|
refs_total: rows.length,
|
|
303
|
+
...text.fields,
|
|
304
|
+
...tpl.fields,
|
|
252
305
|
files,
|
|
253
306
|
tests,
|
|
254
307
|
...viaFields(via, split.bases),
|
|
@@ -266,8 +319,8 @@ function renderRefs(semantic, opened, resolved, header, o) {
|
|
|
266
319
|
})),
|
|
267
320
|
}),
|
|
268
321
|
...(truncated ? { truncated: true } : {}),
|
|
269
|
-
...(o.show.grep ? { unresolved:
|
|
270
|
-
...(
|
|
322
|
+
...(o.show.grep ? { unresolved: grepRows.length === 0 ? 0 : grepRows.slice(0, o.limit) } : {}),
|
|
323
|
+
...(grepRows.length > o.limit ? { unresolved_total: grepRows.length } : {}),
|
|
271
324
|
...outside.fields,
|
|
272
325
|
...scopeFields(o.scope),
|
|
273
326
|
...footer(semantic, o.started, fileList),
|
|
@@ -306,8 +359,20 @@ function depthRows(sites) {
|
|
|
306
359
|
}
|
|
307
360
|
function renderCallers(semantic, opened, resolved, header, o) {
|
|
308
361
|
const sites = semantic.incomingCalls(resolved.absFile, resolved.pos);
|
|
362
|
+
// Every Reference line counts as known: the gap is what `refs --grep` would list.
|
|
363
|
+
const callerText = o.textual
|
|
364
|
+
? (() => {
|
|
365
|
+
const split = semantic.referenceSplit(resolved.absFile, resolved.pos);
|
|
366
|
+
const known = [...split.direct, ...split.declarations, ...split.via, ...split.implementations, ...split.dropped, ...(sites ?? [])];
|
|
367
|
+
const calls = (sites ?? []).filter((s) => s.via === undefined).length;
|
|
368
|
+
return textualFields(o, calls, withTemplates(unresolvedOf(opened, semantic, o, known), o.templates).length, false);
|
|
369
|
+
})()
|
|
370
|
+
: { fields: {}, help: [] };
|
|
371
|
+
const tpl = templateFields(o, callerText.help.length > 0);
|
|
372
|
+
callerText.fields = { ...callerText.fields, ...tpl.fields };
|
|
373
|
+
callerText.help = [...tpl.help, ...callerText.help];
|
|
309
374
|
if (sites === undefined) {
|
|
310
|
-
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`]));
|
|
375
|
+
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`]));
|
|
311
376
|
}
|
|
312
377
|
const rows = sortRows(sites.filter((s) => s.via === undefined));
|
|
313
378
|
const via = sortRows(sites.filter((s) => s.via !== undefined));
|
|
@@ -338,12 +403,14 @@ function renderCallers(semantic, opened, resolved, header, o) {
|
|
|
338
403
|
help.push(`Run \`ts-axi callers ${o.declaredAt} --depth 2\` for who calls the callers`);
|
|
339
404
|
const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
|
|
340
405
|
help.unshift(...outside.help);
|
|
406
|
+
help.unshift(...callerText.help);
|
|
341
407
|
help.push(`Run \`ts-axi refs ${o.declaredAt}${o.decl?.nameless ? " --grep" : ""}\` for every Reference (not only calls)`);
|
|
342
408
|
help.push(...scopeHelp(o.scope));
|
|
343
409
|
return out(block({
|
|
344
410
|
...header,
|
|
345
411
|
...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
|
|
346
412
|
refs_total: rows.length,
|
|
413
|
+
...callerText.fields,
|
|
347
414
|
files,
|
|
348
415
|
tests,
|
|
349
416
|
...viaFields(via, []),
|
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,
|
|
@@ -250,7 +254,9 @@ export function createSyntacticIndex(root, project) {
|
|
|
250
254
|
return list;
|
|
251
255
|
},
|
|
252
256
|
findDeclarations(query) {
|
|
253
|
-
|
|
257
|
+
// A nested holder (`states.cart`) may never be spelled in the file: prefilter on the member.
|
|
258
|
+
const word = query.member ?? query.name;
|
|
259
|
+
const needle = query.fuzzy ? word.toLowerCase() : word;
|
|
254
260
|
const result = [];
|
|
255
261
|
for (const file of index.files) {
|
|
256
262
|
const text = index.text(file);
|
|
@@ -278,6 +284,80 @@ export function createSyntacticIndex(root, project) {
|
|
|
278
284
|
return index;
|
|
279
285
|
}
|
|
280
286
|
const FULL_DIAGNOSTICS_LIMIT = 300;
|
|
287
|
+
const DECLARATION_TWINS = [
|
|
288
|
+
[/\.d\.ts$/, [[".js", ts.Extension.Js], [".jsx", ts.Extension.Jsx], [".mjs", ts.Extension.Mjs], [".cjs", ts.Extension.Cjs]]],
|
|
289
|
+
[/\.d\.mts$/, [[".mjs", ts.Extension.Mjs]]],
|
|
290
|
+
[/\.d\.cts$/, [[".cjs", ts.Extension.Cjs]]],
|
|
291
|
+
];
|
|
292
|
+
/** The implementation beside a `.d.ts` (`index.d.ts` → `index.js`) when it is a Project source file. */
|
|
293
|
+
function implementationTwin(file, isSource) {
|
|
294
|
+
for (const [re, impls] of DECLARATION_TWINS) {
|
|
295
|
+
if (!re.test(file))
|
|
296
|
+
continue;
|
|
297
|
+
const base = file.replace(re, "");
|
|
298
|
+
for (const [ext, extension] of impls)
|
|
299
|
+
if (isSource(base + ext))
|
|
300
|
+
return { file: base + ext, extension };
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
/** `'x'` of a module whose only statement is `module.exports = require('x')` (`exports = module.exports = ...` too). */
|
|
305
|
+
export function passThroughRequire(sf) {
|
|
306
|
+
const statements = sf.statements.filter((s) => !(ts.isExpressionStatement(s) && ts.isStringLiteral(s.expression)));
|
|
307
|
+
const only = statements.length === 1 ? statements[0] : undefined;
|
|
308
|
+
if (!only || !ts.isExpressionStatement(only))
|
|
309
|
+
return undefined;
|
|
310
|
+
let expr = only.expression;
|
|
311
|
+
let assignsModuleExports = false;
|
|
312
|
+
while (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
313
|
+
const left = expr.left;
|
|
314
|
+
if (ts.isPropertyAccessExpression(left) && ts.isIdentifier(left.expression) && left.expression.text === "module" && left.name.text === "exports") {
|
|
315
|
+
assignsModuleExports = true;
|
|
316
|
+
}
|
|
317
|
+
else if (!(ts.isIdentifier(left) && left.text === "exports"))
|
|
318
|
+
return undefined;
|
|
319
|
+
expr = expr.right;
|
|
320
|
+
}
|
|
321
|
+
if (!assignsModuleExports || !ts.isCallExpression(expr) || !ts.isIdentifier(expr.expression) || expr.expression.text !== "require")
|
|
322
|
+
return undefined;
|
|
323
|
+
const [arg] = expr.arguments;
|
|
324
|
+
return expr.arguments.length === 1 && arg && ts.isStringLiteralLike(arg) ? arg.text : undefined;
|
|
325
|
+
}
|
|
326
|
+
const MAX_SHIM_HOPS = 3;
|
|
327
|
+
/**
|
|
328
|
+
* TypeScript's resolution, then two substitutions so References reach implementations:
|
|
329
|
+
* a `.d.ts` with a JS twin in the Project resolves to the twin (TS tries `.d.ts` before `.js`),
|
|
330
|
+
* and a `module.exports = require('x')` shim resolves to `x` (the binder makes no alias of it).
|
|
331
|
+
*/
|
|
332
|
+
function createModuleResolver(options, currentDirectory, isSource, sourceOf) {
|
|
333
|
+
const cache = ts.createModuleResolutionCache(currentDirectory, (s) => (ts.sys.useCaseSensitiveFileNames ? s : s.toLowerCase()), options);
|
|
334
|
+
const resolveOne = (name, containingFile, mode, redirected, hops) => {
|
|
335
|
+
const result = ts.resolveModuleName(name, containingFile, options, ts.sys, cache, redirected, mode);
|
|
336
|
+
const found = result.resolvedModule;
|
|
337
|
+
if (!found)
|
|
338
|
+
return result;
|
|
339
|
+
let resolvedFileName = resolve(found.resolvedFileName);
|
|
340
|
+
let extension = found.extension;
|
|
341
|
+
const twin = implementationTwin(resolvedFileName, isSource);
|
|
342
|
+
if (twin)
|
|
343
|
+
({ file: resolvedFileName, extension } = twin);
|
|
344
|
+
else if (!isSource(resolvedFileName))
|
|
345
|
+
return result;
|
|
346
|
+
if (hops < MAX_SHIM_HOPS && /\.[mc]?jsx?$/.test(resolvedFileName)) {
|
|
347
|
+
const sf = sourceOf(resolvedFileName);
|
|
348
|
+
const inner = sf ? passThroughRequire(sf) : undefined;
|
|
349
|
+
if (inner !== undefined) {
|
|
350
|
+
const next = resolveOne(inner, resolvedFileName, ts.ModuleKind.CommonJS, redirected, hops + 1);
|
|
351
|
+
if (next.resolvedModule && isSource(resolve(next.resolvedModule.resolvedFileName)))
|
|
352
|
+
return next;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!twin)
|
|
356
|
+
return result;
|
|
357
|
+
return { ...result, resolvedModule: { ...found, resolvedFileName, extension, isExternalLibraryImport: false } };
|
|
358
|
+
};
|
|
359
|
+
return (literals, containingFile, redirected, opts, containingSourceFile) => literals.map((literal) => resolveOne(literal.text, containingFile, ts.getModeForUsageLocation(containingSourceFile, literal, opts), redirected, 0));
|
|
360
|
+
}
|
|
281
361
|
export function createSemanticProject(index, scope) {
|
|
282
362
|
const started = performance.now();
|
|
283
363
|
const { project } = index;
|
|
@@ -306,6 +386,7 @@ export function createSemanticProject(index, scope) {
|
|
|
306
386
|
getDirectories: ts.sys.getDirectories,
|
|
307
387
|
realpath: ts.sys.realpath,
|
|
308
388
|
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
|
|
389
|
+
resolveModuleNameLiterals: createModuleResolver(options, currentDirectory, (f) => indexed.has(f), (f) => index.sourceFile(f)),
|
|
309
390
|
};
|
|
310
391
|
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
311
392
|
const program = service.getProgram();
|
|
@@ -406,8 +487,34 @@ export function createSemanticProject(index, scope) {
|
|
|
406
487
|
}
|
|
407
488
|
return [...keys];
|
|
408
489
|
};
|
|
490
|
+
/**
|
|
491
|
+
* The same-named top-level Declaration in the `.d.ts` beside a JS file, when that `.d.ts` is in the
|
|
492
|
+
* program: importers the resolver did not redirect (a package.json `types` field) bind to it.
|
|
493
|
+
*/
|
|
494
|
+
const declarationTwin = (absFile, pos) => {
|
|
495
|
+
if (!/\.[mc]?jsx?$/.test(absFile))
|
|
496
|
+
return undefined;
|
|
497
|
+
const sf = program.getSourceFile(absFile);
|
|
498
|
+
const id = sf ? nodeAt(sf, pos) : undefined;
|
|
499
|
+
if (!id || !ts.isIdentifier(id))
|
|
500
|
+
return undefined;
|
|
501
|
+
const base = absFile.replace(/\.([mc]?)jsx?$/, "");
|
|
502
|
+
const twin = [".d.ts", ".d.mts", ".d.cts"].map((ext) => program.getSourceFile(base + ext)).find((t) => t !== undefined);
|
|
503
|
+
if (!twin)
|
|
504
|
+
return undefined;
|
|
505
|
+
for (const statement of twin.statements) {
|
|
506
|
+
const names = ts.isVariableStatement(statement)
|
|
507
|
+
? statement.declarationList.declarations.map((d) => d.name)
|
|
508
|
+
: "name" in statement && statement.name ? [statement.name] : [];
|
|
509
|
+
const hit = names.find((n) => ts.isIdentifier(n) && n.text === id.text);
|
|
510
|
+
if (hit)
|
|
511
|
+
return { file: twin.fileName, pos: hit.getStart(twin) };
|
|
512
|
+
}
|
|
513
|
+
return undefined;
|
|
514
|
+
};
|
|
409
515
|
const split = (absFile, pos) => {
|
|
410
|
-
const
|
|
516
|
+
const twin = declarationTwin(absFile, pos);
|
|
517
|
+
const groups = [...(service.findReferences(absFile, pos) ?? []), ...(twin ? service.findReferences(twin.file, twin.pos) ?? [] : [])];
|
|
411
518
|
const descriptors = [];
|
|
412
519
|
let target = -1;
|
|
413
520
|
let flagged = -1;
|
|
@@ -514,7 +621,15 @@ export function createSemanticProject(index, scope) {
|
|
|
514
621
|
const items = prepared === undefined ? [] : Array.isArray(prepared) ? prepared : [prepared];
|
|
515
622
|
if (items.length === 0)
|
|
516
623
|
return undefined;
|
|
517
|
-
|
|
624
|
+
const twin = declarationTwin(absFile, pos);
|
|
625
|
+
const twinCalls = twin ? service.provideCallHierarchyIncomingCalls(twin.file, twin.pos) : [];
|
|
626
|
+
const seen = new Set();
|
|
627
|
+
return items
|
|
628
|
+
.flatMap((item) => splitSites(item.file, item.selectionSpan.start, [...service.provideCallHierarchyIncomingCalls(item.file, item.selectionSpan.start), ...twinCalls]))
|
|
629
|
+
.filter((site) => {
|
|
630
|
+
const key = `${site.file}:${site.line}:${site.col}`;
|
|
631
|
+
return !seen.has(key) && Boolean(seen.add(key));
|
|
632
|
+
});
|
|
518
633
|
},
|
|
519
634
|
incomingCallsOf(item) {
|
|
520
635
|
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
|
@@ -13,6 +13,7 @@ export const KINDS = [
|
|
|
13
13
|
"enum",
|
|
14
14
|
"enum-member",
|
|
15
15
|
"namespace",
|
|
16
|
+
"state",
|
|
16
17
|
];
|
|
17
18
|
export const SIGNATURE_MAX = 160;
|
|
18
19
|
const FUNCTION_LIKE = new Set(["function", "method"]);
|
|
@@ -228,6 +229,11 @@ function nameText(name, sf) {
|
|
|
228
229
|
const REGISTRATION_VERBS = new Set(["controller", "service", "factory", "directive", "filter", "component", "run", "config", "provider"]);
|
|
229
230
|
/** Verbs whose callback is a constructor: its `this.x = function` statements are members. */
|
|
230
231
|
const CONSTRUCTOR_VERBS = new Set(["controller", "service", "provider"]);
|
|
232
|
+
/** Router providers whose `.state(...)` / `.when(...)` calls declare a state (ui-router, ngRoute). */
|
|
233
|
+
const STATE_PROVIDER = /^\$?stateProvider$/i;
|
|
234
|
+
const ROUTE_PROVIDER = /^\$?routeProvider$/i;
|
|
235
|
+
/** Directive definition keys whose object literal lists the bindings. */
|
|
236
|
+
const BINDING_KEYS = new Set(["scope", "bindToController", "bindings"]);
|
|
231
237
|
/** `Template.x.<hook>(fn)` lifecycle callbacks. */
|
|
232
238
|
const TEMPLATE_HOOKS = new Set(["onCreated", "onRendered", "onDestroyed"]);
|
|
233
239
|
function unwrap(expr) {
|
|
@@ -378,6 +384,7 @@ export function collectDeclarations(sf, file) {
|
|
|
378
384
|
if (verb === "component" && arg && ts.isObjectLiteralExpression(arg)) {
|
|
379
385
|
nested({ start, end: arg.end, kind: "variable", name, exported: true, signature: `component ${name}`, namePos: arg.getStart(sf), nameless: true });
|
|
380
386
|
objectMembers(arg, name, true);
|
|
387
|
+
bindingMembers(arg, name);
|
|
381
388
|
return;
|
|
382
389
|
}
|
|
383
390
|
const fn = registeredFunction(arg);
|
|
@@ -403,9 +410,123 @@ export function collectDeclarations(sf, file) {
|
|
|
403
410
|
thisMembers(fn.body, name, true);
|
|
404
411
|
if (verb === "factory" || verb === "service")
|
|
405
412
|
returnedMembers(fn.body, name);
|
|
413
|
+
if (verb === "directive")
|
|
414
|
+
for (const obj of returnedObjects(fn.body))
|
|
415
|
+
bindingMembers(obj, name);
|
|
416
|
+
if (verb === "config" || verb === "run")
|
|
417
|
+
routerBody(fn.body);
|
|
406
418
|
};
|
|
419
|
+
/** Object literals a function returns at its top level (a directive definition object). */
|
|
420
|
+
const returnedObjects = (body) => {
|
|
421
|
+
const direct = unwrap(body);
|
|
422
|
+
if (ts.isObjectLiteralExpression(direct))
|
|
423
|
+
return [direct];
|
|
424
|
+
if (!ts.isBlock(body))
|
|
425
|
+
return [];
|
|
426
|
+
return body.statements.flatMap((st) => (ts.isReturnStatement(st) && st.expression && ts.isObjectLiteralExpression(unwrap(st.expression)) ? [unwrap(st.expression)] : []));
|
|
427
|
+
};
|
|
428
|
+
/** `scope: { x: '=' }` / `bindToController` / `bindings` keys as `property` Declarations `<owner>.x`. */
|
|
429
|
+
const bindingMembers = (obj, owner) => {
|
|
430
|
+
for (const prop of obj.properties) {
|
|
431
|
+
if (!ts.isPropertyAssignment(prop) || !BINDING_KEYS.has(nameText(prop.name, sf) ?? ""))
|
|
432
|
+
continue;
|
|
433
|
+
const bindings = unwrap(prop.initializer);
|
|
434
|
+
if (!ts.isObjectLiteralExpression(bindings))
|
|
435
|
+
continue;
|
|
436
|
+
for (const b of bindings.properties) {
|
|
437
|
+
const name = nameText(b.name, sf);
|
|
438
|
+
if (!name || !b.name || !ts.isPropertyAssignment(b))
|
|
439
|
+
continue;
|
|
440
|
+
nested({ start: b.getStart(sf), end: b.end, kind: "property", name, parentName: owner, exported: true, signature: tokenText(b, sf, b.getStart(sf), b.end), namePos: b.name.getStart(sf) });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
/** Function-valued properties of a state object (`resolve.x`, `onEnter`, `views.main.controller`) as methods; nested objects recurse. */
|
|
445
|
+
const stateMembers = (obj, holder, depth) => {
|
|
446
|
+
for (const prop of obj.properties) {
|
|
447
|
+
const name = nameText(prop.name, sf);
|
|
448
|
+
if (!name || !prop.name)
|
|
449
|
+
continue;
|
|
450
|
+
if (ts.isMethodDeclaration(prop)) {
|
|
451
|
+
nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported: true, signature: fullSignatureOf(prop, sf), namePos: prop.name.getStart(sf) });
|
|
452
|
+
}
|
|
453
|
+
else if (ts.isPropertyAssignment(prop)) {
|
|
454
|
+
const value = unwrap(prop.initializer);
|
|
455
|
+
const fn = registeredFunction(value);
|
|
456
|
+
if (fn)
|
|
457
|
+
nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported: true, signature: `${name}${paramsText(fn, sf)}`, namePos: prop.name.getStart(sf) });
|
|
458
|
+
else if (ts.isObjectLiteralExpression(value) && depth < 3)
|
|
459
|
+
stateMembers(value, `${holder}.${name}`, depth + 1);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
/** One `.state('name', {...})`, `.state({ name, ... })` or `.when('/path', {...})` call. */
|
|
464
|
+
const stateCall = (call, route) => {
|
|
465
|
+
const [first, second] = call.arguments;
|
|
466
|
+
const obj = first && ts.isObjectLiteralExpression(first) && !route ? first : second && ts.isObjectLiteralExpression(second) ? second : undefined;
|
|
467
|
+
if (!obj)
|
|
468
|
+
return;
|
|
469
|
+
const literal = (key) => {
|
|
470
|
+
const prop = obj.properties.find((p) => ts.isPropertyAssignment(p) && nameText(p.name, sf) === key);
|
|
471
|
+
return prop && isStringName(prop.initializer) ? prop.initializer.text : undefined;
|
|
472
|
+
};
|
|
473
|
+
const name = isStringName(first) ? first.text : literal("name");
|
|
474
|
+
if (name === undefined)
|
|
475
|
+
return;
|
|
476
|
+
const url = literal("url");
|
|
477
|
+
const parent = route ? "routes" : "states";
|
|
478
|
+
// Plain-literal options are compared, not displayed: a new controller or template is a contract change.
|
|
479
|
+
const options = obj.properties
|
|
480
|
+
.filter((p) => ts.isPropertyAssignment(p) && nameText(p.name, sf) !== "name" && nameText(p.name, sf) !== "url")
|
|
481
|
+
.filter((p) => isStringName(p.initializer) || p.initializer.kind === ts.SyntaxKind.TrueKeyword || p.initializer.kind === ts.SyntaxKind.FalseKeyword)
|
|
482
|
+
.map((p) => tokenText(p, sf, p.getStart(sf), p.end));
|
|
483
|
+
const callee = call.expression;
|
|
484
|
+
nested({
|
|
485
|
+
start: callee.name.getStart(sf),
|
|
486
|
+
end: call.end,
|
|
487
|
+
kind: "state",
|
|
488
|
+
name,
|
|
489
|
+
parentName: parent,
|
|
490
|
+
exported: true,
|
|
491
|
+
signature: `${route ? "route" : "state"} ${name}${url !== undefined && !route ? ` (url ${url})` : ""}${options.length > 0 ? `\n${options.join("; ")}` : ""}`,
|
|
492
|
+
namePos: obj.getStart(sf),
|
|
493
|
+
nameless: true,
|
|
494
|
+
});
|
|
495
|
+
stateMembers(obj, `${parent}.${name}`, 1);
|
|
496
|
+
};
|
|
497
|
+
/** `$stateProvider.state(...).state(...)` and `$routeProvider.when(...)` chains; `.otherwise()` ignored. */
|
|
498
|
+
const routerChain = (expr) => {
|
|
499
|
+
const calls = [];
|
|
500
|
+
let e = unwrap(expr);
|
|
501
|
+
while (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression)) {
|
|
502
|
+
calls.push(e);
|
|
503
|
+
e = e.expression.expression;
|
|
504
|
+
}
|
|
505
|
+
if (!ts.isIdentifier(e))
|
|
506
|
+
return false;
|
|
507
|
+
const route = ROUTE_PROVIDER.test(e.text);
|
|
508
|
+
if (!route && !STATE_PROVIDER.test(e.text))
|
|
509
|
+
return false;
|
|
510
|
+
for (const call of calls.reverse()) {
|
|
511
|
+
const verb = call.expression.name.text;
|
|
512
|
+
if (verb === (route ? "when" : "state"))
|
|
513
|
+
stateCall(call, route);
|
|
514
|
+
}
|
|
515
|
+
return true;
|
|
516
|
+
};
|
|
517
|
+
/** Router chains at the top level of a `.config` / `.run` callback or of a function taking a router provider. */
|
|
518
|
+
const routerBody = (body) => {
|
|
519
|
+
if (!body || !ts.isBlock(body))
|
|
520
|
+
return;
|
|
521
|
+
for (const statement of body.statements)
|
|
522
|
+
if (ts.isExpressionStatement(statement))
|
|
523
|
+
routerChain(statement.expression);
|
|
524
|
+
};
|
|
525
|
+
const takesRouter = (fn) => fn.parameters.some((p) => ts.isIdentifier(p.name) && (STATE_PROVIDER.test(p.name.text) || ROUTE_PROVIDER.test(p.name.text)));
|
|
407
526
|
/** Registrations along a call chain (`angular.module('x').controller(...).service(...)`), in source order. */
|
|
408
527
|
const callChain = (expr) => {
|
|
528
|
+
if (routerChain(expr))
|
|
529
|
+
return;
|
|
409
530
|
const calls = [];
|
|
410
531
|
let e = unwrap(expr);
|
|
411
532
|
while (ts.isCallExpression(e)) {
|
|
@@ -478,6 +599,8 @@ export function collectDeclarations(sf, file) {
|
|
|
478
599
|
add(statement, statement.name, statement.name.text, false);
|
|
479
600
|
if (isConstructorName(statement.name.text))
|
|
480
601
|
thisMembers(statement.body, statement.name.text, inIife ? false : topLevelExported(statement.name.text));
|
|
602
|
+
if (takesRouter(statement))
|
|
603
|
+
routerBody(statement.body);
|
|
481
604
|
}
|
|
482
605
|
else if (ts.isVariableStatement(statement)) {
|
|
483
606
|
for (const decl of statement.declarationList.declarations) {
|
|
@@ -487,6 +610,8 @@ export function collectDeclarations(sf, file) {
|
|
|
487
610
|
const fn = asFunction(decl.initializer);
|
|
488
611
|
if (inIife && fn)
|
|
489
612
|
add(decl, decl.name, name, false);
|
|
613
|
+
if (fn && takesRouter(fn))
|
|
614
|
+
routerBody(fn.body);
|
|
490
615
|
const exported = inIife ? false : topLevelExported(name);
|
|
491
616
|
const init = unwrap(decl.initializer);
|
|
492
617
|
if (ts.isObjectLiteralExpression(init))
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Template usages of AngularJS-style registrations: directives and components as kebab-case
|
|
2
|
+
// elements/attributes, their bindings as attributes, controllers in `ng-controller`, filters
|
|
3
|
+
// after `|`. One regex pass per template line, no HTML parsing.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { listFiles, toRel } from "./walk.js";
|
|
6
|
+
export const TEMPLATE_EXTENSIONS = [".html", ".htm", ".ejs", ".hbs", ".njk", ".pug"];
|
|
7
|
+
export function isTemplatePath(path) {
|
|
8
|
+
return TEMPLATE_EXTENSIONS.some((ext) => path.endsWith(ext));
|
|
9
|
+
}
|
|
10
|
+
/** `wapSelect` → `wap-select`. */
|
|
11
|
+
export function kebab(name) {
|
|
12
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
function escape(text) {
|
|
15
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16
|
+
}
|
|
17
|
+
/** Opening tag `<wap-select` or attribute `wap-select` / `data-wap-select` / `x-wap-select`. */
|
|
18
|
+
function elementOrAttribute(name) {
|
|
19
|
+
return new RegExp(`(?:<|[\\s"'])(?:data-|x-)?${escape(kebab(name))}(?![\\w-])`, "g");
|
|
20
|
+
}
|
|
21
|
+
/** A Declaration's template form; undefined when templates cannot name it. */
|
|
22
|
+
export function templateTarget(decl, fileDecls) {
|
|
23
|
+
const isComponent = (d) => d !== undefined && d.nameless === true && d.signature.startsWith("component ");
|
|
24
|
+
if (decl.nameless && (decl.parentName === "directive" || isComponent(decl)))
|
|
25
|
+
return { symbol: decl.qualifiedName, pattern: elementOrAttribute(decl.name) };
|
|
26
|
+
if (decl.nameless && decl.parentName === "controller") {
|
|
27
|
+
return { symbol: decl.qualifiedName, pattern: new RegExp(`ng-controller\\s*=\\s*["']\\s*${escape(decl.name)}(?![\\w$])`, "g") };
|
|
28
|
+
}
|
|
29
|
+
if (decl.nameless && decl.parentName === "filter")
|
|
30
|
+
return { symbol: decl.qualifiedName, pattern: new RegExp(`(?<!\\|)\\|\\s*${escape(decl.name)}(?![\\w$])`, "g") };
|
|
31
|
+
if (decl.kind === "property" && decl.parentName !== undefined) {
|
|
32
|
+
const owner = fileDecls.find((d) => d.qualifiedName === decl.parentName && d.parentName !== decl.parentName);
|
|
33
|
+
if (owner && owner.nameless && (owner.parentName === "directive" || isComponent(owner))) {
|
|
34
|
+
return {
|
|
35
|
+
symbol: decl.qualifiedName,
|
|
36
|
+
pattern: new RegExp(`\\s(?:data-|x-)?${escape(kebab(decl.name))}(?=[\\s=>/])`, "g"),
|
|
37
|
+
owner: elementOrAttribute(owner.name),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/** Template files of the repo root (git listing, else the bounded walk). */
|
|
44
|
+
export function loadTemplates(root) {
|
|
45
|
+
const files = [];
|
|
46
|
+
for (const abs of listFiles(root, isTemplatePath).files) {
|
|
47
|
+
try {
|
|
48
|
+
files.push({ rel: toRel(root, abs), text: readFileSync(abs, "utf8") });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// unreadable: skipped
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return files;
|
|
55
|
+
}
|
|
56
|
+
/** Usages of `target` in one template text: at most one per line. */
|
|
57
|
+
export function usagesIn(text, target) {
|
|
58
|
+
const result = [];
|
|
59
|
+
const lineStarts = [0];
|
|
60
|
+
for (let i = 0; i < text.length; i++)
|
|
61
|
+
if (text[i] === "\n")
|
|
62
|
+
lineStarts.push(i + 1);
|
|
63
|
+
let lastLine = 0;
|
|
64
|
+
for (const m of text.matchAll(target.pattern)) {
|
|
65
|
+
// Skip the delimiter the pattern consumed (`<`, whitespace, quote, `|`).
|
|
66
|
+
const offset = (m.index ?? 0) + (/^[<\s"'|]/.test(m[0]) ? 1 : 0);
|
|
67
|
+
if (target.owner) {
|
|
68
|
+
// The attribute counts only inside a tag opened by (or carrying) the owner directive.
|
|
69
|
+
const tagStart = text.lastIndexOf("<", offset);
|
|
70
|
+
if (tagStart < 0)
|
|
71
|
+
continue;
|
|
72
|
+
const tag = text.slice(tagStart, offset);
|
|
73
|
+
target.owner.lastIndex = 0;
|
|
74
|
+
if (tag.includes(">") || !target.owner.test(tag))
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let lo = 0;
|
|
78
|
+
let hi = lineStarts.length - 1;
|
|
79
|
+
while (lo < hi) {
|
|
80
|
+
const mid = (lo + hi + 1) >> 1;
|
|
81
|
+
if (lineStarts[mid] <= offset)
|
|
82
|
+
lo = mid;
|
|
83
|
+
else
|
|
84
|
+
hi = mid - 1;
|
|
85
|
+
}
|
|
86
|
+
const line = lo + 1;
|
|
87
|
+
if (line === lastLine)
|
|
88
|
+
continue;
|
|
89
|
+
lastLine = line;
|
|
90
|
+
result.push({ line, col: offset - lineStarts[lo] + 1 });
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
export function templateUsages(files, target) {
|
|
95
|
+
return files.flatMap((f) => usagesIn(f.text, target).map((u) => ({ file: f.rel, ...u })));
|
|
96
|
+
}
|
|
97
|
+
export function templatesLine(usages) {
|
|
98
|
+
return `${usages.length} usages in ${new Set(usages.map((u) => u.file)).size} files`;
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-axi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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": [
|