ts-axi 0.1.15 → 0.1.17
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 +14 -1
- package/dist/src/commands/def.js +19 -5
- package/dist/src/commands/impact.js +10 -5
- package/dist/src/commands/miss.js +225 -0
- package/dist/src/commands/outline.js +11 -5
- package/dist/src/commands/refs.js +76 -11
- package/dist/src/project.js +2 -2
- package/dist/src/scope.js +15 -2
- package/dist/src/symbols.js +28 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,19 @@ The fixture files are 5 to 40 lines long, so a ±40-line read is the whole file:
|
|
|
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
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.
|
|
96
|
+
|
|
84
97
|
## Install
|
|
85
98
|
|
|
86
99
|
Requires Node.js 20+.
|
|
@@ -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` |
|
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;
|
|
@@ -281,11 +282,15 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
|
|
|
281
282
|
}
|
|
282
283
|
return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
283
284
|
}
|
|
284
|
-
/**
|
|
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
|
+
*/
|
|
285
289
|
export function textualScan(opened, name) {
|
|
286
290
|
const rows = [];
|
|
287
|
-
|
|
288
|
-
|
|
291
|
+
const files = [...opened.index.files, ...outsideFiles(opened)];
|
|
292
|
+
sourceFs().prefetch(files);
|
|
293
|
+
for (const abs of files) {
|
|
289
294
|
const text = opened.index.text(abs);
|
|
290
295
|
if (text === undefined || !text.includes(name))
|
|
291
296
|
continue;
|
|
@@ -299,8 +304,8 @@ export function textualScan(opened, name) {
|
|
|
299
304
|
}
|
|
300
305
|
return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
301
306
|
}
|
|
302
|
-
export function textualScanHint(label, matches) {
|
|
303
|
-
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)` : ""}`;
|
|
304
309
|
}
|
|
305
310
|
/** Whether a whole-word count of the name says anything: not for router-called or by-name-injected members (`injected`). */
|
|
306
311
|
export function isTextualCandidate(decl) {
|
|
@@ -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) {
|
|
@@ -8,6 +8,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]));
|
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/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
|
@@ -390,6 +390,25 @@ export function collectDeclarations(sf, file) {
|
|
|
390
390
|
!(ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node)),
|
|
391
391
|
});
|
|
392
392
|
};
|
|
393
|
+
const classMembers = (cls, exported, qualified) => {
|
|
394
|
+
for (const member of cls.members) {
|
|
395
|
+
if (ts.isSemicolonClassElement(member) || ts.isClassStaticBlockDeclaration(member) || ts.isIndexSignatureDeclaration(member))
|
|
396
|
+
continue;
|
|
397
|
+
const memberName = ts.isConstructorDeclaration(member) ? "constructor" : nameText(member.name, sf);
|
|
398
|
+
if (!memberName)
|
|
399
|
+
continue;
|
|
400
|
+
const nameNode = ts.isConstructorDeclaration(member)
|
|
401
|
+
? member.getChildren(sf).find((c) => c.kind === ts.SyntaxKind.ConstructorKeyword)
|
|
402
|
+
: member.name;
|
|
403
|
+
add(member, nameNode, memberName, exported && !isPrivateMember(member), qualified);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
/** `module.exports = class X {...}`, `exports.X = class {...}`, `export default class {...}` expression. */
|
|
407
|
+
const exportedClass = (cls, fallback) => {
|
|
408
|
+
const name = cls.name?.text ?? fallback;
|
|
409
|
+
add(cls, cls.name, name, true);
|
|
410
|
+
classMembers(cls, true, name);
|
|
411
|
+
};
|
|
393
412
|
const nested = (raw) => {
|
|
394
413
|
raws.push({ ...raw, qualifiedName: raw.qualifiedName ?? (raw.parentName ? `${raw.parentName}.${raw.name}` : raw.name), hasBody: true });
|
|
395
414
|
};
|
|
@@ -737,7 +756,9 @@ export function collectDeclarations(sf, file) {
|
|
|
737
756
|
return;
|
|
738
757
|
const target = compact(left, sf);
|
|
739
758
|
if (target === "module.exports") {
|
|
740
|
-
if (ts.
|
|
759
|
+
if (ts.isClassExpression(right))
|
|
760
|
+
exportedClass(right, "exports");
|
|
761
|
+
else if (ts.isObjectLiteralExpression(right))
|
|
741
762
|
objectMembers(right, "exports", true);
|
|
742
763
|
else if (asFunction(right))
|
|
743
764
|
returnedMembers(asFunction(right).body, "exports", true);
|
|
@@ -746,6 +767,8 @@ export function collectDeclarations(sf, file) {
|
|
|
746
767
|
return;
|
|
747
768
|
}
|
|
748
769
|
if (target === `exports.${left.name.text}` || target === `module.exports.${left.name.text}`) {
|
|
770
|
+
if (ts.isClassExpression(right))
|
|
771
|
+
return exportedClass(right, left.name.text);
|
|
749
772
|
if (ts.isObjectLiteralExpression(right))
|
|
750
773
|
objectMembers(right, left.name.text, true);
|
|
751
774
|
else
|
|
@@ -829,7 +852,9 @@ export function collectDeclarations(sf, file) {
|
|
|
829
852
|
}
|
|
830
853
|
else if (ts.isExportAssignment(statement)) {
|
|
831
854
|
const value = unwrap(statement.expression);
|
|
832
|
-
if (ts.
|
|
855
|
+
if (ts.isClassExpression(value))
|
|
856
|
+
exportedClass(value, "default");
|
|
857
|
+
else if (ts.isObjectLiteralExpression(value))
|
|
833
858
|
objectMembers(value, "default", true);
|
|
834
859
|
else
|
|
835
860
|
stateArray(statement.expression);
|
|
@@ -876,17 +901,7 @@ export function collectDeclarations(sf, file) {
|
|
|
876
901
|
add(statement, statement.name, name, exported, parent);
|
|
877
902
|
const qualified = parent ? `${parent}.${name}` : name;
|
|
878
903
|
if (ts.isClassDeclaration(statement)) {
|
|
879
|
-
|
|
880
|
-
if (ts.isSemicolonClassElement(member) || ts.isClassStaticBlockDeclaration(member) || ts.isIndexSignatureDeclaration(member))
|
|
881
|
-
continue;
|
|
882
|
-
const memberName = ts.isConstructorDeclaration(member) ? "constructor" : nameText(member.name, sf);
|
|
883
|
-
if (!memberName)
|
|
884
|
-
continue;
|
|
885
|
-
const nameNode = ts.isConstructorDeclaration(member)
|
|
886
|
-
? member.getChildren(sf).find((c) => c.kind === ts.SyntaxKind.ConstructorKeyword)
|
|
887
|
-
: member.name;
|
|
888
|
-
add(member, nameNode, memberName, exported && !isPrivateMember(member), qualified);
|
|
889
|
-
}
|
|
904
|
+
classMembers(statement, exported, qualified);
|
|
890
905
|
}
|
|
891
906
|
else if (ts.isInterfaceDeclaration(statement)) {
|
|
892
907
|
for (const member of statement.members) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-axi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
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": [
|