ts-axi 0.1.5 → 0.1.7

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; state objects in exported arrays (`` module.exports = [{ name: `${base}.init`, url, resolve }] ``) count too, their template-literal names filled from same-file consts. 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;
@@ -28,12 +29,14 @@ flags[7]:
28
29
  output:
29
30
  base, head, changes (count), changed[n]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
30
31
  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
32
+ 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 (declaring file only for a non-exported local); - for typed rows; such a Change is never local_only
32
33
  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
33
34
  refs[n]{symbol,file,line,context} = References outside the change set; truncated + refs_shown + refs_outside_total when cut
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)
@@ -228,8 +231,11 @@ function blindSpots(semantic, names, changeSet) {
228
231
  function walkExtra(dir, outFiles) {
229
232
  outFiles.push(...walkFiles(dir, { accept: (name) => GREP_EXTRA.some((ext) => name.endsWith(ext)) }).files);
230
233
  }
231
- /** Whole-word matches of `names` in Project files and templates that are not `known` References. */
232
- export function textualMatches(opened, semantic, names, known, skipLines, changeSet) {
234
+ /**
235
+ * Whole-word matches of `names` in Project files and templates that are not `known` References;
236
+ * a name in `localTo` (name → absolute file) is searched in that file only.
237
+ */
238
+ export function textualMatches(opened, semantic, names, known, skipLines, changeSet, localTo = new Map()) {
233
239
  const files = [...semantic.projectFiles];
234
240
  const projectSet = new Set(files);
235
241
  walkExtra(opened.root, files);
@@ -243,6 +249,8 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
243
249
  const declared = new Set(projectSet.has(abs) ? opened.index.declarations(abs).map((d) => d.nameLine) : []);
244
250
  const lines = new Set();
245
251
  for (const name of names) {
252
+ if (localTo.has(name) && localTo.get(name) !== abs)
253
+ continue;
246
254
  for (const m of wordMatches(text, name)) {
247
255
  if (known.has(`${rel}:${m.line}`) || skipLines.has(`${rel}:${m.line}`) || declared.has(m.line) || inRanges(changeSet.get(rel), m.line))
248
256
  continue;
@@ -255,15 +263,27 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
255
263
  }
256
264
  return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
257
265
  }
258
- /** The checker may miss uses: JS declaring file, a string-registered name, or no tsconfig at all. */
266
+ /**
267
+ * The checker may miss uses: JS declaring file, a string-registered name, or no tsconfig at all.
268
+ * A state's hooks (`onEnter`, inline `controller`) are called by the router, never by name: no textual count.
269
+ */
259
270
  export function isUntyped(opened, decl, file) {
271
+ if (decl?.parentName !== undefined && /^(states|routes)\./.test(decl.parentName) && !decl.parentName.endsWith(".resolve"))
272
+ return false;
260
273
  return decl?.nameless === true || opened.index.project.implicit || (file !== undefined && /\.[mc]?jsx?$/.test(file));
261
274
  }
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()) {
275
+ /** A non-exported, non-member, identifier-named Declaration: only its own file can name it. */
276
+ export function isPlainLocal(decl) {
277
+ return decl !== undefined && !decl.exported && !decl.nameless && decl.parentName === undefined;
278
+ }
279
+ /**
280
+ * Whole-word occurrences of `name` in Project files outside `changeSet`; lines declaring a `name` excluded.
281
+ * With `onlyFile` (absolute), that file alone.
282
+ */
283
+ export function textualCount(opened, name, changeSet = new Map(), onlyFile) {
264
284
  let matches = 0;
265
285
  let files = 0;
266
- for (const abs of opened.index.files) {
286
+ for (const abs of onlyFile !== undefined ? [onlyFile] : opened.index.files) {
267
287
  const text = opened.index.text(abs);
268
288
  if (text === undefined || !text.includes(name))
269
289
  continue;
@@ -274,14 +294,30 @@ export function textualCount(opened, name, changeSet = new Map()) {
274
294
  if (n > 0)
275
295
  files++;
276
296
  }
277
- return { matches, files };
297
+ return { matches, files, ...(onlyFile !== undefined ? { fileScoped: true } : {}) };
278
298
  }
279
299
  export function textualLine(count) {
280
- return `${count.matches} matches in ${count.files} files`;
300
+ return count.fileScoped ? `${count.matches} matches in this file` : `${count.matches} matches in ${count.files} files`;
281
301
  }
282
302
  export function textualHint(command, gap) {
283
303
  return `Run \`${command}\` for the ${gap} textual matches the checker cannot resolve (untyped JavaScript)`;
284
304
  }
305
+ /** `--grep` rows plus template usages of the templated rows, one per file:line. */
306
+ function withTemplateRows(textRows, rows, files) {
307
+ const texts = new Map(files.map((f) => [f.rel, f.text]));
308
+ const seen = new Set(textRows.map((u) => `${u.file}:${u.line}`));
309
+ const result = [...textRows];
310
+ for (const row of rows) {
311
+ for (const u of row.templates ?? []) {
312
+ const key = `${u.file}:${u.line}`;
313
+ if (seen.has(key))
314
+ continue;
315
+ seen.add(key);
316
+ result.push({ file: u.file, line: u.line, context: contextOf(lineOf(texts.get(u.file), u.line), u.col) });
317
+ }
318
+ }
319
+ return result.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
320
+ }
285
321
  function siteKey(site) {
286
322
  return `${site.item.file}#${site.item.selectionSpan.start}`;
287
323
  }
@@ -390,9 +426,13 @@ export async function impactCommand(args, ctx) {
390
426
  ...input.missing.map((file) => ({ file, reason: "not in the working tree" })),
391
427
  ];
392
428
  const analysed = [];
429
+ // Templates stay skipped unless they use a changed Symbol (then template_changes).
430
+ const templateInputs = [];
393
431
  for (const file of input.files) {
394
432
  if (/\.d\.[mc]?ts$/.test(file.path))
395
433
  skipped.push({ file: file.path, reason: "declaration file" });
434
+ else if (isTemplatePath(file.path))
435
+ templateInputs.push(file);
396
436
  else if (!isSourcePath(file.path))
397
437
  skipped.push({ file: file.path, reason: "not TS or JS source" });
398
438
  else if (file.status !== "deleted" && !projectFiles.has(opened.index.abs(file.path)))
@@ -431,8 +471,14 @@ export async function impactCommand(args, ctx) {
431
471
  head: input.head,
432
472
  changes: total,
433
473
  };
434
- const skippedBlock = skipped.length > 0 ? capped("skipped", skipped) : {};
474
+ const skipTemplates = (used) => {
475
+ for (const f of templateInputs)
476
+ if (!used.has(f.path))
477
+ skipped.push({ file: f.path, reason: "not TS or JS source" });
478
+ };
435
479
  if (declChanges.length === 0) {
480
+ skipTemplates(new Set());
481
+ const skippedBlock = skipped.length > 0 ? capped("skipped", skipped) : {};
436
482
  const help = total === 0
437
483
  ? [arg === undefined && !staged ? "Run `ts-axi impact main...HEAD` to analyse a branch against main" : "Run `ts-axi impact` for uncommitted changes"]
438
484
  : ["Changes touch imports or module-level statements only - read those lines directly"];
@@ -495,14 +541,41 @@ export async function impactCommand(args, ctx) {
495
541
  for (const row of rows) {
496
542
  if (!row.live || !isUntyped(opened, row.live, row.live.file))
497
543
  continue;
498
- row.textual = textualCount(opened, row.live.name, changeSet);
544
+ const localTo = isPlainLocal(row.live) ? new Map([[row.live.name, opened.index.abs(row.live.file)]]) : undefined;
545
+ row.textual = textualCount(opened, row.live.name, changeSet, localTo?.get(row.live.name));
499
546
  if (row.textual.matches > 0)
500
- row.unresolved = textualMatches(opened, semantic, [row.live.name], known, new Set(), changeSet).length;
547
+ row.unresolved = textualMatches(opened, semantic, [row.live.name], known, new Set(), changeSet, localTo).length;
501
548
  }
549
+ // Template usages: directives, components, bindings, controllers and filters named in .html and friends.
550
+ let templateFiles;
551
+ for (const row of rows) {
552
+ const decl = row.live ?? row.change.before;
553
+ const fileDecls = [
554
+ ...opened.index.declarations(opened.index.abs(row.change.file)),
555
+ ...declChanges.filter((c) => c.file === row.change.file).flatMap((c) => (c.before ? [c.before] : [])),
556
+ ];
557
+ const target = templateTarget(decl, fileDecls);
558
+ if (!target)
559
+ continue;
560
+ templateFiles ??= loadTemplates(opened.root);
561
+ row.template = target;
562
+ row.templates = templateUsages(templateFiles, target);
563
+ }
564
+ const templateChanges = [];
565
+ for (const f of templateInputs) {
566
+ const text = f.afterText ?? f.beforeText;
567
+ if (typeof text !== "string")
568
+ continue;
569
+ const symbols = [...new Set(rows.filter((r) => r.template && usagesIn(text, r.template).length > 0).map((r) => r.symbol))];
570
+ if (symbols.length > 0)
571
+ templateChanges.push({ file: f.path, symbols: symbols.join("; ") });
572
+ }
573
+ skipTemplates(new Set(templateChanges.map((t) => t.file)));
574
+ const skippedBlock = skipped.length > 0 ? capped("skipped", skipped) : {};
502
575
  // Textual matches the checker did not resolve: such a Change is never local_only.
503
576
  const textualGap = (r) => (r.textual && r.textual.matches > r.outside.length ? r.unresolved ?? 0 : 0);
504
577
  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;
578
+ const hasOutside = (r) => r.outside.length > 0 || r.via.length > 0 || textualGap(r) > 0 || (r.templates?.length ?? 0) > 0;
506
579
  const listed = rows
507
580
  .filter(hasOutside)
508
581
  .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 }));
@@ -525,15 +598,27 @@ export async function impactCommand(args, ctx) {
525
598
  };
526
599
  // --- Blind spots, textual matches, deeper callers ---
527
600
  const names = new Map();
601
+ // A name is file-scoped only when every changed Declaration carrying it is a plain local of one file.
602
+ const localTo = new Map();
603
+ const wide = new Set();
528
604
  for (const r of rows) {
529
- const name = (r.change.after ?? r.change.before).name;
605
+ const decl = r.change.after ?? r.change.before;
606
+ const name = decl.name;
530
607
  if (!names.has(name))
531
608
  names.set(name, r.symbol);
609
+ const abs = opened.index.abs(decl.file);
610
+ if (!isPlainLocal(decl) || (localTo.has(name) && localTo.get(name) !== abs))
611
+ wide.add(name);
612
+ else
613
+ localTo.set(name, abs);
532
614
  }
615
+ for (const name of wide)
616
+ localTo.delete(name);
533
617
  const spots = blindSpots(semantic, names, changeSet);
534
- const unresolved = grep
535
- ? textualMatches(opened, semantic, [...names.keys()], known, new Set(spots.map((s) => `${s.file}:${s.line}`)), changeSet)
618
+ const textRows = grep
619
+ ? textualMatches(opened, semantic, [...names.keys()], known, new Set(spots.map((s) => `${s.file}:${s.line}`)), changeSet, localTo)
536
620
  : [];
621
+ const unresolved = grep ? withTemplateRows(textRows, rows, templateFiles ?? []) : [];
537
622
  const touched = [...new Set([...analysed.filter((f) => f.status !== "deleted").map((f) => f.path), ...listed.flatMap((r) => r.outside.map((e) => e.file))])];
538
623
  const diagnostics = semantic.diagnosticsCount(touched);
539
624
  // --- Help ---
@@ -561,6 +646,11 @@ export async function impactCommand(args, ctx) {
561
646
  if (!showVia && listed.some((r) => r.via.length > 0)) {
562
647
  help.push(`Run \`${rerun(arg, parsed, "--via")}\` for References through an interface or base member - they may reach the changed code`);
563
648
  }
649
+ const templated = listed.filter((r) => (r.templates?.length ?? 0) > 0).sort((a, b) => b.templates.length - a.templates.length)[0];
650
+ if (templated && !grep)
651
+ help.push(`Run \`ts-axi refs ${templated.at} --grep\` for the ${templated.templates.length} template usages of ${templated.symbol}`);
652
+ if (templateChanges.length > 0)
653
+ help.push("template_changes use a changed directive or binding - review each against that change");
564
654
  if (skipped.length > 0)
565
655
  help.push("Skipped files are not TS or JS source of the Project - review them by hand");
566
656
  const specHint = specFilesHint(ctx.cwd, opened, skipped, listed.every((r) => fileCounts(r).tests === 0));
@@ -584,6 +674,7 @@ export async function impactCommand(args, ctx) {
584
674
  refs_outside: r.outside.length,
585
675
  via: r.via.length,
586
676
  ...(gap > 0 ? { textual: r.textual ? r.textual.matches : "-" } : {}),
677
+ ...(rows.some((x) => x.template) ? { templates: r.templates ? r.templates.length : "-" } : {}),
587
678
  ...fileCounts(r),
588
679
  })),
589
680
  ...(refRows.length > 0 ? { refs: refRows } : {}),
@@ -593,6 +684,7 @@ export async function impactCommand(args, ctx) {
593
684
  ...(notes.length > 0 ? { notes } : {}),
594
685
  ...(localOnly.length > 0 ? { local_only: localOnlyLine(localOnly.map((r) => `${r.symbol} ${r.change.change}`)) } : {}),
595
686
  ...(fileLevel.length > 0 ? { file_level: fileLevel } : {}),
687
+ ...(templateChanges.length > 0 ? capped("template_changes", templateChanges) : {}),
596
688
  ...capped("blind_spots", spots),
597
689
  ...(grep ? capped("unresolved", unresolved) : {}),
598
690
  ...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
@@ -1,11 +1,12 @@
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 { isUntyped, textualCount, textualHint, textualLine, textualMatches } from "./impact.js";
4
+ import { isPlainLocal, 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";
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); 'n matches in this file' for a non-exported local; 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}
@@ -148,12 +150,37 @@ async function run(args, ctx, invoked) {
148
150
  const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
149
151
  const explicit = Boolean(project || opened.settings.project);
150
152
  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 } : {}) };
153
+ const localFile = isPlainLocal(decl) ? opened.index.abs(decl.file) : undefined;
154
+ const textual = declaredFile !== undefined && isUntyped(opened, decl, declaredFile) ? textualCount(opened, symbol.split(".").pop(), new Map(), localFile) : undefined;
155
+ const tplTarget = decl ? templateTarget(decl, opened.index.declarations(opened.index.abs(decl.file))) : undefined;
156
+ const templates = tplTarget ? templateRows(loadTemplates(opened.root), tplTarget) : undefined;
157
+ const o = { started, symbol, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile, ...(decl ? { decl } : {}), ...(textual ? { textual } : {}), ...(templates ? { templates } : {}) };
153
158
  return mode === "calls"
154
159
  ? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
155
160
  : renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
156
161
  }
162
+ /** Template usages as `--grep` rows. */
163
+ export function templateRows(files, target) {
164
+ const texts = new Map(files.map((f) => [f.rel, f.text]));
165
+ return templateUsages(files, target).map((u) => ({ file: u.file, line: u.line, context: contextOf(lineOf(texts.get(u.file), u.line), u.col) }));
166
+ }
167
+ /** `templates` field and the hint to list them. */
168
+ function templateFields(o, grepShown) {
169
+ if (!o.templates)
170
+ return { fields: {}, help: [] };
171
+ const n = o.templates.length;
172
+ return {
173
+ fields: { templates: templatesLine(o.templates) },
174
+ help: n > 0 && !grepShown ? [`Run \`ts-axi refs ${o.declaredAt} --grep\` for the ${n} template usages`] : [],
175
+ };
176
+ }
177
+ /** Textual rows plus template rows, one per file:line, sorted. */
178
+ function withTemplates(unresolved, templates) {
179
+ if (!templates || templates.length === 0)
180
+ return unresolved;
181
+ const seen = new Set(unresolved.map((u) => `${u.file}:${u.line}`));
182
+ return [...unresolved, ...templates.filter((t) => !seen.has(`${t.file}:${t.line}`))].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
183
+ }
157
184
  const OUTSIDE_ROWS = 5;
158
185
  /**
159
186
  * No References under an explicit Project: importers of the declaring file (or of a barrel
@@ -215,7 +242,7 @@ function viaFields(via, bases) {
215
242
  }
216
243
  /** Whole-word matches of the name on lines that hold no known position (what `--grep` lists). */
217
244
  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());
245
+ 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(), isPlainLocal(o.decl) ? new Map([[o.decl.name, opened.index.abs(o.decl.file)]]) : new Map());
219
246
  }
220
247
  /**
221
248
  * `textual` field and, when the name occurs more often than refs_total and some matches sit on no
@@ -256,12 +283,16 @@ function renderRefs(semantic, opened, resolved, header, o) {
256
283
  const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
257
284
  help.unshift(...outside.help);
258
285
  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);
286
+ const unresolved = o.show.grep || o.textual || o.templates ? unresolvedOf(opened, semantic, o, [...rows, ...via, ...impls]) : [];
287
+ const grepRows = withTemplates(unresolved, o.templates);
288
+ // The --grep hint counts what --grep lists: textual and template rows together.
289
+ const text = textualFields(o, rows.length, grepRows.length, o.show.grep);
290
+ const tpl = templateFields(o, o.show.grep || text.help.length > 0);
261
291
  if (text.help.length > 0)
262
292
  help.unshift(...text.help);
263
- else if (o.decl?.nameless && !o.show.grep)
293
+ else if (o.decl?.nameless && !o.show.grep && tpl.help.length === 0)
264
294
  help.unshift(`Run \`ts-axi refs ${o.declaredAt} --grep\` for textual matches of '${word}'`);
295
+ help.unshift(...tpl.help);
265
296
  if (isFunctionLike(o.kind))
266
297
  help.push(`Run \`ts-axi callers ${o.declaredAt}\` for call sites with their enclosing function`);
267
298
  help.push(`Run \`ts-axi def ${o.declaredAt}\` for the declaration body`);
@@ -271,6 +302,7 @@ function renderRefs(semantic, opened, resolved, header, o) {
271
302
  ...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
272
303
  refs_total: rows.length,
273
304
  ...text.fields,
305
+ ...tpl.fields,
274
306
  files,
275
307
  tests,
276
308
  ...viaFields(via, split.bases),
@@ -288,8 +320,8 @@ function renderRefs(semantic, opened, resolved, header, o) {
288
320
  })),
289
321
  }),
290
322
  ...(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 } : {}),
323
+ ...(o.show.grep ? { unresolved: grepRows.length === 0 ? 0 : grepRows.slice(0, o.limit) } : {}),
324
+ ...(grepRows.length > o.limit ? { unresolved_total: grepRows.length } : {}),
293
325
  ...outside.fields,
294
326
  ...scopeFields(o.scope),
295
327
  ...footer(semantic, o.started, fileList),
@@ -334,9 +366,12 @@ function renderCallers(semantic, opened, resolved, header, o) {
334
366
  const split = semantic.referenceSplit(resolved.absFile, resolved.pos);
335
367
  const known = [...split.direct, ...split.declarations, ...split.via, ...split.implementations, ...split.dropped, ...(sites ?? [])];
336
368
  const calls = (sites ?? []).filter((s) => s.via === undefined).length;
337
- return textualFields(o, calls, unresolvedOf(opened, semantic, o, known).length, false);
369
+ return textualFields(o, calls, withTemplates(unresolvedOf(opened, semantic, o, known), o.templates).length, false);
338
370
  })()
339
371
  : { fields: {}, help: [] };
372
+ const tpl = templateFields(o, callerText.help.length > 0);
373
+ callerText.fields = { ...callerText.fields, ...tpl.fields };
374
+ callerText.help = [...tpl.help, ...callerText.help];
340
375
  if (sites === undefined) {
341
376
  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
377
  }
@@ -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,13 @@ 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
+ /** Keys that, next to a string `name`, make an array element a ui-router state object. */
236
+ const STATE_KEYS = new Set(["url", "resolve", "component", "controller", "template", "templateUrl", "views", "abstract"]);
237
+ /** Directive definition keys whose object literal lists the bindings. */
238
+ const BINDING_KEYS = new Set(["scope", "bindToController", "bindings"]);
231
239
  /** `Template.x.<hook>(fn)` lifecycle callbacks. */
232
240
  const TEMPLATE_HOOKS = new Set(["onCreated", "onRendered", "onDestroyed"]);
233
241
  function unwrap(expr) {
@@ -378,6 +386,7 @@ export function collectDeclarations(sf, file) {
378
386
  if (verb === "component" && arg && ts.isObjectLiteralExpression(arg)) {
379
387
  nested({ start, end: arg.end, kind: "variable", name, exported: true, signature: `component ${name}`, namePos: arg.getStart(sf), nameless: true });
380
388
  objectMembers(arg, name, true);
389
+ bindingMembers(arg, name);
381
390
  return;
382
391
  }
383
392
  const fn = registeredFunction(arg);
@@ -403,9 +412,182 @@ export function collectDeclarations(sf, file) {
403
412
  thisMembers(fn.body, name, true);
404
413
  if (verb === "factory" || verb === "service")
405
414
  returnedMembers(fn.body, name);
415
+ if (verb === "directive")
416
+ for (const obj of returnedObjects(fn.body))
417
+ bindingMembers(obj, name);
418
+ if (verb === "config" || verb === "run")
419
+ routerBody(fn.body);
406
420
  };
421
+ /** Object literals a function returns at its top level (a directive definition object). */
422
+ const returnedObjects = (body) => {
423
+ const direct = unwrap(body);
424
+ if (ts.isObjectLiteralExpression(direct))
425
+ return [direct];
426
+ if (!ts.isBlock(body))
427
+ return [];
428
+ return body.statements.flatMap((st) => (ts.isReturnStatement(st) && st.expression && ts.isObjectLiteralExpression(unwrap(st.expression)) ? [unwrap(st.expression)] : []));
429
+ };
430
+ /** `scope: { x: '=' }` / `bindToController` / `bindings` keys as `property` Declarations `<owner>.x`. */
431
+ const bindingMembers = (obj, owner) => {
432
+ for (const prop of obj.properties) {
433
+ if (!ts.isPropertyAssignment(prop) || !BINDING_KEYS.has(nameText(prop.name, sf) ?? ""))
434
+ continue;
435
+ const bindings = unwrap(prop.initializer);
436
+ if (!ts.isObjectLiteralExpression(bindings))
437
+ continue;
438
+ for (const b of bindings.properties) {
439
+ const name = nameText(b.name, sf);
440
+ if (!name || !b.name || !ts.isPropertyAssignment(b))
441
+ continue;
442
+ 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) });
443
+ }
444
+ }
445
+ };
446
+ /** Function-valued properties of a state object (`resolve.x`, `onEnter`, `views.main.controller`) as methods; nested objects recurse. */
447
+ const stateMembers = (obj, holder, depth) => {
448
+ for (const prop of obj.properties) {
449
+ const name = nameText(prop.name, sf);
450
+ if (!name || !prop.name)
451
+ continue;
452
+ if (ts.isMethodDeclaration(prop)) {
453
+ nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported: true, signature: fullSignatureOf(prop, sf), namePos: prop.name.getStart(sf) });
454
+ }
455
+ else if (ts.isPropertyAssignment(prop)) {
456
+ const value = unwrap(prop.initializer);
457
+ const fn = registeredFunction(value);
458
+ if (fn)
459
+ 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) });
460
+ else if (ts.isObjectLiteralExpression(value) && depth < 3)
461
+ stateMembers(value, `${holder}.${name}`, depth + 1);
462
+ }
463
+ }
464
+ };
465
+ /** Same-file string consts (`const base = 'main.x'`), for state names built from them; ambiguous names dropped. */
466
+ let stringConsts;
467
+ const constValue = (id, depth) => {
468
+ if (!stringConsts) {
469
+ stringConsts = new Map();
470
+ const visit = (node) => {
471
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
472
+ const init = unwrap(node.initializer);
473
+ if (isStringName(init) || ts.isTemplateExpression(init))
474
+ stringConsts.set(node.name.text, stringConsts.has(node.name.text) ? null : init);
475
+ }
476
+ ts.forEachChild(node, visit);
477
+ };
478
+ visit(sf);
479
+ }
480
+ const init = stringConsts.get(id);
481
+ return init && depth < 3 ? stateNameOf(init, depth + 1) : undefined;
482
+ };
483
+ /** A literal, a template literal (`${...}` = a same-file string const, else `*`) or a const identifier. */
484
+ const stateNameOf = (expr, depth = 0) => {
485
+ const e = unwrap(expr);
486
+ if (isStringName(e))
487
+ return e.text;
488
+ if (ts.isTemplateExpression(e)) {
489
+ return e.head.text + e.templateSpans.map((s) => (ts.isIdentifier(s.expression) ? (constValue(s.expression.text, depth) ?? "*") : "*") + s.literal.text).join("");
490
+ }
491
+ if (ts.isIdentifier(e))
492
+ return constValue(e.text, depth) ?? e.text;
493
+ return undefined;
494
+ };
495
+ const propOf = (obj, key) => obj.properties.find((p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && nameText(p.name, sf) === key);
496
+ /** `name` of a state object literal: `name: 'x'`, `` name: `${base}.x` ``, `name: base` or `{ name }`. */
497
+ const stateObjectName = (obj) => {
498
+ const prop = propOf(obj, "name");
499
+ if (prop && ts.isPropertyAssignment(prop))
500
+ return stateNameOf(prop.initializer);
501
+ if (prop && ts.isShorthandPropertyAssignment(prop))
502
+ return stateNameOf(prop.name);
503
+ return undefined;
504
+ };
505
+ /** A ui-router state object (`.state()` argument or array element) or an ngRoute config as a `state` Declaration plus its callbacks. */
506
+ const stateFromObject = (obj, name, start, end, route) => {
507
+ const url = propOf(obj, "url");
508
+ const urlText = url && ts.isPropertyAssignment(url) && isStringName(url.initializer) ? url.initializer.text : undefined;
509
+ const parent = route ? "routes" : "states";
510
+ // Plain-literal options are compared, not displayed: a new controller or template is a contract change.
511
+ const options = obj.properties
512
+ .filter((p) => ts.isPropertyAssignment(p) && nameText(p.name, sf) !== "name" && nameText(p.name, sf) !== "url")
513
+ .filter((p) => isStringName(p.initializer) || p.initializer.kind === ts.SyntaxKind.TrueKeyword || p.initializer.kind === ts.SyntaxKind.FalseKeyword)
514
+ .map((p) => tokenText(p, sf, p.getStart(sf), p.end));
515
+ nested({
516
+ start,
517
+ end,
518
+ kind: "state",
519
+ name,
520
+ parentName: parent,
521
+ exported: true,
522
+ signature: `${route ? "route" : "state"} ${name}${urlText !== undefined && !route ? ` (url ${urlText})` : ""}${options.length > 0 ? `\n${options.join("; ")}` : ""}`,
523
+ namePos: obj.getStart(sf),
524
+ nameless: true,
525
+ });
526
+ stateMembers(obj, `${parent}.${name}`, 1);
527
+ };
528
+ /** One `.state('name', {...})`, `.state({ name, ... })` or `.when('/path', {...})` call. */
529
+ const stateCall = (call, route) => {
530
+ const [first, second] = call.arguments;
531
+ const obj = first && ts.isObjectLiteralExpression(first) && !route ? first : second && ts.isObjectLiteralExpression(second) ? second : undefined;
532
+ if (!obj)
533
+ return;
534
+ const name = isStringName(first) ? first.text : route ? undefined : stateObjectName(obj);
535
+ if (name === undefined)
536
+ return;
537
+ stateFromObject(obj, name, call.expression.name.getStart(sf), call.end, route);
538
+ };
539
+ /** State objects among the elements of an array literal, or of `[...].concat([...], ...)`; other values ignored. */
540
+ const stateArray = (expr) => {
541
+ const e = unwrap(expr);
542
+ if (ts.isArrayLiteralExpression(e)) {
543
+ for (const el of e.elements) {
544
+ const obj = unwrap(el);
545
+ if (!ts.isObjectLiteralExpression(obj) || !obj.properties.some((p) => STATE_KEYS.has(nameText(p.name, sf) ?? "")))
546
+ continue;
547
+ const name = stateObjectName(obj);
548
+ if (name !== undefined)
549
+ stateFromObject(obj, name, obj.getStart(sf), obj.end, false);
550
+ }
551
+ }
552
+ else if (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === "concat") {
553
+ stateArray(e.expression.expression);
554
+ for (const arg of e.arguments)
555
+ stateArray(arg);
556
+ }
557
+ };
558
+ /** `$stateProvider.state(...).state(...)` and `$routeProvider.when(...)` chains; `.otherwise()` ignored. */
559
+ const routerChain = (expr) => {
560
+ const calls = [];
561
+ let e = unwrap(expr);
562
+ while (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression)) {
563
+ calls.push(e);
564
+ e = e.expression.expression;
565
+ }
566
+ if (!ts.isIdentifier(e))
567
+ return false;
568
+ const route = ROUTE_PROVIDER.test(e.text);
569
+ if (!route && !STATE_PROVIDER.test(e.text))
570
+ return false;
571
+ for (const call of calls.reverse()) {
572
+ const verb = call.expression.name.text;
573
+ if (verb === (route ? "when" : "state"))
574
+ stateCall(call, route);
575
+ }
576
+ return true;
577
+ };
578
+ /** Router chains at the top level of a `.config` / `.run` callback or of a function taking a router provider. */
579
+ const routerBody = (body) => {
580
+ if (!body || !ts.isBlock(body))
581
+ return;
582
+ for (const statement of body.statements)
583
+ if (ts.isExpressionStatement(statement))
584
+ routerChain(statement.expression);
585
+ };
586
+ const takesRouter = (fn) => fn.parameters.some((p) => ts.isIdentifier(p.name) && (STATE_PROVIDER.test(p.name.text) || ROUTE_PROVIDER.test(p.name.text)));
407
587
  /** Registrations along a call chain (`angular.module('x').controller(...).service(...)`), in source order. */
408
588
  const callChain = (expr) => {
589
+ if (routerChain(expr))
590
+ return;
409
591
  const calls = [];
410
592
  let e = unwrap(expr);
411
593
  while (ts.isCallExpression(e)) {
@@ -446,8 +628,12 @@ export function collectDeclarations(sf, file) {
446
628
  if (target === "module.exports") {
447
629
  if (ts.isObjectLiteralExpression(right))
448
630
  objectMembers(right, "exports", true);
631
+ else
632
+ stateArray(right);
449
633
  return;
450
634
  }
635
+ if (target === `exports.${left.name.text}` || target === `module.exports.${left.name.text}`)
636
+ stateArray(right);
451
637
  const owner = left.expression;
452
638
  if (ts.isPropertyAccessExpression(owner) && owner.name.text === "prototype" && ts.isIdentifier(owner.expression)) {
453
639
  const cls = owner.expression.text;
@@ -478,6 +664,8 @@ export function collectDeclarations(sf, file) {
478
664
  add(statement, statement.name, statement.name.text, false);
479
665
  if (isConstructorName(statement.name.text))
480
666
  thisMembers(statement.body, statement.name.text, inIife ? false : topLevelExported(statement.name.text));
667
+ if (takesRouter(statement))
668
+ routerBody(statement.body);
481
669
  }
482
670
  else if (ts.isVariableStatement(statement)) {
483
671
  for (const decl of statement.declarationList.declarations) {
@@ -487,8 +675,11 @@ export function collectDeclarations(sf, file) {
487
675
  const fn = asFunction(decl.initializer);
488
676
  if (inIife && fn)
489
677
  add(decl, decl.name, name, false);
678
+ if (fn && takesRouter(fn))
679
+ routerBody(fn.body);
490
680
  const exported = inIife ? false : topLevelExported(name);
491
681
  const init = unwrap(decl.initializer);
682
+ stateArray(init);
492
683
  if (ts.isObjectLiteralExpression(init))
493
684
  objectMembers(init, name, exported);
494
685
  else if (fn && isConstructorName(name))
@@ -512,6 +703,9 @@ export function collectDeclarations(sf, file) {
512
703
  else
513
704
  callChain(expr);
514
705
  }
706
+ else if (ts.isExportAssignment(statement)) {
707
+ stateArray(statement.expression);
708
+ }
515
709
  else if (ts.isIfStatement(statement)) {
516
710
  scriptStatement(statement.thenStatement, inIife);
517
711
  if (statement.elseStatement)
@@ -0,0 +1,138 @@
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
+ const TAG_SPAN_LINES = 40;
57
+ /**
58
+ * Text of the tag open at `offset` (from its `<`), or undefined when `offset` is outside a tag or
59
+ * inside a quoted value. Forward scan from up to 40 lines back; `>` inside quotes does not close.
60
+ * Tolerant of broken markup: never throws, an unclosed quote just ends the tag at the scan point.
61
+ */
62
+ function openTagBefore(text, offset, lineStarts) {
63
+ let lo = 0;
64
+ let hi = lineStarts.length - 1;
65
+ while (lo < hi) {
66
+ const mid = (lo + hi + 1) >> 1;
67
+ if (lineStarts[mid] <= offset)
68
+ lo = mid;
69
+ else
70
+ hi = mid - 1;
71
+ }
72
+ let tagStart = -1;
73
+ let quote = "";
74
+ for (let i = lineStarts[Math.max(0, lo - TAG_SPAN_LINES)]; i < offset; i++) {
75
+ const c = text[i];
76
+ if (tagStart < 0) {
77
+ if (c === "<" && /[A-Za-z]/.test(text[i + 1] ?? ""))
78
+ tagStart = i;
79
+ }
80
+ else if (quote) {
81
+ if (c === quote)
82
+ quote = "";
83
+ }
84
+ else if (c === '"' || c === "'") {
85
+ quote = c;
86
+ }
87
+ else if (c === ">") {
88
+ tagStart = -1;
89
+ }
90
+ else if (c === "<" && /[A-Za-z]/.test(text[i + 1] ?? "")) {
91
+ tagStart = i;
92
+ }
93
+ }
94
+ return tagStart < 0 || quote ? undefined : text.slice(tagStart, offset);
95
+ }
96
+ /** Usages of `target` in one template text: at most one per line. */
97
+ export function usagesIn(text, target) {
98
+ const result = [];
99
+ const lineStarts = [0];
100
+ for (let i = 0; i < text.length; i++)
101
+ if (text[i] === "\n")
102
+ lineStarts.push(i + 1);
103
+ let lastLine = 0;
104
+ for (const m of text.matchAll(target.pattern)) {
105
+ // Skip the delimiter the pattern consumed (`<`, whitespace, quote, `|`).
106
+ const offset = (m.index ?? 0) + (/^[<\s"'|]/.test(m[0]) ? 1 : 0);
107
+ if (target.owner) {
108
+ // The attribute counts only inside a tag opened by (or carrying) the owner directive.
109
+ const tag = openTagBefore(text, offset, lineStarts);
110
+ if (tag === undefined)
111
+ continue;
112
+ target.owner.lastIndex = 0;
113
+ if (!target.owner.test(tag))
114
+ continue;
115
+ }
116
+ let lo = 0;
117
+ let hi = lineStarts.length - 1;
118
+ while (lo < hi) {
119
+ const mid = (lo + hi + 1) >> 1;
120
+ if (lineStarts[mid] <= offset)
121
+ lo = mid;
122
+ else
123
+ hi = mid - 1;
124
+ }
125
+ const line = lo + 1;
126
+ if (line === lastLine)
127
+ continue;
128
+ lastLine = line;
129
+ result.push({ line, col: offset - lineStarts[lo] + 1 });
130
+ }
131
+ return result;
132
+ }
133
+ export function templateUsages(files, target) {
134
+ return files.flatMap((f) => usagesIn(f.text, target).map((u) => ({ file: f.rel, ...u })));
135
+ }
136
+ export function templatesLine(usages) {
137
+ return `${usages.length} usages in ${new Set(usages.map((u) => u.file)).size} files`;
138
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-axi",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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": [