ts-axi 0.1.1 → 0.1.2
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 +6 -6
- package/dist/src/commands/impact.js +79 -19
- package/dist/src/commands/refs.js +98 -22
- package/dist/src/project.js +226 -31
- package/dist/src/references.js +69 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,9 +11,9 @@ $ ts-axi impact main...HEAD
|
|
|
11
11
|
base: 923fb4d7ddc8f4e5c4df9029be388f8cb29ce095
|
|
12
12
|
head: HEAD
|
|
13
13
|
changes: 2
|
|
14
|
-
changed[2]{symbol,kind,change,exported,at,refs_total,refs_outside,files,tests}:
|
|
15
|
-
formatPrice,function,signature,true,"src/lib/format.ts:5",13,12,6,2
|
|
16
|
-
Cart.total,method,body,true,"src/lib/cart.ts:32",8,8,5,3
|
|
14
|
+
changed[2]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}:
|
|
15
|
+
formatPrice,function,signature,true,"src/lib/format.ts:5",13,12,0,6,2
|
|
16
|
+
Cart.total,method,body,true,"src/lib/cart.ts:32",8,8,0,5,3
|
|
17
17
|
refs[20]{symbol,file,line,context}:
|
|
18
18
|
formatPrice,src/components/Counter.tsx,2,"import { formatPrice } from '../lib';"
|
|
19
19
|
formatPrice,src/components/Counter.tsx,12,"{cups} cups = {formatPrice(cups * CUP_CENTS)}"
|
|
@@ -65,10 +65,10 @@ npx skills add cmosgh/ts-axi --skill ts-axi
|
|
|
65
65
|
| --- | --- |
|
|
66
66
|
| `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 |
|
|
67
67
|
| `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` |
|
|
68
|
-
| `refs <target>` | References of one symbol, grouped by file, with test counts. `--limit`, `--kind calls` |
|
|
69
|
-
| `callers <target>` | Incoming calls of a function-like symbol. `--depth 1..3` |
|
|
68
|
+
| `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` |
|
|
69
|
+
| `callers <target>` | Incoming calls of a function-like symbol, split the same way (`via_interface`, `--via`). `--depth 1..3` |
|
|
70
70
|
| `outline <file>` | Top-level declarations in source order, class members nested. `--exports` |
|
|
71
|
-
| `impact [<git-range> \| -]` | Declarations changed by the working tree (untracked files included; `--staged`), a git range (`main...HEAD`) or a unified diff on stdin (`bb-axi pr diff 42 --raw \| ts-axi impact -`), riskiest first: change class (`signature`, `removed`, `type`, `added`, `body`, `modified`), References outside the change set, test-file counts, `blind_spots`, `local_only`, `file_level` and `skipped` (non-TS files in the diff). `--limit`, `--depth 2..3`, `--grep`. Analyses the current checkout |
|
|
71
|
+
| `impact [<git-range> \| -]` | Declarations changed by the working tree (untracked files included; `--staged`), a git range (`main...HEAD`) or a unified diff on stdin (`bb-axi pr diff 42 --raw \| ts-axi impact -`), riskiest first: change class (`signature`, `removed`, `type`, `added`, `body`, `modified`), References outside the change set (direct only), a `via` count of calls through an interface or base member (`--via` lists them), test-file counts, `notes` (a signature change of a member that implements an interface member), `blind_spots`, `local_only`, `file_level` and `skipped` (non-TS files in the diff). `--limit`, `--depth 2..3`, `--grep`. Analyses the current checkout |
|
|
72
72
|
| `config` | Show effective settings with their source; `config set <key> <value>` and `config unset <key>` write `.ts-axi.json` |
|
|
73
73
|
|
|
74
74
|
`ts-axi <command> --help` prints a concise, complete flag reference. `-P <tsconfig>` selects a project on any command that may analyse types; `--json` prints the decoded payload.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
3
3
|
import ts from "typescript";
|
|
4
4
|
import { bool, intFlag, parseArgs, flag } from "../args.js";
|
|
5
5
|
import { changedRanges, parseUnifiedDiff } from "../diff.js";
|
|
6
6
|
import { AxiError, usageError } from "../errors.js";
|
|
7
7
|
import { diffNameStatus, diffUnified, isGitRepo, resolveBaseRev, showFile, untrackedFiles } from "../git.js";
|
|
8
8
|
import { allocateBudget, classifyFile, compareChanges, formatRanges, inRanges, localOnlyLine, matchKey, reverseApply, wordMatches, } from "../impact.js";
|
|
9
|
-
import { createSemanticProject, SOURCE_EXTENSIONS } from "../project.js";
|
|
9
|
+
import { createSemanticProject, loadProject, SOURCE_EXTENSIONS } from "../project.js";
|
|
10
10
|
import { block, helpBlock, out } from "../render.js";
|
|
11
11
|
import { isTestFile } from "../settings.js";
|
|
12
12
|
import { isFunctionLike } from "../symbols.js";
|
|
@@ -22,12 +22,14 @@ input:
|
|
|
22
22
|
(none) = working tree vs HEAD (staged + unstaged + untracked); --staged = index vs HEAD
|
|
23
23
|
<git-range> = any git diff range (main...HEAD, abc123, v1..v2); - = unified diff on stdin (needs the after side checked out)
|
|
24
24
|
analysis is of the current checkout - for a range whose head is not the working tree, check that head out first
|
|
25
|
-
flags[
|
|
26
|
-
--staged, --depth <1-3> (callers_depthN[n]{file,count} for N > 1), --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} ref rows across the report), --grep (adds unresolved[n]{file,line,context} - textual matches that are not References; also searches .astro/.vue/.svelte/.mdx/.html), --test-glob <glob> (repeatable), -P <tsconfig>, --json
|
|
25
|
+
flags[7]:
|
|
26
|
+
--staged, --via (adds References through an interface or base member to refs[n] with a via column), --depth <1-3> (callers_depthN[n]{file,count} for N > 1), --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} ref rows across the report), --grep (adds unresolved[n]{file,line,context} - textual matches that are not References; also searches .astro/.vue/.svelte/.mdx/.html), --test-glob <glob> (repeatable), -P <tsconfig>, --json
|
|
27
27
|
output:
|
|
28
|
-
base, head, changes (count), changed[n]{symbol,kind,change,exported,at,refs_total,refs_outside,files,tests}
|
|
28
|
+
base, head, changes (count), changed[n]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
|
|
29
29
|
change = signature | removed | type | added | body | modified (before unavailable) - riskiest first
|
|
30
|
+
refs_* / files / tests count direct References only; via = References outside the change set that reach the Symbol through an interface or base member it implements (or name an override of it) - they MAY call it
|
|
30
31
|
refs[n]{symbol,file,line,context} = References outside the change set; truncated + refs_shown + refs_outside_total when cut
|
|
32
|
+
notes[n]{symbol,note} = a signature change of a member that implements an interface or base member (the contract may break)
|
|
31
33
|
local_only = Changes with no Reference outside the change set; file_level[n]{file,lines} = changed lines outside any Declaration
|
|
32
34
|
blind_spots[n]{symbol,file,line,reason} = uses the checker cannot follow (any; string keys; unresolved export *)
|
|
33
35
|
skipped[n]{file,reason} = files in the diff that were not analysed (.astro, .json, outside the Project)
|
|
@@ -41,6 +43,7 @@ examples[5]:
|
|
|
41
43
|
`;
|
|
42
44
|
const SPECS = [
|
|
43
45
|
{ name: "--staged", boolean: true },
|
|
46
|
+
{ name: "--via", boolean: true },
|
|
44
47
|
{ name: "--depth" },
|
|
45
48
|
{ name: "--limit" },
|
|
46
49
|
{ name: "--grep", boolean: true },
|
|
@@ -316,6 +319,36 @@ function rerun(arg, parsed, extra) {
|
|
|
316
319
|
parts.push(extra);
|
|
317
320
|
return `${arg === "-" ? "<diff> | " : ""}${parts.join(" ")}`;
|
|
318
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Spec files skipped as "not in the Project" (a tsconfig.lib.json / tsconfig.app.json that excludes
|
|
324
|
+
* them): name the sibling tsconfig that includes them so tests get counted.
|
|
325
|
+
*/
|
|
326
|
+
function specFilesHint(cwd, opened, skipped, noTests) {
|
|
327
|
+
const tsconfig = opened.location.path;
|
|
328
|
+
if (!tsconfig)
|
|
329
|
+
return undefined;
|
|
330
|
+
const outside = skipped.filter((s) => s.reason === "not in the Project");
|
|
331
|
+
const tests = outside.filter((s) => isTestFile(s.file, opened.settings));
|
|
332
|
+
if (tests.length === 0 || !(tests.length === outside.length || noTests))
|
|
333
|
+
return undefined;
|
|
334
|
+
const dir = dirname(tsconfig);
|
|
335
|
+
const name = basename(tsconfig);
|
|
336
|
+
const order = name === "tsconfig.lib.json" || name === "tsconfig.app.json" ? ["tsconfig.json", "tsconfig.spec.json"] : ["tsconfig.spec.json", "tsconfig.json"];
|
|
337
|
+
const candidates = order.map((n) => join(dir, n)).filter((c) => c !== tsconfig && existsSync(c));
|
|
338
|
+
// Prefer the one that really includes a skipped spec file (an Nx tsconfig.json may only hold references).
|
|
339
|
+
const includes = (c) => {
|
|
340
|
+
try {
|
|
341
|
+
const files = new Set(loadProject(opened.root, { path: c, implicit: false }).fileNames);
|
|
342
|
+
return tests.some((t) => files.has(opened.index.abs(t.file)));
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
const pick = candidates.find(includes) ?? candidates[0];
|
|
349
|
+
const rel = (abs) => relative(cwd, abs).split(sep).join("/");
|
|
350
|
+
return `Spec files are outside ${rel(tsconfig)}; run with -P ${pick ? rel(pick) : "<sibling tsconfig.json that includes them>"} to count tests`;
|
|
351
|
+
}
|
|
319
352
|
export async function impactCommand(args, ctx) {
|
|
320
353
|
const parsed = parseArgs(args, SPECS, "ts-axi impact");
|
|
321
354
|
if (parsed.positionals.length > 1) {
|
|
@@ -328,6 +361,7 @@ export async function impactCommand(args, ctx) {
|
|
|
328
361
|
const limit = intFlag(parsed, "--limit", DEFAULT_LIMIT, { min: 1, max: MAX_LIMIT });
|
|
329
362
|
const depth = intFlag(parsed, "--depth", 1, { min: 1, max: 3 });
|
|
330
363
|
const grep = bool(parsed, "--grep");
|
|
364
|
+
const showVia = bool(parsed, "--via");
|
|
331
365
|
const started = performance.now();
|
|
332
366
|
const opened = open(ctx.cwd, parsed);
|
|
333
367
|
const input = arg === "-"
|
|
@@ -387,6 +421,9 @@ export async function impactCommand(args, ctx) {
|
|
|
387
421
|
: ["Changes touch imports or module-level statements only - read those lines directly"];
|
|
388
422
|
if (skipped.length > 0)
|
|
389
423
|
help.push("Skipped files are not TS or JS source of the Project - review them by hand");
|
|
424
|
+
const specHint = specFilesHint(ctx.cwd, opened, skipped, true);
|
|
425
|
+
if (specHint)
|
|
426
|
+
help.push(specHint);
|
|
390
427
|
return out(block({
|
|
391
428
|
...header,
|
|
392
429
|
...(fileLevel.length > 0 ? { file_level: fileLevel } : {}),
|
|
@@ -408,6 +445,8 @@ export async function impactCommand(args, ctx) {
|
|
|
408
445
|
at: `${change.file}:${(live ?? source).nameLine}`,
|
|
409
446
|
all: [],
|
|
410
447
|
outside: [],
|
|
448
|
+
via: [],
|
|
449
|
+
bases: [],
|
|
411
450
|
};
|
|
412
451
|
});
|
|
413
452
|
for (const row of rows) {
|
|
@@ -415,33 +454,43 @@ export async function impactCommand(args, ctx) {
|
|
|
415
454
|
changeSet.set(row.change.file, [...(changeSet.get(row.change.file) ?? []), [row.live.line, row.live.endLine]]);
|
|
416
455
|
}
|
|
417
456
|
const known = new Set();
|
|
457
|
+
const byPosition = (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col;
|
|
418
458
|
for (const row of rows) {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
459
|
+
let entries = [];
|
|
460
|
+
if (row.live) {
|
|
461
|
+
const split = semantic.referenceSplit(opened.index.abs(row.live.file), row.live.namePos);
|
|
462
|
+
entries = [...split.declarations, ...split.direct];
|
|
463
|
+
// Sibling implementations and via uses are known positions, not textual misses for --grep.
|
|
464
|
+
for (const e of [...split.via, ...split.implementations, ...split.dropped])
|
|
465
|
+
known.add(`${e.file}:${e.line}`);
|
|
466
|
+
row.via = split.via.filter((e) => !inRanges(changeSet.get(e.file), e.line)).sort(byPosition);
|
|
467
|
+
row.bases = split.bases;
|
|
468
|
+
}
|
|
469
|
+
else if (row.change.change === "removed") {
|
|
470
|
+
entries = brokenReferences(semantic, row.change.before);
|
|
471
|
+
}
|
|
424
472
|
for (const e of entries)
|
|
425
473
|
known.add(`${e.file}:${e.line}`);
|
|
426
474
|
row.all = entries.filter((e) => !e.isDefinition);
|
|
427
|
-
row.outside = row.all
|
|
428
|
-
.filter((e) => !inRanges(changeSet.get(e.file), e.line))
|
|
429
|
-
.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col);
|
|
475
|
+
row.outside = row.all.filter((e) => !inRanges(changeSet.get(e.file), e.line)).sort(byPosition);
|
|
430
476
|
}
|
|
477
|
+
const shownRefs = (r) => (showVia ? [...r.outside, ...r.via].sort(byPosition) : r.outside);
|
|
478
|
+
const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0;
|
|
431
479
|
const listed = rows
|
|
432
|
-
.filter(
|
|
480
|
+
.filter(hasOutside)
|
|
433
481
|
.sort((a, b) => compareChanges({ change: a.change.change, exported: a.exported, refsOutside: a.outside.length, file: a.change.file, line: (a.live ?? a.change.before).line }, { change: b.change.change, exported: b.exported, refsOutside: b.outside.length, file: b.change.file, line: (b.live ?? b.change.before).line }));
|
|
434
482
|
const localOnly = rows
|
|
435
|
-
.filter((r) => r
|
|
483
|
+
.filter((r) => !hasOutside(r))
|
|
436
484
|
.sort((a, b) => compareChanges({ change: a.change.change, exported: a.exported, refsOutside: 0, file: a.change.file, line: (a.live ?? a.change.before).line }, { change: b.change.change, exported: b.exported, refsOutside: 0, file: b.change.file, line: (b.live ?? b.change.before).line }));
|
|
437
|
-
const quotas = allocateBudget(listed.map((r) => r.
|
|
438
|
-
const refRows = listed.flatMap((r, i) => r.
|
|
485
|
+
const quotas = allocateBudget(listed.map((r) => shownRefs(r).length), limit);
|
|
486
|
+
const refRows = listed.flatMap((r, i) => shownRefs(r).slice(0, quotas[i]).map((e) => ({
|
|
439
487
|
symbol: r.symbol,
|
|
440
488
|
file: e.file,
|
|
441
489
|
line: e.line,
|
|
490
|
+
...(showVia ? { via: e.via ?? "-" } : {}),
|
|
442
491
|
context: contextOf(lineOf(opened.index.text(opened.index.abs(e.file)), e.line), e.col),
|
|
443
492
|
})));
|
|
444
|
-
const outsideTotal = listed.reduce((n, r) => n + r.
|
|
493
|
+
const outsideTotal = listed.reduce((n, r) => n + shownRefs(r).length, 0);
|
|
445
494
|
const truncated = refRows.length < outsideTotal;
|
|
446
495
|
const fileCounts = (r) => {
|
|
447
496
|
const files = [...new Set(r.outside.map((e) => e.file))];
|
|
@@ -479,8 +528,17 @@ export async function impactCommand(args, ctx) {
|
|
|
479
528
|
if (depth === 1 && rows.some((r) => r.live && isFunctionLike(r.kind) && r.outside.length > 0)) {
|
|
480
529
|
help.push(`Run \`${rerun(arg, parsed, "--depth 2")}\` for who calls the callers`);
|
|
481
530
|
}
|
|
531
|
+
if (!showVia && listed.some((r) => r.via.length > 0)) {
|
|
532
|
+
help.push(`Run \`${rerun(arg, parsed, "--via")}\` for References through an interface or base member - they may reach the changed code`);
|
|
533
|
+
}
|
|
482
534
|
if (skipped.length > 0)
|
|
483
535
|
help.push("Skipped files are not TS or JS source of the Project - review them by hand");
|
|
536
|
+
const specHint = specFilesHint(ctx.cwd, opened, skipped, listed.every((r) => fileCounts(r).tests === 0));
|
|
537
|
+
if (specHint)
|
|
538
|
+
help.push(specHint);
|
|
539
|
+
const notes = rows
|
|
540
|
+
.filter((r) => r.change.change === "signature" && r.bases.length > 0)
|
|
541
|
+
.map((r) => ({ symbol: r.symbol, note: `implements ${r.bases.join(" and ")}` }));
|
|
484
542
|
return out(block({
|
|
485
543
|
...header,
|
|
486
544
|
...(listed.length > 0
|
|
@@ -493,12 +551,14 @@ export async function impactCommand(args, ctx) {
|
|
|
493
551
|
at: r.at,
|
|
494
552
|
refs_total: r.all.length,
|
|
495
553
|
refs_outside: r.outside.length,
|
|
554
|
+
via: r.via.length,
|
|
496
555
|
...fileCounts(r),
|
|
497
556
|
})),
|
|
498
557
|
refs: refRows,
|
|
499
558
|
}
|
|
500
559
|
: {}),
|
|
501
560
|
...(truncated ? { truncated: true, refs_shown: refRows.length, refs_outside_total: outsideTotal } : {}),
|
|
561
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
502
562
|
...(localOnly.length > 0 ? { local_only: localOnlyLine(localOnly.map((r) => `${r.symbol} ${r.change.change}`)) } : {}),
|
|
503
563
|
...(fileLevel.length > 0 ? { file_level: fileLevel } : {}),
|
|
504
564
|
...capped("blind_spots", spots),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { relative, sep } from "node:path";
|
|
2
|
+
import { bool, expectPositionals, flag, intFlag, parseArgs } from "../args.js";
|
|
3
|
+
import { createSemanticProject, findOutsideProject, nearestTsconfig } from "../project.js";
|
|
4
|
+
import { viaLine } from "../references.js";
|
|
3
5
|
import { block, helpBlock, out } from "../render.js";
|
|
4
6
|
import { isTestFile } from "../settings.js";
|
|
5
7
|
import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
|
|
@@ -7,13 +9,19 @@ import { contextOf, declarationAt, filterSuffix, lineOf, matchRows, narrowHints,
|
|
|
7
9
|
const DEFAULT_LIMIT = 50;
|
|
8
10
|
const MAX_LIMIT = 500;
|
|
9
11
|
const DEPTH_ROWS = 50;
|
|
10
|
-
const SHARED_FLAGS = `--kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace> (narrows a name), --in <dir|glob> (repeatable; Declarations in these files), --exported, --fuzzy, --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} rows), --test-glob <glob> (repeatable; extra Test file pattern), -P <tsconfig>, --json`;
|
|
12
|
+
const SHARED_FLAGS = `--kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace> (narrows a name), --in <dir|glob> (repeatable; Declarations in these files), --exported, --fuzzy, --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} rows), --test-glob <glob> (repeatable; extra Test file pattern), --via (adds via rows with a via column), -P <tsconfig>, --json`;
|
|
13
|
+
const VIA_DOC = `via_interface: n (Iface.member) = References through an interface or base member the Symbol implements or overrides - they MAY reach it; not in refs_total
|
|
14
|
+
via_implementations: n (Impl.member) = References to implementations or overrides of the Symbol; not in refs_total
|
|
15
|
+
implementations: n = members that implement or override the Symbol (their Declarations are never refs)
|
|
16
|
+
matches: 0 under -P lists found_outside_project[n]{file,line} when the name is declared outside that Project`;
|
|
11
17
|
export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
|
|
12
|
-
flags[
|
|
18
|
+
flags[11]:
|
|
13
19
|
${SHARED_FLAGS}
|
|
20
|
+
--implementations (lists implementations[n]{file,line,symbol} instead of the count)
|
|
14
21
|
--kind calls = same as \`ts-axi callers\`
|
|
15
22
|
output:
|
|
16
23
|
symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,context} (Declarations excluded), elapsed_ms
|
|
24
|
+
${VIA_DOC}
|
|
17
25
|
an ambiguous name lists matches[n] + the disambiguated forms; nothing is analysed
|
|
18
26
|
examples[4]:
|
|
19
27
|
ts-axi refs User.save
|
|
@@ -22,12 +30,13 @@ examples[4]:
|
|
|
22
30
|
ts-axi refs runSync --limit 200
|
|
23
31
|
`;
|
|
24
32
|
export const CALLERS_HELP = `usage: ts-axi callers <name | Class.member | path:line[:col]> [flags]
|
|
25
|
-
flags[
|
|
33
|
+
flags[11]:
|
|
26
34
|
${SHARED_FLAGS}
|
|
27
35
|
--depth <1-3> (default 1; levels > 1 print callers_depthN[n]{caller,file,count}, no locations)
|
|
28
36
|
output:
|
|
29
37
|
symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,caller,context}, elapsed_ms
|
|
30
38
|
caller = enclosing function or method; (top-level) for module code
|
|
39
|
+
${VIA_DOC}
|
|
31
40
|
examples[3]:
|
|
32
41
|
ts-axi callers runSync
|
|
33
42
|
ts-axi callers User.save --depth 2
|
|
@@ -45,7 +54,8 @@ async function run(args, ctx, invoked) {
|
|
|
45
54
|
...TARGET_FLAGS,
|
|
46
55
|
{ name: "--limit" },
|
|
47
56
|
{ name: "--test-glob", repeatable: true },
|
|
48
|
-
|
|
57
|
+
{ name: "--via", boolean: true },
|
|
58
|
+
...(invoked === "calls" ? [{ name: "--depth" }] : [{ name: "--implementations", boolean: true }]),
|
|
49
59
|
];
|
|
50
60
|
const parsed = parseArgs(args, specs, command);
|
|
51
61
|
const [raw] = expectPositionals(parsed, 1, `${command} <name | Class.member | path:line[:col]> [flags]`);
|
|
@@ -57,6 +67,7 @@ async function run(args, ctx, invoked) {
|
|
|
57
67
|
const label = targetLabel(target);
|
|
58
68
|
const project = flag(parsed, "-P");
|
|
59
69
|
const name = mode === "calls" ? "callers" : "refs";
|
|
70
|
+
const show = { via: bool(parsed, "--via"), implementations: invoked === "refs" && bool(parsed, "--implementations") };
|
|
60
71
|
const started = performance.now();
|
|
61
72
|
const opened = open(ctx.cwd, parsed, positionStartDir(ctx.cwd, target));
|
|
62
73
|
const warning = opened.warning ? { warning: opened.warning } : {};
|
|
@@ -65,6 +76,23 @@ async function run(args, ctx, invoked) {
|
|
|
65
76
|
if (target.kind === "name") {
|
|
66
77
|
const groups = resolveNameTarget(opened, target, f);
|
|
67
78
|
if (groups.length === 0) {
|
|
79
|
+
// Under an explicit Project the name may live in a sibling one: say so before suggesting --fuzzy.
|
|
80
|
+
const outside = project || opened.settings.project
|
|
81
|
+
? findOutsideProject(opened.index, { name: target.name, ...(target.member !== undefined ? { member: target.member } : {}), fuzzy: f.fuzzy })
|
|
82
|
+
: [];
|
|
83
|
+
if (outside.length > 0) {
|
|
84
|
+
const tsconfig = nearestTsconfig(opened.index.abs(outside[0].file), opened.root);
|
|
85
|
+
const rel = tsconfig ? relative(ctx.cwd, tsconfig).split(sep).join("/") : undefined;
|
|
86
|
+
return out(block({
|
|
87
|
+
...warning,
|
|
88
|
+
matches: 0,
|
|
89
|
+
found_outside_project: outside.map((d) => ({ file: d.file, line: d.nameLine })),
|
|
90
|
+
hint: `symbol is declared outside the selected project - run with -P ${rel ?? "<its tsconfig>"} or without -P`,
|
|
91
|
+
}), helpBlock([
|
|
92
|
+
...(rel ? [`Run \`ts-axi ${name} ${label} -P ${rel}\``] : []),
|
|
93
|
+
`Run \`ts-axi ${name} ${outside[0].file}:${outside[0].nameLine}\` to use the nearest tsconfig of that file`,
|
|
94
|
+
]));
|
|
95
|
+
}
|
|
68
96
|
const suggested = suggestions(opened, target);
|
|
69
97
|
return out(block({ ...warning, matches: 0, ...(suggested.length > 0 ? { did_you_mean: suggested } : {}) }), helpBlock([
|
|
70
98
|
...suggested.slice(0, 2).map((s) => `Run \`ts-axi ${name} ${s}\``),
|
|
@@ -96,8 +124,8 @@ async function run(args, ctx, invoked) {
|
|
|
96
124
|
const declaredAt = decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
|
|
97
125
|
const header = { ...warning, symbol, kind, declared_at: declaredAt };
|
|
98
126
|
return mode === "calls"
|
|
99
|
-
? renderCallers(semantic, opened, resolved, header, { limit, depth, started, symbol, declaredAt })
|
|
100
|
-
: renderRefs(semantic, opened, resolved, header, { limit, started, symbol, declaredAt, kind });
|
|
127
|
+
? renderCallers(semantic, opened, resolved, header, { limit, depth, started, symbol, declaredAt, show })
|
|
128
|
+
: renderRefs(semantic, opened, resolved, header, { limit, started, symbol, declaredAt, kind, show });
|
|
101
129
|
}
|
|
102
130
|
function sortRows(rows) {
|
|
103
131
|
return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col);
|
|
@@ -113,17 +141,38 @@ function footer(semantic, started, touched) {
|
|
|
113
141
|
const diagnostics = semantic.diagnosticsCount(touched);
|
|
114
142
|
return { elapsed_ms: Math.round(performance.now() - started), ...(diagnostics > 0 ? { diagnostics } : {}) };
|
|
115
143
|
}
|
|
144
|
+
/** `via_interface` / `via_implementations` counts, plus the hint to see their rows. */
|
|
145
|
+
function viaFields(via, bases) {
|
|
146
|
+
const base = via.filter((v) => v.through === "base");
|
|
147
|
+
const impl = via.filter((v) => v.through === "implementation");
|
|
148
|
+
return {
|
|
149
|
+
...(bases.length > 0 || base.length > 0 ? { via_interface: viaLine(base.length, [...bases, ...base.map((v) => v.via)]) } : {}),
|
|
150
|
+
...(impl.length > 0 ? { via_implementations: viaLine(impl.length, impl.map((v) => v.via)) } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** Direct rows, plus via rows (with a `via` column) under `--via`. */
|
|
154
|
+
function mergedRows(direct, via, show) {
|
|
155
|
+
return show.via ? sortRows([...direct, ...via]) : direct;
|
|
156
|
+
}
|
|
116
157
|
function renderRefs(semantic, opened, resolved, header, o) {
|
|
117
|
-
const
|
|
158
|
+
const split = semantic.referenceSplit(resolved.absFile, resolved.pos);
|
|
159
|
+
const rows = sortRows([...split.direct]);
|
|
160
|
+
const via = sortRows([...split.via]);
|
|
161
|
+
const impls = sortRows([...split.implementations]);
|
|
118
162
|
const { files, tests, fileList } = summary(opened, rows);
|
|
119
|
-
const
|
|
120
|
-
const
|
|
163
|
+
const listed = mergedRows(rows, via, o.show);
|
|
164
|
+
const shown = listed.slice(0, o.limit);
|
|
165
|
+
const truncated = shown.length < listed.length;
|
|
121
166
|
const help = [];
|
|
122
167
|
if (truncated) {
|
|
123
168
|
help.push(o.limit < MAX_LIMIT
|
|
124
|
-
? `Showing ${shown.length} of ${
|
|
125
|
-
: `Showing ${shown.length} of ${
|
|
169
|
+
? `Showing ${shown.length} of ${listed.length}; run \`ts-axi refs ${o.declaredAt}${o.show.via ? " --via" : ""} --limit ${Math.min(MAX_LIMIT, listed.length)}\` for more`
|
|
170
|
+
: `Showing ${shown.length} of ${listed.length}; narrow by reading one file at a time`);
|
|
126
171
|
}
|
|
172
|
+
if (via.length > 0 && !o.show.via)
|
|
173
|
+
help.push(`Run \`ts-axi refs ${o.declaredAt} --via\` for the ${via.length} via References (${viaNames(via)})`);
|
|
174
|
+
if (impls.length > 0 && !o.show.implementations)
|
|
175
|
+
help.push(`Run \`ts-axi refs ${o.declaredAt} --implementations\` to list the ${impls.length} implementations`);
|
|
127
176
|
if (isFunctionLike(o.kind))
|
|
128
177
|
help.push(`Run \`ts-axi callers ${o.declaredAt}\` for call sites with their enclosing function`);
|
|
129
178
|
help.push(`Run \`ts-axi def ${o.declaredAt}\` for the declaration body`);
|
|
@@ -132,13 +181,27 @@ function renderRefs(semantic, opened, resolved, header, o) {
|
|
|
132
181
|
refs_total: rows.length,
|
|
133
182
|
files,
|
|
134
183
|
tests,
|
|
135
|
-
...(
|
|
184
|
+
...viaFields(via, split.bases),
|
|
185
|
+
...(impls.length > 0
|
|
186
|
+
? { implementations: o.show.implementations ? impls.map((r) => ({ file: r.file, line: r.line, symbol: r.name })) : impls.length }
|
|
187
|
+
: {}),
|
|
188
|
+
...(shown.length === 0
|
|
136
189
|
? {}
|
|
137
|
-
: {
|
|
190
|
+
: {
|
|
191
|
+
refs: shown.map((r) => ({
|
|
192
|
+
file: r.file,
|
|
193
|
+
line: r.line,
|
|
194
|
+
...(o.show.via ? { via: r.via ?? "-" } : {}),
|
|
195
|
+
context: contextCell(opened, r),
|
|
196
|
+
})),
|
|
197
|
+
}),
|
|
138
198
|
...(truncated ? { truncated: true } : {}),
|
|
139
199
|
...footer(semantic, o.started, fileList),
|
|
140
200
|
}), helpBlock(help));
|
|
141
201
|
}
|
|
202
|
+
function viaNames(via) {
|
|
203
|
+
return [...new Set(via.map((v) => v.via))].join("; ");
|
|
204
|
+
}
|
|
142
205
|
function itemKey(site) {
|
|
143
206
|
return `${site.item.file}#${site.item.selectionSpan.start}`;
|
|
144
207
|
}
|
|
@@ -172,13 +235,15 @@ function renderCallers(semantic, opened, resolved, header, o) {
|
|
|
172
235
|
if (sites === undefined) {
|
|
173
236
|
return out(block({ ...header, refs_total: 0, note: "not callable - no call hierarchy for this Symbol", ...footer(semantic, o.started, []) }), helpBlock([`Run \`ts-axi refs ${o.declaredAt}\` for every Reference`]));
|
|
174
237
|
}
|
|
175
|
-
const rows = sortRows(
|
|
238
|
+
const rows = sortRows(sites.filter((s) => s.via === undefined));
|
|
239
|
+
const via = sortRows(sites.filter((s) => s.via !== undefined));
|
|
176
240
|
const { files, tests, fileList } = summary(opened, rows);
|
|
177
|
-
const
|
|
178
|
-
const
|
|
241
|
+
const listed = mergedRows(rows, via, o.show);
|
|
242
|
+
const shown = listed.slice(0, o.limit);
|
|
243
|
+
const truncated = shown.length < listed.length;
|
|
179
244
|
const levels = {};
|
|
180
245
|
const visited = new Set();
|
|
181
|
-
let level =
|
|
246
|
+
let level = listed;
|
|
182
247
|
for (let d = 2; d <= o.depth; d++) {
|
|
183
248
|
level = nextLevel(semantic, level, visited);
|
|
184
249
|
const agg = depthRows(level);
|
|
@@ -190,8 +255,10 @@ function renderCallers(semantic, opened, resolved, header, o) {
|
|
|
190
255
|
}
|
|
191
256
|
const help = [];
|
|
192
257
|
if (truncated) {
|
|
193
|
-
help.push(`Showing ${shown.length} of ${
|
|
258
|
+
help.push(`Showing ${shown.length} of ${listed.length}; run \`ts-axi callers ${o.declaredAt}${o.show.via ? " --via" : ""} --limit ${Math.min(MAX_LIMIT, listed.length)}\` for more`);
|
|
194
259
|
}
|
|
260
|
+
if (via.length > 0 && !o.show.via)
|
|
261
|
+
help.push(`Run \`ts-axi callers ${o.declaredAt} --via\` for the ${via.length} via calls (${viaNames(via)})`);
|
|
195
262
|
const top = shown.find((r) => r.caller !== "(top-level)");
|
|
196
263
|
if (top && o.depth === 1)
|
|
197
264
|
help.push(`Run \`ts-axi callers ${o.declaredAt} --depth 2\` for who calls the callers`);
|
|
@@ -201,9 +268,18 @@ function renderCallers(semantic, opened, resolved, header, o) {
|
|
|
201
268
|
refs_total: rows.length,
|
|
202
269
|
files,
|
|
203
270
|
tests,
|
|
204
|
-
...(
|
|
271
|
+
...viaFields(via, []),
|
|
272
|
+
...(shown.length === 0
|
|
205
273
|
? {}
|
|
206
|
-
: {
|
|
274
|
+
: {
|
|
275
|
+
refs: shown.map((r) => ({
|
|
276
|
+
file: r.file,
|
|
277
|
+
line: r.line,
|
|
278
|
+
caller: r.caller,
|
|
279
|
+
...(o.show.via ? { via: r.via ?? "-" } : {}),
|
|
280
|
+
context: contextCell(opened, r),
|
|
281
|
+
})),
|
|
282
|
+
}),
|
|
207
283
|
...(truncated ? { truncated: true } : {}),
|
|
208
284
|
...levels,
|
|
209
285
|
...footer(semantic, o.started, fileList),
|
package/dist/src/project.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "n
|
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import ts from "typescript";
|
|
6
6
|
import { usageError } from "./errors.js";
|
|
7
|
+
import { classifyReferenceGroups } from "./references.js";
|
|
7
8
|
import { collectDeclarations } from "./symbols.js";
|
|
8
9
|
export const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
|
|
9
10
|
const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git"]);
|
|
@@ -130,6 +131,75 @@ export function parseSource(file, text) {
|
|
|
130
131
|
export function relPath(root, abs) {
|
|
131
132
|
return relative(root, abs).split(sep).join("/");
|
|
132
133
|
}
|
|
134
|
+
function declMatches(decl, query) {
|
|
135
|
+
const needle = query.fuzzy ? query.name.toLowerCase() : query.name;
|
|
136
|
+
if (query.member !== undefined) {
|
|
137
|
+
if (decl.parentName === undefined)
|
|
138
|
+
return false;
|
|
139
|
+
const parent = decl.parentName.split(".").pop();
|
|
140
|
+
return query.fuzzy
|
|
141
|
+
? parent.toLowerCase().includes(needle) && decl.name.toLowerCase().includes(query.member.toLowerCase())
|
|
142
|
+
: parent === query.name && decl.name === query.member;
|
|
143
|
+
}
|
|
144
|
+
return query.fuzzy ? decl.name.toLowerCase().includes(needle) : decl.name === query.name;
|
|
145
|
+
}
|
|
146
|
+
// Same bounds as the home view's walk: this runs on an empty result, it must stay cheap.
|
|
147
|
+
const OUTSIDE_WALK_CAP = 20_000;
|
|
148
|
+
const OUTSIDE_WALK_MS = 600;
|
|
149
|
+
/**
|
|
150
|
+
* Declarations matching `query` in source files below the repo root that are not in the Project
|
|
151
|
+
* (syntactic, bounded by file count and time; stops after `max` hits).
|
|
152
|
+
*/
|
|
153
|
+
export function findOutsideProject(index, query, max = 3) {
|
|
154
|
+
const inProject = new Set(index.files);
|
|
155
|
+
const deadline = Date.now() + OUTSIDE_WALK_MS;
|
|
156
|
+
const hits = [];
|
|
157
|
+
const stack = [index.root];
|
|
158
|
+
let visited = 0;
|
|
159
|
+
while (stack.length > 0 && hits.length < max && visited < OUTSIDE_WALK_CAP && Date.now() <= deadline) {
|
|
160
|
+
const dir = stack.pop();
|
|
161
|
+
let entries;
|
|
162
|
+
try {
|
|
163
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
for (const entry of entries.sort((a, b) => b.name.localeCompare(a.name))) {
|
|
169
|
+
const path = join(dir, entry.name);
|
|
170
|
+
if (entry.isDirectory()) {
|
|
171
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
172
|
+
stack.push(path);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!entry.isFile() || !isSourceFile(entry.name) || inProject.has(path))
|
|
176
|
+
continue;
|
|
177
|
+
visited++;
|
|
178
|
+
const text = index.text(path);
|
|
179
|
+
if (text === undefined || !text.includes(query.name))
|
|
180
|
+
continue;
|
|
181
|
+
for (const decl of index.declarations(path)) {
|
|
182
|
+
if (declMatches(decl, query))
|
|
183
|
+
hits.push(decl);
|
|
184
|
+
if (hits.length >= max)
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (hits.length >= max)
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return hits.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
192
|
+
}
|
|
193
|
+
/** Nearest `tsconfig.json` from the directory of `abs` up to `root` (inclusive). */
|
|
194
|
+
export function nearestTsconfig(abs, root) {
|
|
195
|
+
for (let dir = dirname(abs);; dir = dirname(dir)) {
|
|
196
|
+
const candidate = join(dir, "tsconfig.json");
|
|
197
|
+
if (existsSync(candidate))
|
|
198
|
+
return candidate;
|
|
199
|
+
if (dir === root || dirname(dir) === dir)
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
133
203
|
export function createSyntacticIndex(root, project) {
|
|
134
204
|
const texts = new Map();
|
|
135
205
|
const sources = new Map();
|
|
@@ -178,21 +248,9 @@ export function createSyntacticIndex(root, project) {
|
|
|
178
248
|
continue;
|
|
179
249
|
if (query.fuzzy ? !text.toLowerCase().includes(needle) : !text.includes(needle))
|
|
180
250
|
continue;
|
|
181
|
-
for (const decl of index.declarations(file))
|
|
182
|
-
if (query
|
|
183
|
-
if (decl.parentName === undefined)
|
|
184
|
-
continue;
|
|
185
|
-
const parent = decl.parentName.split(".").pop();
|
|
186
|
-
const ok = query.fuzzy
|
|
187
|
-
? parent.toLowerCase().includes(needle) && decl.name.toLowerCase().includes(query.member.toLowerCase())
|
|
188
|
-
: parent === query.name && decl.name === query.member;
|
|
189
|
-
if (ok)
|
|
190
|
-
result.push(decl);
|
|
191
|
-
}
|
|
192
|
-
else if (query.fuzzy ? decl.name.toLowerCase().includes(needle) : decl.name === query.name) {
|
|
251
|
+
for (const decl of index.declarations(file))
|
|
252
|
+
if (declMatches(decl, query))
|
|
193
253
|
result.push(decl);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
254
|
}
|
|
197
255
|
return result;
|
|
198
256
|
},
|
|
@@ -271,28 +329,165 @@ export function createSemanticProject(index) {
|
|
|
271
329
|
}
|
|
272
330
|
return sites;
|
|
273
331
|
};
|
|
332
|
+
const checker = program.getTypeChecker();
|
|
333
|
+
/** Deepest node whose span holds `pos`. */
|
|
334
|
+
const nodeAt = (sf, pos) => {
|
|
335
|
+
let node = sf;
|
|
336
|
+
for (;;) {
|
|
337
|
+
const child = ts.forEachChild(node, (c) => (c.getStart(sf) <= pos && pos < c.end ? c : undefined));
|
|
338
|
+
if (!child)
|
|
339
|
+
return node;
|
|
340
|
+
node = child;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
const isMemberNode = (node) => ts.isMethodDeclaration(node) || ts.isMethodSignature(node) || ts.isPropertyDeclaration(node) || ts.isPropertySignature(node) ||
|
|
344
|
+
ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isPropertyAssignment(node);
|
|
345
|
+
const isTypeContainer = (node) => ts.isClassLike(node) || ts.isInterfaceDeclaration(node);
|
|
346
|
+
const memberAt = (fileName, pos) => {
|
|
347
|
+
const sf = program.getSourceFile(fileName);
|
|
348
|
+
if (!sf)
|
|
349
|
+
return undefined;
|
|
350
|
+
const parent = nodeAt(sf, pos).parent;
|
|
351
|
+
return parent && isMemberNode(parent) && parent.name.getStart(sf) === pos ? parent : undefined;
|
|
352
|
+
};
|
|
353
|
+
const memberKey = (member) => `${resolve(member.getSourceFile().fileName)}#${member.name.getStart(member.getSourceFile())}`;
|
|
354
|
+
const memberName = (member) => {
|
|
355
|
+
const owner = member.parent;
|
|
356
|
+
const ownerName = isTypeContainer(owner) && owner.name ? owner.name.text
|
|
357
|
+
: ts.isObjectLiteralExpression(owner) && ts.isVariableDeclaration(owner.parent) && ts.isIdentifier(owner.parent.name) ? owner.parent.name.text
|
|
358
|
+
: "(object)";
|
|
359
|
+
return `${ownerName}.${member.name.getText(member.getSourceFile())}`;
|
|
360
|
+
};
|
|
361
|
+
/** Keys of every member `member` implements or overrides, through extends/implements (transitively) or an object literal's contextual type. */
|
|
362
|
+
const basesOf = (member) => {
|
|
363
|
+
if (ts.isPrivateIdentifier(member.name))
|
|
364
|
+
return [];
|
|
365
|
+
const name = ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) || ts.isNumericLiteral(member.name) ? member.name.text : undefined;
|
|
366
|
+
if (name === undefined)
|
|
367
|
+
return [];
|
|
368
|
+
const keys = new Set();
|
|
369
|
+
const seen = new Set();
|
|
370
|
+
const fromType = (type) => {
|
|
371
|
+
for (const decl of checker.getPropertyOfType(type, name)?.declarations ?? []) {
|
|
372
|
+
if (decl !== member && isMemberNode(decl))
|
|
373
|
+
keys.add(memberKey(decl));
|
|
374
|
+
}
|
|
375
|
+
for (const decl of type.getSymbol()?.declarations ?? [])
|
|
376
|
+
if (isTypeContainer(decl))
|
|
377
|
+
fromHeritage(decl);
|
|
378
|
+
};
|
|
379
|
+
const fromHeritage = (container) => {
|
|
380
|
+
if (seen.has(container))
|
|
381
|
+
return;
|
|
382
|
+
seen.add(container);
|
|
383
|
+
for (const clause of container.heritageClauses ?? [])
|
|
384
|
+
for (const t of clause.types)
|
|
385
|
+
fromType(checker.getTypeAtLocation(t));
|
|
386
|
+
};
|
|
387
|
+
const owner = member.parent;
|
|
388
|
+
if (isTypeContainer(owner))
|
|
389
|
+
fromHeritage(owner);
|
|
390
|
+
else if (ts.isObjectLiteralExpression(owner)) {
|
|
391
|
+
const contextual = checker.getContextualType(owner);
|
|
392
|
+
if (contextual)
|
|
393
|
+
fromType(contextual);
|
|
394
|
+
}
|
|
395
|
+
return [...keys];
|
|
396
|
+
};
|
|
397
|
+
const split = (absFile, pos) => {
|
|
398
|
+
const groups = service.findReferences(absFile, pos) ?? [];
|
|
399
|
+
const descriptors = [];
|
|
400
|
+
let target = -1;
|
|
401
|
+
let flagged = -1;
|
|
402
|
+
for (const group of groups) {
|
|
403
|
+
const def = group.definition;
|
|
404
|
+
const member = memberAt(def.fileName, def.textSpan.start);
|
|
405
|
+
const bases = member ? basesOf(member) : [];
|
|
406
|
+
const typeMember = member !== undefined && (isTypeContainer(member.parent) || bases.length > 0);
|
|
407
|
+
const groupKey = member ? memberKey(member) : `${resolve(def.fileName)}#${def.textSpan.start}`;
|
|
408
|
+
const refs = [];
|
|
409
|
+
for (const ref of group.references) {
|
|
410
|
+
const loc = location(ref.fileName, ref.textSpan.start);
|
|
411
|
+
const refFile = resolve(ref.fileName);
|
|
412
|
+
if (refFile === resolve(absFile) && ref.textSpan.start <= pos && pos <= ref.textSpan.start + ref.textSpan.length && target < 0) {
|
|
413
|
+
target = descriptors.length;
|
|
414
|
+
}
|
|
415
|
+
if (ref.isDefinition && flagged < 0)
|
|
416
|
+
flagged = descriptors.length;
|
|
417
|
+
if (!loc)
|
|
418
|
+
continue;
|
|
419
|
+
const sf = program.getSourceFile(ref.fileName);
|
|
420
|
+
const parent = nodeAt(sf, ref.textSpan.start).parent;
|
|
421
|
+
refs.push({
|
|
422
|
+
key: `${loc.file}:${loc.line}:${loc.col}`,
|
|
423
|
+
isDefinition: ref.isDefinition ?? false,
|
|
424
|
+
atDefinition: `${refFile}#${ref.textSpan.start}` === groupKey,
|
|
425
|
+
isSuper: parent !== undefined && ts.isPropertyAccessExpression(parent) && parent.expression.kind === ts.SyntaxKind.SuperKeyword,
|
|
426
|
+
entry: { ...loc, isDefinition: ref.isDefinition ?? false, isWrite: ref.isWriteAccess },
|
|
427
|
+
absFile: refFile,
|
|
428
|
+
start: ref.textSpan.start,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
descriptors.push({ key: groupKey, name: member ? memberName(member) : def.name, member: typeMember, bases, refs });
|
|
432
|
+
}
|
|
433
|
+
const index = target >= 0 ? target : Math.max(0, flagged);
|
|
434
|
+
const c = classifyReferenceGroups(descriptors, index);
|
|
435
|
+
const raw = new Map();
|
|
436
|
+
const buckets = new Map();
|
|
437
|
+
for (const d of descriptors)
|
|
438
|
+
for (const r of d.refs)
|
|
439
|
+
raw.set(r.key, r);
|
|
440
|
+
for (const r of [...c.direct, ...c.declarations])
|
|
441
|
+
buckets.set(r.key, "direct");
|
|
442
|
+
for (const v of c.via)
|
|
443
|
+
buckets.set(v.ref.key, "via");
|
|
444
|
+
for (const r of [...c.dropped, ...c.implementations.map((i) => i.ref)])
|
|
445
|
+
buckets.set(r.key, "dropped");
|
|
446
|
+
return {
|
|
447
|
+
result: {
|
|
448
|
+
direct: c.direct.map((r) => r.entry),
|
|
449
|
+
declarations: c.declarations.map((r) => ({ ...r.entry, isDefinition: true })),
|
|
450
|
+
via: c.via.map((v) => ({ ...v.ref.entry, via: v.via, through: v.through })),
|
|
451
|
+
implementations: c.implementations.map((i) => ({ ...i.ref.entry, name: i.name })),
|
|
452
|
+
dropped: c.dropped.map((r) => r.entry),
|
|
453
|
+
bases: c.bases,
|
|
454
|
+
},
|
|
455
|
+
raw,
|
|
456
|
+
buckets,
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
/** Call sites keyed against the reference split: direct and via kept, sibling calls dropped. */
|
|
460
|
+
const splitSites = (absFile, pos, calls) => {
|
|
461
|
+
const { result, raw, buckets } = split(absFile, pos);
|
|
462
|
+
const viaOf = new Map(result.via.map((v) => [`${v.file}:${v.line}:${v.col}`, v]));
|
|
463
|
+
const sites = [];
|
|
464
|
+
for (const call of calls) {
|
|
465
|
+
for (const span of call.fromSpans) {
|
|
466
|
+
const loc = location(call.from.file, span.start);
|
|
467
|
+
if (!loc)
|
|
468
|
+
continue;
|
|
469
|
+
const file = resolve(call.from.file);
|
|
470
|
+
const hits = [...raw.values()].filter((r) => r.absFile === file && span.start <= r.start && r.start <= span.start + span.length);
|
|
471
|
+
const kinds = hits.map((h) => buckets.get(h.key));
|
|
472
|
+
if (kinds.length > 0 && !kinds.includes("direct") && !kinds.includes("via"))
|
|
473
|
+
continue;
|
|
474
|
+
const viaHit = kinds.includes("direct") ? undefined : hits.find((h) => viaOf.has(h.key));
|
|
475
|
+
sites.push({ ...loc, caller: callerName(call.from), item: call.from, ...(viaHit ? { via: viaOf.get(viaHit.key).via, through: viaOf.get(viaHit.key).through } : {}) });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return sites;
|
|
479
|
+
};
|
|
274
480
|
return {
|
|
275
481
|
index,
|
|
276
482
|
service,
|
|
277
483
|
program,
|
|
278
484
|
projectFiles,
|
|
279
485
|
findReferences(absFile, pos) {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const loc = location(ref.fileName, ref.textSpan.start);
|
|
286
|
-
if (!loc)
|
|
287
|
-
continue;
|
|
288
|
-
const key = `${loc.file}:${loc.line}:${loc.col}`;
|
|
289
|
-
if (seen.has(key))
|
|
290
|
-
continue;
|
|
291
|
-
seen.add(key);
|
|
292
|
-
entries.push({ ...loc, isDefinition: ref.isDefinition ?? false, isWrite: ref.isWriteAccess });
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
return entries;
|
|
486
|
+
const { result } = split(absFile, pos);
|
|
487
|
+
return [...result.declarations, ...result.direct];
|
|
488
|
+
},
|
|
489
|
+
referenceSplit(absFile, pos) {
|
|
490
|
+
return split(absFile, pos).result;
|
|
296
491
|
},
|
|
297
492
|
definitionAt(absFile, pos) {
|
|
298
493
|
const defs = service.getDefinitionAtPosition(absFile, pos) ?? [];
|
|
@@ -307,7 +502,7 @@ export function createSemanticProject(index) {
|
|
|
307
502
|
const items = prepared === undefined ? [] : Array.isArray(prepared) ? prepared : [prepared];
|
|
308
503
|
if (items.length === 0)
|
|
309
504
|
return undefined;
|
|
310
|
-
return items.flatMap((item) =>
|
|
505
|
+
return items.flatMap((item) => splitSites(item.file, item.selectionSpan.start, service.provideCallHierarchyIncomingCalls(item.file, item.selectionSpan.start)));
|
|
311
506
|
},
|
|
312
507
|
incomingCallsOf(item) {
|
|
313
508
|
if (item.kind === ts.ScriptElementKind.scriptElement)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Pure split of `findReferences` groups into direct / via / implementation / dropped.
|
|
2
|
+
// The LanguageService resolves a class member through the interface or base member it
|
|
3
|
+
// implements, so its result also holds sibling implementations and their call sites.
|
|
4
|
+
// A position reported in several groups keeps its most specific bucket.
|
|
5
|
+
const RANK = { direct: 0, declaration: 1, implementation: 2, via: 3, dropped: 4 };
|
|
6
|
+
/**
|
|
7
|
+
* Rules: the target group is direct. When the target is a class/interface member, a group it
|
|
8
|
+
* implements/overrides (a base) is via, minus Declarations and `super` calls; a group that
|
|
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) are direct.
|
|
11
|
+
*/
|
|
12
|
+
export function classifyReferenceGroups(groups, targetIndex) {
|
|
13
|
+
const target = groups[targetIndex];
|
|
14
|
+
const best = new Map();
|
|
15
|
+
const put = (bucket, ref, name = "", through) => {
|
|
16
|
+
const prev = best.get(ref.key);
|
|
17
|
+
if (!prev || RANK[bucket] < RANK[prev.bucket])
|
|
18
|
+
best.set(ref.key, { bucket, ref, name, ...(through ? { through } : {}) });
|
|
19
|
+
};
|
|
20
|
+
const bases = new Set(target?.bases ?? []);
|
|
21
|
+
groups.forEach((group, i) => {
|
|
22
|
+
const isTarget = i === targetIndex;
|
|
23
|
+
const relation = isTarget || !target?.member || !group.member
|
|
24
|
+
? "direct"
|
|
25
|
+
: bases.has(group.key)
|
|
26
|
+
? "base"
|
|
27
|
+
: group.bases.includes(target.key)
|
|
28
|
+
? "derived"
|
|
29
|
+
: "sibling";
|
|
30
|
+
for (const ref of group.refs) {
|
|
31
|
+
switch (relation) {
|
|
32
|
+
case "direct":
|
|
33
|
+
put(ref.isDefinition || (isTarget && ref.atDefinition) ? "declaration" : "direct", ref);
|
|
34
|
+
break;
|
|
35
|
+
case "base":
|
|
36
|
+
put(ref.isDefinition || ref.atDefinition || ref.isSuper ? "dropped" : "via", ref, group.name, "base");
|
|
37
|
+
break;
|
|
38
|
+
case "derived":
|
|
39
|
+
if (ref.atDefinition)
|
|
40
|
+
put("implementation", ref, group.name);
|
|
41
|
+
else
|
|
42
|
+
put("via", ref, group.name, "implementation");
|
|
43
|
+
break;
|
|
44
|
+
default:
|
|
45
|
+
put("dropped", ref);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const result = { direct: [], declarations: [], via: [], implementations: [], dropped: [], bases: [] };
|
|
50
|
+
for (const { bucket, ref, name, through } of best.values()) {
|
|
51
|
+
if (bucket === "direct")
|
|
52
|
+
result.direct.push(ref);
|
|
53
|
+
else if (bucket === "declaration")
|
|
54
|
+
result.declarations.push(ref);
|
|
55
|
+
else if (bucket === "via")
|
|
56
|
+
result.via.push({ ref, via: name, through: through });
|
|
57
|
+
else if (bucket === "implementation")
|
|
58
|
+
result.implementations.push({ ref, name });
|
|
59
|
+
else
|
|
60
|
+
result.dropped.push(ref);
|
|
61
|
+
}
|
|
62
|
+
result.bases = groups.filter((g, i) => i !== targetIndex && bases.has(g.key)).map((g) => g.name);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/** `7 (FieldParser.parse)`: a count with the distinct member names, no commas. */
|
|
66
|
+
export function viaLine(count, names) {
|
|
67
|
+
const distinct = [...new Set(names)];
|
|
68
|
+
return distinct.length === 0 ? String(count) : `${count} (${distinct.join("; ")})`;
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-axi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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": [
|