ts-axi 0.1.5 → 0.1.6

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
@@ -41,7 +41,7 @@ Read-only: no lint, no refactor, no rename.
41
41
 
42
42
  Early, but already used in real reviews. What the numbers say so far:
43
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.
44
+ - **Correctness on the test corpus.** 163 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
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
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
47
 
@@ -175,7 +175,7 @@ decls[4]{line,kind,name,exported,signature}:
175
175
  17,function,cartBadge,true,directive cartBadge()
176
176
  ```
177
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`.
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`. ui-router and ngRoute states are Declarations of kind `state` (`states.cart`, with callbacks as `states.cart.resolve.items`), so an edit inside a `resolve` names that callback. Directives, components, their `scope`/`bindings` keys, controllers and filters also get `templates: <n> usages in <m> files` (kebab-case elements and attributes, `ng-controller`, `| filter` in `.html`/`.htm`/`.ejs`/`.hbs`/`.njk`/`.pug`), a `templates` column in `impact`, and a changed template that uses a changed directive or binding is listed under `template_changes` instead of `skipped`.
179
179
 
180
180
  ## Monorepos
181
181
 
@@ -219,7 +219,7 @@ One dependency: the `typescript` compiler API (ADR in `docs/adr/0001-typescript-
219
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.
220
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
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.
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.
222
+ - Script-style JavaScript (AngularJS registrations and 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.
223
223
  - Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
224
224
 
225
225
  Every semantic command starts cold and prints `elapsed_ms`.
@@ -5,7 +5,7 @@ import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.j
5
5
  import { BODY_LINES, declarationAt, filterSuffix, MATCH_ROWS, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, } from "./common.js";
6
6
  export const DEF_HELP = `usage: ts-axi def <name | Class.member | path:line[:col]> [flags]
7
7
  flags[7]:
8
- --kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace>, --in <dir|glob> (repeatable; restrict to files), --exported, --fuzzy (substring, case-insensitive), --full (whole body; default first ${BODY_LINES} lines), -P <tsconfig>, --json
8
+ --kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace|state>, --in <dir|glob> (repeatable; restrict to files), --exported, --fuzzy (substring, case-insensitive), --full (whole body; default first ${BODY_LINES} lines), -P <tsconfig>, --json
9
9
  output:
10
10
  1 match = file, line, kind, exported, signature + body fenced with file line numbers
11
11
  several = matches[n]{file,line,kind,exported,signature} (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms (--in, --kind, Class.member, --exported) with how many each keeps
@@ -11,6 +11,7 @@ import { block, helpBlock, out } from "../render.js";
11
11
  import { isTestFile } from "../settings.js";
12
12
  import { isFunctionLike } from "../symbols.js";
13
13
  import { IMPACT_HOPS, scopeFields, scopeHelp, semanticFor } from "../scope.js";
14
+ import { isTemplatePath, loadTemplates, templateTarget, templateUsages, usagesIn } from "../templates.js";
14
15
  import { walkFiles } from "../walk.js";
15
16
  import { contextOf, lineOf, open, projectFields } from "./common.js";
16
17
  const DEFAULT_LIMIT = 50;
@@ -34,6 +35,8 @@ output:
34
35
  notes[n]{symbol,note} = a signature change of a member that implements an interface or base member (the contract may break)
35
36
  local_only = Changes with no Reference outside the change set; file_level[n]{file,lines} = changed lines outside any Declaration
36
37
  blind_spots[n]{symbol,file,line,reason} = uses the checker cannot follow (any; string keys; unresolved export *)
38
+ templates = column added only when a changed Symbol is one templates name (directive, component, binding, controller, filter): its usages in .html/.htm/.ejs/.hbs/.njk/.pug files; - for other rows; such a Change is never local_only
39
+ template_changes[n]{file,symbols} = templates in the diff that use a changed directive, binding, controller or filter - review them with that change
37
40
  skipped[n]{file,reason} = files in the diff that were not analysed (.astro, .json, outside the Project)
38
41
  tests: 0 on an exported signature or body change = no Test file references it
39
42
  scope = in a monorepo without -P the program is the touched files + two levels of importers (scope_partial: true when capped)
@@ -255,8 +258,13 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
255
258
  }
256
259
  return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
257
260
  }
258
- /** The checker may miss uses: JS declaring file, a string-registered name, or no tsconfig at all. */
261
+ /**
262
+ * The checker may miss uses: JS declaring file, a string-registered name, or no tsconfig at all.
263
+ * A state's hooks (`onEnter`, inline `controller`) are called by the router, never by name: no textual count.
264
+ */
259
265
  export function isUntyped(opened, decl, file) {
266
+ if (decl?.parentName !== undefined && /^(states|routes)\./.test(decl.parentName) && !decl.parentName.endsWith(".resolve"))
267
+ return false;
260
268
  return decl?.nameless === true || opened.index.project.implicit || (file !== undefined && /\.[mc]?jsx?$/.test(file));
261
269
  }
262
270
  /** Whole-word occurrences of `name` in Project files outside `changeSet`; lines declaring a `name` excluded. */
@@ -282,6 +290,22 @@ export function textualLine(count) {
282
290
  export function textualHint(command, gap) {
283
291
  return `Run \`${command}\` for the ${gap} textual matches the checker cannot resolve (untyped JavaScript)`;
284
292
  }
293
+ /** `--grep` rows plus template usages of the templated rows, one per file:line. */
294
+ function withTemplateRows(textRows, rows, files) {
295
+ const texts = new Map(files.map((f) => [f.rel, f.text]));
296
+ const seen = new Set(textRows.map((u) => `${u.file}:${u.line}`));
297
+ const result = [...textRows];
298
+ for (const row of rows) {
299
+ for (const u of row.templates ?? []) {
300
+ const key = `${u.file}:${u.line}`;
301
+ if (seen.has(key))
302
+ continue;
303
+ seen.add(key);
304
+ result.push({ file: u.file, line: u.line, context: contextOf(lineOf(texts.get(u.file), u.line), u.col) });
305
+ }
306
+ }
307
+ return result.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
308
+ }
285
309
  function siteKey(site) {
286
310
  return `${site.item.file}#${site.item.selectionSpan.start}`;
287
311
  }
@@ -390,9 +414,13 @@ export async function impactCommand(args, ctx) {
390
414
  ...input.missing.map((file) => ({ file, reason: "not in the working tree" })),
391
415
  ];
392
416
  const analysed = [];
417
+ // Templates stay skipped unless they use a changed Symbol (then template_changes).
418
+ const templateInputs = [];
393
419
  for (const file of input.files) {
394
420
  if (/\.d\.[mc]?ts$/.test(file.path))
395
421
  skipped.push({ file: file.path, reason: "declaration file" });
422
+ else if (isTemplatePath(file.path))
423
+ templateInputs.push(file);
396
424
  else if (!isSourcePath(file.path))
397
425
  skipped.push({ file: file.path, reason: "not TS or JS source" });
398
426
  else if (file.status !== "deleted" && !projectFiles.has(opened.index.abs(file.path)))
@@ -431,8 +459,14 @@ export async function impactCommand(args, ctx) {
431
459
  head: input.head,
432
460
  changes: total,
433
461
  };
434
- const skippedBlock = skipped.length > 0 ? capped("skipped", skipped) : {};
462
+ const skipTemplates = (used) => {
463
+ for (const f of templateInputs)
464
+ if (!used.has(f.path))
465
+ skipped.push({ file: f.path, reason: "not TS or JS source" });
466
+ };
435
467
  if (declChanges.length === 0) {
468
+ skipTemplates(new Set());
469
+ const skippedBlock = skipped.length > 0 ? capped("skipped", skipped) : {};
436
470
  const help = total === 0
437
471
  ? [arg === undefined && !staged ? "Run `ts-axi impact main...HEAD` to analyse a branch against main" : "Run `ts-axi impact` for uncommitted changes"]
438
472
  : ["Changes touch imports or module-level statements only - read those lines directly"];
@@ -499,10 +533,36 @@ export async function impactCommand(args, ctx) {
499
533
  if (row.textual.matches > 0)
500
534
  row.unresolved = textualMatches(opened, semantic, [row.live.name], known, new Set(), changeSet).length;
501
535
  }
536
+ // Template usages: directives, components, bindings, controllers and filters named in .html and friends.
537
+ let templateFiles;
538
+ for (const row of rows) {
539
+ const decl = row.live ?? row.change.before;
540
+ const fileDecls = [
541
+ ...opened.index.declarations(opened.index.abs(row.change.file)),
542
+ ...declChanges.filter((c) => c.file === row.change.file).flatMap((c) => (c.before ? [c.before] : [])),
543
+ ];
544
+ const target = templateTarget(decl, fileDecls);
545
+ if (!target)
546
+ continue;
547
+ templateFiles ??= loadTemplates(opened.root);
548
+ row.template = target;
549
+ row.templates = templateUsages(templateFiles, target);
550
+ }
551
+ const templateChanges = [];
552
+ for (const f of templateInputs) {
553
+ const text = f.afterText ?? f.beforeText;
554
+ if (typeof text !== "string")
555
+ continue;
556
+ const symbols = [...new Set(rows.filter((r) => r.template && usagesIn(text, r.template).length > 0).map((r) => r.symbol))];
557
+ if (symbols.length > 0)
558
+ templateChanges.push({ file: f.path, symbols: symbols.join("; ") });
559
+ }
560
+ skipTemplates(new Set(templateChanges.map((t) => t.file)));
561
+ const skippedBlock = skipped.length > 0 ? capped("skipped", skipped) : {};
502
562
  // Textual matches the checker did not resolve: such a Change is never local_only.
503
563
  const textualGap = (r) => (r.textual && r.textual.matches > r.outside.length ? r.unresolved ?? 0 : 0);
504
564
  const shownRefs = (r) => (showVia ? [...r.outside, ...r.via].sort(byPosition) : r.outside);
505
- const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0 || textualGap(r) > 0;
565
+ const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0 || textualGap(r) > 0 || (r.templates?.length ?? 0) > 0;
506
566
  const listed = rows
507
567
  .filter(hasOutside)
508
568
  .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 }));
@@ -531,9 +591,10 @@ export async function impactCommand(args, ctx) {
531
591
  names.set(name, r.symbol);
532
592
  }
533
593
  const spots = blindSpots(semantic, names, changeSet);
534
- const unresolved = grep
594
+ const textRows = grep
535
595
  ? textualMatches(opened, semantic, [...names.keys()], known, new Set(spots.map((s) => `${s.file}:${s.line}`)), changeSet)
536
596
  : [];
597
+ const unresolved = grep ? withTemplateRows(textRows, rows, templateFiles ?? []) : [];
537
598
  const touched = [...new Set([...analysed.filter((f) => f.status !== "deleted").map((f) => f.path), ...listed.flatMap((r) => r.outside.map((e) => e.file))])];
538
599
  const diagnostics = semantic.diagnosticsCount(touched);
539
600
  // --- Help ---
@@ -561,6 +622,11 @@ export async function impactCommand(args, ctx) {
561
622
  if (!showVia && listed.some((r) => r.via.length > 0)) {
562
623
  help.push(`Run \`${rerun(arg, parsed, "--via")}\` for References through an interface or base member - they may reach the changed code`);
563
624
  }
625
+ const templated = listed.filter((r) => (r.templates?.length ?? 0) > 0).sort((a, b) => b.templates.length - a.templates.length)[0];
626
+ if (templated && !grep)
627
+ help.push(`Run \`ts-axi refs ${templated.at} --grep\` for the ${templated.templates.length} template usages of ${templated.symbol}`);
628
+ if (templateChanges.length > 0)
629
+ help.push("template_changes use a changed directive or binding - review each against that change");
564
630
  if (skipped.length > 0)
565
631
  help.push("Skipped files are not TS or JS source of the Project - review them by hand");
566
632
  const specHint = specFilesHint(ctx.cwd, opened, skipped, listed.every((r) => fileCounts(r).tests === 0));
@@ -584,6 +650,7 @@ export async function impactCommand(args, ctx) {
584
650
  refs_outside: r.outside.length,
585
651
  via: r.via.length,
586
652
  ...(gap > 0 ? { textual: r.textual ? r.textual.matches : "-" } : {}),
653
+ ...(rows.some((x) => x.template) ? { templates: r.templates ? r.templates.length : "-" } : {}),
587
654
  ...fileCounts(r),
588
655
  })),
589
656
  ...(refRows.length > 0 ? { refs: refRows } : {}),
@@ -593,6 +660,7 @@ export async function impactCommand(args, ctx) {
593
660
  ...(notes.length > 0 ? { notes } : {}),
594
661
  ...(localOnly.length > 0 ? { local_only: localOnlyLine(localOnly.map((r) => `${r.symbol} ${r.change.change}`)) } : {}),
595
662
  ...(fileLevel.length > 0 ? { file_level: fileLevel } : {}),
663
+ ...(templateChanges.length > 0 ? capped("template_changes", templateChanges) : {}),
596
664
  ...capped("blind_spots", spots),
597
665
  ...(grep ? capped("unresolved", unresolved) : {}),
598
666
  ...depthLevels(semantic, rows, depth),
@@ -12,6 +12,7 @@ flags[3]:
12
12
  output:
13
13
  one file = file + decls[n]{line,kind,name,exported,signature}; several = decls[n]{file,line,kind,name,exported,signature}
14
14
  class members follow their class as Class.member; at most ${MAX_ROWS} rows
15
+ kind state = a ui-router / ngRoute state as states.<name> (routes.<path>); its callbacks follow as states.<name>.resolve.<key> / states.<name>.onEnter
15
16
  examples[3]:
16
17
  ts-axi outline src/models/user.ts
17
18
  ts-axi outline src/models/user.ts --exported
@@ -6,6 +6,7 @@ import { findOutsideProject, nearestTsconfig } from "../project.js";
6
6
  import { viaLine } from "../references.js";
7
7
  import { block, helpBlock, out } from "../render.js";
8
8
  import { importerIndexFor, REFS_HOPS, scopeFields, scopeHelp, semanticFor } from "../scope.js";
9
+ import { loadTemplates, templatesLine, templateTarget, templateUsages } from "../templates.js";
9
10
  import { byDepthThenPath } from "../walk.js";
10
11
  import { detectWorkspace, isMultiProject } from "../workspace.js";
11
12
  import { isTestFile } from "../settings.js";
@@ -14,7 +15,7 @@ import { contextOf, declarationAt, filterSuffix, lineOf, MATCH_ROWS, matchRows,
14
15
  const DEFAULT_LIMIT = 50;
15
16
  const MAX_LIMIT = 500;
16
17
  const DEPTH_ROWS = 50;
17
- 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`;
18
+ 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>, --json`;
18
19
  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
19
20
  via_implementations: n (Impl.member) = References to implementations or overrides of the Symbol; not in refs_total
20
21
  implementations: n = members that implement or override the Symbol (their Declarations are never refs)
@@ -22,7 +23,8 @@ const VIA_DOC = `via_interface: n (Iface.member) = References through an interfa
22
23
  refs_total: 0 under -P in a workspace lists found_outside_project[n]{file,line} = importers outside that Project
23
24
  scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
24
25
  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`;
26
+ 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
27
+ 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); refs --grep lists them in unresolved`;
26
28
  export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
27
29
  flags[12]:
28
30
  ${SHARED_FLAGS}
@@ -149,11 +151,35 @@ async function run(args, ctx, invoked) {
149
151
  const explicit = Boolean(project || opened.settings.project);
150
152
  const declaredFile = decl?.file ?? (def && def.line > 0 ? def.file : undefined);
151
153
  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 } : {}) };
154
+ const tplTarget = decl ? templateTarget(decl, opened.index.declarations(opened.index.abs(decl.file))) : undefined;
155
+ const templates = tplTarget ? templateRows(loadTemplates(opened.root), tplTarget) : undefined;
156
+ const o = { started, symbol, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile, ...(decl ? { decl } : {}), ...(textual ? { textual } : {}), ...(templates ? { templates } : {}) };
153
157
  return mode === "calls"
154
158
  ? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
155
159
  : renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
156
160
  }
161
+ /** Template usages as `--grep` rows. */
162
+ export function templateRows(files, target) {
163
+ const texts = new Map(files.map((f) => [f.rel, f.text]));
164
+ return templateUsages(files, target).map((u) => ({ file: u.file, line: u.line, context: contextOf(lineOf(texts.get(u.file), u.line), u.col) }));
165
+ }
166
+ /** `templates` field and the hint to list them. */
167
+ function templateFields(o, grepShown) {
168
+ if (!o.templates)
169
+ return { fields: {}, help: [] };
170
+ const n = o.templates.length;
171
+ return {
172
+ fields: { templates: templatesLine(o.templates) },
173
+ help: n > 0 && !grepShown ? [`Run \`ts-axi refs ${o.declaredAt} --grep\` for the ${n} template usages`] : [],
174
+ };
175
+ }
176
+ /** Textual rows plus template rows, one per file:line, sorted. */
177
+ function withTemplates(unresolved, templates) {
178
+ if (!templates || templates.length === 0)
179
+ return unresolved;
180
+ const seen = new Set(unresolved.map((u) => `${u.file}:${u.line}`));
181
+ return [...unresolved, ...templates.filter((t) => !seen.has(`${t.file}:${t.line}`))].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
182
+ }
157
183
  const OUTSIDE_ROWS = 5;
158
184
  /**
159
185
  * No References under an explicit Project: importers of the declaring file (or of a barrel
@@ -256,12 +282,16 @@ function renderRefs(semantic, opened, resolved, header, o) {
256
282
  const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
257
283
  help.unshift(...outside.help);
258
284
  const word = o.symbol.split(".").pop();
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);
285
+ const unresolved = o.show.grep || o.textual || o.templates ? unresolvedOf(opened, semantic, o, [...rows, ...via, ...impls]) : [];
286
+ const grepRows = withTemplates(unresolved, o.templates);
287
+ // The --grep hint counts what --grep lists: textual and template rows together.
288
+ const text = textualFields(o, rows.length, grepRows.length, o.show.grep);
289
+ const tpl = templateFields(o, o.show.grep || text.help.length > 0);
261
290
  if (text.help.length > 0)
262
291
  help.unshift(...text.help);
263
- else if (o.decl?.nameless && !o.show.grep)
292
+ else if (o.decl?.nameless && !o.show.grep && tpl.help.length === 0)
264
293
  help.unshift(`Run \`ts-axi refs ${o.declaredAt} --grep\` for textual matches of '${word}'`);
294
+ help.unshift(...tpl.help);
265
295
  if (isFunctionLike(o.kind))
266
296
  help.push(`Run \`ts-axi callers ${o.declaredAt}\` for call sites with their enclosing function`);
267
297
  help.push(`Run \`ts-axi def ${o.declaredAt}\` for the declaration body`);
@@ -271,6 +301,7 @@ function renderRefs(semantic, opened, resolved, header, o) {
271
301
  ...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
272
302
  refs_total: rows.length,
273
303
  ...text.fields,
304
+ ...tpl.fields,
274
305
  files,
275
306
  tests,
276
307
  ...viaFields(via, split.bases),
@@ -288,8 +319,8 @@ function renderRefs(semantic, opened, resolved, header, o) {
288
319
  })),
289
320
  }),
290
321
  ...(truncated ? { truncated: true } : {}),
291
- ...(o.show.grep ? { unresolved: unresolved.length === 0 ? 0 : unresolved.slice(0, o.limit) } : {}),
292
- ...(unresolved.length > o.limit ? { unresolved_total: unresolved.length } : {}),
322
+ ...(o.show.grep ? { unresolved: grepRows.length === 0 ? 0 : grepRows.slice(0, o.limit) } : {}),
323
+ ...(grepRows.length > o.limit ? { unresolved_total: grepRows.length } : {}),
293
324
  ...outside.fields,
294
325
  ...scopeFields(o.scope),
295
326
  ...footer(semantic, o.started, fileList),
@@ -334,9 +365,12 @@ function renderCallers(semantic, opened, resolved, header, o) {
334
365
  const split = semantic.referenceSplit(resolved.absFile, resolved.pos);
335
366
  const known = [...split.direct, ...split.declarations, ...split.via, ...split.implementations, ...split.dropped, ...(sites ?? [])];
336
367
  const calls = (sites ?? []).filter((s) => s.via === undefined).length;
337
- return textualFields(o, calls, unresolvedOf(opened, semantic, o, known).length, false);
368
+ return textualFields(o, calls, withTemplates(unresolvedOf(opened, semantic, o, known), o.templates).length, false);
338
369
  })()
339
370
  : { fields: {}, help: [] };
371
+ const tpl = templateFields(o, callerText.help.length > 0);
372
+ callerText.fields = { ...callerText.fields, ...tpl.fields };
373
+ callerText.help = [...tpl.help, ...callerText.help];
340
374
  if (sites === undefined) {
341
375
  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`]));
342
376
  }
@@ -254,7 +254,9 @@ export function createSyntacticIndex(root, project) {
254
254
  return list;
255
255
  },
256
256
  findDeclarations(query) {
257
- const needle = query.fuzzy ? query.name.toLowerCase() : query.name;
257
+ // A nested holder (`states.cart`) may never be spelled in the file: prefilter on the member.
258
+ const word = query.member ?? query.name;
259
+ const needle = query.fuzzy ? word.toLowerCase() : word;
258
260
  const result = [];
259
261
  for (const file of index.files) {
260
262
  const text = index.text(file);
@@ -13,6 +13,7 @@ export const KINDS = [
13
13
  "enum",
14
14
  "enum-member",
15
15
  "namespace",
16
+ "state",
16
17
  ];
17
18
  export const SIGNATURE_MAX = 160;
18
19
  const FUNCTION_LIKE = new Set(["function", "method"]);
@@ -228,6 +229,11 @@ function nameText(name, sf) {
228
229
  const REGISTRATION_VERBS = new Set(["controller", "service", "factory", "directive", "filter", "component", "run", "config", "provider"]);
229
230
  /** Verbs whose callback is a constructor: its `this.x = function` statements are members. */
230
231
  const CONSTRUCTOR_VERBS = new Set(["controller", "service", "provider"]);
232
+ /** Router providers whose `.state(...)` / `.when(...)` calls declare a state (ui-router, ngRoute). */
233
+ const STATE_PROVIDER = /^\$?stateProvider$/i;
234
+ const ROUTE_PROVIDER = /^\$?routeProvider$/i;
235
+ /** Directive definition keys whose object literal lists the bindings. */
236
+ const BINDING_KEYS = new Set(["scope", "bindToController", "bindings"]);
231
237
  /** `Template.x.<hook>(fn)` lifecycle callbacks. */
232
238
  const TEMPLATE_HOOKS = new Set(["onCreated", "onRendered", "onDestroyed"]);
233
239
  function unwrap(expr) {
@@ -378,6 +384,7 @@ export function collectDeclarations(sf, file) {
378
384
  if (verb === "component" && arg && ts.isObjectLiteralExpression(arg)) {
379
385
  nested({ start, end: arg.end, kind: "variable", name, exported: true, signature: `component ${name}`, namePos: arg.getStart(sf), nameless: true });
380
386
  objectMembers(arg, name, true);
387
+ bindingMembers(arg, name);
381
388
  return;
382
389
  }
383
390
  const fn = registeredFunction(arg);
@@ -403,9 +410,123 @@ export function collectDeclarations(sf, file) {
403
410
  thisMembers(fn.body, name, true);
404
411
  if (verb === "factory" || verb === "service")
405
412
  returnedMembers(fn.body, name);
413
+ if (verb === "directive")
414
+ for (const obj of returnedObjects(fn.body))
415
+ bindingMembers(obj, name);
416
+ if (verb === "config" || verb === "run")
417
+ routerBody(fn.body);
406
418
  };
419
+ /** Object literals a function returns at its top level (a directive definition object). */
420
+ const returnedObjects = (body) => {
421
+ const direct = unwrap(body);
422
+ if (ts.isObjectLiteralExpression(direct))
423
+ return [direct];
424
+ if (!ts.isBlock(body))
425
+ return [];
426
+ return body.statements.flatMap((st) => (ts.isReturnStatement(st) && st.expression && ts.isObjectLiteralExpression(unwrap(st.expression)) ? [unwrap(st.expression)] : []));
427
+ };
428
+ /** `scope: { x: '=' }` / `bindToController` / `bindings` keys as `property` Declarations `<owner>.x`. */
429
+ const bindingMembers = (obj, owner) => {
430
+ for (const prop of obj.properties) {
431
+ if (!ts.isPropertyAssignment(prop) || !BINDING_KEYS.has(nameText(prop.name, sf) ?? ""))
432
+ continue;
433
+ const bindings = unwrap(prop.initializer);
434
+ if (!ts.isObjectLiteralExpression(bindings))
435
+ continue;
436
+ for (const b of bindings.properties) {
437
+ const name = nameText(b.name, sf);
438
+ if (!name || !b.name || !ts.isPropertyAssignment(b))
439
+ continue;
440
+ nested({ start: b.getStart(sf), end: b.end, kind: "property", name, parentName: owner, exported: true, signature: tokenText(b, sf, b.getStart(sf), b.end), namePos: b.name.getStart(sf) });
441
+ }
442
+ }
443
+ };
444
+ /** Function-valued properties of a state object (`resolve.x`, `onEnter`, `views.main.controller`) as methods; nested objects recurse. */
445
+ const stateMembers = (obj, holder, depth) => {
446
+ for (const prop of obj.properties) {
447
+ const name = nameText(prop.name, sf);
448
+ if (!name || !prop.name)
449
+ continue;
450
+ if (ts.isMethodDeclaration(prop)) {
451
+ nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported: true, signature: fullSignatureOf(prop, sf), namePos: prop.name.getStart(sf) });
452
+ }
453
+ else if (ts.isPropertyAssignment(prop)) {
454
+ const value = unwrap(prop.initializer);
455
+ const fn = registeredFunction(value);
456
+ if (fn)
457
+ nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported: true, signature: `${name}${paramsText(fn, sf)}`, namePos: prop.name.getStart(sf) });
458
+ else if (ts.isObjectLiteralExpression(value) && depth < 3)
459
+ stateMembers(value, `${holder}.${name}`, depth + 1);
460
+ }
461
+ }
462
+ };
463
+ /** One `.state('name', {...})`, `.state({ name, ... })` or `.when('/path', {...})` call. */
464
+ const stateCall = (call, route) => {
465
+ const [first, second] = call.arguments;
466
+ const obj = first && ts.isObjectLiteralExpression(first) && !route ? first : second && ts.isObjectLiteralExpression(second) ? second : undefined;
467
+ if (!obj)
468
+ return;
469
+ const literal = (key) => {
470
+ const prop = obj.properties.find((p) => ts.isPropertyAssignment(p) && nameText(p.name, sf) === key);
471
+ return prop && isStringName(prop.initializer) ? prop.initializer.text : undefined;
472
+ };
473
+ const name = isStringName(first) ? first.text : literal("name");
474
+ if (name === undefined)
475
+ return;
476
+ const url = literal("url");
477
+ const parent = route ? "routes" : "states";
478
+ // Plain-literal options are compared, not displayed: a new controller or template is a contract change.
479
+ const options = obj.properties
480
+ .filter((p) => ts.isPropertyAssignment(p) && nameText(p.name, sf) !== "name" && nameText(p.name, sf) !== "url")
481
+ .filter((p) => isStringName(p.initializer) || p.initializer.kind === ts.SyntaxKind.TrueKeyword || p.initializer.kind === ts.SyntaxKind.FalseKeyword)
482
+ .map((p) => tokenText(p, sf, p.getStart(sf), p.end));
483
+ const callee = call.expression;
484
+ nested({
485
+ start: callee.name.getStart(sf),
486
+ end: call.end,
487
+ kind: "state",
488
+ name,
489
+ parentName: parent,
490
+ exported: true,
491
+ signature: `${route ? "route" : "state"} ${name}${url !== undefined && !route ? ` (url ${url})` : ""}${options.length > 0 ? `\n${options.join("; ")}` : ""}`,
492
+ namePos: obj.getStart(sf),
493
+ nameless: true,
494
+ });
495
+ stateMembers(obj, `${parent}.${name}`, 1);
496
+ };
497
+ /** `$stateProvider.state(...).state(...)` and `$routeProvider.when(...)` chains; `.otherwise()` ignored. */
498
+ const routerChain = (expr) => {
499
+ const calls = [];
500
+ let e = unwrap(expr);
501
+ while (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression)) {
502
+ calls.push(e);
503
+ e = e.expression.expression;
504
+ }
505
+ if (!ts.isIdentifier(e))
506
+ return false;
507
+ const route = ROUTE_PROVIDER.test(e.text);
508
+ if (!route && !STATE_PROVIDER.test(e.text))
509
+ return false;
510
+ for (const call of calls.reverse()) {
511
+ const verb = call.expression.name.text;
512
+ if (verb === (route ? "when" : "state"))
513
+ stateCall(call, route);
514
+ }
515
+ return true;
516
+ };
517
+ /** Router chains at the top level of a `.config` / `.run` callback or of a function taking a router provider. */
518
+ const routerBody = (body) => {
519
+ if (!body || !ts.isBlock(body))
520
+ return;
521
+ for (const statement of body.statements)
522
+ if (ts.isExpressionStatement(statement))
523
+ routerChain(statement.expression);
524
+ };
525
+ const takesRouter = (fn) => fn.parameters.some((p) => ts.isIdentifier(p.name) && (STATE_PROVIDER.test(p.name.text) || ROUTE_PROVIDER.test(p.name.text)));
407
526
  /** Registrations along a call chain (`angular.module('x').controller(...).service(...)`), in source order. */
408
527
  const callChain = (expr) => {
528
+ if (routerChain(expr))
529
+ return;
409
530
  const calls = [];
410
531
  let e = unwrap(expr);
411
532
  while (ts.isCallExpression(e)) {
@@ -478,6 +599,8 @@ export function collectDeclarations(sf, file) {
478
599
  add(statement, statement.name, statement.name.text, false);
479
600
  if (isConstructorName(statement.name.text))
480
601
  thisMembers(statement.body, statement.name.text, inIife ? false : topLevelExported(statement.name.text));
602
+ if (takesRouter(statement))
603
+ routerBody(statement.body);
481
604
  }
482
605
  else if (ts.isVariableStatement(statement)) {
483
606
  for (const decl of statement.declarationList.declarations) {
@@ -487,6 +610,8 @@ export function collectDeclarations(sf, file) {
487
610
  const fn = asFunction(decl.initializer);
488
611
  if (inIife && fn)
489
612
  add(decl, decl.name, name, false);
613
+ if (fn && takesRouter(fn))
614
+ routerBody(fn.body);
490
615
  const exported = inIife ? false : topLevelExported(name);
491
616
  const init = unwrap(decl.initializer);
492
617
  if (ts.isObjectLiteralExpression(init))
@@ -0,0 +1,99 @@
1
+ // Template usages of AngularJS-style registrations: directives and components as kebab-case
2
+ // elements/attributes, their bindings as attributes, controllers in `ng-controller`, filters
3
+ // after `|`. One regex pass per template line, no HTML parsing.
4
+ import { readFileSync } from "node:fs";
5
+ import { listFiles, toRel } from "./walk.js";
6
+ export const TEMPLATE_EXTENSIONS = [".html", ".htm", ".ejs", ".hbs", ".njk", ".pug"];
7
+ export function isTemplatePath(path) {
8
+ return TEMPLATE_EXTENSIONS.some((ext) => path.endsWith(ext));
9
+ }
10
+ /** `wapSelect` → `wap-select`. */
11
+ export function kebab(name) {
12
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
13
+ }
14
+ function escape(text) {
15
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
+ }
17
+ /** Opening tag `<wap-select` or attribute `wap-select` / `data-wap-select` / `x-wap-select`. */
18
+ function elementOrAttribute(name) {
19
+ return new RegExp(`(?:<|[\\s"'])(?:data-|x-)?${escape(kebab(name))}(?![\\w-])`, "g");
20
+ }
21
+ /** A Declaration's template form; undefined when templates cannot name it. */
22
+ export function templateTarget(decl, fileDecls) {
23
+ const isComponent = (d) => d !== undefined && d.nameless === true && d.signature.startsWith("component ");
24
+ if (decl.nameless && (decl.parentName === "directive" || isComponent(decl)))
25
+ return { symbol: decl.qualifiedName, pattern: elementOrAttribute(decl.name) };
26
+ if (decl.nameless && decl.parentName === "controller") {
27
+ return { symbol: decl.qualifiedName, pattern: new RegExp(`ng-controller\\s*=\\s*["']\\s*${escape(decl.name)}(?![\\w$])`, "g") };
28
+ }
29
+ if (decl.nameless && decl.parentName === "filter")
30
+ return { symbol: decl.qualifiedName, pattern: new RegExp(`(?<!\\|)\\|\\s*${escape(decl.name)}(?![\\w$])`, "g") };
31
+ if (decl.kind === "property" && decl.parentName !== undefined) {
32
+ const owner = fileDecls.find((d) => d.qualifiedName === decl.parentName && d.parentName !== decl.parentName);
33
+ if (owner && owner.nameless && (owner.parentName === "directive" || isComponent(owner))) {
34
+ return {
35
+ symbol: decl.qualifiedName,
36
+ pattern: new RegExp(`\\s(?:data-|x-)?${escape(kebab(decl.name))}(?=[\\s=>/])`, "g"),
37
+ owner: elementOrAttribute(owner.name),
38
+ };
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+ /** Template files of the repo root (git listing, else the bounded walk). */
44
+ export function loadTemplates(root) {
45
+ const files = [];
46
+ for (const abs of listFiles(root, isTemplatePath).files) {
47
+ try {
48
+ files.push({ rel: toRel(root, abs), text: readFileSync(abs, "utf8") });
49
+ }
50
+ catch {
51
+ // unreadable: skipped
52
+ }
53
+ }
54
+ return files;
55
+ }
56
+ /** Usages of `target` in one template text: at most one per line. */
57
+ export function usagesIn(text, target) {
58
+ const result = [];
59
+ const lineStarts = [0];
60
+ for (let i = 0; i < text.length; i++)
61
+ if (text[i] === "\n")
62
+ lineStarts.push(i + 1);
63
+ let lastLine = 0;
64
+ for (const m of text.matchAll(target.pattern)) {
65
+ // Skip the delimiter the pattern consumed (`<`, whitespace, quote, `|`).
66
+ const offset = (m.index ?? 0) + (/^[<\s"'|]/.test(m[0]) ? 1 : 0);
67
+ if (target.owner) {
68
+ // The attribute counts only inside a tag opened by (or carrying) the owner directive.
69
+ const tagStart = text.lastIndexOf("<", offset);
70
+ if (tagStart < 0)
71
+ continue;
72
+ const tag = text.slice(tagStart, offset);
73
+ target.owner.lastIndex = 0;
74
+ if (tag.includes(">") || !target.owner.test(tag))
75
+ continue;
76
+ }
77
+ let lo = 0;
78
+ let hi = lineStarts.length - 1;
79
+ while (lo < hi) {
80
+ const mid = (lo + hi + 1) >> 1;
81
+ if (lineStarts[mid] <= offset)
82
+ lo = mid;
83
+ else
84
+ hi = mid - 1;
85
+ }
86
+ const line = lo + 1;
87
+ if (line === lastLine)
88
+ continue;
89
+ lastLine = line;
90
+ result.push({ line, col: offset - lineStarts[lo] + 1 });
91
+ }
92
+ return result;
93
+ }
94
+ export function templateUsages(files, target) {
95
+ return files.flatMap((f) => usagesIn(f.text, target).map((u) => ({ file: f.rel, ...u })));
96
+ }
97
+ export function templatesLine(usages) {
98
+ return `${usages.length} usages in ${new Set(usages.map((u) => u.file)).size} files`;
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-axi",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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": [