ts-axi 0.1.17 → 0.1.19

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
@@ -58,11 +58,11 @@ Known limits: `.astro` / `.vue` / `.svelte` files are not analysed (they appear
58
58
  | task | repo | ts-axi tokens | grep tokens | ratio | recall |
59
59
  | --- | --- | ---: | ---: | ---: | --- |
60
60
  | def of a class method | astro sample | 117 | 266 | 0.44 | 100% (1/1) |
61
- | callers of a function | astro sample | 227 | 1169 | 0.19 | 100% (8/8) |
62
- | refs of a class member | astro sample | 216 | 1133 | 0.19 | 100% (8/8) |
63
- | impact of a signature change | astro sample | 737 | 1304 | 0.56 | 100% (14/14) |
61
+ | callers of a function | astro sample | 204 | 1169 | 0.17 | 100% (8/8, 3 by count) |
62
+ | refs of a class member | astro sample | 170 | 1133 | 0.15 | 100% (8/8, 5 by count) |
63
+ | impact of a signature change | astro sample | 687 | 1304 | 0.53 | 100% (14/14) |
64
64
  | template uses of a directive | legacy-js | 409 | 289 | 1.42 | 100% (5/5) |
65
- | refs of an untyped CommonJS member | legacy-js | 387 | 401 | 0.96 | 100% (5/5) |
65
+ | refs of an untyped CommonJS member | legacy-js | 369 | 401 | 0.92 | 100% (5/5, 2 by count) |
66
66
 
67
67
  - def of a class method is over 0.3 - the class file has 36 lines, so the grep side reads all of it once.
68
68
  - impact of a signature change is over 0.3 - the followed hint repeats the first output with the extra rows (refs on the changed function); impact alone is 0.33.
@@ -123,8 +123,8 @@ The hook runs `ts-axi` with no arguments: it reads only the file tree and git st
123
123
  | --- | --- |
124
124
  | `ts-axi` | Home view: project root, tsconfig used (or implicit), source file count, test-file glob and match counts, git branch and dirty count, next steps. Under a second, exit 0 everywhere |
125
125
  | `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` and, when the name still occurs in the code, the `refs <name> --grep` that lists those textual matches. Before saying none, a name that is only a function-local Declaration (`local_to: Comp`) or only declared in a file the tsconfig leaves out (`project: outside (Deno edge/deno.json)`) is found syntactically and shown like a match |
126
- | `refs <target>` | References of one symbol, grouped by file, with test counts. A class member that implements an interface (or overrides a base) member counts only its direct References; calls through the interface are `via_interface: n (Iface.member)`, sibling implementations are dropped, and an interface member reports `implementations: n`. `--limit`, `--kind calls`, `--via` (via rows with a `via` column), `--implementations` |
127
- | `callers <target>` | Incoming calls of a function-like symbol, split the same way (`via_interface`, `--via`). `--depth 1..3` |
126
+ | `refs <target>` | References of one symbol, grouped by file, with test counts. A class member that implements an interface (or overrides a base) member counts only its direct References; calls through the interface are `via_interface: n (Iface.member)`, sibling implementations are dropped, and an interface member reports `implementations: n`. Rows in Test files come last and are collapsed to `test_refs[n]{file,count}` unless `--tests`. A property also names `same_name_members` (a DTO filled from an entity: same name, no Reference). `--limit`, `--kind calls`, `--via` (via rows with a `via` column), `--implementations`, `--tests` |
127
+ | `callers <target>` | Incoming calls of a function-like symbol, split the same way (`via_interface`, `--via`). Only call expressions count: `x.fn.mockResolvedValue(...)` or `expect(x.fn)` are `not_calls: n` (listed by `refs`). Test rows collapse like `refs`. `--depth 1..3`, `--tests` |
128
128
  | `outline <file>` | Top-level declarations in source order, class members nested: `{line,kind,name,signature}` (`{at,kind,name,signature}` over several files). `--exports`, `--fields exported` |
129
129
  | `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`. Rows default to `{symbol,change,at,refs_outside}`; `--fields kind,exported,refs_total,via,files,tests` (or `all`) adds columns. `--size` adds a `size` block (files, hunks at zero context, lines added and removed, patch characters, exported signature changes, removed symbols) for routing a review by diff size. Analyses the current checkout |
130
130
  | `anchor [<git-range> \| - \| --staged] <file:line[-end]>...` | Checks review-comment anchors: a line is commentable when the diff shows it (added or context; removed or context with `--side old`). Rows are `{anchor,ok,reason,symbol}`: a reason with the nearest hunk when not, and the enclosing Declaration (`Cart.total#method`) as a stable key for the finding. Git sources diff at `--context 3` (what Bitbucket and GitHub show); a stdin diff is used as given. `--fields in_diff,in_file,nearest,change,other_line` (or `all`). Exit 3 when any anchor is not ok, with the full output |
@@ -319,7 +319,7 @@ One dependency: the `typescript` compiler API (ADR in `docs/adr/0001-typescript-
319
319
  - `impact` reads the "before" version of touched files with `git show` and compares signatures to classify each change (`added`, `removed`, `signature`, `body`, `type`, `modified`). Uses it cannot follow (access through `any`, dynamic string keys) are reported as `blind_spots`, never silently dropped.
320
320
  - Module resolution keeps TypeScript's rules with two substitutions so References reach implementations: a `.d.ts` that has a JavaScript twin in the Project (`index.d.ts` beside `index.js`) resolves to the twin, and a CommonJS shim whose only statement is `module.exports = require('./x')` resolves to `./x`, so `require('..')` reaches the real function. A `.d.ts` twin that still ends up in the program is queried too and its References merged.
321
321
  - Script-style JavaScript (AngularJS registrations with their `this` / `var self = this` members and factory objects, ui-router states, Meteor methods and helpers, `module.exports` objects, prototype methods, IIFE helpers) gets nested declarations with qualified names, so `outline` lists them and `impact` names the function an edit touches instead of a file-level change.
322
- - Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
322
+ - Type errors in the analysed project never block anything; they are reported as a `diagnostics` count (the whole Project up to 300 files, else the files with results). `--diagnostics` on `refs`, `callers` and `impact` lists them (`diagnostic_rows[n]{file,line,code,message}`, at most 20). They rarely change the result; an unresolved import can hide References.
323
323
 
324
324
  Every semantic command starts cold and prints `elapsed_ms`.
325
325
 
@@ -353,6 +353,7 @@ CI runs the first two on Node 22 and 24, the built CLI on Node 20, and the integ
353
353
  - [`mongo-rw-axi`](https://www.npmjs.com/package/mongo-rw-axi): Write-capable MongoDB CLI for agents, loopback hosts only
354
354
  - [`mysql-axi`](https://www.npmjs.com/package/mysql-axi): Read-only MySQL CLI for agents
355
355
  - [`mysql-rw-axi`](https://www.npmjs.com/package/mysql-rw-axi): Write-capable MySQL CLI for agents, loopback hosts only
356
+ - [`secrets-axi`](https://www.npmjs.com/package/secrets-axi): Secrets guard CLI for agents
356
357
  <!-- axi-tools:end -->
357
358
 
358
359
  ## License
@@ -374,3 +374,24 @@ export function lineOf(text, line) {
374
374
  return lines[line - 1] ?? "";
375
375
  }
376
376
  export { clip, hasGlobMagic };
377
+ const DIAGNOSTIC_ROWS = 20;
378
+ /** Help text for `diagnostics` and `--diagnostics`, shared by refs, callers and impact. */
379
+ export const DIAGNOSTICS_DOC = "diagnostics: n = TypeScript errors (syntax + type) in the analysed files - the whole Project up to 300 files, else the files with results; they rarely change the result, but an unresolved import there can hide References; --diagnostics lists them";
380
+ /**
381
+ * `diagnostics: n`, plus `diagnostic_rows` under `--diagnostics`, or a hint to list them.
382
+ * `rerun` is the same command with `--diagnostics` added.
383
+ */
384
+ export function diagnosticsFields(semantic, touched, show, rerun) {
385
+ if (!show) {
386
+ const n = semantic.diagnosticsCount(touched);
387
+ return n === 0 ? { fields: {}, help: [] } : { fields: { diagnostics: n }, help: [`Run \`${rerun}\` to list the ${n} TypeScript errors counted in diagnostics`] };
388
+ }
389
+ const rows = semantic.diagnostics(touched);
390
+ return {
391
+ fields: {
392
+ diagnostics: rows.length,
393
+ ...(rows.length > 0 ? { diagnostic_rows: rows.slice(0, DIAGNOSTIC_ROWS).map((d) => ({ file: d.file, line: d.line, code: `TS${d.code}`, message: clip(d.message, CONTEXT_MAX) })) } : {}),
394
+ },
395
+ help: rows.length > DIAGNOSTIC_ROWS ? [`Showing ${DIAGNOSTIC_ROWS} of ${rows.length} diagnostics; run \`tsc --noEmit\` in the Project for all`] : [],
396
+ };
397
+ }
@@ -14,7 +14,7 @@ import { isFunctionLike } from "../symbols.js";
14
14
  import { IMPACT_HOPS, scopeFields, scopeHelp, semanticFor } from "../scope.js";
15
15
  import { isTemplatePath, loadTemplates, templateTarget, templateUsages, usagesIn } from "../templates.js";
16
16
  import { walkFiles } from "../walk.js";
17
- import { contextOf, lineOf, open, projectFields } from "./common.js";
17
+ import { contextOf, DIAGNOSTICS_DOC, diagnosticsFields, lineOf, open, projectFields } from "./common.js";
18
18
  import { outsideFiles } from "./miss.js";
19
19
  const DEFAULT_LIMIT = 50;
20
20
  const MAX_LIMIT = 500;
@@ -26,8 +26,8 @@ input:
26
26
  (none) = working tree vs HEAD (staged + unstaged + untracked); --staged = index vs HEAD
27
27
  <git-range> = any git diff range (main...HEAD, abc123, v1..v2); - = unified diff on stdin (needs the after side checked out)
28
28
  analysis is of the current checkout - for a range whose head is not the working tree, check that head out first
29
- flags[9]:
30
- --staged, --size (adds size{files,hunks,lines_added,lines_removed,patch_chars,exported_signature_changes,removed_symbols} - hunks are counted at zero context for a git source, as given for -; untracked files count as added),--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), --fields <kind,exported,refs_total,via,files,tests|all> (extra changed[n] columns), -P <tsconfig>, --json
29
+ flags[10]:
30
+ --staged, --size (adds size{files,hunks,lines_added,lines_removed,patch_chars,exported_signature_changes,removed_symbols} - hunks are counted at zero context for a git source, as given for -; untracked files count as added),--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), --fields <kind,exported,refs_total,via,files,tests|all> (extra changed[n] columns), --diagnostics (lists the TypeScript errors behind diagnostics), -P <tsconfig>, --json
31
31
  output:
32
32
  base, head, changes (count), changed[n]{symbol,change,at,refs_outside} by default; --fields adds columns in place, --fields all gives {symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
33
33
  change = signature | removed | type | added | body | modified (before unavailable) - riskiest first
@@ -42,6 +42,7 @@ output:
42
42
  skipped[n]{file,reason} = files in the diff that were not analysed (.astro, .json, outside the Project)
43
43
  tests: 0 on an exported signature or body change = no Test file references it (columns from --fields exported,tests)
44
44
  scope = in a monorepo without -P the program is the touched files + two levels of importers (scope_partial: true when capped)
45
+ ${DIAGNOSTICS_DOC}
45
46
  examples[6]:
46
47
  ts-axi impact
47
48
  ts-axi impact --staged
@@ -59,6 +60,7 @@ const SPECS = [
59
60
  { name: "--test-glob", repeatable: true },
60
61
  { name: "--fields" },
61
62
  { name: "--size", boolean: true },
63
+ { name: "--diagnostics", boolean: true },
62
64
  { name: "-P" },
63
65
  ];
64
66
  // Opt-in columns of changed[n]; the default keeps symbol, change, at, refs_outside.
@@ -458,7 +460,7 @@ function depthLevels(semantic, rows, depth) {
458
460
  let level = [];
459
461
  for (const row of rows) {
460
462
  if (row.live && isFunctionLike(row.kind))
461
- level.push(...(semantic.incomingCalls(semantic.index.abs(row.live.file), row.live.namePos) ?? []));
463
+ level.push(...(semantic.incomingCalls(semantic.index.abs(row.live.file), row.live.namePos) ?? []).filter((s) => !s.notCall));
462
464
  }
463
465
  const visited = new Set();
464
466
  for (let d = 2; d <= depth; d++) {
@@ -469,7 +471,7 @@ function depthLevels(semantic, rows, depth) {
469
471
  level = [];
470
472
  for (const [key, site] of items) {
471
473
  visited.add(key);
472
- level.push(...semantic.incomingCallsOf(site.item));
474
+ level.push(...semantic.incomingCallsOf(site.item).filter((s) => !s.notCall));
473
475
  }
474
476
  const counts = new Map();
475
477
  for (const site of level)
@@ -788,7 +790,7 @@ export async function impactCommand(args, ctx) {
788
790
  : [];
789
791
  const unresolved = grep ? withTemplateRows(textRows, rows, templateFiles ?? []) : [];
790
792
  const touched = [...new Set([...analysed.filter((f) => f.status !== "deleted").map((f) => f.path), ...listed.flatMap((r) => r.outside.map((e) => e.file))])];
791
- const diagnostics = semantic.diagnosticsCount(touched);
793
+ const diagnostics = diagnosticsFields(semantic, touched, bool(parsed, "--diagnostics"), rerun(arg, parsed, "--diagnostics"));
792
794
  // --- Help ---
793
795
  const help = [];
794
796
  const top = listed.find((r) => r.live) ?? localOnly.find((r) => r.live);
@@ -827,6 +829,7 @@ export async function impactCommand(args, ctx) {
827
829
  if (specHint)
828
830
  help.push(specHint);
829
831
  help.push(...scopeHelp(scope));
832
+ help.push(...diagnostics.help);
830
833
  const notes = rows
831
834
  .filter((r) => r.change.change === "signature" && r.bases.length > 0)
832
835
  .map((r) => ({ symbol: r.symbol, note: `implements ${r.bases.join(" and ")}` }));
@@ -865,6 +868,6 @@ export async function impactCommand(args, ctx) {
865
868
  ...skippedBlock,
866
869
  ...scopeFields(scope),
867
870
  elapsed_ms: Math.round(performance.now() - started),
868
- ...(diagnostics > 0 ? { diagnostics } : {}),
871
+ ...diagnostics.fields,
869
872
  }), helpBlock(help));
870
873
  }
@@ -12,15 +12,19 @@ import { isOutside, missFallback, missRows, notSearched, outsideLine, outsideRea
12
12
  import { detectWorkspace, isMultiProject } from "../workspace.js";
13
13
  import { isTestFile } from "../settings.js";
14
14
  import { isFunctionLike, kindFromScriptElement, parseTarget, registrationOf } from "../symbols.js";
15
- import { contextOf, declarationAt, filterSuffix, lineOf, MATCH_ROWS, matchRows, narrowHints, open, positionStartDir, projectFields, readFilters, useRef, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
15
+ import { contextOf, declarationAt, DIAGNOSTICS_DOC, diagnosticsFields, filterSuffix, lineOf, MATCH_ROWS, matchRows, narrowHints, open, positionStartDir, projectFields, readFilters, useRef, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
16
16
  const DEFAULT_LIMIT = 50;
17
17
  const MAX_LIMIT = 500;
18
18
  const DEPTH_ROWS = 50;
19
- const SHARED_FLAGS = `--kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace|state> (narrows a name), --in <dir|glob> (repeatable; Declarations in these files), --exported, --fuzzy, --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} rows), --test-glob <glob> (repeatable; extra Test file pattern), --via (adds via rows with a via column), -P <tsconfig>, --ref <rev> (read the code at a git revision; working tree ignored), --json`;
19
+ const TEST_ROWS = 20;
20
+ const SAME_NAME_ROWS = 5;
21
+ const SHARED_FLAGS = `--tests (lists Test file rows after the others; default a per-file count), --diagnostics (lists the TypeScript errors behind diagnostics), --kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace|state> (narrows a name), --in <dir|glob> (repeatable; Declarations in these files), --exported, --fuzzy, --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} rows), --test-glob <glob> (repeatable; extra Test file pattern), --via (adds via rows with a via column), -P <tsconfig>, --ref <rev> (read the code at a git revision; working tree ignored), --json`;
20
22
  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
21
23
  via_implementations: n (Impl.member) = References to implementations or overrides of the Symbol; not in refs_total
22
24
  implementations: n = members that implement or override the Symbol (their Declarations are never refs)
23
25
  matches: 0 under -P lists found_outside_project[n]{file,line} when the name is declared outside that Project
26
+ test_refs[n]{file,count} = rows in Test files, counted per file (biggest first, at most ${TEST_ROWS}); --tests lists them as rows after the others
27
+ ${DIAGNOSTICS_DOC}
24
28
  local_to: Comp = the name is a function-local Declaration (not indexed): its References from the declaring file
25
29
  matches: 0 + project: outside (...) = declared only in a file the tsconfig leaves out: outside_project + unresolved list its textual matches; the help position target runs the checker with the left-out files added (project: outside on that output)
26
30
  matches[n]{at,kind,name,where} = several function-local or left-out Declarations; pick one by its path:line:col
@@ -30,13 +34,14 @@ const VIA_DOC = `via_interface: n (Iface.member) = References through an interfa
30
34
  textual: n matches in m files = whole-word count of the name in Project files (JavaScript declarations and implicit projects; a state counts its full dotted name as a whole token, templates included; not for a state's resolve keys and hooks - injected by name, not imported); 'n matches in this file' only for a real local (a parameter, a binding inside a function or IIFE, a non-exported top-level binding of an ES module or of a CommonJS file whose module.exports never hands it out) - members, object properties and script globals count Project-wide; above refs_total = uses the checker cannot resolve - see refs --grep
31
35
  templates: n usages in m files = template uses (.html .htm .ejs .hbs .njk .pug) of a directive or component (<wap-select / wap-select / data-wap-select), its bindings (fallback-display-property= on that tag), a controller (ng-controller) or a filter (| money) - also for a function registered by identifier (.directive('fancyBox', fancyBox)), under the registered name; refs --grep lists them in unresolved`;
32
36
  export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
33
- flags[13]:
37
+ flags[15]:
34
38
  ${SHARED_FLAGS}
35
39
  --implementations (lists implementations[n]{file,line,symbol} instead of the count)
36
40
  --grep (adds unresolved[n]{file,line,context} - textual matches of the name that are not References; also searches .astro/.vue/.svelte/.mdx/.html)
37
41
  --kind calls = same as \`ts-axi callers\`
38
42
  output:
39
- symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,context} (Declarations excluded), elapsed_ms
43
+ symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,context} (Declarations excluded; non-test files first), elapsed_ms
44
+ same_name_members: n (Other.prop) = for a property - members of other types with the same name (a DTO filled from an entity); their References are not counted; the names are listed up to ${SAME_NAME_ROWS}
40
45
  ${VIA_DOC}
41
46
  note = the name is registered by a string ('Name' in .controller('Name', fn), or fn a same-file function named by identifier) - its uses are textual; add --grep
42
47
  matches: 0 = no Declaration of the name; help counts its whole-word matches in source files (left-out files too), and --grep lists them in unresolved with note 'no declaration found - textual matches only' (outside_project = the share in files the tsconfig leaves out)
@@ -49,11 +54,13 @@ examples[4]:
49
54
  ts-axi refs runSync --limit 200
50
55
  `;
51
56
  export const CALLERS_HELP = `usage: ts-axi callers <name | Class.member | path:line[:col]> [flags]
52
- flags[12]:
57
+ flags[14]:
53
58
  ${SHARED_FLAGS}
54
59
  --depth <1-3> (default 1; levels > 1 print callers_depthN[n]{caller,file,count}, no locations)
55
60
  output:
56
- symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,caller,context}, elapsed_ms
61
+ symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,caller,context} (non-test files first), elapsed_ms
62
+ refs_total = calls only - the callee of a call, new, tagged template, decorator or JSX tag (fn.call/apply/bind count)
63
+ not_calls: n = References that are no call (mock setup like x.fn.mockResolvedValue; expect(x.fn); a callback passed on) - listed by refs
57
64
  caller = enclosing function or method; (top-level) for module code
58
65
  ${VIA_DOC}
59
66
  examples[3]:
@@ -74,6 +81,8 @@ async function run(args, ctx, invoked) {
74
81
  { name: "--limit" },
75
82
  { name: "--test-glob", repeatable: true },
76
83
  { name: "--via", boolean: true },
84
+ { name: "--tests", boolean: true },
85
+ { name: "--diagnostics", boolean: true },
77
86
  ...(invoked === "calls" ? [{ name: "--depth" }] : [{ name: "--implementations", boolean: true }, { name: "--grep", boolean: true }]),
78
87
  ];
79
88
  const parsed = parseArgs(args, specs, command);
@@ -90,6 +99,8 @@ async function run(args, ctx, invoked) {
90
99
  via: bool(parsed, "--via"),
91
100
  implementations: invoked === "refs" && bool(parsed, "--implementations"),
92
101
  grep: invoked === "refs" && bool(parsed, "--grep"),
102
+ tests: bool(parsed, "--tests"),
103
+ diagnostics: bool(parsed, "--diagnostics"),
93
104
  };
94
105
  const started = performance.now();
95
106
  ctx = useRef(ctx, parsed);
@@ -314,9 +325,57 @@ function summary(opened, rows) {
314
325
  function contextCell(opened, row) {
315
326
  return contextOf(lineOf(opened.index.text(opened.index.abs(row.file)), row.line), row.col);
316
327
  }
317
- function footer(semantic, started, touched) {
318
- const diagnostics = semantic.diagnosticsCount(touched);
319
- return { elapsed_ms: Math.round(performance.now() - started), ...(diagnostics > 0 ? { diagnostics } : {}) };
328
+ function footer(semantic, o, touched, command) {
329
+ const d = diagnosticsFields(semantic, touched, o.show.diagnostics, diagnosticsRerun(o, command));
330
+ return { fields: { elapsed_ms: Math.round(performance.now() - o.started), ...d.fields }, help: d.help };
331
+ }
332
+ /**
333
+ * Non-test rows first. Without `--tests` the Test file rows leave the list and become
334
+ * `test_refs[n]{file,count}`, with a hint to list them.
335
+ */
336
+ function testSplit(opened, rows, o, command) {
337
+ const isTest = new Map();
338
+ const testFile = (file) => {
339
+ if (!isTest.has(file))
340
+ isTest.set(file, isTestFile(file, opened.settings));
341
+ return isTest.get(file);
342
+ };
343
+ const prod = rows.filter((r) => !testFile(r.file));
344
+ const tests = rows.filter((r) => testFile(r.file));
345
+ if (o.show.tests || tests.length === 0)
346
+ return { listed: [...prod, ...tests], fields: {}, help: [] };
347
+ const counts = new Map();
348
+ for (const r of tests)
349
+ counts.set(r.file, (counts.get(r.file) ?? 0) + 1);
350
+ const perFile = [...counts].map(([file, count]) => ({ file, count })).sort((a, b) => b.count - a.count || a.file.localeCompare(b.file));
351
+ return {
352
+ listed: prod,
353
+ fields: { test_refs: perFile.slice(0, TEST_ROWS), ...(perFile.length > TEST_ROWS ? { test_refs_total: perFile.length } : {}) },
354
+ help: [`Run \`ts-axi ${command} ${o.declaredAt}${o.show.via ? " --via" : ""} --tests\` to list the ${tests.length} rows in Test files`],
355
+ };
356
+ }
357
+ /** Rerun with `--diagnostics` added. */
358
+ function diagnosticsRerun(o, command) {
359
+ return `ts-axi ${command} ${o.declaredAt}${o.show.via ? " --via" : ""}${o.show.tests ? " --tests" : ""} --diagnostics`;
360
+ }
361
+ /** Properties of other types with the property's name: an entity field copied into a DTO is not a Reference. */
362
+ function sameNameMembers(opened, o, known) {
363
+ const decl = o.decl;
364
+ if (!decl || decl.kind !== "property" || decl.parentName === undefined)
365
+ return { fields: {}, help: [] };
366
+ const names = [
367
+ ...new Set(opened.index
368
+ .findDeclarations({ name: decl.name })
369
+ .filter((d) => d.kind === "property" && d.parentName !== undefined && d.qualifiedName !== decl.qualifiedName && !known.has(d.qualifiedName))
370
+ .sort((a, b) => byDepthThenPath(a.file, b.file))
371
+ .map((d) => d.qualifiedName)),
372
+ ];
373
+ if (names.length === 0)
374
+ return { fields: {}, help: [] };
375
+ return {
376
+ fields: { same_name_members: names.length <= SAME_NAME_ROWS ? viaLine(names.length, names) : names.length },
377
+ help: names.length <= SAME_NAME_ROWS ? [`Run \`ts-axi refs ${names[0]}\` for References of the same-named ${names[0]}`] : [],
378
+ };
320
379
  }
321
380
  const NAMELESS_NOTE = "registered by a string - References to it are textual (templates; DI arrays; route configs)";
322
381
  /** `via_interface` / `via_implementations` counts, plus the hint to see their rows. */
@@ -358,15 +417,17 @@ function renderRefs(semantic, opened, resolved, header, o) {
358
417
  const via = sortRows([...split.via]);
359
418
  const impls = sortRows([...split.implementations]);
360
419
  const { files, tests, fileList } = summary(opened, rows);
361
- const listed = mergedRows(rows, via, o.show);
420
+ const testRows = testSplit(opened, mergedRows(rows, via, o.show), o, "refs");
421
+ const listed = testRows.listed;
362
422
  const shown = listed.slice(0, o.limit);
363
423
  const truncated = shown.length < listed.length;
364
424
  const help = [];
365
425
  if (truncated) {
366
426
  help.push(o.limit < MAX_LIMIT
367
- ? `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`
427
+ ? `Showing ${shown.length} of ${listed.length}; run \`ts-axi refs ${o.declaredAt}${o.show.via ? " --via" : ""}${o.show.tests ? " --tests" : ""} --limit ${Math.min(MAX_LIMIT, listed.length)}\` for more`
368
428
  : `Showing ${shown.length} of ${listed.length}; narrow by reading one file at a time`);
369
429
  }
430
+ help.push(...testRows.help);
370
431
  if (via.length > 0 && !o.show.via)
371
432
  help.push(`Run \`ts-axi refs ${o.declaredAt} --via\` for the ${via.length} via References (${viaNames(via)})`);
372
433
  if (impls.length > 0 && !o.show.implementations)
@@ -384,10 +445,14 @@ function renderRefs(semantic, opened, resolved, header, o) {
384
445
  else if (o.decl?.nameless && word && !o.show.grep && tpl.help.length === 0)
385
446
  help.unshift(`Run \`ts-axi refs ${o.declaredAt} --grep\` for textual matches of '${word}'`);
386
447
  help.unshift(...tpl.help);
448
+ const same = sameNameMembers(opened, o, new Set([...split.bases, ...via.map((v) => v.via), ...impls.map((i) => i.name)]));
449
+ help.push(...same.help);
387
450
  if (isFunctionLike(o.kind))
388
451
  help.push(`Run \`ts-axi callers ${o.declaredAt}\` for call sites with their enclosing function`);
389
452
  help.push(`Run \`ts-axi def ${o.declaredAt}\` for the declaration body`);
390
453
  help.push(...scopeHelp(o.scope));
454
+ const foot = footer(semantic, o, fileList, "refs");
455
+ help.push(...foot.help);
391
456
  return out(block({
392
457
  ...header,
393
458
  ...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
@@ -396,6 +461,7 @@ function renderRefs(semantic, opened, resolved, header, o) {
396
461
  ...tpl.fields,
397
462
  files,
398
463
  tests,
464
+ ...same.fields,
399
465
  ...viaFields(via, split.bases),
400
466
  ...(impls.length > 0
401
467
  ? { implementations: o.show.implementations ? impls.map((r) => ({ file: r.file, line: r.line, symbol: r.name })) : impls.length }
@@ -411,11 +477,12 @@ function renderRefs(semantic, opened, resolved, header, o) {
411
477
  })),
412
478
  }),
413
479
  ...(truncated ? { truncated: true } : {}),
480
+ ...testRows.fields,
414
481
  ...(o.show.grep ? { unresolved: grepRows.length === 0 ? 0 : grepRows.slice(0, o.limit) } : {}),
415
482
  ...(grepRows.length > o.limit ? { unresolved_total: grepRows.length } : {}),
416
483
  ...outside.fields,
417
484
  ...scopeFields(o.scope),
418
- ...footer(semantic, o.started, fileList),
485
+ ...foot.fields,
419
486
  }), helpBlock(help));
420
487
  }
421
488
  function viaNames(via) {
@@ -435,7 +502,7 @@ function nextLevel(semantic, sites, visited) {
435
502
  if (visited.has(key))
436
503
  continue;
437
504
  visited.add(key);
438
- next.push(...semantic.incomingCallsOf(site.item));
505
+ next.push(...semantic.incomingCallsOf(site.item).filter((s) => !s.notCall));
439
506
  }
440
507
  return next;
441
508
  }
@@ -456,7 +523,7 @@ function renderCallers(semantic, opened, resolved, header, o) {
456
523
  ? (() => {
457
524
  const split = semantic.referenceSplit(resolved.absFile, resolved.pos);
458
525
  const known = [...split.direct, ...split.declarations, ...split.via, ...split.implementations, ...split.dropped, ...(sites ?? [])];
459
- const calls = (sites ?? []).filter((s) => s.via === undefined).length;
526
+ const calls = (sites ?? []).filter((s) => s.via === undefined && !s.notCall).length;
460
527
  return textualFields(o, calls, withTemplates(unresolvedOf(opened, semantic, o, known), o.templates).length, false);
461
528
  })()
462
529
  : { fields: {}, help: [] };
@@ -464,17 +531,23 @@ function renderCallers(semantic, opened, resolved, header, o) {
464
531
  callerText.fields = { ...callerText.fields, ...tpl.fields };
465
532
  callerText.help = [...tpl.help, ...callerText.help];
466
533
  if (sites === undefined) {
467
- return out(block({ ...header, refs_total: 0, ...callerText.fields, note: "not callable - no call hierarchy for this Symbol", ...scopeFields(o.scope), ...footer(semantic, o.started, []) }), helpBlock([...callerText.help, `Run \`ts-axi refs ${o.declaredAt}\` for every Reference`]));
534
+ const foot = footer(semantic, o, [], "callers");
535
+ return out(block({ ...header, refs_total: 0, ...callerText.fields, note: "not callable - no call hierarchy for this Symbol", ...scopeFields(o.scope), ...foot.fields }), helpBlock([...callerText.help, `Run \`ts-axi refs ${o.declaredAt}\` for every Reference`, ...foot.help]));
468
536
  }
469
- const rows = sortRows(sites.filter((s) => s.via === undefined));
470
- const via = sortRows(sites.filter((s) => s.via !== undefined));
537
+ const notCalls = sites.filter((s) => s.notCall && s.via === undefined).length;
538
+ const calls = sites.filter((s) => !s.notCall);
539
+ const rows = sortRows(calls.filter((s) => s.via === undefined));
540
+ const via = sortRows(calls.filter((s) => s.via !== undefined));
471
541
  const { files, tests, fileList } = summary(opened, rows);
472
- const listed = mergedRows(rows, via, o.show);
542
+ const merged = mergedRows(rows, via, o.show);
543
+ const testRows = testSplit(opened, merged, o, "callers");
544
+ const listed = testRows.listed;
473
545
  const shown = listed.slice(0, o.limit);
474
546
  const truncated = shown.length < listed.length;
475
547
  const levels = {};
476
548
  const visited = new Set();
477
- let level = listed;
549
+ // Deeper levels start from every call, Test files included.
550
+ let level = merged;
478
551
  for (let d = 2; d <= o.depth; d++) {
479
552
  level = nextLevel(semantic, level, visited);
480
553
  const agg = depthRows(level);
@@ -486,8 +559,9 @@ function renderCallers(semantic, opened, resolved, header, o) {
486
559
  }
487
560
  const help = [];
488
561
  if (truncated) {
489
- 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`);
562
+ help.push(`Showing ${shown.length} of ${listed.length}; run \`ts-axi callers ${o.declaredAt}${o.show.via ? " --via" : ""}${o.show.tests ? " --tests" : ""} --limit ${Math.min(MAX_LIMIT, listed.length)}\` for more`);
490
563
  }
564
+ help.push(...testRows.help);
491
565
  if (via.length > 0 && !o.show.via)
492
566
  help.push(`Run \`ts-axi callers ${o.declaredAt} --via\` for the ${via.length} via calls (${viaNames(via)})`);
493
567
  const top = shown.find((r) => r.caller !== "(top-level)");
@@ -496,12 +570,17 @@ function renderCallers(semantic, opened, resolved, header, o) {
496
570
  const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
497
571
  help.unshift(...outside.help);
498
572
  help.unshift(...callerText.help);
499
- help.push(`Run \`ts-axi refs ${o.declaredAt}${o.decl?.nameless ? " --grep" : ""}\` for every Reference (not only calls)`);
573
+ help.push(notCalls > 0
574
+ ? `Run \`ts-axi refs ${o.declaredAt}${o.decl?.nameless ? " --grep" : ""}\` for the ${notCalls} not_calls with every other Reference`
575
+ : `Run \`ts-axi refs ${o.declaredAt}${o.decl?.nameless ? " --grep" : ""}\` for every Reference (not only calls)`);
500
576
  help.push(...scopeHelp(o.scope));
577
+ const foot = footer(semantic, o, fileList, "callers");
578
+ help.push(...foot.help);
501
579
  return out(block({
502
580
  ...header,
503
581
  ...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
504
582
  refs_total: rows.length,
583
+ ...(notCalls > 0 ? { not_calls: notCalls } : {}),
505
584
  ...callerText.fields,
506
585
  files,
507
586
  tests,
@@ -518,9 +597,10 @@ function renderCallers(semantic, opened, resolved, header, o) {
518
597
  })),
519
598
  }),
520
599
  ...(truncated ? { truncated: true } : {}),
600
+ ...testRows.fields,
521
601
  ...levels,
522
602
  ...outside.fields,
523
603
  ...scopeFields(o.scope),
524
- ...footer(semantic, o.started, fileList),
604
+ ...foot.fields,
525
605
  }), helpBlock(help));
526
606
  }
@@ -452,7 +452,7 @@ export function createSemanticProject(index, scope) {
452
452
  for (const span of call.fromSpans) {
453
453
  const loc = location(call.from.file, span.start);
454
454
  if (loc)
455
- sites.push({ ...loc, caller: callerName(call.from), item: call.from });
455
+ sites.push({ ...loc, caller: callerName(call.from), item: call.from, ...(isCallTarget(call.from.file, span.start) ? {} : { notCall: true }) });
456
456
  }
457
457
  }
458
458
  return sites;
@@ -468,6 +468,41 @@ export function createSemanticProject(index, scope) {
468
468
  node = child;
469
469
  }
470
470
  };
471
+ const CALL_FORWARDERS = new Set(["call", "apply", "bind"]);
472
+ const skipOuter = (node) => {
473
+ let n = node;
474
+ while (n.parent && (ts.isParenthesizedExpression(n.parent) || ts.isNonNullExpression(n.parent) || ts.isAsExpression(n.parent) || ts.isTypeAssertionExpression(n.parent) || ts.isSatisfiesExpression(n.parent)))
475
+ n = n.parent;
476
+ return n;
477
+ };
478
+ /**
479
+ * The Reference at `start` is what a call, `new`, tagged template, decorator or JSX tag invokes
480
+ * (`fn.call/apply/bind` count). The call hierarchy also reports any `x.method` property access.
481
+ */
482
+ const isCallTarget = (fileName, start) => {
483
+ const sf = program.getSourceFile(fileName);
484
+ if (!sf)
485
+ return true;
486
+ let n = nodeAt(sf, start);
487
+ const up = n.parent;
488
+ if (up && ((ts.isPropertyAccessExpression(up) && up.name === n) || (ts.isElementAccessExpression(up) && up.argumentExpression === n)))
489
+ n = up;
490
+ n = skipOuter(n);
491
+ const p = n.parent;
492
+ if (!p)
493
+ return false;
494
+ if ((ts.isCallExpression(p) || ts.isNewExpression(p)) && p.expression === n)
495
+ return true;
496
+ if ((ts.isTaggedTemplateExpression(p) && p.tag === n) || ts.isDecorator(p))
497
+ return true;
498
+ if ((ts.isJsxOpeningElement(p) || ts.isJsxSelfClosingElement(p)) && p.tagName === n)
499
+ return true;
500
+ if (ts.isPropertyAccessExpression(p) && p.expression === n && CALL_FORWARDERS.has(p.name.text)) {
501
+ const call = skipOuter(p).parent;
502
+ return call !== undefined && ts.isCallExpression(call) && skipOuter(p) === call.expression;
503
+ }
504
+ return false;
505
+ };
471
506
  const isMemberNode = (node) => ts.isMethodDeclaration(node) || ts.isMethodSignature(node) || ts.isPropertyDeclaration(node) || ts.isPropertySignature(node) ||
472
507
  ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isPropertyAssignment(node);
473
508
  const isTypeContainer = (node) => ts.isClassLike(node) || ts.isInterfaceDeclaration(node);
@@ -547,9 +582,91 @@ export function createSemanticProject(index, scope) {
547
582
  }
548
583
  return undefined;
549
584
  };
550
- const split = (absFile, pos) => {
585
+ /** Where findReferences stops on CommonJS: a `module.exports` property of the Symbol, and the locals of `const { x } = require()`. */
586
+ const commonJsHop = (fileName, start) => {
587
+ const sf = program.getSourceFile(fileName);
588
+ if (!sf)
589
+ return undefined;
590
+ const id = nodeAt(sf, start);
591
+ if (!ts.isIdentifier(id))
592
+ return undefined;
593
+ const parent = id.parent;
594
+ if ((ts.isShorthandPropertyAssignment(parent) && parent.name === id) || (ts.isPropertyAssignment(parent) && parent.initializer === id)) {
595
+ return isModuleExportsObject(parent.parent) ? parent.name.getStart(sf) : undefined;
596
+ }
597
+ const element = ts.isBindingElement(parent) ? parent : undefined;
598
+ if (!element || !ts.isIdentifier(element.name) || !ts.isObjectBindingPattern(element.parent))
599
+ return undefined;
600
+ const decl = element.parent.parent;
601
+ if (!ts.isVariableDeclaration(decl) || !decl.initializer || !isRequireCall(decl.initializer))
602
+ return undefined;
603
+ return element.name.getStart(sf);
604
+ };
605
+ /** findReferences of the Symbol at `pos`, with the CommonJS hops of `commonJsHop` merged into the target group. */
606
+ const referenceGroups = (absFile, pos) => {
551
607
  const twin = declarationTwin(absFile, pos);
552
608
  const groups = [...(service.findReferences(absFile, pos) ?? []), ...(twin ? service.findReferences(twin.file, twin.pos) ?? [] : [])];
609
+ const covers = (r) => resolve(r.fileName) === resolve(absFile) && r.textSpan.start <= pos && pos <= r.textSpan.start + r.textSpan.length;
610
+ const target = groups.find((g) => g.references.some(covers));
611
+ if (!target)
612
+ return { groups, extra: [] };
613
+ const seen = new Set(groups.flatMap((g) => g.references.map((r) => `${resolve(r.fileName)}#${r.textSpan.start}`)));
614
+ const extra = [];
615
+ const hops = new Set();
616
+ const queue = [...target.references];
617
+ for (let i = 0; i < queue.length && i < 500; i++) {
618
+ const hop = commonJsHop(queue[i].fileName, queue[i].textSpan.start);
619
+ const hopKey = `${resolve(queue[i].fileName)}#${hop}`;
620
+ if (hop === undefined || hops.has(hopKey))
621
+ continue;
622
+ hops.add(hopKey);
623
+ for (const g of service.findReferences(queue[i].fileName, hop) ?? []) {
624
+ for (const r of g.references) {
625
+ const key = `${resolve(r.fileName)}#${r.textSpan.start}`;
626
+ // A renamed local (`{ x: y }`) declares `y`; its uses are the References.
627
+ if (seen.has(key) || (r.isDefinition && resolve(r.fileName) === resolve(queue[i].fileName) && r.textSpan.start === hop && hop !== queue[i].textSpan.start))
628
+ continue;
629
+ seen.add(key);
630
+ const entry = { ...r, isDefinition: false };
631
+ extra.push(entry);
632
+ queue.push(entry);
633
+ }
634
+ }
635
+ }
636
+ return { groups: extra.length === 0 ? groups : groups.map((g) => (g === target ? { ...g, references: [...g.references, ...extra] } : g)), extra };
637
+ };
638
+ /** Call sites among CommonJS-hop References; call hierarchy does not reach a destructured `require()` local. */
639
+ const commonJsCalls = (refs) => {
640
+ const sites = [];
641
+ for (const ref of refs) {
642
+ const sf = program.getSourceFile(ref.fileName);
643
+ const loc = location(ref.fileName, ref.textSpan.start);
644
+ if (!sf || !loc)
645
+ continue;
646
+ const id = nodeAt(sf, ref.textSpan.start);
647
+ const call = id.parent;
648
+ if (!(ts.isCallExpression(call) || ts.isNewExpression(call)) || call.expression !== id)
649
+ continue;
650
+ const item = enclosingItem(sf, call);
651
+ sites.push({ ...loc, caller: callerName(item), item });
652
+ }
653
+ return sites;
654
+ };
655
+ /** Call hierarchy item of the nearest named function around `node`, else the file itself. */
656
+ const enclosingItem = (sf, node) => {
657
+ for (let n = node.parent; n && !ts.isSourceFile(n); n = n.parent) {
658
+ if (!ts.isFunctionLike(n))
659
+ continue;
660
+ const prepared = service.prepareCallHierarchy(sf.fileName, n.getStart(sf));
661
+ const item = Array.isArray(prepared) ? prepared[0] : prepared;
662
+ if (item && item.kind !== ts.ScriptElementKind.scriptElement)
663
+ return item;
664
+ }
665
+ const span = { start: 0, length: sf.end };
666
+ return { name: sf.fileName, kind: ts.ScriptElementKind.scriptElement, file: sf.fileName, span, selectionSpan: span };
667
+ };
668
+ const split = (absFile, pos) => {
669
+ const { groups } = referenceGroups(absFile, pos);
553
670
  const descriptors = [];
554
671
  let target = -1;
555
672
  let flagged = -1;
@@ -626,11 +743,29 @@ export function createSemanticProject(index, scope) {
626
743
  if (kinds.length > 0 && !kinds.includes("direct") && !kinds.includes("via"))
627
744
  continue;
628
745
  const viaHit = kinds.includes("direct") ? undefined : hits.find((h) => viaOf.has(h.key));
629
- sites.push({ ...loc, caller: callerName(call.from), item: call.from, ...(viaHit ? { via: viaOf.get(viaHit.key).via, through: viaOf.get(viaHit.key).through } : {}) });
746
+ sites.push({
747
+ ...loc,
748
+ caller: callerName(call.from),
749
+ item: call.from,
750
+ ...(isCallTarget(call.from.file, span.start) ? {} : { notCall: true }),
751
+ ...(viaHit ? { via: viaOf.get(viaHit.key).via, through: viaOf.get(viaHit.key).through } : {}),
752
+ });
630
753
  }
631
754
  }
632
755
  return sites;
633
756
  };
757
+ // A subgraph program mixes projects under one set of options: only the touched files count there.
758
+ const diagnosticsOf = (touched) => {
759
+ const files = !scope && projectFiles.length <= FULL_DIAGNOSTICS_LIMIT ? projectFiles : touched.map((f) => index.abs(f));
760
+ const result = [];
761
+ for (const file of files) {
762
+ const sf = program.getSourceFile(file);
763
+ if (!sf)
764
+ continue;
765
+ result.push(...program.getSyntacticDiagnostics(sf), ...program.getSemanticDiagnostics(sf));
766
+ }
767
+ return result;
768
+ };
634
769
  return {
635
770
  index,
636
771
  service,
@@ -661,6 +796,7 @@ export function createSemanticProject(index, scope) {
661
796
  const seen = new Set();
662
797
  return items
663
798
  .flatMap((item) => splitSites(item.file, item.selectionSpan.start, [...service.provideCallHierarchyIncomingCalls(item.file, item.selectionSpan.start), ...twinCalls]))
799
+ .concat(commonJsCalls(referenceGroups(absFile, pos).extra))
664
800
  .filter((site) => {
665
801
  const key = `${site.file}:${site.line}:${site.col}`;
666
802
  return !seen.has(key) && Boolean(seen.add(key));
@@ -675,16 +811,18 @@ export function createSemanticProject(index, scope) {
675
811
  return service.getQuickInfoAtPosition(absFile, pos);
676
812
  },
677
813
  diagnosticsCount(touched = []) {
678
- // A subgraph program mixes projects under one set of options: count the touched files only.
679
- const files = !scope && projectFiles.length <= FULL_DIAGNOSTICS_LIMIT ? projectFiles : touched.map((f) => index.abs(f));
680
- let count = 0;
681
- for (const file of files) {
682
- const sf = program.getSourceFile(file);
683
- if (!sf)
684
- continue;
685
- count += program.getSyntacticDiagnostics(sf).length + program.getSemanticDiagnostics(sf).length;
686
- }
687
- return count;
814
+ return diagnosticsOf(touched).length;
815
+ },
816
+ diagnostics(touched = []) {
817
+ return diagnosticsOf(touched).map((d) => {
818
+ const lc = d.file?.getLineAndCharacterOfPosition(d.start ?? 0);
819
+ return {
820
+ file: d.file ? index.rel(resolve(d.file.fileName)) : "-",
821
+ line: lc ? lc.line + 1 : 0,
822
+ code: d.code,
823
+ message: ts.flattenDiagnosticMessageText(d.messageText, " "),
824
+ };
825
+ });
688
826
  },
689
827
  elapsedMs: () => Math.round(performance.now() - started),
690
828
  };
@@ -726,3 +864,20 @@ export function openProject(cwd, options = {}) {
726
864
  const index = createSyntacticIndex(root, project);
727
865
  return { cwd, root, location, index, ...(project.implicit ? { warning: IMPLICIT_WARNING } : {}) };
728
866
  }
867
+ /** The object of `module.exports = {...}`. */
868
+ function isModuleExportsObject(node) {
869
+ if (!ts.isObjectLiteralExpression(node))
870
+ return false;
871
+ let assign = node.parent;
872
+ while (ts.isParenthesizedExpression(assign))
873
+ assign = assign.parent;
874
+ if (!ts.isBinaryExpression(assign) || assign.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
875
+ return false;
876
+ const left = assign.left;
877
+ return ts.isPropertyAccessExpression(left) && ts.isIdentifier(left.expression) && left.expression.text === "module" && left.name.text === "exports";
878
+ }
879
+ function isRequireCall(expr) {
880
+ while (ts.isParenthesizedExpression(expr))
881
+ expr = expr.expression;
882
+ return ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) && expr.expression.text === "require" && expr.arguments.length === 1;
883
+ }
@@ -441,6 +441,10 @@ export function collectDeclarations(sf, file) {
441
441
  nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported, signature: headUntil(prop, fn, sf), namePos: prop.name.getStart(sf) });
442
442
  else if (scope && ts.isIdentifier(value))
443
443
  aliasedMember(scope, value, name, holder, exported, prop.name);
444
+ else if (holder === "exports" && !ts.isIdentifier(value)) {
445
+ // `module.exports = { x: make(...) }`: the property is the only Declaration a destructured require can reach.
446
+ nested({ start: prop.getStart(sf), end: prop.end, kind: "property", name, parentName: holder, exported, signature: clip(tokenText(prop, sf, prop.getStart(sf), prop.end)), namePos: prop.name.getStart(sf) });
447
+ }
444
448
  }
445
449
  else if (scope && ts.isShorthandPropertyAssignment(prop)) {
446
450
  aliasedMember(scope, prop.name, name, holder, exported, prop.name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-axi",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
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": [