ts-axi 0.1.4 → 0.1.5

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
@@ -37,7 +37,17 @@ help[5]:
37
37
 
38
38
  Read-only: no lint, no refactor, no rename.
39
39
 
40
- > **Status: early (v0.1.x).**
40
+ ## Status (v0.1.4)
41
+
42
+ Early, but already used in real reviews. What the numbers say so far:
43
+
44
+ - **Correctness on the test corpus.** 152 unit and command tests over five synthetic fixtures (a TS project with an interface and three implementations, an Nx-style workspace with `tsconfig.base.json` paths and a pnpm workspace, a script-style JavaScript tree, a CommonJS package and an ESM package without tsconfig), plus 39 integration tests that run the built binary against a vendored Astro + React one-pager (`test/integration/sample`: barrel re-exports, JSX islands, custom hooks, a JS consumer of a TS export, a `.ts-axi.json` test glob). They pin exact line numbers, reference counts, test counts, change classes and the interface split.
45
+ - **A real monorepo** (Nx, 142 projects, ~5.4k source files after gitignore): `refs` on a service method finds its two callers in *another* project in 3-5 s without any flag; `impact` on a 7-file commit reports 15 changes with cross-project references in ~5 s; `callers` on a method that implements an interface shared by seven classes reports the 1 direct call plus the spec calls, where the raw compiler API returned 8 unrelated implementations. Before the workspace scope, the same `impact` ran out of memory.
46
+ - **Against grep, in characters an agent has to read** (same monorepo, measured by a reviewer session): `callers` 720 vs ~3.5k for the two greps a reviewer needs - and grep still leaves the interface ambiguity to the human; `impact` on a commit 4.7k vs ~7.3k for per-symbol greps plus the `git show --stat` needed to learn the symbol list. `def` on a very common name was *worse* than grep until nested git worktrees were excluded (96 matches, 92 of them copies); it is 4 now.
47
+
48
+ - **Plain JavaScript** (no tsconfig, measured on two public packages): on p-limit (ESM, `index.js` beside `index.d.ts`) `refs pLimit` went from 1 to 48 References, 35 of them in `test.js`, which now counts as a Test file; on express (CommonJS, `index.js` = `module.exports = require('./lib/express')`) `callers createApplication` went from 2 call sites in 2 files to 678 in 94 files, 67 of them Test files (every one of the 22 test files that does `require('..')` included). The rule: References are semantic wherever the checker can follow them, and every Symbol declared in JavaScript (or in an implicit project) also prints `textual: <n> matches in <m> files`; when the text names it more often than the checker found, `help` points at `--grep` for the rest. `refs app.render` on express is the honest case: the method sits on an untyped expando object, the checker finds 0 References, and the output says `textual: 132 matches in 21 files` with the `--grep` hint instead of a bare 0; `impact` adds a `textual` column for such a Change rather than calling it local-only.
49
+
50
+ Known limits: `.astro` / `.vue` / `.svelte` files are not analysed (they appear under `skipped`, and `--grep` searches them textually); every semantic command starts cold (0.3 s on a small project, 2-5 s on the monorepo, dominated by the library `.d.ts` files that barrels pull in); import specifiers are extracted textually, so computed specifiers and bundler-only aliases are missed.
41
51
 
42
52
  ## Install
43
53
 
@@ -73,7 +83,99 @@ npx skills add cmosgh/ts-axi --skill ts-axi
73
83
 
74
84
  `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.
75
85
 
76
- <!-- TODO(phase 4): paste real examples for def / refs / outline / config from the fixture project. -->
86
+ ### A review, end to end
87
+
88
+ ```sh
89
+ ts-axi # which project, how many files, how many are tests
90
+ ts-axi impact main...HEAD # what the branch changes and who depends on it
91
+ ts-axi callers src/lib/format.ts:5 # the risky symbol: every call site with its enclosing function
92
+ ts-axi def src/lib/cart.ts:32 # read one declaration, numbered for inline comments
93
+ ts-axi outline src/lib/cart.ts # the rest of the file, without reading it
94
+ ```
95
+
96
+ Real output from the sample project (`test/integration/sample`):
97
+
98
+ ```
99
+ $ ts-axi callers formatPrice
100
+ symbol: formatPrice
101
+ kind: function
102
+ declared_at: "src/lib/format.ts:5"
103
+ refs_total: 7
104
+ files: 6
105
+ tests: 2
106
+ refs[7]{file,line,caller,context}:
107
+ src/components/Counter.tsx,12,Counter,"{cups} cups = {formatPrice(cups * CUP_CENTS)}"
108
+ src/components/ProductCard.tsx,13,ProductCard,"<p>{formatPrice(item.priceCents)}</p>"
109
+ src/lib/analytics.js,4,trackPurchase,"return { event: 'purchase', value: formatPrice(cents, 'EUR') };"
110
+ src/lib/cart.it.ts,8,(top-level),expect(cart.total()).toBe(formatPrice(3700));
111
+ src/lib/cart.ts,34,Cart.total,return formatPrice(cents);
112
+ src/lib/format.test.ts,6,(top-level),expect(formatPrice(1850)).toBe('18.50 USD');
113
+ src/lib/format.test.ts,9,(top-level),"expect(formatPrice(305, 'EUR')).toBe('3.05 EUR');"
114
+ elapsed_ms: 549
115
+ ```
116
+
117
+ ```
118
+ $ ts-axi def Cart.total
119
+ file: src/lib/cart.ts
120
+ line: 32
121
+ kind: method
122
+ exported: true
123
+ signature: "total(): string"
124
+ body:
125
+ ---
126
+ 32 | total(): string {
127
+ 33 | const cents = this.items.reduce((sum, i) => sum + i.priceCents * i.qty, 0);
128
+ 34 | return formatPrice(cents);
129
+ 35 | }
130
+ ---
131
+ ```
132
+
133
+ The line numbers in front of the body are file lines, so an agent can cite or comment on them directly. An ambiguous name lists the candidates and the exact commands that narrow them:
134
+
135
+ ```
136
+ $ ts-axi def parse
137
+ symbol: parse
138
+ matches[2]{file,line,kind,exported,signature}:
139
+ src/lib/parse.ts,1,function,true,"export function parse(input: string): number"
140
+ src/lib/query.ts,1,function,true,"export function parse(q: string): Record<string, string>"
141
+ help[4]:
142
+ - "Run `ts-axi def src/lib/parse.ts:1` for the one in src/lib/parse.ts"
143
+ - "Run `ts-axi def src/lib/query.ts:1` for the one in src/lib/query.ts"
144
+ ...
145
+ ```
146
+
147
+ ### Interfaces are not conflated
148
+
149
+ `findReferences` in the compiler resolves a call like `parser.parse(raw)` (with `parser: FieldParser`) to every implementation of `parse`, and lists the other implementations' declarations as references. For a review that inflates the blast radius badly. ts-axi keeps the split:
150
+
151
+ ```
152
+ $ ts-axi callers DateParser.parse
153
+ symbol: DateParser.parse
154
+ refs_total: 1
155
+ via_interface: 1 (FieldParser.parse)
156
+ refs[1]{file,line,caller,context}:
157
+ src/services/dates.ts,4,dayOf,return new DateParser().parse(x);
158
+
159
+ $ ts-axi refs FieldParser.parse
160
+ refs_total: 1
161
+ via_implementations: 1 (DateParser.parse)
162
+ implementations: 3
163
+ refs[1]{file,line,context}:
164
+ src/parsers/registry.ts,5,return parser.parse(raw);
165
+ ```
166
+
167
+ ### Script-style JavaScript
168
+
169
+ ```
170
+ $ ts-axi outline client/app.js
171
+ decls[4]{line,kind,name,exported,signature}:
172
+ 6,function,CartCtrl,true,"controller CartCtrl($scope, priceService)"
173
+ 12,function,priceService,true,service priceService()
174
+ 13,method,priceService.sum,true,this.sum = function (items)
175
+ 17,function,cartBadge,true,directive cartBadge()
176
+ ```
177
+
178
+ An edit inside `CartCtrl` is reported by `impact` as `CartCtrl body`, not as a file-level change. Names registered by a string have no identifier the checker can follow; `refs` says so and offers `--grep`.
77
179
 
78
180
  ## Monorepos
79
181
 
@@ -91,7 +193,7 @@ Files are listed with `git ls-files` (tracked plus untracked, gitignore applied)
91
193
 
92
194
  ## Test files
93
195
 
94
- A file counts as a test by path pattern. Defaults: `**/*.test.*`, `**/*.spec.*`, `**/__tests__/**`, `**/test/**`, `**/tests/**`, `**/e2e/**`. The home view shows `test_globs` and `test_files` so a wrong rule is visible.
196
+ A file counts as a test by path pattern. Defaults: `**/*.test.*`, `**/*.spec.*`, `**/__tests__/**`, `**/test/**`, `**/tests/**`, `**/e2e/**`, `**/spec/**`, `**/test.*`, `**/*_test.*`. The home view shows `test_globs` and `test_files` so a wrong rule is visible.
95
197
 
96
198
  ## `.ts-axi.json`
97
199
 
@@ -116,6 +218,7 @@ One dependency: the `typescript` compiler API (ADR in `docs/adr/0001-typescript-
116
218
  - `outline`, `def` and the home view only parse files (`ts.createSourceFile`): no tsconfig needed, well under a second.
117
219
  - `refs`, `callers` and `impact` build a `LanguageService` over the nearest `tsconfig.json` (or an implicit project of every source file when none exists, with a warning; in a monorepo or an implicit project of more than 3000 files, over the importer subgraph described above) and ask it for references and call hierarchy. Symbol identity comes from the checker, so `user.save` resolves to `User.save` through imports and re-exports.
118
220
  - `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.
221
+ - 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.
119
222
  - Script-style JavaScript (AngularJS registrations, 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.
120
223
  - Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
121
224
 
@@ -128,7 +231,17 @@ corepack pnpm install
128
231
  corepack pnpm run check # typecheck, tests, build, skill freshness
129
232
  ```
130
233
 
131
- `skills/ts-axi/SKILL.md` is generated: `corepack pnpm run build:skill`. See `PLAN.md` for the spec and `GLOSSARY.md` for the vocabulary.
234
+ `skills/ts-axi/SKILL.md` is generated: `corepack pnpm run build:skill`. See `PLAN.md` for the spec, `GLOSSARY.md` for the vocabulary and `docs/adr/` for the two engine decisions (TypeScript LanguageService over ts-morph / tree-sitter; importer subgraph over a configured project list).
235
+
236
+ ### Tests
237
+
238
+ | Suite | Runs | Covers |
239
+ | --- | --- | --- |
240
+ | `test/unit.test.ts`, `test/workspace.test.ts` | `pnpm test` | target parsing, signatures, change classification, diff parsing, settings, glob matching, reference classification, workspace detection, import resolution |
241
+ | `test/commands.test.ts` | `pnpm test` | the whole CLI through `main()` over five fixtures copied to temp git repos: `test/fixtures/project` (TS, interface + implementations, barrel, JS consumer, `any` access), `test/fixtures/workspace` (Nx + pnpm workspace, `paths`, references-only tsconfigs), `test/fixtures/legacy-js` (AngularJS, Meteor, prototype, IIFE, CommonJS), `test/fixtures/commonjs` (`require('..')` shim, expando methods, `.d.ts` twin), `test/fixtures/esm-js` (`"type": "module"`, `index.js` + `index.d.ts`) |
242
+ | `test/integration/*.test.ts` | `pnpm run test:integration` | the built binary against the vendored Astro + React sample in `test/integration/sample` (installs its own dependencies once; copied to a temp git repo per run, see `test/integration/README.md`) |
243
+
244
+ CI runs the first two on Node 22 and 24, the built CLI on Node 20, and the integration suite on Node 24.
132
245
 
133
246
  ## License
134
247
 
@@ -28,6 +28,7 @@ flags[7]:
28
28
  output:
29
29
  base, head, changes (count), changed[n]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
30
30
  change = signature | removed | type | added | body | modified (before unavailable) - riskiest first
31
+ textual = column added only when an untyped (JavaScript; implicit project) Symbol has more whole-word matches outside the change set than refs_outside - those are uses the checker cannot resolve; - for typed rows; such a Change is never local_only
31
32
  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
32
33
  refs[n]{symbol,file,line,context} = References outside the change set; truncated + refs_shown + refs_outside_total when cut
33
34
  notes[n]{symbol,note} = a signature change of a member that implements an interface or base member (the contract may break)
@@ -254,6 +255,33 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
254
255
  }
255
256
  return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
256
257
  }
258
+ /** The checker may miss uses: JS declaring file, a string-registered name, or no tsconfig at all. */
259
+ export function isUntyped(opened, decl, file) {
260
+ return decl?.nameless === true || opened.index.project.implicit || (file !== undefined && /\.[mc]?jsx?$/.test(file));
261
+ }
262
+ /** Whole-word occurrences of `name` in Project files outside `changeSet`; lines declaring a `name` excluded. */
263
+ export function textualCount(opened, name, changeSet = new Map()) {
264
+ let matches = 0;
265
+ let files = 0;
266
+ for (const abs of opened.index.files) {
267
+ const text = opened.index.text(abs);
268
+ if (text === undefined || !text.includes(name))
269
+ continue;
270
+ const rel = opened.index.rel(abs);
271
+ const declared = new Set(opened.index.declarations(abs).filter((d) => d.name === name).map((d) => d.nameLine));
272
+ const n = wordMatches(text, name).filter((m) => !declared.has(m.line) && !inRanges(changeSet.get(rel), m.line)).length;
273
+ matches += n;
274
+ if (n > 0)
275
+ files++;
276
+ }
277
+ return { matches, files };
278
+ }
279
+ export function textualLine(count) {
280
+ return `${count.matches} matches in ${count.files} files`;
281
+ }
282
+ export function textualHint(command, gap) {
283
+ return `Run \`${command}\` for the ${gap} textual matches the checker cannot resolve (untyped JavaScript)`;
284
+ }
257
285
  function siteKey(site) {
258
286
  return `${site.item.file}#${site.item.selectionSpan.start}`;
259
287
  }
@@ -464,8 +492,17 @@ export async function impactCommand(args, ctx) {
464
492
  row.all = entries.filter((e) => !e.isDefinition);
465
493
  row.outside = row.all.filter((e) => !inRanges(changeSet.get(e.file), e.line)).sort(byPosition);
466
494
  }
495
+ for (const row of rows) {
496
+ if (!row.live || !isUntyped(opened, row.live, row.live.file))
497
+ continue;
498
+ row.textual = textualCount(opened, row.live.name, changeSet);
499
+ if (row.textual.matches > 0)
500
+ row.unresolved = textualMatches(opened, semantic, [row.live.name], known, new Set(), changeSet).length;
501
+ }
502
+ // Textual matches the checker did not resolve: such a Change is never local_only.
503
+ const textualGap = (r) => (r.textual && r.textual.matches > r.outside.length ? r.unresolved ?? 0 : 0);
467
504
  const shownRefs = (r) => (showVia ? [...r.outside, ...r.via].sort(byPosition) : r.outside);
468
- const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0;
505
+ const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0 || textualGap(r) > 0;
469
506
  const listed = rows
470
507
  .filter(hasOutside)
471
508
  .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 }));
@@ -513,7 +550,10 @@ export async function impactCommand(args, ctx) {
513
550
  help.push(`${removedTop.symbol} was removed but is still referenced - those files break`);
514
551
  if (truncated)
515
552
  help.push(`Showing ${refRows.length} of ${outsideTotal} ref rows; run \`${rerun(arg, parsed, `--limit ${Math.min(MAX_LIMIT, outsideTotal)}`)}\` for more`);
516
- if (spots.length > 0 && !grep)
553
+ const gap = listed.reduce((n, r) => n + textualGap(r), 0);
554
+ if (gap > 0 && !grep)
555
+ help.unshift(textualHint(rerun(arg, parsed, "--grep"), gap));
556
+ if (spots.length > 0 && !grep && gap === 0)
517
557
  help.push(`Run \`${rerun(arg, parsed, "--grep")}\` for textual matches the checker cannot see`);
518
558
  if (depth === 1 && rows.some((r) => r.live && isFunctionLike(r.kind) && r.outside.length > 0)) {
519
559
  help.push(`Run \`${rerun(arg, parsed, "--depth 2")}\` for who calls the callers`);
@@ -543,9 +583,10 @@ export async function impactCommand(args, ctx) {
543
583
  refs_total: r.all.length,
544
584
  refs_outside: r.outside.length,
545
585
  via: r.via.length,
586
+ ...(gap > 0 ? { textual: r.textual ? r.textual.matches : "-" } : {}),
546
587
  ...fileCounts(r),
547
588
  })),
548
- refs: refRows,
589
+ ...(refRows.length > 0 ? { refs: refRows } : {}),
549
590
  }
550
591
  : {}),
551
592
  ...(truncated ? { truncated: true, refs_shown: refRows.length, refs_outside_total: outsideTotal } : {}),
@@ -1,7 +1,7 @@
1
1
  import { relative, sep } from "node:path";
2
2
  import { bool, expectPositionals, flag, intFlag, parseArgs } from "../args.js";
3
3
  import { importerClosure } from "../importers.js";
4
- import { textualMatches } from "./impact.js";
4
+ import { isUntyped, textualCount, textualHint, textualLine, textualMatches } from "./impact.js";
5
5
  import { findOutsideProject, nearestTsconfig } from "../project.js";
6
6
  import { viaLine } from "../references.js";
7
7
  import { block, helpBlock, out } from "../render.js";
@@ -21,7 +21,8 @@ const VIA_DOC = `via_interface: n (Iface.member) = References through an interfa
21
21
  matches: 0 under -P lists found_outside_project[n]{file,line} when the name is declared outside that Project
22
22
  refs_total: 0 under -P in a workspace lists found_outside_project[n]{file,line} = importers outside that Project
23
23
  scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
24
- tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references`;
24
+ tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references
25
+ textual: n matches in m files = whole-word count of the name in Project files (JavaScript declarations and implicit projects); above refs_total = uses the checker cannot resolve - see refs --grep`;
25
26
  export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
26
27
  flags[12]:
27
28
  ${SHARED_FLAGS}
@@ -146,7 +147,9 @@ async function run(args, ctx, invoked) {
146
147
  const declaredAt = decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
147
148
  const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
148
149
  const explicit = Boolean(project || opened.settings.project);
149
- const o = { started, symbol, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile, ...(decl ? { decl } : {}) };
150
+ const declaredFile = decl?.file ?? (def && def.line > 0 ? def.file : undefined);
151
+ const textual = declaredFile !== undefined && isUntyped(opened, decl, declaredFile) ? textualCount(opened, symbol.split(".").pop()) : undefined;
152
+ const o = { started, symbol, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile, ...(decl ? { decl } : {}), ...(textual ? { textual } : {}) };
150
153
  return mode === "calls"
151
154
  ? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
152
155
  : renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
@@ -210,6 +213,23 @@ function viaFields(via, bases) {
210
213
  ...(impl.length > 0 ? { via_implementations: viaLine(impl.length, impl.map((v) => v.via)) } : {}),
211
214
  };
212
215
  }
216
+ /** Whole-word matches of the name on lines that hold no known position (what `--grep` lists). */
217
+ function unresolvedOf(opened, semantic, o, known) {
218
+ return textualMatches(opened, semantic, [o.symbol.split(".").pop()], new Set(known.map((r) => `${r.file}:${r.line}`)), new Set(o.decl ? [`${o.decl.file}:${o.decl.line}`, `${o.decl.file}:${o.decl.nameLine}`] : []), new Map());
219
+ }
220
+ /**
221
+ * `textual` field and, when the name occurs more often than refs_total and some matches sit on no
222
+ * Reference line (`unresolved` = the `--grep` rows), the `--grep` hint.
223
+ */
224
+ function textualFields(o, refsTotal, unresolved, grepShown) {
225
+ if (!o.textual)
226
+ return { fields: {}, help: [] };
227
+ const gap = o.textual.matches > refsTotal ? unresolved : 0;
228
+ return {
229
+ fields: { textual: textualLine(o.textual) },
230
+ help: gap > 0 && !grepShown ? [textualHint(`ts-axi refs ${o.declaredAt} --grep`, gap)] : [],
231
+ };
232
+ }
213
233
  /** Direct rows, plus via rows (with a `via` column) under `--via`. */
214
234
  function mergedRows(direct, via, show) {
215
235
  return show.via ? sortRows([...direct, ...via]) : direct;
@@ -236,10 +256,11 @@ function renderRefs(semantic, opened, resolved, header, o) {
236
256
  const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
237
257
  help.unshift(...outside.help);
238
258
  const word = o.symbol.split(".").pop();
239
- const unresolved = o.show.grep
240
- ? textualMatches(opened, semantic, [word], new Set([...rows, ...via, ...impls].map((r) => `${r.file}:${r.line}`)), new Set(o.decl ? [`${o.decl.file}:${o.decl.line}`, `${o.decl.file}:${o.decl.nameLine}`] : []), new Map())
241
- : [];
242
- if (o.decl?.nameless && !o.show.grep)
259
+ const unresolved = o.show.grep || o.textual ? unresolvedOf(opened, semantic, o, [...rows, ...via, ...impls]) : [];
260
+ const text = textualFields(o, rows.length, unresolved.length, o.show.grep);
261
+ if (text.help.length > 0)
262
+ help.unshift(...text.help);
263
+ else if (o.decl?.nameless && !o.show.grep)
243
264
  help.unshift(`Run \`ts-axi refs ${o.declaredAt} --grep\` for textual matches of '${word}'`);
244
265
  if (isFunctionLike(o.kind))
245
266
  help.push(`Run \`ts-axi callers ${o.declaredAt}\` for call sites with their enclosing function`);
@@ -249,6 +270,7 @@ function renderRefs(semantic, opened, resolved, header, o) {
249
270
  ...header,
250
271
  ...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
251
272
  refs_total: rows.length,
273
+ ...text.fields,
252
274
  files,
253
275
  tests,
254
276
  ...viaFields(via, split.bases),
@@ -306,8 +328,17 @@ function depthRows(sites) {
306
328
  }
307
329
  function renderCallers(semantic, opened, resolved, header, o) {
308
330
  const sites = semantic.incomingCalls(resolved.absFile, resolved.pos);
331
+ // Every Reference line counts as known: the gap is what `refs --grep` would list.
332
+ const callerText = o.textual
333
+ ? (() => {
334
+ const split = semantic.referenceSplit(resolved.absFile, resolved.pos);
335
+ const known = [...split.direct, ...split.declarations, ...split.via, ...split.implementations, ...split.dropped, ...(sites ?? [])];
336
+ const calls = (sites ?? []).filter((s) => s.via === undefined).length;
337
+ return textualFields(o, calls, unresolvedOf(opened, semantic, o, known).length, false);
338
+ })()
339
+ : { fields: {}, help: [] };
309
340
  if (sites === undefined) {
310
- return out(block({ ...header, refs_total: 0, note: "not callable - no call hierarchy for this Symbol", ...scopeFields(o.scope), ...footer(semantic, o.started, []) }), helpBlock([`Run \`ts-axi refs ${o.declaredAt}\` for every Reference`]));
341
+ 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`]));
311
342
  }
312
343
  const rows = sortRows(sites.filter((s) => s.via === undefined));
313
344
  const via = sortRows(sites.filter((s) => s.via !== undefined));
@@ -338,12 +369,14 @@ function renderCallers(semantic, opened, resolved, header, o) {
338
369
  help.push(`Run \`ts-axi callers ${o.declaredAt} --depth 2\` for who calls the callers`);
339
370
  const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
340
371
  help.unshift(...outside.help);
372
+ help.unshift(...callerText.help);
341
373
  help.push(`Run \`ts-axi refs ${o.declaredAt}${o.decl?.nameless ? " --grep" : ""}\` for every Reference (not only calls)`);
342
374
  help.push(...scopeHelp(o.scope));
343
375
  return out(block({
344
376
  ...header,
345
377
  ...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
346
378
  refs_total: rows.length,
379
+ ...callerText.fields,
347
380
  files,
348
381
  tests,
349
382
  ...viaFields(via, []),
@@ -72,6 +72,10 @@ function isSourceFile(name) {
72
72
  export function walkSourceFiles(root) {
73
73
  return listFiles(root, isSourceFile).files;
74
74
  }
75
+ // `Bundler` resolves `require('..')` (directory `main`/`index.js`), ESM `./x.js` specifiers and
76
+ // `"type": "module"` packages alike; NodeNext/Node10 gave the same References on CommonJS and ESM
77
+ // samples. What breaks plain JavaScript is not the option set but `.d.ts` twins and
78
+ // `module.exports = require()` shims, handled by `resolveModuleNameLiterals` in the host.
75
79
  export const IMPLICIT_COMPILER_OPTIONS = {
76
80
  allowJs: true,
77
81
  checkJs: false,
@@ -278,6 +282,80 @@ export function createSyntacticIndex(root, project) {
278
282
  return index;
279
283
  }
280
284
  const FULL_DIAGNOSTICS_LIMIT = 300;
285
+ const DECLARATION_TWINS = [
286
+ [/\.d\.ts$/, [[".js", ts.Extension.Js], [".jsx", ts.Extension.Jsx], [".mjs", ts.Extension.Mjs], [".cjs", ts.Extension.Cjs]]],
287
+ [/\.d\.mts$/, [[".mjs", ts.Extension.Mjs]]],
288
+ [/\.d\.cts$/, [[".cjs", ts.Extension.Cjs]]],
289
+ ];
290
+ /** The implementation beside a `.d.ts` (`index.d.ts` → `index.js`) when it is a Project source file. */
291
+ function implementationTwin(file, isSource) {
292
+ for (const [re, impls] of DECLARATION_TWINS) {
293
+ if (!re.test(file))
294
+ continue;
295
+ const base = file.replace(re, "");
296
+ for (const [ext, extension] of impls)
297
+ if (isSource(base + ext))
298
+ return { file: base + ext, extension };
299
+ }
300
+ return undefined;
301
+ }
302
+ /** `'x'` of a module whose only statement is `module.exports = require('x')` (`exports = module.exports = ...` too). */
303
+ export function passThroughRequire(sf) {
304
+ const statements = sf.statements.filter((s) => !(ts.isExpressionStatement(s) && ts.isStringLiteral(s.expression)));
305
+ const only = statements.length === 1 ? statements[0] : undefined;
306
+ if (!only || !ts.isExpressionStatement(only))
307
+ return undefined;
308
+ let expr = only.expression;
309
+ let assignsModuleExports = false;
310
+ while (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
311
+ const left = expr.left;
312
+ if (ts.isPropertyAccessExpression(left) && ts.isIdentifier(left.expression) && left.expression.text === "module" && left.name.text === "exports") {
313
+ assignsModuleExports = true;
314
+ }
315
+ else if (!(ts.isIdentifier(left) && left.text === "exports"))
316
+ return undefined;
317
+ expr = expr.right;
318
+ }
319
+ if (!assignsModuleExports || !ts.isCallExpression(expr) || !ts.isIdentifier(expr.expression) || expr.expression.text !== "require")
320
+ return undefined;
321
+ const [arg] = expr.arguments;
322
+ return expr.arguments.length === 1 && arg && ts.isStringLiteralLike(arg) ? arg.text : undefined;
323
+ }
324
+ const MAX_SHIM_HOPS = 3;
325
+ /**
326
+ * TypeScript's resolution, then two substitutions so References reach implementations:
327
+ * a `.d.ts` with a JS twin in the Project resolves to the twin (TS tries `.d.ts` before `.js`),
328
+ * and a `module.exports = require('x')` shim resolves to `x` (the binder makes no alias of it).
329
+ */
330
+ function createModuleResolver(options, currentDirectory, isSource, sourceOf) {
331
+ const cache = ts.createModuleResolutionCache(currentDirectory, (s) => (ts.sys.useCaseSensitiveFileNames ? s : s.toLowerCase()), options);
332
+ const resolveOne = (name, containingFile, mode, redirected, hops) => {
333
+ const result = ts.resolveModuleName(name, containingFile, options, ts.sys, cache, redirected, mode);
334
+ const found = result.resolvedModule;
335
+ if (!found)
336
+ return result;
337
+ let resolvedFileName = resolve(found.resolvedFileName);
338
+ let extension = found.extension;
339
+ const twin = implementationTwin(resolvedFileName, isSource);
340
+ if (twin)
341
+ ({ file: resolvedFileName, extension } = twin);
342
+ else if (!isSource(resolvedFileName))
343
+ return result;
344
+ if (hops < MAX_SHIM_HOPS && /\.[mc]?jsx?$/.test(resolvedFileName)) {
345
+ const sf = sourceOf(resolvedFileName);
346
+ const inner = sf ? passThroughRequire(sf) : undefined;
347
+ if (inner !== undefined) {
348
+ const next = resolveOne(inner, resolvedFileName, ts.ModuleKind.CommonJS, redirected, hops + 1);
349
+ if (next.resolvedModule && isSource(resolve(next.resolvedModule.resolvedFileName)))
350
+ return next;
351
+ }
352
+ }
353
+ if (!twin)
354
+ return result;
355
+ return { ...result, resolvedModule: { ...found, resolvedFileName, extension, isExternalLibraryImport: false } };
356
+ };
357
+ return (literals, containingFile, redirected, opts, containingSourceFile) => literals.map((literal) => resolveOne(literal.text, containingFile, ts.getModeForUsageLocation(containingSourceFile, literal, opts), redirected, 0));
358
+ }
281
359
  export function createSemanticProject(index, scope) {
282
360
  const started = performance.now();
283
361
  const { project } = index;
@@ -306,6 +384,7 @@ export function createSemanticProject(index, scope) {
306
384
  getDirectories: ts.sys.getDirectories,
307
385
  realpath: ts.sys.realpath,
308
386
  useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
387
+ resolveModuleNameLiterals: createModuleResolver(options, currentDirectory, (f) => indexed.has(f), (f) => index.sourceFile(f)),
309
388
  };
310
389
  const service = ts.createLanguageService(host, ts.createDocumentRegistry());
311
390
  const program = service.getProgram();
@@ -406,8 +485,34 @@ export function createSemanticProject(index, scope) {
406
485
  }
407
486
  return [...keys];
408
487
  };
488
+ /**
489
+ * The same-named top-level Declaration in the `.d.ts` beside a JS file, when that `.d.ts` is in the
490
+ * program: importers the resolver did not redirect (a package.json `types` field) bind to it.
491
+ */
492
+ const declarationTwin = (absFile, pos) => {
493
+ if (!/\.[mc]?jsx?$/.test(absFile))
494
+ return undefined;
495
+ const sf = program.getSourceFile(absFile);
496
+ const id = sf ? nodeAt(sf, pos) : undefined;
497
+ if (!id || !ts.isIdentifier(id))
498
+ return undefined;
499
+ const base = absFile.replace(/\.([mc]?)jsx?$/, "");
500
+ const twin = [".d.ts", ".d.mts", ".d.cts"].map((ext) => program.getSourceFile(base + ext)).find((t) => t !== undefined);
501
+ if (!twin)
502
+ return undefined;
503
+ for (const statement of twin.statements) {
504
+ const names = ts.isVariableStatement(statement)
505
+ ? statement.declarationList.declarations.map((d) => d.name)
506
+ : "name" in statement && statement.name ? [statement.name] : [];
507
+ const hit = names.find((n) => ts.isIdentifier(n) && n.text === id.text);
508
+ if (hit)
509
+ return { file: twin.fileName, pos: hit.getStart(twin) };
510
+ }
511
+ return undefined;
512
+ };
409
513
  const split = (absFile, pos) => {
410
- const groups = service.findReferences(absFile, pos) ?? [];
514
+ const twin = declarationTwin(absFile, pos);
515
+ const groups = [...(service.findReferences(absFile, pos) ?? []), ...(twin ? service.findReferences(twin.file, twin.pos) ?? [] : [])];
411
516
  const descriptors = [];
412
517
  let target = -1;
413
518
  let flagged = -1;
@@ -514,7 +619,15 @@ export function createSemanticProject(index, scope) {
514
619
  const items = prepared === undefined ? [] : Array.isArray(prepared) ? prepared : [prepared];
515
620
  if (items.length === 0)
516
621
  return undefined;
517
- return items.flatMap((item) => splitSites(item.file, item.selectionSpan.start, service.provideCallHierarchyIncomingCalls(item.file, item.selectionSpan.start)));
622
+ const twin = declarationTwin(absFile, pos);
623
+ const twinCalls = twin ? service.provideCallHierarchyIncomingCalls(twin.file, twin.pos) : [];
624
+ const seen = new Set();
625
+ return items
626
+ .flatMap((item) => splitSites(item.file, item.selectionSpan.start, [...service.provideCallHierarchyIncomingCalls(item.file, item.selectionSpan.start), ...twinCalls]))
627
+ .filter((site) => {
628
+ const key = `${site.file}:${site.line}:${site.col}`;
629
+ return !seen.has(key) && Boolean(seen.add(key));
630
+ });
518
631
  },
519
632
  incomingCallsOf(item) {
520
633
  if (item.kind === ts.ScriptElementKind.scriptElement)
@@ -11,6 +11,9 @@ export const DEFAULT_TEST_GLOBS = [
11
11
  "**/test/**",
12
12
  "**/tests/**",
13
13
  "**/e2e/**",
14
+ "**/spec/**",
15
+ "**/test.*",
16
+ "**/*_test.*",
14
17
  ];
15
18
  export const SETTINGS_FILE = ".ts-axi.json";
16
19
  export const SETTINGS_KEYS = ["testGlobs", "testGlobsReplace", "project"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-axi",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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": [