ts-axi 0.1.14 → 0.1.16
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 +19 -6
- package/dist/src/commands/common.js +2 -1
- package/dist/src/commands/def.js +19 -5
- package/dist/src/commands/impact.js +20 -9
- package/dist/src/commands/miss.js +225 -0
- package/dist/src/commands/outline.js +11 -5
- package/dist/src/commands/refs.js +78 -13
- package/dist/src/project.js +2 -2
- package/dist/src/references.js +4 -2
- package/dist/src/scope.js +15 -2
- package/dist/src/symbols.js +97 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,11 +75,24 @@ The fixture files are 5 to 40 lines long, so a ±40-line read is the whole file:
|
|
|
75
75
|
| task | ts-axi tokens | grep tokens | ratio | recall |
|
|
76
76
|
| --- | ---: | ---: | ---: | --- |
|
|
77
77
|
| def of a service method | 496 | 1256 | 0.40 | 1/1 |
|
|
78
|
-
| callers of a service method (
|
|
79
|
-
| refs of a service method with a common name |
|
|
80
|
-
| impact of a two-commit fix (4 changed symbols) |
|
|
78
|
+
| callers of a service method (96 call sites) | 6587 | 54314 | 0.12 | 96/96 |
|
|
79
|
+
| refs of a service method with a common name | 4653 | 147944 | 0.03 | 77/77 (244 more hits are other declarations of the name) |
|
|
80
|
+
| impact of a two-commit fix (4 changed symbols) | 2255 | 23948 | 0.09 | 44/44 (5 in the diff) |
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
The ts-axi side follows the `--limit` hint when a list is cut at 50 rows, as an agent would. Most rows past the first 50 are member accesses through a deep-mock mapped type (`DeepMocked<Service>`) in test files: `findReferences` returns them as extra groups defined at the method itself, and they count as References (before 0.1.15 they were dropped, and recall was 17/96 and 18/77).
|
|
83
|
+
|
|
84
|
+
### Speed
|
|
85
|
+
|
|
86
|
+
The same runs count tool calls (each ts-axi command, `git grep`, `git diff` and file read is one call) and time the commands. An agent's task time is mostly model round-trips, so the estimate adds an **assumed** 3 s per call to the measured command time (`TURN_MS` in `test/compare.ts`). On the Nx monorepo:
|
|
87
|
+
|
|
88
|
+
| task | ts-axi calls | grep calls | ts-axi command time | grep command time | est. task time ts-axi | est. task time grep |
|
|
89
|
+
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
90
|
+
| def of a service method | 1 | 3 | 1.4 s | 0.94 s | 4.4 s | 9.9 s |
|
|
91
|
+
| callers of a service method | 2 | 28 | 11.1 s | 0.20 s | 17.1 s | 84.2 s |
|
|
92
|
+
| refs of a service method with a common name | 2 | 100 | 6.3 s | 0.27 s | 12.3 s | 300.3 s |
|
|
93
|
+
| impact of a two-commit fix | 2 | 19 | 8.1 s | 0.22 s | 14.1 s | 57.2 s |
|
|
94
|
+
|
|
95
|
+
Each ts-axi command is slower than a `git grep` (every call starts cold and builds a program: 3-6 s per semantic call here), but it replaces dozens of reads. The integration test checks that ts-axi never needs more tool calls than the grep workflow; times are printed, not asserted.
|
|
83
96
|
|
|
84
97
|
## Install
|
|
85
98
|
|
|
@@ -109,7 +122,7 @@ The hook runs `ts-axi` with no arguments: it reads only the file tree and git st
|
|
|
109
122
|
| Command | What it does |
|
|
110
123
|
| --- | --- |
|
|
111
124
|
| `ts-axi` | Home view: project root, tsconfig used (or implicit), source file count, test-file glob and match counts, git branch and dirty count, next steps. Under a second, exit 0 everywhere |
|
|
112
|
-
| `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` and, when the name still occurs in the code, the `refs <name> --grep` that lists those textual matches |
|
|
125
|
+
| `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` and, when the name still occurs in the code, the `refs <name> --grep` that lists those textual matches. Before saying none, a name that is only a function-local Declaration (`local_to: Comp`) or only declared in a file the tsconfig leaves out (`project: outside (Deno edge/deno.json)`) is found syntactically and shown like a match |
|
|
113
126
|
| `refs <target>` | References of one symbol, grouped by file, with test counts. A class member that implements an interface (or overrides a base) member counts only its direct References; calls through the interface are `via_interface: n (Iface.member)`, sibling implementations are dropped, and an interface member reports `implementations: n`. `--limit`, `--kind calls`, `--via` (via rows with a `via` column), `--implementations` |
|
|
114
127
|
| `callers <target>` | Incoming calls of a function-like symbol, split the same way (`via_interface`, `--via`). `--depth 1..3` |
|
|
115
128
|
| `outline <file>` | Top-level declarations in source order, class members nested: `{line,kind,name,signature}` (`{at,kind,name,signature}` over several files). `--exports`, `--fields exported` |
|
|
@@ -305,7 +318,7 @@ One dependency: the `typescript` compiler API (ADR in `docs/adr/0001-typescript-
|
|
|
305
318
|
- `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.
|
|
306
319
|
- `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.
|
|
307
320
|
- 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.
|
|
308
|
-
- 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.
|
|
321
|
+
- Script-style JavaScript (AngularJS registrations with their `this` / `var self = this` members and factory objects, 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.
|
|
309
322
|
- Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
|
|
310
323
|
|
|
311
324
|
Every semantic command starts cold and prints `elapsed_ms`.
|
|
@@ -259,7 +259,8 @@ export function resolvePosition(index, cwd, target) {
|
|
|
259
259
|
return start >= lineStart && start < lineEnd;
|
|
260
260
|
});
|
|
261
261
|
const decls = index.declarations(abs);
|
|
262
|
-
|
|
262
|
+
// `self.loadItem = loadItem`: the member name there stands for the nested function's member too.
|
|
263
|
+
const declFor = (id) => decls.find((d) => d.namePos === id.getStart(sf)) ?? decls.find((d) => d.aliasPos === id.getStart(sf));
|
|
263
264
|
const pick = (id) => {
|
|
264
265
|
const decl = declFor(id);
|
|
265
266
|
return decl ? { kind: "decl", decl, abs } : { kind: "ident", abs, pos: id.getStart(sf), name: id.text };
|
package/dist/src/commands/def.js
CHANGED
|
@@ -2,6 +2,7 @@ import { bool, expectPositionals, flag, parseArgs } from "../args.js";
|
|
|
2
2
|
import { semanticFor } from "../scope.js";
|
|
3
3
|
import { textualScan, textualScanHint } from "./impact.js";
|
|
4
4
|
import { block, fenced, helpBlock, out } from "../render.js";
|
|
5
|
+
import { isOutside, missFallback, missRows, notSearched, outsideReason } from "./miss.js";
|
|
5
6
|
import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
|
|
6
7
|
import { BODY_LINES, declarationAt, filterSuffix, MATCH_ROWS, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, useRef, } from "./common.js";
|
|
7
8
|
export const DEF_HELP = `usage: ts-axi def <name | Class.member | path:line[:col]> [flags]
|
|
@@ -10,7 +11,8 @@ flags[8]:
|
|
|
10
11
|
output:
|
|
11
12
|
1 match = file, line, kind, exported, signature + body fenced with file line numbers
|
|
12
13
|
several = matches[n]{at,kind,signature} (at = path:line) (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms (--in, --kind, Class.member, --exported) with how many each keeps
|
|
13
|
-
|
|
14
|
+
not indexed = a function-local Declaration (local_to: Comp) or one in a file the tsconfig leaves out (project: outside (not in tsconfig.json | own tsconfig x | Deno x/deno.json)) shows the same way; several = matches[n]{at,kind,name,where} (at = path:line:col)
|
|
15
|
+
none = matches: 0 + did_you_mean; when the name still occurs in source files (left-out files too), help gives the refs --grep that lists those textual matches
|
|
14
16
|
examples[5]:
|
|
15
17
|
ts-axi def User
|
|
16
18
|
ts-axi def User.save
|
|
@@ -19,13 +21,13 @@ examples[5]:
|
|
|
19
21
|
ts-axi def sav --fuzzy --exported
|
|
20
22
|
`;
|
|
21
23
|
const COMMAND = "ts-axi def";
|
|
22
|
-
function detail(ctx, decls, full, label, extra = {}) {
|
|
24
|
+
function detail(ctx, decls, full, label, extra = {}, at) {
|
|
23
25
|
const primary = decls.find((d) => d.hasBody) ?? decls[0];
|
|
24
26
|
const first = decls[0];
|
|
25
27
|
const from = Math.min(...decls.map((d) => d.line));
|
|
26
28
|
const to = Math.max(...decls.map((d) => d.endLine));
|
|
27
29
|
const body = numberedLines(ctx.index.text(ctx.index.abs(primary.file)) ?? "", from, to, full ? undefined : BODY_LINES);
|
|
28
|
-
const position = `${first.file}:${first.nameLine}`;
|
|
30
|
+
const position = at ?? `${first.file}:${first.nameLine}`;
|
|
29
31
|
const help = [`Run \`ts-axi refs ${position}\` for its references`];
|
|
30
32
|
if (isFunctionLike(primary.kind))
|
|
31
33
|
help.push(`Run \`ts-axi callers ${position}\` for its call sites`);
|
|
@@ -40,6 +42,7 @@ function detail(ctx, decls, full, label, extra = {}) {
|
|
|
40
42
|
exported: primary.exported,
|
|
41
43
|
signature: first.signature,
|
|
42
44
|
...(decls.filter((d) => !d.hasBody).length > 0 ? { overloads: decls.filter((d) => !d.hasBody).length } : {}),
|
|
45
|
+
...(isOutside(ctx, ctx.index.abs(primary.file)) ? { project: outsideReason(ctx, ctx.index.abs(primary.file)) } : {}),
|
|
43
46
|
...extra,
|
|
44
47
|
}), fenced("body", body), helpBlock(help));
|
|
45
48
|
}
|
|
@@ -78,9 +81,19 @@ export async function defCommand(args, ctx) {
|
|
|
78
81
|
if (groups.length === 1)
|
|
79
82
|
return detail(opened, groups[0].decls, full, label);
|
|
80
83
|
if (groups.length === 0) {
|
|
84
|
+
const miss = missFallback(opened, target, f);
|
|
85
|
+
if (miss.hits.length === 1) {
|
|
86
|
+
const hit = miss.hits[0];
|
|
87
|
+
return detail(opened, [hit.decl], full, hit.at, hit.localTo !== undefined ? { local_to: hit.localTo } : {}, hit.at);
|
|
88
|
+
}
|
|
89
|
+
if (miss.hits.length > 1) {
|
|
90
|
+
return out(block({ ...(opened.warning ? { warning: opened.warning } : {}), symbol: label, ...missRows(miss, MATCH_ROWS) }), helpBlock(miss.hits.slice(0, 3).map((h) => `Run \`ts-axi def ${h.at}\` for the one ${h.localTo !== undefined ? `in ${h.localTo}` : `in ${h.decl.file}`}`)));
|
|
91
|
+
}
|
|
81
92
|
const suggested = suggestions(opened, target);
|
|
82
|
-
const
|
|
83
|
-
const
|
|
93
|
+
const rows = f.fuzzy ? [] : textualScan(opened, target.member ?? target.name);
|
|
94
|
+
const text = rows.length;
|
|
95
|
+
const outsideCount = rows.filter((r) => isOutside(opened, opened.index.abs(r.file))).length;
|
|
96
|
+
const help = [...(text > 0 ? [textualScanHint(label, text, outsideCount)] : []), ...suggested.slice(0, 2).map((s) => `Run \`ts-axi def ${s}\``)];
|
|
84
97
|
if (!f.fuzzy)
|
|
85
98
|
help.push(`Run \`ts-axi def ${label} --fuzzy\` for substring matches`);
|
|
86
99
|
if (f.kind || f.in.length > 0 || f.exported)
|
|
@@ -89,6 +102,7 @@ export async function defCommand(args, ctx) {
|
|
|
89
102
|
...(opened.warning ? { warning: opened.warning } : {}),
|
|
90
103
|
matches: 0,
|
|
91
104
|
...(suggested.length > 0 ? { did_you_mean: suggested } : {}),
|
|
105
|
+
...notSearched(miss),
|
|
92
106
|
}), helpBlock(help));
|
|
93
107
|
}
|
|
94
108
|
return out(block({
|
|
@@ -15,6 +15,7 @@ import { IMPACT_HOPS, scopeFields, scopeHelp, semanticFor } from "../scope.js";
|
|
|
15
15
|
import { isTemplatePath, loadTemplates, templateTarget, templateUsages, usagesIn } from "../templates.js";
|
|
16
16
|
import { walkFiles } from "../walk.js";
|
|
17
17
|
import { contextOf, lineOf, open, projectFields } from "./common.js";
|
|
18
|
+
import { outsideFiles } from "./miss.js";
|
|
18
19
|
const DEFAULT_LIMIT = 50;
|
|
19
20
|
const MAX_LIMIT = 500;
|
|
20
21
|
const SIDE_ROWS = 20;
|
|
@@ -251,7 +252,9 @@ function walkExtra(dir, outFiles) {
|
|
|
251
252
|
* a name in `localTo` (name → absolute file) is searched in that file only.
|
|
252
253
|
*/
|
|
253
254
|
export function textualMatches(opened, semantic, names, known, skipLines, changeSet, localTo = new Map()) {
|
|
254
|
-
|
|
255
|
+
// The whole Project, like `textualCount`: a subgraph program misses DI uses, which import nothing.
|
|
256
|
+
const byRel = new Map([...semantic.projectFiles, ...opened.index.files].map((f) => [opened.index.rel(f), f]));
|
|
257
|
+
const files = [...byRel.values()];
|
|
255
258
|
const projectSet = new Set(files);
|
|
256
259
|
walkExtra(opened.root, files);
|
|
257
260
|
sourceFs().prefetch(files);
|
|
@@ -262,7 +265,7 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
|
|
|
262
265
|
continue;
|
|
263
266
|
const rel = opened.index.rel(abs);
|
|
264
267
|
// A Declaration of another Symbol with the same name is provably not a use.
|
|
265
|
-
const declared = new Set(projectSet.has(abs) ? opened.index.declarations(abs).
|
|
268
|
+
const declared = new Set(projectSet.has(abs) ? opened.index.declarations(abs).flatMap((d) => (d.aliasLine ? [d.nameLine, d.aliasLine] : [d.nameLine])) : []);
|
|
266
269
|
const lines = new Set();
|
|
267
270
|
for (const { text: name, token } of names) {
|
|
268
271
|
if (localTo.has(name) && localTo.get(name) !== abs)
|
|
@@ -279,11 +282,15 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
|
|
|
279
282
|
}
|
|
280
283
|
return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
281
284
|
}
|
|
282
|
-
/**
|
|
285
|
+
/**
|
|
286
|
+
* Whole-word matches of `name` in the Project files and the source files it leaves out, one row per line:
|
|
287
|
+
* the fallback when no Declaration matches.
|
|
288
|
+
*/
|
|
283
289
|
export function textualScan(opened, name) {
|
|
284
290
|
const rows = [];
|
|
285
|
-
|
|
286
|
-
|
|
291
|
+
const files = [...opened.index.files, ...outsideFiles(opened)];
|
|
292
|
+
sourceFs().prefetch(files);
|
|
293
|
+
for (const abs of files) {
|
|
287
294
|
const text = opened.index.text(abs);
|
|
288
295
|
if (text === undefined || !text.includes(name))
|
|
289
296
|
continue;
|
|
@@ -297,8 +304,8 @@ export function textualScan(opened, name) {
|
|
|
297
304
|
}
|
|
298
305
|
return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
299
306
|
}
|
|
300
|
-
export function textualScanHint(label, matches) {
|
|
301
|
-
return `Run \`ts-axi refs ${label} --grep\` for ${matches} textual matches`;
|
|
307
|
+
export function textualScanHint(label, matches, outside = 0) {
|
|
308
|
+
return `Run \`ts-axi refs ${label} --grep\` for ${matches} textual matches${outside > 0 ? ` (${outside} in files outside the Project)` : ""}`;
|
|
302
309
|
}
|
|
303
310
|
/** Whether a whole-word count of the name says anything: not for router-called or by-name-injected members (`injected`). */
|
|
304
311
|
export function isTextualCandidate(decl) {
|
|
@@ -317,6 +324,10 @@ export function textualNeedle(subject, kind) {
|
|
|
317
324
|
}
|
|
318
325
|
return { text: symbol.split(".").pop(), token: false };
|
|
319
326
|
}
|
|
327
|
+
/** A Reference on a member's aliasing line (`self.x = x`) is part of its Declaration, not a use. */
|
|
328
|
+
export function onAliasLine(decl, e) {
|
|
329
|
+
return decl?.aliasLine !== undefined && e.file === decl.file && e.line === decl.aliasLine;
|
|
330
|
+
}
|
|
320
331
|
/** Whether `any`-typed property accesses of the name can be uses of the Symbol: same rule as `isTextualCandidate`. */
|
|
321
332
|
export function isBlindSpotCandidate(decl) {
|
|
322
333
|
return decl?.injected !== true;
|
|
@@ -407,7 +418,7 @@ export function textualCount(opened, needle, changeSet = new Map(), onlyFile) {
|
|
|
407
418
|
if (text === undefined || !text.includes(name))
|
|
408
419
|
continue;
|
|
409
420
|
const rel = opened.index.rel(abs);
|
|
410
|
-
const declared = new Set(project.has(abs) ? opened.index.declarations(abs).filter((d) => d.name === name).
|
|
421
|
+
const declared = new Set(project.has(abs) ? opened.index.declarations(abs).filter((d) => d.name === name).flatMap((d) => (d.aliasLine ? [d.nameLine, d.aliasLine] : [d.nameLine])) : []);
|
|
411
422
|
const n = wordMatches(text, name, token).filter((m) => !declared.has(m.line) && !inRanges(changeSet.get(rel), m.line)).length;
|
|
412
423
|
matches += n;
|
|
413
424
|
if (n > 0)
|
|
@@ -683,7 +694,7 @@ export async function impactCommand(args, ctx) {
|
|
|
683
694
|
}
|
|
684
695
|
for (const e of entries)
|
|
685
696
|
known.add(`${e.file}:${e.line}`);
|
|
686
|
-
row.all = entries.filter((e) => !e.isDefinition);
|
|
697
|
+
row.all = entries.filter((e) => !e.isDefinition && !onAliasLine(row.live, e));
|
|
687
698
|
row.outside = row.all.filter((e) => !inRanges(changeSet.get(e.file), e.line)).sort(byPosition);
|
|
688
699
|
}
|
|
689
700
|
for (const row of rows) {
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// The miss fallback: when a name target matches no indexed Declaration, look for it syntactically in
|
|
2
|
+
// source files the tsconfig leaves out and in function-local Declarations, so a miss is never silent.
|
|
3
|
+
import { dirname, join, posix, relative, sep } from "node:path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import { sourceFs } from "../fs.js";
|
|
6
|
+
import { matchesGlob } from "../glob.js";
|
|
7
|
+
import { declMatches, isSourceFile, nearestTsconfig } from "../project.js";
|
|
8
|
+
import { byDepthThenPath, listFiles } from "../walk.js";
|
|
9
|
+
import { declarationAt } from "./common.js";
|
|
10
|
+
/** At most this many files that pass the name prefilter are parsed; the rest are counted in `not_searched`. */
|
|
11
|
+
export const MISS_PARSE_CAP = 2_000;
|
|
12
|
+
const ANON = "(anonymous)";
|
|
13
|
+
function nameOf(node) {
|
|
14
|
+
return node && (ts.isIdentifier(node) || ts.isPrivateIdentifier(node) || ts.isStringLiteral(node)) ? node.text : undefined;
|
|
15
|
+
}
|
|
16
|
+
/** Name of a function-like node: its own, or the variable / property / member it is assigned to. */
|
|
17
|
+
function functionName(fn) {
|
|
18
|
+
const parent = fn.parent;
|
|
19
|
+
const cls = parent && (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) ? nameOf(parent.name) ?? ANON : undefined;
|
|
20
|
+
if (ts.isConstructorDeclaration(fn))
|
|
21
|
+
return `${cls ?? ANON}.constructor`;
|
|
22
|
+
const own = nameOf(fn.name);
|
|
23
|
+
if (own !== undefined)
|
|
24
|
+
return cls !== undefined ? `${cls}.${own}` : own;
|
|
25
|
+
if (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent))
|
|
26
|
+
return nameOf(parent.name);
|
|
27
|
+
if (ts.isPropertyDeclaration(parent)) {
|
|
28
|
+
const owner = parent.parent;
|
|
29
|
+
return `${nameOf(owner.name) ?? ANON}.${nameOf(parent.name) ?? ANON}`;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
/** Nearest named enclosing function; anonymous callbacks (`useEffect(() => ...)`) are skipped. */
|
|
34
|
+
function ownerOf(node) {
|
|
35
|
+
for (let n = node.parent; n && !ts.isSourceFile(n); n = n.parent) {
|
|
36
|
+
if (ts.isFunctionLike(n)) {
|
|
37
|
+
const name = functionName(n);
|
|
38
|
+
if (name !== undefined)
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return "(top-level block)";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Function, class and plain `const/let/var x` Declarations named `name` that sit in a function or block
|
|
46
|
+
* body (not top level, not a namespace). Parameters, destructured and loop bindings are left out.
|
|
47
|
+
*/
|
|
48
|
+
export function findLocals(sf, name) {
|
|
49
|
+
const found = [];
|
|
50
|
+
const nested = (statement) => !ts.isSourceFile(statement.parent) && !ts.isModuleBlock(statement.parent);
|
|
51
|
+
const visit = (node) => {
|
|
52
|
+
if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name?.text === name && nested(node)) {
|
|
53
|
+
found.push({ pos: node.name.getStart(sf), name, owner: ownerOf(node) });
|
|
54
|
+
}
|
|
55
|
+
else if (ts.isVariableDeclaration(node) &&
|
|
56
|
+
ts.isIdentifier(node.name) &&
|
|
57
|
+
node.name.text === name &&
|
|
58
|
+
ts.isVariableStatement(node.parent.parent) &&
|
|
59
|
+
nested(node.parent.parent)) {
|
|
60
|
+
found.push({ pos: node.name.getStart(sf), name, owner: ownerOf(node) });
|
|
61
|
+
}
|
|
62
|
+
ts.forEachChild(node, visit);
|
|
63
|
+
};
|
|
64
|
+
visit(sf);
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
const outsideCache = new WeakMap();
|
|
68
|
+
/**
|
|
69
|
+
* Source files below the repo root the Project does not list (gitignored, node_modules and `.d.ts`
|
|
70
|
+
* dropped). Empty for a workspace or implicit Project: those already list every source file.
|
|
71
|
+
*/
|
|
72
|
+
export function outsideFiles(opened) {
|
|
73
|
+
const cached = outsideCache.get(opened);
|
|
74
|
+
if (cached)
|
|
75
|
+
return cached.files;
|
|
76
|
+
const { project } = opened.index;
|
|
77
|
+
let files = [];
|
|
78
|
+
let capped = false;
|
|
79
|
+
if (!project.scope && !project.implicit) {
|
|
80
|
+
const known = new Set(opened.index.files);
|
|
81
|
+
// node_modules and build dirs are already dropped by the shared skip rule.
|
|
82
|
+
const listing = listFiles(opened.root, isSourceFile, { maxFiles: 20_000, deadline: Date.now() + 600 });
|
|
83
|
+
files = listing.files.filter((f) => !known.has(f));
|
|
84
|
+
capped = listing.capped;
|
|
85
|
+
}
|
|
86
|
+
outsideCache.set(opened, { files, set: new Set(files), capped });
|
|
87
|
+
return files;
|
|
88
|
+
}
|
|
89
|
+
const projectSets = new WeakMap();
|
|
90
|
+
/** Lists the left-out files only for a file the Project does not hold, so hit paths stay listing-free. */
|
|
91
|
+
export function isOutside(opened, abs) {
|
|
92
|
+
let known = projectSets.get(opened);
|
|
93
|
+
if (!known)
|
|
94
|
+
projectSets.set(opened, (known = new Set(opened.index.files)));
|
|
95
|
+
if (known.has(abs))
|
|
96
|
+
return false;
|
|
97
|
+
outsideFiles(opened);
|
|
98
|
+
return outsideCache.get(opened).set.has(abs);
|
|
99
|
+
}
|
|
100
|
+
/** `outside (Deno edge/deno.json)`, `outside (own tsconfig x/tsconfig.json)` or `outside (not in tsconfig.json)`. */
|
|
101
|
+
export function outsideReason(opened, abs) {
|
|
102
|
+
const root = opened.root;
|
|
103
|
+
for (let dir = dirname(abs);; dir = dirname(dir)) {
|
|
104
|
+
for (const name of ["deno.json", "deno.jsonc"]) {
|
|
105
|
+
if (sourceFs().exists(join(dir, name)))
|
|
106
|
+
return `outside (Deno ${opened.index.rel(join(dir, name))})`;
|
|
107
|
+
}
|
|
108
|
+
if (dir === root || dirname(dir) === dir)
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
const own = nearestTsconfig(abs, root);
|
|
112
|
+
const projectTsconfig = opened.index.project.tsconfig;
|
|
113
|
+
if (own && own !== projectTsconfig)
|
|
114
|
+
return `outside (own tsconfig ${opened.index.rel(own)})`;
|
|
115
|
+
return projectTsconfig ? `outside (not in ${relative(opened.cwd, projectTsconfig).split(sep).join("/")})` : "outside (not in the Project)";
|
|
116
|
+
}
|
|
117
|
+
/** `--in` globs are cwd-relative; files are root-relative. */
|
|
118
|
+
function rootGlobs(opened, globs) {
|
|
119
|
+
const prefix = relative(opened.root, opened.cwd).split(sep).join("/");
|
|
120
|
+
return globs.map((g) => {
|
|
121
|
+
const clean = g.replace(/^\.\//, "");
|
|
122
|
+
return prefix && !prefix.startsWith("..") ? posix.join(prefix, clean) : clean;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function inGlobs(rel, globs) {
|
|
126
|
+
return globs.length === 0 || globs.some((g) => matchesGlob(rel, g));
|
|
127
|
+
}
|
|
128
|
+
function nameCol(sf, pos) {
|
|
129
|
+
const lc = sf.getLineAndCharacterOfPosition(pos);
|
|
130
|
+
return { line: lc.line + 1, col: lc.character + 1 };
|
|
131
|
+
}
|
|
132
|
+
/** Declarations of the name outside the Project, plus function-local ones anywhere (see the header). */
|
|
133
|
+
export function missFallback(opened, target, f) {
|
|
134
|
+
if (f.fuzzy)
|
|
135
|
+
return { hits: [], skipped: 0, walkCapped: false };
|
|
136
|
+
const { index } = opened;
|
|
137
|
+
const globs = rootGlobs(opened, f.in);
|
|
138
|
+
const outside = outsideFiles(opened);
|
|
139
|
+
const word = target.member ?? target.name;
|
|
140
|
+
const pick = (list) => (globs.length === 0 ? list : list.filter((abs) => inGlobs(index.rel(abs), globs)));
|
|
141
|
+
const outsidePicked = pick(outside);
|
|
142
|
+
const candidates = [...pick(index.files), ...outsidePicked];
|
|
143
|
+
sourceFs().prefetch(outsidePicked);
|
|
144
|
+
const hits = [];
|
|
145
|
+
let parsed = 0;
|
|
146
|
+
let skipped = 0;
|
|
147
|
+
for (const abs of candidates) {
|
|
148
|
+
const text = index.text(abs);
|
|
149
|
+
if (text === undefined || !text.includes(word))
|
|
150
|
+
continue;
|
|
151
|
+
if (++parsed > MISS_PARSE_CAP) {
|
|
152
|
+
skipped++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const sf = index.sourceFile(abs);
|
|
156
|
+
if (!sf)
|
|
157
|
+
continue;
|
|
158
|
+
const reason = isOutside(opened, abs) ? outsideReason(opened, abs) : undefined;
|
|
159
|
+
const add = (decl, localTo) => {
|
|
160
|
+
if (f.kind !== undefined && decl.kind !== f.kind)
|
|
161
|
+
return;
|
|
162
|
+
if (f.exported && !decl.exported)
|
|
163
|
+
return;
|
|
164
|
+
const { line, col } = nameCol(sf, decl.namePos);
|
|
165
|
+
hits.push({ decl, abs, at: `${decl.file}:${line}:${col}`, ...(localTo !== undefined ? { localTo } : {}), ...(reason ? { outside: reason } : {}) });
|
|
166
|
+
};
|
|
167
|
+
const indexed = index.declarations(abs);
|
|
168
|
+
if (reason)
|
|
169
|
+
for (const d of indexed)
|
|
170
|
+
if (declMatches(d, { name: target.name, ...(target.member !== undefined ? { member: target.member } : {}) }))
|
|
171
|
+
add(d);
|
|
172
|
+
if (target.member !== undefined)
|
|
173
|
+
continue;
|
|
174
|
+
const known = new Set(indexed.map((d) => d.namePos));
|
|
175
|
+
for (const local of findLocals(sf, target.name)) {
|
|
176
|
+
if (known.has(local.pos))
|
|
177
|
+
continue;
|
|
178
|
+
const decl = declarationAt(index, abs, local.pos, "variable");
|
|
179
|
+
if (decl)
|
|
180
|
+
add(decl, local.owner);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
hits.sort((a, b) => byDepthThenPath(a.decl.file, b.decl.file) || a.decl.namePos - b.decl.namePos);
|
|
184
|
+
return { hits, skipped, walkCapped: outsideCache.get(opened).capped };
|
|
185
|
+
}
|
|
186
|
+
/** `local to Comp`, `outside (...)` or both: the `where` cell and the single-hit fields. */
|
|
187
|
+
export function whereOf(hit) {
|
|
188
|
+
return [hit.localTo !== undefined ? `local to ${hit.localTo}` : undefined, hit.outside].filter(Boolean).join("; ");
|
|
189
|
+
}
|
|
190
|
+
/** Fields a single fallback hit adds to a detail or refs header. */
|
|
191
|
+
export function missFields(hit) {
|
|
192
|
+
return { ...(hit.localTo !== undefined ? { local_to: hit.localTo } : {}), ...(hit.outside ? { project: hit.outside } : {}) };
|
|
193
|
+
}
|
|
194
|
+
/** `matches[n]{at,kind,name,where}` for several fallback hits. */
|
|
195
|
+
export function missRows(result, max) {
|
|
196
|
+
const rows = result.hits.slice(0, max).map((h) => ({ at: h.at, kind: h.decl.kind, name: h.decl.qualifiedName, where: whereOf(h) }));
|
|
197
|
+
return {
|
|
198
|
+
matches: rows,
|
|
199
|
+
...(result.hits.length > max ? { matches_total: result.hits.length, truncated: true } : {}),
|
|
200
|
+
...notSearched(result),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** `not_searched` when a bound stopped the fallback. */
|
|
204
|
+
export function notSearched(result) {
|
|
205
|
+
const parts = [
|
|
206
|
+
...(result.skipped > 0 ? [`${result.skipped} files past the ${MISS_PARSE_CAP}-file cap`] : []),
|
|
207
|
+
...(result.walkCapped ? ["files past the 20000-file or 0.6 s walk bound (no git)"] : []),
|
|
208
|
+
];
|
|
209
|
+
return parts.length > 0 ? { not_searched: parts.join("; ") } : {};
|
|
210
|
+
}
|
|
211
|
+
/** Outside-Project source files whose text holds `name`: extra roots so a semantic run sees their uses. */
|
|
212
|
+
export function outsideSeeds(opened, name) {
|
|
213
|
+
return outsideFiles(opened).filter((abs) => opened.index.text(abs)?.includes(name));
|
|
214
|
+
}
|
|
215
|
+
/** `3 matches in 2 files (edge/fn; tools)`: textual rows from outside-Project files, at most 3 dirs. */
|
|
216
|
+
export function outsideLine(opened, rows) {
|
|
217
|
+
const outside = new Set(outsideFiles(opened).map((abs) => opened.index.rel(abs)));
|
|
218
|
+
const mine = rows.filter((r) => outside.has(r.file));
|
|
219
|
+
if (mine.length === 0)
|
|
220
|
+
return undefined;
|
|
221
|
+
const files = [...new Set(mine.map((r) => r.file))];
|
|
222
|
+
const dirs = [...new Set(files.map((f) => posix.dirname(f)))];
|
|
223
|
+
const shown = dirs.slice(0, 3).join("; ") + (dirs.length > 3 ? `; +${dirs.length - 3} more` : "");
|
|
224
|
+
return `${mine.length} matches in ${files.length} files (${shown})`;
|
|
225
|
+
}
|
|
@@ -3,7 +3,8 @@ import { posix, relative, resolve, sep } from "node:path";
|
|
|
3
3
|
import { bool, flag, parseArgs, parseFields } from "../args.js";
|
|
4
4
|
import { usageError } from "../errors.js";
|
|
5
5
|
import { hasGlobMagic, matchesGlob } from "../glob.js";
|
|
6
|
-
import { createSyntacticIndex, findRepoRoot, openProject, SOURCE_EXTENSIONS } from "../project.js";
|
|
6
|
+
import { createSyntacticIndex, findRepoRoot, isSourceFile, openProject, SOURCE_EXTENSIONS } from "../project.js";
|
|
7
|
+
import { listFiles } from "../walk.js";
|
|
7
8
|
import { block, helpBlock, out } from "../render.js";
|
|
8
9
|
import { useRef } from "./common.js";
|
|
9
10
|
const MAX_ROWS = 300;
|
|
@@ -55,13 +56,17 @@ export async function outlineCommand(args, ctx) {
|
|
|
55
56
|
const index = globs.length > 0
|
|
56
57
|
? openProject(ctx.cwd, override ? { override } : {}).index
|
|
57
58
|
: createSyntacticIndex(root, { root, tsconfig: null, implicit: true, fileNames: files, options: {} });
|
|
59
|
+
let outsideCount = 0;
|
|
58
60
|
if (globs.length > 0) {
|
|
59
61
|
const rooted = globs.map((g) => (cwdPrefix && !cwdPrefix.startsWith("..") ? posix.join(cwdPrefix, g.replace(/^\.\//, "")) : g));
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
const matching = (list) => list.filter((file) => rooted.some((g) => matchesGlob(index.rel(file), g)));
|
|
63
|
+
const inProject = matching(index.files);
|
|
64
|
+
// Files the tsconfig leaves out parse alone, like a plain path.
|
|
65
|
+
const outside = inProject.length === 0 && files.length === 0 ? matching(listFiles(root, isSourceFile, { maxFiles: 20_000, deadline: Date.now() + 600 }).files) : [];
|
|
66
|
+
outsideCount = outside.length;
|
|
67
|
+
files.push(...inProject, ...outside);
|
|
63
68
|
if (files.length === 0) {
|
|
64
|
-
return out(block({ files: 0, decls: 0 }), helpBlock([`No
|
|
69
|
+
return out(block({ files: 0, decls: 0 }), helpBlock([`No source file matches ${globs.join(" ")}; paths are relative to the current directory`]));
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
const unique = [...new Set(files)];
|
|
@@ -97,6 +102,7 @@ export async function outlineCommand(args, ctx) {
|
|
|
97
102
|
...(single ? { file: index.rel(unique[0]) } : { files: unique.length }),
|
|
98
103
|
decls: rows.length === 0 ? 0 : rows,
|
|
99
104
|
...(decls.length > MAX_ROWS ? { truncated: true, decls_total: decls.length } : {}),
|
|
105
|
+
...(outsideCount > 0 ? { note: `${outsideCount} files outside the Project` } : {}),
|
|
100
106
|
}), helpBlock(help));
|
|
101
107
|
}
|
|
102
108
|
function isInterfaceMember(fileDecls, d) {
|
|
@@ -1,13 +1,14 @@
|
|
|
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 { isPlainLocal, isTextualCandidate, isUntyped, textualCount, textualNeedle, textualHint, textualLine, textualMatches, textualScan, textualScanHint } from "./impact.js";
|
|
4
|
+
import { isPlainLocal, isTextualCandidate, onAliasLine, isUntyped, textualCount, textualNeedle, textualHint, textualLine, textualMatches, textualScan, textualScanHint } 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
9
|
import { loadTemplates, templatesLine, templateTarget, templateUsages } from "../templates.js";
|
|
10
10
|
import { byDepthThenPath } from "../walk.js";
|
|
11
|
+
import { isOutside, missFallback, missRows, notSearched, outsideLine, outsideReason, outsideSeeds } from "./miss.js";
|
|
11
12
|
import { detectWorkspace, isMultiProject } from "../workspace.js";
|
|
12
13
|
import { isTestFile } from "../settings.js";
|
|
13
14
|
import { isFunctionLike, kindFromScriptElement, parseTarget, registrationOf } from "../symbols.js";
|
|
@@ -20,6 +21,9 @@ const VIA_DOC = `via_interface: n (Iface.member) = References through an interfa
|
|
|
20
21
|
via_implementations: n (Impl.member) = References to implementations or overrides of the Symbol; not in refs_total
|
|
21
22
|
implementations: n = members that implement or override the Symbol (their Declarations are never refs)
|
|
22
23
|
matches: 0 under -P lists found_outside_project[n]{file,line} when the name is declared outside that Project
|
|
24
|
+
local_to: Comp = the name is a function-local Declaration (not indexed): its References from the declaring file
|
|
25
|
+
matches: 0 + project: outside (...) = declared only in a file the tsconfig leaves out: outside_project + unresolved list its textual matches; the help position target runs the checker with the left-out files added (project: outside on that output)
|
|
26
|
+
matches[n]{at,kind,name,where} = several function-local or left-out Declarations; pick one by its path:line:col
|
|
23
27
|
refs_total: 0 under -P in a workspace lists found_outside_project[n]{file,line} = importers outside that Project
|
|
24
28
|
scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
|
|
25
29
|
tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references
|
|
@@ -35,7 +39,7 @@ output:
|
|
|
35
39
|
symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,context} (Declarations excluded), elapsed_ms
|
|
36
40
|
${VIA_DOC}
|
|
37
41
|
note = the name is registered by a string ('Name' in .controller('Name', fn), or fn a same-file function named by identifier) - its uses are textual; add --grep
|
|
38
|
-
matches: 0 = no Declaration of the name; help counts its whole-word matches in
|
|
42
|
+
matches: 0 = no Declaration of the name; help counts its whole-word matches in source files (left-out files too), and --grep lists them in unresolved with note 'no declaration found - textual matches only' (outside_project = the share in files the tsconfig leaves out)
|
|
39
43
|
path:line = the declaration that starts the line (a function before its parameters, a property before its value's parameters; siblings like 'const a = 1, b = 2' list identifiers - add :col)
|
|
40
44
|
an ambiguous name lists matches[n] (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms; nothing is analysed
|
|
41
45
|
examples[4]:
|
|
@@ -93,6 +97,8 @@ async function run(args, ctx, invoked) {
|
|
|
93
97
|
const warning = opened.warning ? { warning: opened.warning } : {};
|
|
94
98
|
// Resolve to exactly one Symbol before building a program.
|
|
95
99
|
let resolved;
|
|
100
|
+
let local;
|
|
101
|
+
let miss;
|
|
96
102
|
if (target.kind === "name") {
|
|
97
103
|
const groups = resolveNameTarget(opened, target, f);
|
|
98
104
|
if (groups.length === 0) {
|
|
@@ -113,13 +119,33 @@ async function run(args, ctx, invoked) {
|
|
|
113
119
|
`Run \`ts-axi ${name} ${outside[0].file}:${outside[0].nameLine}\` to use the nearest tsconfig of that file`,
|
|
114
120
|
]));
|
|
115
121
|
}
|
|
122
|
+
miss = missFallback(opened, target, f);
|
|
123
|
+
if (miss.hits.length > 1) {
|
|
124
|
+
return out(block({ ...warning, symbol: label, ambiguous: `${miss.hits.length} Declarations outside the index - pick one`, ...missRows(miss, MATCH_ROWS) }), helpBlock(miss.hits.slice(0, 3).map((h) => `Run \`ts-axi ${name} ${h.at}\` for the one ${h.localTo !== undefined ? `in ${h.localTo}` : `in ${h.decl.file}`}`)));
|
|
125
|
+
}
|
|
126
|
+
const hit = miss.hits[0];
|
|
127
|
+
if (hit && hit.localTo !== undefined) {
|
|
128
|
+
// A local is file-scoped: the normal position path finds every Reference.
|
|
129
|
+
local = hit;
|
|
130
|
+
resolved = { absFile: hit.abs, pos: hit.decl.namePos, decl: hit.decl };
|
|
131
|
+
}
|
|
132
|
+
else if (hit) {
|
|
133
|
+
return outsideMiss(opened, hit, { warning, name, limit, label: target.member ?? target.name });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (resolved) {
|
|
137
|
+
// A local found by the miss fallback.
|
|
138
|
+
}
|
|
139
|
+
else if (groups.length === 0) {
|
|
116
140
|
// No Declaration, but the name may still be written: an untyped property set up elsewhere.
|
|
117
141
|
const text = f.fuzzy ? [] : textualScan(opened, target.member ?? target.name);
|
|
142
|
+
const outsideText = outsideLine(opened, text);
|
|
118
143
|
if (show.grep) {
|
|
119
144
|
return out(block({
|
|
120
145
|
...warning,
|
|
121
146
|
matches: 0,
|
|
122
147
|
note: "no declaration found - textual matches only",
|
|
148
|
+
...(outsideText ? { outside_project: outsideText } : {}),
|
|
123
149
|
unresolved: text.length === 0 ? 0 : text.slice(0, limit),
|
|
124
150
|
...(text.length > limit ? { unresolved_total: text.length } : {}),
|
|
125
151
|
}), helpBlock([
|
|
@@ -128,17 +154,19 @@ async function run(args, ctx, invoked) {
|
|
|
128
154
|
]));
|
|
129
155
|
}
|
|
130
156
|
const suggested = suggestions(opened, target);
|
|
131
|
-
return out(block({ ...warning, matches: 0, ...(suggested.length > 0 ? { did_you_mean: suggested } : {}) }), helpBlock([
|
|
132
|
-
...(text.length > 0 ? [textualScanHint(label, text.length)] : []),
|
|
157
|
+
return out(block({ ...warning, matches: 0, ...(suggested.length > 0 ? { did_you_mean: suggested } : {}), ...(miss ? notSearched(miss) : {}) }), helpBlock([
|
|
158
|
+
...(text.length > 0 ? [textualScanHint(label, text.length, text.filter((r) => isOutside(opened, opened.index.abs(r.file))).length)] : []),
|
|
133
159
|
...suggested.slice(0, 2).map((s) => `Run \`ts-axi ${name} ${s}\``),
|
|
134
160
|
...(f.fuzzy ? [] : [`Run \`ts-axi def ${label} --fuzzy\` for substring matches`]),
|
|
135
161
|
]));
|
|
136
162
|
}
|
|
137
|
-
if (groups.length > 1) {
|
|
163
|
+
else if (groups.length > 1) {
|
|
138
164
|
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)));
|
|
139
165
|
}
|
|
140
|
-
|
|
141
|
-
|
|
166
|
+
else {
|
|
167
|
+
const decl = groups[0].decls[0];
|
|
168
|
+
resolved = { absFile: opened.index.abs(decl.file), pos: decl.namePos, decl, seeds: [...new Set(groups[0].decls.map((d) => opened.index.abs(d.file)))] };
|
|
169
|
+
}
|
|
142
170
|
}
|
|
143
171
|
else {
|
|
144
172
|
const at = resolvePosition(opened.index, ctx.cwd, target);
|
|
@@ -150,13 +178,21 @@ async function run(args, ctx, invoked) {
|
|
|
150
178
|
}
|
|
151
179
|
resolved = at.kind === "decl" ? { absFile: at.abs, pos: at.decl.namePos, decl: at.decl } : { absFile: at.abs, pos: at.pos };
|
|
152
180
|
}
|
|
153
|
-
|
|
154
|
-
|
|
181
|
+
// A file the tsconfig leaves out brings the other left-out files that name the identifier.
|
|
182
|
+
const seedsOf = (files, abs, pos) => {
|
|
183
|
+
if (!isOutside(opened, abs))
|
|
184
|
+
return files;
|
|
185
|
+
const sf = opened.index.sourceFile(abs);
|
|
186
|
+
const word = sf ? /^[\w$]+/.exec(sf.text.slice(pos))?.[0] : undefined;
|
|
187
|
+
return [...new Set([...files, ...(word ? outsideSeeds(opened, word) : [])])];
|
|
188
|
+
};
|
|
189
|
+
let built = semanticFor(opened, seedsOf(resolved.seeds ?? [resolved.absFile], resolved.absFile, resolved.pos), resolved.decl ? REFS_HOPS : 0);
|
|
190
|
+
if (!resolved.decl && (built.scope || isOutside(opened, resolved.absFile))) {
|
|
155
191
|
// A use position: find its Declaration in a small program, then scope around the declaring file.
|
|
156
192
|
const at = built.semantic.definitionAt(resolved.absFile, resolved.pos);
|
|
157
193
|
if (at && at.line > 0)
|
|
158
194
|
resolved = { absFile: at.absFile, pos: at.pos };
|
|
159
|
-
built = semanticFor(opened, [resolved.absFile], REFS_HOPS);
|
|
195
|
+
built = semanticFor(opened, seedsOf([resolved.absFile], resolved.absFile, resolved.pos), REFS_HOPS);
|
|
160
196
|
}
|
|
161
197
|
const { semantic, scope } = built;
|
|
162
198
|
const def = semantic.definitionAt(resolved.absFile, resolved.pos);
|
|
@@ -166,8 +202,17 @@ async function run(args, ctx, invoked) {
|
|
|
166
202
|
const decl = (found && registrationOf(found, opened.index.declarations(opened.index.abs(found.file)))) ?? found;
|
|
167
203
|
const kind = decl?.kind ?? kindFromScriptElement(def?.kind ?? semantic.quickInfo(resolved.absFile, resolved.pos)?.kind ?? "");
|
|
168
204
|
const symbol = decl?.qualifiedName ?? def?.name ?? label;
|
|
169
|
-
const declaredAt = decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
|
|
170
|
-
const
|
|
205
|
+
const declaredAt = local ? local.at : decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
|
|
206
|
+
const declaredAbs = decl ? opened.index.abs(decl.file) : def && def.line > 0 ? def.absFile : undefined;
|
|
207
|
+
const header = {
|
|
208
|
+
...warning,
|
|
209
|
+
...projectFields(opened),
|
|
210
|
+
symbol,
|
|
211
|
+
kind,
|
|
212
|
+
declared_at: declaredAt,
|
|
213
|
+
...(local ? { local_to: local.localTo } : {}),
|
|
214
|
+
...(declaredAbs && isOutside(opened, declaredAbs) ? { project: outsideReason(opened, declaredAbs) } : {}),
|
|
215
|
+
};
|
|
171
216
|
const explicit = Boolean(project || opened.settings.project);
|
|
172
217
|
const declaredFile = decl?.file ?? (def && def.line > 0 ? def.file : undefined);
|
|
173
218
|
const localFile = isPlainLocal(opened.index, decl) ? opened.index.abs(decl.file) : undefined;
|
|
@@ -180,6 +225,26 @@ async function run(args, ctx, invoked) {
|
|
|
180
225
|
? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
|
|
181
226
|
: renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
|
|
182
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* One Declaration in a file the tsconfig leaves out: no semantic program for a name target, so the
|
|
230
|
+
* textual rows of the name (Project + left-out files) and the position target that does run one.
|
|
231
|
+
*/
|
|
232
|
+
function outsideMiss(opened, hit, o) {
|
|
233
|
+
const rows = textualScan(opened, o.label).filter((r) => !(r.file === hit.decl.file && r.line === hit.decl.nameLine));
|
|
234
|
+
const outsideText = outsideLine(opened, rows);
|
|
235
|
+
return out(block({
|
|
236
|
+
...o.warning,
|
|
237
|
+
matches: 0,
|
|
238
|
+
declared_at: `${hit.decl.file}:${hit.decl.nameLine}`,
|
|
239
|
+
project: hit.outside,
|
|
240
|
+
...(outsideText ? { outside_project: outsideText } : {}),
|
|
241
|
+
unresolved: rows.length === 0 ? 0 : rows.slice(0, o.limit),
|
|
242
|
+
...(rows.length > o.limit ? { unresolved_total: rows.length } : {}),
|
|
243
|
+
}), helpBlock([
|
|
244
|
+
`Run \`ts-axi ${o.name} ${hit.at}\` for semantic ${o.name === "callers" ? "call sites" : "References"} - a position target adds the left-out files to the program`,
|
|
245
|
+
`Run \`ts-axi def ${hit.at}\` for the declaration body`,
|
|
246
|
+
]));
|
|
247
|
+
}
|
|
183
248
|
/** Template usages as `--grep` rows. */
|
|
184
249
|
export function templateRows(files, target) {
|
|
185
250
|
const texts = new Map(files.map((f) => [f.rel, f.text]));
|
|
@@ -289,7 +354,7 @@ function mergedRows(direct, via, show) {
|
|
|
289
354
|
}
|
|
290
355
|
function renderRefs(semantic, opened, resolved, header, o) {
|
|
291
356
|
const split = semantic.referenceSplit(resolved.absFile, resolved.pos);
|
|
292
|
-
const rows = sortRows(
|
|
357
|
+
const rows = sortRows(split.direct.filter((e) => !onAliasLine(o.decl, e)));
|
|
293
358
|
const via = sortRows([...split.via]);
|
|
294
359
|
const impls = sortRows([...split.implementations]);
|
|
295
360
|
const { files, tests, fileList } = summary(opened, rows);
|
package/dist/src/project.js
CHANGED
|
@@ -102,7 +102,7 @@ export function discoverTsconfig(startDir, options = {}) {
|
|
|
102
102
|
dir = parent;
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
function isSourceFile(name) {
|
|
105
|
+
export function isSourceFile(name) {
|
|
106
106
|
return SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)) && !/\.d\.[mc]?ts$/.test(name);
|
|
107
107
|
}
|
|
108
108
|
/** Every source file below `root` from the shared listing (`src/walk.ts`: git, else fs walk), minus `.d.ts`. */
|
|
@@ -204,7 +204,7 @@ export function parseSource(file, text) {
|
|
|
204
204
|
export function relPath(root, abs) {
|
|
205
205
|
return relative(root, abs).split(sep).join("/");
|
|
206
206
|
}
|
|
207
|
-
function declMatches(decl, query) {
|
|
207
|
+
export function declMatches(decl, query) {
|
|
208
208
|
const needle = query.fuzzy ? query.name.toLowerCase() : query.name;
|
|
209
209
|
if (query.member !== undefined) {
|
|
210
210
|
if (decl.parentName === undefined)
|
package/dist/src/references.js
CHANGED
|
@@ -7,7 +7,8 @@ const RANK = { direct: 0, declaration: 1, implementation: 2, via: 3, dropped: 4
|
|
|
7
7
|
* Rules: the target group is direct. When the target is a class/interface member, a group it
|
|
8
8
|
* implements/overrides (a base) is via, minus Declarations and `super` calls; a group that
|
|
9
9
|
* implements/overrides the target is an implementation (its Declaration) or via (its uses);
|
|
10
|
-
* any other member group is a sibling and dropped. Non-member groups (import aliases)
|
|
10
|
+
* any other member group is a sibling and dropped. Non-member groups (import aliases) and groups
|
|
11
|
+
* defined at the target itself (mock mapped types) are direct.
|
|
11
12
|
*/
|
|
12
13
|
export function classifyReferenceGroups(groups, targetIndex) {
|
|
13
14
|
const target = groups[targetIndex];
|
|
@@ -19,7 +20,8 @@ export function classifyReferenceGroups(groups, targetIndex) {
|
|
|
19
20
|
};
|
|
20
21
|
const bases = new Set(target?.bases ?? []);
|
|
21
22
|
groups.forEach((group, i) => {
|
|
22
|
-
|
|
23
|
+
// Mock shapes (`{ [K in keyof T]: … } & T`) come back as extra groups defined at the target itself.
|
|
24
|
+
const isTarget = i === targetIndex || (target !== undefined && group.key === target.key);
|
|
23
25
|
const relation = isTarget || !target?.member || !group.member
|
|
24
26
|
? "direct"
|
|
25
27
|
: bases.has(group.key)
|
package/dist/src/scope.js
CHANGED
|
@@ -99,8 +99,21 @@ export function planScope(index, seeds, hops, extra = []) {
|
|
|
99
99
|
}
|
|
100
100
|
/** A LanguageService over the Project, or over the importer subgraph of `seeds` when the Project is scoped. */
|
|
101
101
|
export function semanticFor(ctx, seeds, hops, extra = []) {
|
|
102
|
-
if (!ctx.index.project.scope)
|
|
103
|
-
|
|
102
|
+
if (!ctx.index.project.scope) {
|
|
103
|
+
// A seed the tsconfig leaves out (an excluded dir) joins the Project's roots, or the service cannot see it.
|
|
104
|
+
const known = new Set(ctx.index.files);
|
|
105
|
+
const outside = seeds.filter((s) => !known.has(s) && sourceFs().exists(s));
|
|
106
|
+
if (outside.length === 0)
|
|
107
|
+
return { semantic: createSemanticProject(ctx.index) };
|
|
108
|
+
const { project } = ctx.index;
|
|
109
|
+
return {
|
|
110
|
+
semantic: createSemanticProject(ctx.index, {
|
|
111
|
+
rootNames: [...project.fileNames, ...new Set(outside)],
|
|
112
|
+
options: project.options,
|
|
113
|
+
currentDirectory: project.tsconfig ? dirname(project.tsconfig) : ctx.index.root,
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
104
117
|
const { program, info } = planScope(ctx.index, seeds, hops, extra);
|
|
105
118
|
return { semantic: createSemanticProject(ctx.index, program), scope: info };
|
|
106
119
|
}
|
package/dist/src/symbols.js
CHANGED
|
@@ -289,27 +289,57 @@ function functionByIdentifier(call, arg) {
|
|
|
289
289
|
let scope = call;
|
|
290
290
|
while (!ts.isSourceFile(scope) && !(ts.isBlock(scope) && ts.isFunctionLike(scope.parent)))
|
|
291
291
|
scope = scope.parent;
|
|
292
|
-
|
|
292
|
+
return functionInStatements(scope.statements, ref.text);
|
|
293
|
+
}
|
|
294
|
+
/** The function declaration or function-valued variable `name` among `statements` (any position: hoisting), with its `$inject`. */
|
|
295
|
+
function functionInStatements(statements, name) {
|
|
293
296
|
let found;
|
|
294
297
|
let inject;
|
|
295
298
|
for (const st of statements) {
|
|
296
|
-
if (ts.isFunctionDeclaration(st) && st.name?.text ===
|
|
299
|
+
if (ts.isFunctionDeclaration(st) && st.name?.text === name && st.body)
|
|
297
300
|
found = { fn: st, name: st.name };
|
|
298
301
|
else if (ts.isVariableStatement(st)) {
|
|
299
302
|
for (const d of st.declarationList.declarations) {
|
|
300
303
|
const fn = asFunction(d.initializer);
|
|
301
|
-
if (fn && ts.isIdentifier(d.name) && d.name.text ===
|
|
304
|
+
if (fn && ts.isIdentifier(d.name) && d.name.text === name)
|
|
302
305
|
found = { fn, name: d.name };
|
|
303
306
|
}
|
|
304
307
|
}
|
|
305
308
|
else if (ts.isExpressionStatement(st) && ts.isBinaryExpression(st.expression) && ts.isPropertyAccessExpression(st.expression.left)) {
|
|
306
309
|
const { left, right } = st.expression;
|
|
307
|
-
if (left.name.text === "$inject" && ts.isIdentifier(left.expression) && left.expression.text ===
|
|
310
|
+
if (left.name.text === "$inject" && ts.isIdentifier(left.expression) && left.expression.text === name && ts.isArrayLiteralExpression(right))
|
|
308
311
|
inject = right;
|
|
309
312
|
}
|
|
310
313
|
}
|
|
311
314
|
return found && (inject ? { ...found, inject } : found);
|
|
312
315
|
}
|
|
316
|
+
/** Names bound to `this` at the top level of a body (`var self = this`, `const vm = this`). */
|
|
317
|
+
function thisAliases(statements) {
|
|
318
|
+
const names = new Set();
|
|
319
|
+
for (const st of statements) {
|
|
320
|
+
if (!ts.isVariableStatement(st))
|
|
321
|
+
continue;
|
|
322
|
+
for (const d of st.declarationList.declarations) {
|
|
323
|
+
if (ts.isIdentifier(d.name) && d.initializer && unwrap(d.initializer).kind === ts.SyntaxKind.ThisKeyword)
|
|
324
|
+
names.add(d.name.text);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return names;
|
|
328
|
+
}
|
|
329
|
+
/** The object literal of a top-level `var <name> = {...}` among `statements`. */
|
|
330
|
+
function objectVariable(statements, name) {
|
|
331
|
+
let found;
|
|
332
|
+
for (const st of statements) {
|
|
333
|
+
if (!ts.isVariableStatement(st))
|
|
334
|
+
continue;
|
|
335
|
+
for (const d of st.declarationList.declarations) {
|
|
336
|
+
const init = d.initializer && unwrap(d.initializer);
|
|
337
|
+
if (ts.isIdentifier(d.name) && d.name.text === name && init && ts.isObjectLiteralExpression(init))
|
|
338
|
+
found = init;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return found;
|
|
342
|
+
}
|
|
313
343
|
function isStringName(node) {
|
|
314
344
|
return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node));
|
|
315
345
|
}
|
|
@@ -363,8 +393,21 @@ export function collectDeclarations(sf, file) {
|
|
|
363
393
|
const nested = (raw) => {
|
|
364
394
|
raws.push({ ...raw, qualifiedName: raw.qualifiedName ?? (raw.parentName ? `${raw.parentName}.${raw.name}` : raw.name), hasBody: true });
|
|
365
395
|
};
|
|
366
|
-
/**
|
|
367
|
-
|
|
396
|
+
/**
|
|
397
|
+
* `<holder>.<name>` for a nested function named by identifier (`self.x = loadItem`, `{ x: loadItem }`): the
|
|
398
|
+
* function's own span, signature and name, so its line and the aliasing line both resolve to the member.
|
|
399
|
+
*/
|
|
400
|
+
const aliasedMember = (scope, ref, name, holder, exported, alias) => {
|
|
401
|
+
const target = functionInStatements(scope, ref.text);
|
|
402
|
+
if (!target)
|
|
403
|
+
return;
|
|
404
|
+
const decl = ts.isFunctionDeclaration(target.fn) ? target.fn : target.name.parent;
|
|
405
|
+
const list = decl.parent;
|
|
406
|
+
const node = ts.isVariableDeclaration(decl) && ts.isVariableDeclarationList(list) && ts.isVariableStatement(list.parent) && list.declarations[0] === decl ? list.parent : decl;
|
|
407
|
+
nested({ start: node.getStart(sf), end: node.end, kind: "method", name, parentName: holder, exported, signature: fullSignatureOf(decl, sf), namePos: target.name.getStart(sf), aliasPos: alias.getStart(sf) });
|
|
408
|
+
};
|
|
409
|
+
/** Method and function-valued properties of an object literal as `<holder>.<name>`; with `scope`, also identifiers naming its functions. */
|
|
410
|
+
const objectMembers = (obj, holder, exported, scope) => {
|
|
368
411
|
for (const prop of obj.properties) {
|
|
369
412
|
const name = nameText(prop.name, sf);
|
|
370
413
|
if (!name || !prop.name)
|
|
@@ -374,35 +417,60 @@ export function collectDeclarations(sf, file) {
|
|
|
374
417
|
}
|
|
375
418
|
else if (ts.isPropertyAssignment(prop)) {
|
|
376
419
|
const fn = asFunction(prop.initializer);
|
|
420
|
+
const value = unwrap(prop.initializer);
|
|
377
421
|
if (fn)
|
|
378
422
|
nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported, signature: headUntil(prop, fn, sf), namePos: prop.name.getStart(sf) });
|
|
423
|
+
else if (scope && ts.isIdentifier(value))
|
|
424
|
+
aliasedMember(scope, value, name, holder, exported, prop.name);
|
|
425
|
+
}
|
|
426
|
+
else if (scope && ts.isShorthandPropertyAssignment(prop)) {
|
|
427
|
+
aliasedMember(scope, prop.name, name, holder, exported, prop.name);
|
|
379
428
|
}
|
|
380
429
|
}
|
|
381
430
|
};
|
|
382
|
-
/**
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
return;
|
|
386
|
-
for (const statement of body.statements) {
|
|
431
|
+
/** `<holder>.x = function | <nested function>` at the top level of a body; `isHolder` matches `this`, its aliases or a returned object. */
|
|
432
|
+
const memberAssignments = (statements, isHolder, owner, exported) => {
|
|
433
|
+
for (const statement of statements) {
|
|
387
434
|
if (!ts.isExpressionStatement(statement) || !ts.isBinaryExpression(statement.expression))
|
|
388
435
|
continue;
|
|
389
436
|
const { left, operatorToken, right } = statement.expression;
|
|
390
|
-
if (operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isPropertyAccessExpression(left) || left.expression
|
|
437
|
+
if (operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isPropertyAccessExpression(left) || !isHolder(left.expression))
|
|
391
438
|
continue;
|
|
392
439
|
const value = unwrap(right);
|
|
393
440
|
if (ts.isObjectLiteralExpression(value))
|
|
394
441
|
objectMembers(value, `${owner}.${left.name.text}`, exported);
|
|
442
|
+
if (ts.isIdentifier(value))
|
|
443
|
+
aliasedMember(statements, value, left.name.text, owner, exported, left.name);
|
|
395
444
|
const fn = asFunction(right);
|
|
396
445
|
if (!fn)
|
|
397
446
|
continue;
|
|
398
447
|
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) });
|
|
399
448
|
}
|
|
400
449
|
};
|
|
401
|
-
/**
|
|
450
|
+
/** `this.x = ...` (also through `var self = this`) at the top level of a constructor-like body. */
|
|
451
|
+
const thisMembers = (body, owner, exported) => {
|
|
452
|
+
if (!body || !ts.isBlock(body))
|
|
453
|
+
return;
|
|
454
|
+
const aliases = thisAliases(body.statements);
|
|
455
|
+
memberAssignments(body.statements, (e) => e.kind === ts.SyntaxKind.ThisKeyword || (ts.isIdentifier(e) && aliases.has(e.text)), owner, exported);
|
|
456
|
+
};
|
|
457
|
+
/** Object literals a function returns at its top level (a factory's API, a deps object), directly or as `var service = {...}; return service`. */
|
|
402
458
|
const returnedMembers = (body, owner, exported) => {
|
|
403
|
-
if (body)
|
|
404
|
-
|
|
405
|
-
|
|
459
|
+
if (!body)
|
|
460
|
+
return;
|
|
461
|
+
const scope = ts.isBlock(body) ? body.statements : undefined;
|
|
462
|
+
for (const obj of returnedObjects(body))
|
|
463
|
+
objectMembers(obj, owner, exported, scope);
|
|
464
|
+
for (const st of scope ?? []) {
|
|
465
|
+
const ret = ts.isReturnStatement(st) && st.expression ? unwrap(st.expression) : undefined;
|
|
466
|
+
if (!ret || !ts.isIdentifier(ret))
|
|
467
|
+
continue;
|
|
468
|
+
const obj = objectVariable(scope, ret.text);
|
|
469
|
+
if (!obj)
|
|
470
|
+
continue;
|
|
471
|
+
objectMembers(obj, owner, exported, scope);
|
|
472
|
+
memberAssignments(scope, (e) => ts.isIdentifier(e) && e.text === ret.text, owner, exported);
|
|
473
|
+
}
|
|
406
474
|
};
|
|
407
475
|
const topLevelExported = (name) => raws.find((r) => r.qualifiedName === name && r.parentName === undefined)?.exported ?? false;
|
|
408
476
|
const isConstructorName = (name) => /^[A-Z]/.test(name);
|
|
@@ -851,7 +919,18 @@ export function collectDeclarations(sf, file) {
|
|
|
851
919
|
const kept = registeredBodies.length === 0
|
|
852
920
|
? raws
|
|
853
921
|
: raws.filter((r) => r.registered || !registeredBodies.some(([s, e]) => r.namePos >= s && r.namePos < e));
|
|
854
|
-
|
|
922
|
+
// One function named twice for the same holder (`this.x = x` and `return { x }`) is one member.
|
|
923
|
+
const seen = new Set();
|
|
924
|
+
const unique = kept.filter((r) => {
|
|
925
|
+
if (r.aliasPos === undefined)
|
|
926
|
+
return true;
|
|
927
|
+
const key = `${r.qualifiedName}@${r.namePos}`;
|
|
928
|
+
if (seen.has(key))
|
|
929
|
+
return false;
|
|
930
|
+
seen.add(key);
|
|
931
|
+
return true;
|
|
932
|
+
});
|
|
933
|
+
const ordered = unique.map((r, i) => ({ r, i })).sort((a, b) => a.r.start - b.r.start || a.i - b.i);
|
|
855
934
|
return ordered.map(({ r }) => {
|
|
856
935
|
const startLc = sf.getLineAndCharacterOfPosition(r.start);
|
|
857
936
|
const full = r.signature;
|
|
@@ -872,6 +951,7 @@ export function collectDeclarations(sf, file) {
|
|
|
872
951
|
fullSignature: full,
|
|
873
952
|
...(r.nameless ? { nameless: true } : {}),
|
|
874
953
|
...(r.injected ? { injected: true } : {}),
|
|
954
|
+
...(r.aliasPos !== undefined ? { aliasPos: r.aliasPos, aliasLine: sf.getLineAndCharacterOfPosition(r.aliasPos).line + 1 } : {}),
|
|
875
955
|
};
|
|
876
956
|
});
|
|
877
957
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-axi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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": [
|