ts-axi 0.1.0 → 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 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[6]:
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
- const entries = row.live
420
- ? semantic.findReferences(opened.index.abs(row.live.file), row.live.namePos)
421
- : row.change.change === "removed"
422
- ? brokenReferences(semantic, row.change.before)
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((r) => r.outside.length > 0)
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.outside.length === 0)
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.outside.length), limit);
438
- const refRows = listed.flatMap((r, i) => r.outside.slice(0, quotas[i]).map((e) => ({
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.outside.length, 0);
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 { expectPositionals, flag, intFlag, parseArgs } from "../args.js";
2
- import { createSemanticProject } from "../project.js";
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[9]:
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[10]:
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
- ...(invoked === "calls" ? [{ name: "--depth" }] : []),
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 rows = sortRows(semantic.findReferences(resolved.absFile, resolved.pos).filter((r) => !r.isDefinition));
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 shown = rows.slice(0, o.limit);
120
- const truncated = shown.length < rows.length;
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 ${rows.length}; run \`ts-axi refs ${o.declaredAt} --limit ${Math.min(MAX_LIMIT, rows.length)}\` for more`
125
- : `Showing ${shown.length} of ${rows.length}; narrow by reading one file at a time`);
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
- ...(rows.length === 0
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
- : { refs: shown.map((r) => ({ file: r.file, line: r.line, context: contextCell(opened, r) })) }),
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([...sites]);
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 shown = rows.slice(0, o.limit);
178
- const truncated = shown.length < rows.length;
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 = rows;
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 ${rows.length}; run \`ts-axi callers ${o.declaredAt} --limit ${Math.min(MAX_LIMIT, rows.length)}\` for more`);
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
- ...(rows.length === 0
271
+ ...viaFields(via, []),
272
+ ...(shown.length === 0
205
273
  ? {}
206
- : { refs: shown.map((r) => ({ file: r.file, line: r.line, caller: r.caller, context: contextCell(opened, r) })) }),
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),
@@ -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.member !== undefined) {
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 groups = service.findReferences(absFile, pos) ?? [];
281
- const seen = new Set();
282
- const entries = [];
283
- for (const group of groups) {
284
- for (const ref of group.references) {
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) => toCallSites(service.provideCallHierarchyIncomingCalls(item.file, item.selectionSpan.start)));
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.0",
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": [