ts-axi 0.1.3 → 0.1.4

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
@@ -116,6 +116,7 @@ One dependency: the `typescript` compiler API (ADR in `docs/adr/0001-typescript-
116
116
  - `outline`, `def` and the home view only parse files (`ts.createSourceFile`): no tsconfig needed, well under a second.
117
117
  - `refs`, `callers` and `impact` build a `LanguageService` over the nearest `tsconfig.json` (or an implicit project of every source file when none exists, with a warning; in a monorepo or an implicit project of more than 3000 files, over the importer subgraph described above) and ask it for references and call hierarchy. Symbol identity comes from the checker, so `user.save` resolves to `User.save` through imports and re-exports.
118
118
  - `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.
119
+ - 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.
119
120
  - Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
120
121
 
121
122
  Every semantic command starts cold and prints `elapsed_ms`.
@@ -96,35 +96,89 @@ export function positionOf(group) {
96
96
  const d = group.decls[0];
97
97
  return `${d.file}:${d.nameLine}`;
98
98
  }
99
+ /** Ambiguity lists stop here; the narrowing hints cover the rest. */
100
+ export const MATCH_ROWS = 20;
101
+ /** `matches` (first MATCH_ROWS, shallowest first) plus `matches_total` / `truncated` past that. */
99
102
  export function matchRows(groups) {
100
- return groups.map((g) => ({
103
+ const rows = groups.slice(0, MATCH_ROWS).map((g) => ({
101
104
  file: g.primary.file,
102
105
  line: g.decls[0].nameLine,
103
106
  kind: g.primary.kind,
104
107
  exported: g.primary.exported,
105
108
  signature: g.decls[0].signature,
106
109
  }));
110
+ return { matches: rows, ...(groups.length > MATCH_ROWS ? { matches_total: groups.length, truncated: true } : {}) };
107
111
  }
108
- /** Concrete hints that narrow an ambiguous name to one Symbol. */
109
- export function narrowHints(command, groups, f, label) {
112
+ function countBy(items, key) {
113
+ const counts = new Map();
114
+ for (const item of items) {
115
+ const k = key(item);
116
+ if (k !== undefined)
117
+ counts.set(k, (counts.get(k) ?? 0) + 1);
118
+ }
119
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
120
+ }
121
+ /** Longest common directory of `files` ('' when they share none). */
122
+ function commonDir(files) {
123
+ const split = files.map((f) => posix.dirname(f).split("/").filter((p) => p !== "" && p !== "."));
124
+ const first = split[0] ?? [];
125
+ let n = 0;
126
+ while (n < first.length && split.every((parts) => parts[n] === first[n]))
127
+ n++;
128
+ return first.slice(0, n).join("/");
129
+ }
130
+ const IDENT_PART = /^[A-Za-z_$][\w$]*$/;
131
+ /** Root-relative path → the form `--in` expects (relative to cwd). */
132
+ function cwdGlob(ctx, rootRel) {
133
+ const prefix = relative(ctx.root, ctx.cwd).split(sep).join("/");
134
+ if (!prefix || prefix.startsWith(".."))
135
+ return rootRel;
136
+ return posix.relative(prefix, rootRel) || ".";
137
+ }
138
+ /** Concrete hints that narrow an ambiguous name, computed from the matches themselves. */
139
+ export function narrowHints(ctx, command, groups, f, label, target) {
140
+ const total = groups.length;
110
141
  const hints = groups.slice(0, 2).map((g) => `Run \`ts-axi ${command} ${positionOf(g)}\` for the one in ${g.primary.file}`);
111
- const first = groups[0].primary;
112
- const dirs = new Set(groups.map((g) => posix.dirname(g.primary.file)));
113
- const dir = posix.dirname(first.file);
114
- if (dirs.size > 1 && [...dirs].filter((d) => d === dir || d.startsWith(`${dir}/`)).length === 1) {
115
- hints.push(`Run \`ts-axi ${command} ${label} --in ${dir}\` to keep matches below ${dir}`);
142
+ const files = groups.map((g) => g.primary.file);
143
+ // --in: the subtrees right below the common directory, largest first.
144
+ const prefix = commonDir(files);
145
+ const below = countBy(files, (file) => {
146
+ const rest = (prefix ? file.slice(prefix.length + 1) : file).split("/");
147
+ return rest.length > 1 ? posix.join(prefix, rest[0]) : file;
148
+ });
149
+ if (below.length > 1) {
150
+ for (const [dir, n] of below.slice(0, 2))
151
+ hints.push(`Run \`ts-axi ${command} ${label} --in ${cwdGlob(ctx, dir)}\` to keep ${n} of ${total}`);
152
+ }
153
+ else if (prefix) {
154
+ hints.push(`Run \`ts-axi ${command} ${label} --in ${cwdGlob(ctx, prefix)}\` - all ${total} are below ${prefix}`);
116
155
  }
117
- else {
118
- hints.push(`Run \`ts-axi ${command} ${label} --in ${first.file}\` to keep matches in one file`);
156
+ if (!f.kind) {
157
+ const kinds = countBy(groups, (g) => g.primary.kind);
158
+ if (kinds.length > 1)
159
+ for (const [kind, n] of kinds.slice(0, 3))
160
+ hints.push(`Run \`ts-axi ${command} ${label} --kind ${kind}\` to keep ${n} of ${total}`);
119
161
  }
120
- const kinds = new Set(groups.map((g) => g.primary.kind));
121
- if (kinds.size > 1 && !f.kind)
122
- hints.push(`Run \`ts-axi ${command} ${label} --kind ${first.kind}\` to keep one kind`);
123
- if (!f.exported && groups.some((g) => g.primary.exported) && groups.some((g) => !g.primary.exported)) {
124
- hints.push(`Run \`ts-axi ${command} ${label} --exported\` to keep exported Declarations`);
162
+ if (target && target.member === undefined) {
163
+ const parents = countBy(groups, (g) => g.primary.parentName?.split(".").pop());
164
+ const distinct = parents.length > 1 || (parents.length === 1 && groups.some((g) => g.primary.parentName === undefined));
165
+ if (distinct) {
166
+ for (const [parent, n] of parents.filter(([p]) => IDENT_PART.test(p)).slice(0, 2)) {
167
+ hints.push(`Run \`ts-axi ${command} ${parent}.${target.name}${filterSuffixOf(label, target)}\` to keep ${n} of ${total}`);
168
+ }
169
+ }
170
+ }
171
+ const exported = groups.filter((g) => g.primary.exported).length;
172
+ if (!f.exported && exported > 0 && exported < total) {
173
+ hints.push(`Run \`ts-axi ${command} ${label} --exported\` to keep ${exported} of ${total}`);
125
174
  }
126
175
  return hints;
127
176
  }
177
+ /** The flags part of a hint label (`label` = target + filter suffix). */
178
+ function filterSuffixOf(label, target) {
179
+ const bare = target.member ? `${target.name}.${target.member}` : target.name;
180
+ return label.startsWith(bare) ? label.slice(bare.length) : "";
181
+ }
128
182
  export function resolvePositionFile(cwd, file) {
129
183
  const abs = resolve(cwd, file);
130
184
  if (!existsSync(abs)) {
@@ -176,6 +230,10 @@ export function resolvePosition(index, cwd, target) {
176
230
  const hit = onLine.find((id) => id.getStart(sf) <= offset && offset <= id.end) ?? onLine.find((id) => id.getStart(sf) >= offset);
177
231
  return hit ? pick(hit) : { kind: "none", abs, file, reason: `no identifier at ${file}:${target.line}:${target.col}` };
178
232
  }
233
+ // A string-registered name has no identifier: its line stands for it.
234
+ const nameless = decls.find((d) => d.nameless && d.nameLine === target.line);
235
+ if (nameless)
236
+ return { kind: "decl", decl: nameless, abs };
179
237
  if (onLine.length === 0)
180
238
  return { kind: "none", abs, file, reason: `no identifier on ${file}:${target.line}` };
181
239
  const constructorDecl = decls.find((d) => d.nameLine === target.line && d.name === "constructor");
@@ -2,13 +2,13 @@ import { bool, expectPositionals, flag, parseArgs } from "../args.js";
2
2
  import { semanticFor } from "../scope.js";
3
3
  import { block, fenced, helpBlock, out } from "../render.js";
4
4
  import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
5
- import { BODY_LINES, declarationAt, filterSuffix, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, } from "./common.js";
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
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
9
9
  output:
10
10
  1 match = file, line, kind, exported, signature + body fenced with file line numbers
11
- several = matches[n]{file,line,kind,exported,signature} + how to narrow
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
12
12
  none = matches: 0 + did_you_mean
13
13
  examples[5]:
14
14
  ts-axi def User
@@ -91,6 +91,6 @@ export async function defCommand(args, ctx) {
91
91
  return out(block({
92
92
  ...(opened.warning ? { warning: opened.warning } : {}),
93
93
  symbol: label,
94
- matches: matchRows(groups),
95
- }), helpBlock(narrowHints("def", groups, f, `${label}${filterSuffix(f, project)}`)));
94
+ ...matchRows(groups),
95
+ }), helpBlock(narrowHints(opened, "def", groups, f, `${label}${filterSuffix(f, project)}`, target)));
96
96
  }
@@ -227,7 +227,8 @@ function blindSpots(semantic, names, changeSet) {
227
227
  function walkExtra(dir, outFiles) {
228
228
  outFiles.push(...walkFiles(dir, { accept: (name) => GREP_EXTRA.some((ext) => name.endsWith(ext)) }).files);
229
229
  }
230
- function textualMatches(opened, semantic, names, known, skipLines, changeSet) {
230
+ /** Whole-word matches of `names` in Project files and templates that are not `known` References. */
231
+ export function textualMatches(opened, semantic, names, known, skipLines, changeSet) {
231
232
  const files = [...semantic.projectFiles];
232
233
  const projectSet = new Set(files);
233
234
  walkExtra(opened.root, files);
@@ -1,6 +1,7 @@
1
1
  import { relative, sep } from "node:path";
2
2
  import { bool, expectPositionals, flag, intFlag, parseArgs } from "../args.js";
3
3
  import { importerClosure } from "../importers.js";
4
+ import { textualMatches } from "./impact.js";
4
5
  import { findOutsideProject, nearestTsconfig } from "../project.js";
5
6
  import { viaLine } from "../references.js";
6
7
  import { block, helpBlock, out } from "../render.js";
@@ -9,7 +10,7 @@ import { byDepthThenPath } from "../walk.js";
9
10
  import { detectWorkspace, isMultiProject } from "../workspace.js";
10
11
  import { isTestFile } from "../settings.js";
11
12
  import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
12
- import { contextOf, declarationAt, filterSuffix, lineOf, matchRows, narrowHints, open, positionStartDir, projectFields, readFilters, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
13
+ import { contextOf, declarationAt, filterSuffix, lineOf, MATCH_ROWS, matchRows, narrowHints, open, positionStartDir, projectFields, readFilters, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
13
14
  const DEFAULT_LIMIT = 50;
14
15
  const MAX_LIMIT = 500;
15
16
  const DEPTH_ROWS = 50;
@@ -22,14 +23,16 @@ const VIA_DOC = `via_interface: n (Iface.member) = References through an interfa
22
23
  scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
23
24
  tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references`;
24
25
  export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
25
- flags[11]:
26
+ flags[12]:
26
27
  ${SHARED_FLAGS}
27
28
  --implementations (lists implementations[n]{file,line,symbol} instead of the count)
29
+ --grep (adds unresolved[n]{file,line,context} - textual matches of the name that are not References; also searches .astro/.vue/.svelte/.mdx/.html)
28
30
  --kind calls = same as \`ts-axi callers\`
29
31
  output:
30
32
  symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,context} (Declarations excluded), elapsed_ms
31
33
  ${VIA_DOC}
32
- an ambiguous name lists matches[n] + the disambiguated forms; nothing is analysed
34
+ note = the name is registered by a string ('Name' in .controller('Name', fn)) - its uses are textual; add --grep
35
+ an ambiguous name lists matches[n] (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms; nothing is analysed
33
36
  examples[4]:
34
37
  ts-axi refs User.save
35
38
  ts-axi refs src/models/user.ts:12
@@ -62,7 +65,7 @@ async function run(args, ctx, invoked) {
62
65
  { name: "--limit" },
63
66
  { name: "--test-glob", repeatable: true },
64
67
  { name: "--via", boolean: true },
65
- ...(invoked === "calls" ? [{ name: "--depth" }] : [{ name: "--implementations", boolean: true }]),
68
+ ...(invoked === "calls" ? [{ name: "--depth" }] : [{ name: "--implementations", boolean: true }, { name: "--grep", boolean: true }]),
66
69
  ];
67
70
  const parsed = parseArgs(args, specs, command);
68
71
  const [raw] = expectPositionals(parsed, 1, `${command} <name | Class.member | path:line[:col]> [flags]`);
@@ -74,7 +77,11 @@ async function run(args, ctx, invoked) {
74
77
  const label = targetLabel(target);
75
78
  const project = flag(parsed, "-P");
76
79
  const name = mode === "calls" ? "callers" : "refs";
77
- const show = { via: bool(parsed, "--via"), implementations: invoked === "refs" && bool(parsed, "--implementations") };
80
+ const show = {
81
+ via: bool(parsed, "--via"),
82
+ implementations: invoked === "refs" && bool(parsed, "--implementations"),
83
+ grep: invoked === "refs" && bool(parsed, "--grep"),
84
+ };
78
85
  const started = performance.now();
79
86
  const opened = open(ctx.cwd, parsed, positionStartDir(ctx.cwd, target));
80
87
  const warning = opened.warning ? { warning: opened.warning } : {};
@@ -107,7 +114,7 @@ async function run(args, ctx, invoked) {
107
114
  ]));
108
115
  }
109
116
  if (groups.length > 1) {
110
- return out(block({ ...warning, symbol: label, ambiguous: `${groups.length} Symbols - pick one`, matches: matchRows(groups) }), helpBlock(narrowHints(name, groups, f, `${label}${filterSuffix(f, project)}`)));
117
+ return out(block({ ...warning, symbol: label, ambiguous: `${groups.length} Symbols - pick one`, ...matchRows(groups) }), helpBlock(narrowHints(opened, name, groups, f, `${label}${filterSuffix(f, project)}`, target)));
111
118
  }
112
119
  const decl = groups[0].decls[0];
113
120
  resolved = { absFile: opened.index.abs(decl.file), pos: decl.namePos, decl, seeds: [...new Set(groups[0].decls.map((d) => opened.index.abs(d.file)))] };
@@ -139,7 +146,7 @@ async function run(args, ctx, invoked) {
139
146
  const declaredAt = decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
140
147
  const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
141
148
  const explicit = Boolean(project || opened.settings.project);
142
- const o = { started, symbol, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile };
149
+ const o = { started, symbol, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile, ...(decl ? { decl } : {}) };
143
150
  return mode === "calls"
144
151
  ? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
145
152
  : renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
@@ -193,6 +200,7 @@ function footer(semantic, started, touched) {
193
200
  const diagnostics = semantic.diagnosticsCount(touched);
194
201
  return { elapsed_ms: Math.round(performance.now() - started), ...(diagnostics > 0 ? { diagnostics } : {}) };
195
202
  }
203
+ const NAMELESS_NOTE = "registered by a string - References to it are textual (templates; DI arrays; route configs)";
196
204
  /** `via_interface` / `via_implementations` counts, plus the hint to see their rows. */
197
205
  function viaFields(via, bases) {
198
206
  const base = via.filter((v) => v.through === "base");
@@ -227,12 +235,19 @@ function renderRefs(semantic, opened, resolved, header, o) {
227
235
  help.push(`Run \`ts-axi refs ${o.declaredAt} --implementations\` to list the ${impls.length} implementations`);
228
236
  const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
229
237
  help.unshift(...outside.help);
238
+ const word = o.symbol.split(".").pop();
239
+ const unresolved = o.show.grep
240
+ ? textualMatches(opened, semantic, [word], new Set([...rows, ...via, ...impls].map((r) => `${r.file}:${r.line}`)), new Set(o.decl ? [`${o.decl.file}:${o.decl.line}`, `${o.decl.file}:${o.decl.nameLine}`] : []), new Map())
241
+ : [];
242
+ if (o.decl?.nameless && !o.show.grep)
243
+ help.unshift(`Run \`ts-axi refs ${o.declaredAt} --grep\` for textual matches of '${word}'`);
230
244
  if (isFunctionLike(o.kind))
231
245
  help.push(`Run \`ts-axi callers ${o.declaredAt}\` for call sites with their enclosing function`);
232
246
  help.push(`Run \`ts-axi def ${o.declaredAt}\` for the declaration body`);
233
247
  help.push(...scopeHelp(o.scope));
234
248
  return out(block({
235
249
  ...header,
250
+ ...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
236
251
  refs_total: rows.length,
237
252
  files,
238
253
  tests,
@@ -251,6 +266,8 @@ function renderRefs(semantic, opened, resolved, header, o) {
251
266
  })),
252
267
  }),
253
268
  ...(truncated ? { truncated: true } : {}),
269
+ ...(o.show.grep ? { unresolved: unresolved.length === 0 ? 0 : unresolved.slice(0, o.limit) } : {}),
270
+ ...(unresolved.length > o.limit ? { unresolved_total: unresolved.length } : {}),
254
271
  ...outside.fields,
255
272
  ...scopeFields(o.scope),
256
273
  ...footer(semantic, o.started, fileList),
@@ -321,10 +338,11 @@ function renderCallers(semantic, opened, resolved, header, o) {
321
338
  help.push(`Run \`ts-axi callers ${o.declaredAt} --depth 2\` for who calls the callers`);
322
339
  const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
323
340
  help.unshift(...outside.help);
324
- help.push(`Run \`ts-axi refs ${o.declaredAt}\` for every Reference (not only calls)`);
341
+ help.push(`Run \`ts-axi refs ${o.declaredAt}${o.decl?.nameless ? " --grep" : ""}\` for every Reference (not only calls)`);
325
342
  help.push(...scopeHelp(o.scope));
326
343
  return out(block({
327
344
  ...header,
345
+ ...(o.decl?.nameless ? { note: NAMELESS_NOTE } : {}),
328
346
  refs_total: rows.length,
329
347
  files,
330
348
  tests,
@@ -1,8 +1,5 @@
1
- // Pure `impact` logic: changed ranges -> Declarations -> Change classes, ordering
2
- // and row budgets. No git, no LanguageService: everything here is unit-testable.
3
- import ts from "typescript";
4
1
  import { parseSource } from "./project.js";
5
- import { collectDeclarations, fullSignatureOf, kindOf } from "./symbols.js";
2
+ import { collectDeclarations } from "./symbols.js";
6
3
  const TYPE_LEVEL = new Set(["interface", "type", "enum"]);
7
4
  export function matchKey(decl) {
8
5
  return `${decl.qualifiedName}#${decl.kind}`;
@@ -64,30 +61,11 @@ export function toRanges(lines) {
64
61
  export function formatRanges(ranges) {
65
62
  return ranges.map(([a, b]) => (a === b ? String(a) : `${a}-${b}`)).join(" ");
66
63
  }
67
- function nameStart(node, sf) {
68
- if (ts.isConstructorDeclaration(node)) {
69
- return node.getChildren(sf).find((c) => c.kind === ts.SyntaxKind.ConstructorKeyword)?.getStart(sf) ?? node.getStart(sf);
70
- }
71
- const name = node.name;
72
- return name ? name.getStart(sf) : node.getStart(sf);
73
- }
74
64
  /** Full Signature per match key; overloads are joined so adding one is a `signature` change. */
75
- export function signatures(sf, decls) {
76
- const nodes = new Map();
77
- const visit = (node) => {
78
- if (kindOf(node) !== undefined) {
79
- const at = nameStart(node, sf);
80
- // A variable statement starts at `export const`; collectDeclarations keys the declarator name.
81
- if (!nodes.has(at))
82
- nodes.set(at, node);
83
- }
84
- ts.forEachChild(node, visit);
85
- };
86
- visit(sf);
65
+ export function signatures(decls) {
87
66
  const result = new Map();
88
67
  for (const decl of decls) {
89
- const node = nodes.get(decl.namePos);
90
- const text = node ? fullSignatureOf(node, sf) : decl.signature;
68
+ const text = decl.fullSignature ?? decl.signature;
91
69
  const key = matchKey(decl);
92
70
  const prev = result.get(key);
93
71
  result.set(key, prev === undefined ? text : `${prev}\n${text}`);
@@ -110,8 +88,8 @@ export function classifyFile(input) {
110
88
  const beforeMap = beforeSide ? mapRanges(beforeDecls, input.before) : { touched: new Map(), outside: [] };
111
89
  const afterByKey = new Map(afterDecls.map((d) => [matchKey(d), d]));
112
90
  const beforeByKey = new Map(beforeDecls.map((d) => [matchKey(d), d]));
113
- const afterSigs = afterSide ? signatures(afterSide.sf, afterDecls) : new Map();
114
- const beforeSigs = beforeSide ? signatures(beforeSide.sf, beforeDecls) : new Map();
91
+ const afterSigs = afterSide ? signatures(afterDecls) : new Map();
92
+ const beforeSigs = beforeSide ? signatures(beforeDecls) : new Map();
115
93
  const keys = [...new Set([...afterMap.touched.keys(), ...beforeMap.touched.keys()])];
116
94
  const changes = [];
117
95
  for (const key of keys) {
@@ -155,6 +155,14 @@ function bodyOf(node) {
155
155
  function openBrace(node, sf) {
156
156
  return node.getChildren(sf).find((c) => c.kind === ts.SyntaxKind.OpenBraceToken)?.getStart(sf);
157
157
  }
158
+ /** Text up to the body of a function initializer, or up to the end of the type annotation (the initializer is the body). */
159
+ function headText(node, sf, init, typeEnd) {
160
+ if (isFunctionInitializer(init)) {
161
+ const text = tokenText(node, sf, node.getStart(sf), init.body.getStart(sf));
162
+ return ts.isArrowFunction(init) ? text.replace(/\s*=>$/, " =>") : text;
163
+ }
164
+ return tokenText(node, sf, node.getStart(sf), typeEnd);
165
+ }
158
166
  /** Uncapped Signature text; `signatureOf` clips it for display. */
159
167
  export function fullSignatureOf(node, sf) {
160
168
  if (ts.isVariableDeclaration(node)) {
@@ -163,13 +171,13 @@ export function fullSignatureOf(node, sf) {
163
171
  const head = ts.isVariableDeclarationList(list) && ts.isVariableStatement(statement)
164
172
  ? tokenText(statement, sf, statement.getStart(sf), list.declarations[0].getStart(sf))
165
173
  : "";
166
- const fn = isFunctionInitializer(node.initializer) ? node.initializer : undefined;
167
- const end = fn ? fn.body.getStart(sf) : node.end;
168
- let text = tokenText(node, sf, node.getStart(sf), end);
169
- if (fn && ts.isArrowFunction(fn))
170
- text = text.replace(/\s*=>$/, " =>");
171
- return `${head} ${text}`.trim();
174
+ return `${head} ${headText(node, sf, node.initializer, (node.type ?? node.exclamationToken ?? node.name).end)}`.trim();
175
+ }
176
+ if (ts.isPropertyDeclaration(node)) {
177
+ return headText(node, sf, node.initializer, (node.type ?? node.exclamationToken ?? node.questionToken ?? node.name).end).replace(/;$/, "");
172
178
  }
179
+ if (ts.isEnumMember(node))
180
+ return tokenText(node, sf, node.getStart(sf), node.name.end);
173
181
  if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
174
182
  return tokenText(node, sf, node.getStart(sf), openBrace(node, sf) ?? node.end);
175
183
  }
@@ -216,12 +224,76 @@ function nameText(name, sf) {
216
224
  return name.text;
217
225
  return name.getText(sf);
218
226
  }
227
+ /** Registration verbs whose `('Name', fn)` call introduces a nested Declaration (AngularJS style). */
228
+ const REGISTRATION_VERBS = new Set(["controller", "service", "factory", "directive", "filter", "component", "run", "config", "provider"]);
229
+ /** Verbs whose callback is a constructor: its `this.x = function` statements are members. */
230
+ const CONSTRUCTOR_VERBS = new Set(["controller", "service", "provider"]);
231
+ /** `Template.x.<hook>(fn)` lifecycle callbacks. */
232
+ const TEMPLATE_HOOKS = new Set(["onCreated", "onRendered", "onDestroyed"]);
233
+ function unwrap(expr) {
234
+ let e = expr;
235
+ for (;;) {
236
+ if (ts.isParenthesizedExpression(e))
237
+ e = e.expression;
238
+ else if (ts.isPrefixUnaryExpression(e))
239
+ e = e.operand;
240
+ else if (ts.isVoidExpression(e))
241
+ e = e.expression;
242
+ else
243
+ return e;
244
+ }
245
+ }
246
+ function asFunction(expr) {
247
+ if (!expr)
248
+ return undefined;
249
+ const e = unwrap(expr);
250
+ return ts.isFunctionExpression(e) || ts.isArrowFunction(e) ? e : undefined;
251
+ }
252
+ /** Statements of an IIFE body: `(function () {...})()`, `!function () {...}()`, `(function () {...}).call(this)`. */
253
+ function iifeBody(expr) {
254
+ if (!expr)
255
+ return undefined;
256
+ const call = unwrap(expr);
257
+ if (!ts.isCallExpression(call))
258
+ return undefined;
259
+ let callee = unwrap(call.expression);
260
+ if (ts.isPropertyAccessExpression(callee) && (callee.name.text === "call" || callee.name.text === "apply"))
261
+ callee = unwrap(callee.expression);
262
+ const fn = asFunction(callee);
263
+ return fn && ts.isBlock(fn.body) ? fn.body.statements : undefined;
264
+ }
265
+ /** The callback of a registration: a function, or the last element of a DI array. */
266
+ function registeredFunction(arg) {
267
+ if (!arg)
268
+ return undefined;
269
+ if (ts.isArrayLiteralExpression(arg))
270
+ return asFunction(arg.elements[arg.elements.length - 1]);
271
+ return asFunction(arg);
272
+ }
273
+ function isStringName(node) {
274
+ return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node));
275
+ }
276
+ function compact(node, sf) {
277
+ return node.getText(sf).replace(/\s+/g, "");
278
+ }
279
+ /** Parameter list (and return type) of a function as `(a, b): T`. */
280
+ function paramsText(fn, sf) {
281
+ const params = fn.parameters.map((p) => tokenText(p, sf, p.getStart(sf), p.end)).join(", ");
282
+ return `(${params})${fn.type ? `: ${tokenText(fn.type, sf, fn.type.getStart(sf), fn.type.end)}` : ""}`;
283
+ }
284
+ /** `node` text up to the body of its function value; `=>` kept for arrows. */
285
+ function headUntil(node, fn, sf) {
286
+ const text = tokenText(node, sf, node.getStart(sf), fn.body.getStart(sf));
287
+ return ts.isArrowFunction(fn) ? text.replace(/\s*=>$/, " =>") : text;
288
+ }
219
289
  /**
220
- * Top-level Declarations of a file in source order, with class, interface and
221
- * enum members and namespace members following their parent.
290
+ * Declarations of a file in source order: top-level ones with class, interface
291
+ * and enum members and namespace members following their parent, plus nested
292
+ * declarations of script-style code (registrations, IIFE helpers, prototype and
293
+ * object-literal methods; see GLOSSARY.md Declaration).
222
294
  */
223
295
  export function collectDeclarations(sf, file) {
224
- const result = [];
296
+ const raws = [];
225
297
  const local = localExportNames(sf);
226
298
  const add = (node, nameNode, name, exported, parent) => {
227
299
  const kind = kindOf(node);
@@ -231,26 +303,228 @@ export function collectDeclarations(sf, file) {
231
303
  ? node.parent.parent
232
304
  : node;
233
305
  const start = startNode.getStart(sf);
234
- const startLc = sf.getLineAndCharacterOfPosition(start);
235
- const namePos = nameNode ? nameNode.getStart(sf) : start;
236
306
  const body = bodyOf(ts.isVariableDeclaration(node) && isFunctionInitializer(node.initializer) ? node.initializer : node);
237
- result.push({
238
- file,
239
- line: startLc.line + 1,
240
- col: startLc.character + 1,
241
- endLine: sf.getLineAndCharacterOfPosition(node.end).line + 1,
307
+ raws.push({
308
+ start,
309
+ end: node.end,
242
310
  kind,
243
311
  name,
244
312
  qualifiedName: parent ? `${parent}.${name}` : name,
245
313
  exported,
246
- signature: signatureOf(node, sf),
314
+ signature: fullSignatureOf(node, sf),
247
315
  ...(parent ? { parentName: parent } : {}),
248
- namePos,
249
- nameLine: sf.getLineAndCharacterOfPosition(namePos).line + 1,
316
+ namePos: nameNode ? nameNode.getStart(sf) : start,
250
317
  hasBody: body !== undefined ||
251
318
  !(ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node)),
252
319
  });
253
320
  };
321
+ const nested = (raw) => {
322
+ raws.push({ ...raw, qualifiedName: raw.qualifiedName ?? (raw.parentName ? `${raw.parentName}.${raw.name}` : raw.name), hasBody: true });
323
+ };
324
+ /** Method and function-valued properties of an object literal as `<holder>.<name>`. */
325
+ const objectMembers = (obj, holder, exported) => {
326
+ for (const prop of obj.properties) {
327
+ const name = nameText(prop.name, sf);
328
+ if (!name || !prop.name)
329
+ continue;
330
+ if (ts.isMethodDeclaration(prop)) {
331
+ nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported, signature: fullSignatureOf(prop, sf), namePos: prop.name.getStart(sf) });
332
+ }
333
+ else if (ts.isPropertyAssignment(prop)) {
334
+ const fn = asFunction(prop.initializer);
335
+ if (fn)
336
+ nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported, signature: headUntil(prop, fn, sf), namePos: prop.name.getStart(sf) });
337
+ }
338
+ }
339
+ };
340
+ /** `this.x = function` statements at the top level of a constructor-like body. */
341
+ const thisMembers = (body, owner, exported) => {
342
+ if (!body || !ts.isBlock(body))
343
+ return;
344
+ for (const statement of body.statements) {
345
+ if (!ts.isExpressionStatement(statement) || !ts.isBinaryExpression(statement.expression))
346
+ continue;
347
+ const { left, operatorToken, right } = statement.expression;
348
+ const fn = asFunction(right);
349
+ if (operatorToken.kind !== ts.SyntaxKind.EqualsToken || !fn || !ts.isPropertyAccessExpression(left) || left.expression.kind !== ts.SyntaxKind.ThisKeyword)
350
+ continue;
351
+ nested({ start: statement.getStart(sf), end: statement.end, kind: "method", name: left.name.text, parentName: owner, exported, signature: headUntil(statement.expression, fn, sf), namePos: left.name.getStart(sf) });
352
+ }
353
+ };
354
+ /** Top-level object literals returned by a factory: its API. */
355
+ const returnedMembers = (body, owner) => {
356
+ if (ts.isObjectLiteralExpression(unwrap(body)))
357
+ return objectMembers(unwrap(body), owner, true);
358
+ if (!ts.isBlock(body))
359
+ return;
360
+ for (const statement of body.statements) {
361
+ if (ts.isReturnStatement(statement) && statement.expression && ts.isObjectLiteralExpression(unwrap(statement.expression))) {
362
+ objectMembers(unwrap(statement.expression), owner, true);
363
+ }
364
+ }
365
+ };
366
+ const topLevelExported = (name) => raws.find((r) => r.qualifiedName === name && r.parentName === undefined)?.exported ?? false;
367
+ const isConstructorName = (name) => /^[A-Z]/.test(name);
368
+ /** `.controller('Name', fn)` and friends; `.run(fn)` / `.config(fn)` take the verb as name. */
369
+ const registration = (call, verb) => {
370
+ const [first, second] = call.arguments;
371
+ const named = isStringName(first);
372
+ if (!named && verb !== "run" && verb !== "config")
373
+ return;
374
+ const name = named ? first.text : verb;
375
+ const arg = named ? second : first;
376
+ const callee = call.expression;
377
+ const start = named ? first.getStart(sf) : callee.name.getStart(sf);
378
+ if (verb === "component" && arg && ts.isObjectLiteralExpression(arg)) {
379
+ nested({ start, end: arg.end, kind: "variable", name, exported: true, signature: `component ${name}`, namePos: arg.getStart(sf), nameless: true });
380
+ objectMembers(arg, name, true);
381
+ return;
382
+ }
383
+ const fn = registeredFunction(arg);
384
+ if (!fn)
385
+ return;
386
+ const inject = arg && ts.isArrayLiteralExpression(arg) ? ` [${arg.elements.slice(0, -1).map((e) => e.getText(sf)).join(", ")}]` : "";
387
+ nested({
388
+ start,
389
+ end: arg.end,
390
+ kind: "function",
391
+ name,
392
+ qualifiedName: name,
393
+ parentName: verb,
394
+ exported: true,
395
+ signature: `${verb} ${name}${paramsText(fn, sf)}`,
396
+ namePos: (ts.isFunctionExpression(fn) && fn.name ? fn.name : fn).getStart(sf),
397
+ nameless: true,
398
+ });
399
+ // The DI array is compared, not displayed: reordering injections is a contract change.
400
+ if (inject)
401
+ raws[raws.length - 1].signature += `\n${inject.trim()}`;
402
+ if (CONSTRUCTOR_VERBS.has(verb))
403
+ thisMembers(fn.body, name, true);
404
+ if (verb === "factory" || verb === "service")
405
+ returnedMembers(fn.body, name);
406
+ };
407
+ /** Registrations along a call chain (`angular.module('x').controller(...).service(...)`), in source order. */
408
+ const callChain = (expr) => {
409
+ const calls = [];
410
+ let e = unwrap(expr);
411
+ while (ts.isCallExpression(e)) {
412
+ const callee = e.expression;
413
+ if (ts.isPropertyAccessExpression(callee)) {
414
+ calls.push({ call: e, name: callee.name.text });
415
+ e = callee.expression;
416
+ }
417
+ else {
418
+ break;
419
+ }
420
+ }
421
+ for (const { call, name } of calls.reverse()) {
422
+ const callee = call.expression;
423
+ const receiver = compact(callee.expression, sf);
424
+ const first = call.arguments[0];
425
+ if (receiver === "Meteor" && name === "methods" && first && ts.isObjectLiteralExpression(first)) {
426
+ objectMembers(first, "methods", true);
427
+ }
428
+ else if (receiver.startsWith("Template") && (name === "helpers" || name === "events") && first && ts.isObjectLiteralExpression(first)) {
429
+ objectMembers(first, `${receiver}.${name}`, true);
430
+ }
431
+ else if (receiver.startsWith("Template") && TEMPLATE_HOOKS.has(name) && asFunction(first)) {
432
+ nested({ start: call.getStart(sf), end: call.end, kind: "method", name, parentName: receiver, exported: true, signature: `${receiver}.${headUntil(first, asFunction(first), sf)}`, namePos: callee.name.getStart(sf) });
433
+ }
434
+ else if (REGISTRATION_VERBS.has(name)) {
435
+ registration(call, name);
436
+ }
437
+ }
438
+ };
439
+ /** `exports.x = fn`, `module.exports = {...}`, `Foo.prototype.bar = fn`, `Foo.bar = fn`. */
440
+ const assignment = (statement, expr) => {
441
+ const left = expr.left;
442
+ const right = unwrap(expr.right);
443
+ if (!ts.isPropertyAccessExpression(left))
444
+ return;
445
+ const target = compact(left, sf);
446
+ if (target === "module.exports") {
447
+ if (ts.isObjectLiteralExpression(right))
448
+ objectMembers(right, "exports", true);
449
+ return;
450
+ }
451
+ const owner = left.expression;
452
+ if (ts.isPropertyAccessExpression(owner) && owner.name.text === "prototype" && ts.isIdentifier(owner.expression)) {
453
+ const cls = owner.expression.text;
454
+ const fn = asFunction(right);
455
+ if (fn)
456
+ nested({ start: statement.getStart(sf), end: statement.end, kind: "method", name: left.name.text, parentName: cls, exported: topLevelExported(cls), signature: headUntil(expr, fn, sf), namePos: left.name.getStart(sf) });
457
+ return;
458
+ }
459
+ if (ts.isIdentifier(left.name) && left.name.text === "prototype" && ts.isIdentifier(owner) && ts.isObjectLiteralExpression(right)) {
460
+ objectMembers(right, owner.text, topLevelExported(owner.text));
461
+ return;
462
+ }
463
+ const fn = asFunction(right);
464
+ if (!fn)
465
+ return;
466
+ const holder = target === `module.exports.${left.name.text}` ? "exports" : ts.isIdentifier(owner) ? owner.text : undefined;
467
+ if (holder === undefined)
468
+ return;
469
+ const exported = holder === "exports" || topLevelExported(holder);
470
+ nested({ start: statement.getStart(sf), end: statement.end, kind: "method", name: left.name.text, parentName: holder, exported, signature: headUntil(expr, fn, sf), namePos: left.name.getStart(sf) });
471
+ };
472
+ /** Script-style patterns of one module-level statement; `inIife` adds the IIFE's own functions. */
473
+ const scriptStatement = (statement, inIife) => {
474
+ if (ts.isFunctionDeclaration(statement)) {
475
+ if (!statement.name || !statement.body)
476
+ return;
477
+ if (inIife)
478
+ add(statement, statement.name, statement.name.text, false);
479
+ if (isConstructorName(statement.name.text))
480
+ thisMembers(statement.body, statement.name.text, inIife ? false : topLevelExported(statement.name.text));
481
+ }
482
+ else if (ts.isVariableStatement(statement)) {
483
+ for (const decl of statement.declarationList.declarations) {
484
+ if (!ts.isIdentifier(decl.name) || !decl.initializer)
485
+ continue;
486
+ const name = decl.name.text;
487
+ const fn = asFunction(decl.initializer);
488
+ if (inIife && fn)
489
+ add(decl, decl.name, name, false);
490
+ const exported = inIife ? false : topLevelExported(name);
491
+ const init = unwrap(decl.initializer);
492
+ if (ts.isObjectLiteralExpression(init))
493
+ objectMembers(init, name, exported);
494
+ else if (fn && isConstructorName(name))
495
+ thisMembers(fn.body, name, exported);
496
+ else {
497
+ const body = iifeBody(decl.initializer);
498
+ if (body)
499
+ scriptStatements(body, true);
500
+ else
501
+ callChain(decl.initializer);
502
+ }
503
+ }
504
+ }
505
+ else if (ts.isExpressionStatement(statement)) {
506
+ const expr = unwrap(statement.expression);
507
+ const body = iifeBody(expr);
508
+ if (body)
509
+ scriptStatements(body, true);
510
+ else if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.EqualsToken)
511
+ assignment(statement, expr);
512
+ else
513
+ callChain(expr);
514
+ }
515
+ else if (ts.isIfStatement(statement)) {
516
+ scriptStatement(statement.thenStatement, inIife);
517
+ if (statement.elseStatement)
518
+ scriptStatement(statement.elseStatement, inIife);
519
+ }
520
+ else if (ts.isBlock(statement)) {
521
+ scriptStatements(statement.statements, inIife);
522
+ }
523
+ };
524
+ const scriptStatements = (statements, inIife) => {
525
+ for (const statement of statements)
526
+ scriptStatement(statement, inIife);
527
+ };
254
528
  const visitStatements = (statements, parent, parentExported) => {
255
529
  for (const statement of statements) {
256
530
  if (ts.isVariableStatement(statement)) {
@@ -260,6 +534,8 @@ export function collectDeclarations(sf, file) {
260
534
  const exported = parent ? parentExported && hasModifier(statement, ts.SyntaxKind.ExportKeyword) : isTopLevelExported(statement, decl.name.text, local);
261
535
  add(decl, decl.name, decl.name.text, exported, parent);
262
536
  }
537
+ if (!parent)
538
+ scriptStatement(statement, false);
263
539
  continue;
264
540
  }
265
541
  if (ts.isFunctionDeclaration(statement) ||
@@ -307,11 +583,39 @@ export function collectDeclarations(sf, file) {
307
583
  else if (ts.isModuleDeclaration(statement) && statement.body && ts.isModuleBlock(statement.body)) {
308
584
  visitStatements(statement.body.statements, qualified, exported);
309
585
  }
586
+ else if (ts.isFunctionDeclaration(statement) && !parent) {
587
+ scriptStatement(statement, false);
588
+ }
589
+ continue;
310
590
  }
591
+ if (!parent)
592
+ scriptStatement(statement, false);
311
593
  }
312
594
  };
313
595
  visitStatements(sf.statements, undefined, true);
314
- return result;
596
+ // Nested declarations are found after their holder statement; source order is start order.
597
+ const ordered = raws.map((r, i) => ({ r, i })).sort((a, b) => a.r.start - b.r.start || a.i - b.i);
598
+ return ordered.map(({ r }) => {
599
+ const startLc = sf.getLineAndCharacterOfPosition(r.start);
600
+ const full = r.signature;
601
+ return {
602
+ file,
603
+ line: startLc.line + 1,
604
+ col: startLc.character + 1,
605
+ endLine: sf.getLineAndCharacterOfPosition(r.end).line + 1,
606
+ kind: r.kind,
607
+ name: r.name,
608
+ qualifiedName: r.qualifiedName,
609
+ exported: r.exported,
610
+ signature: clip(full.split("\n")[0]),
611
+ ...(r.parentName !== undefined ? { parentName: r.parentName } : {}),
612
+ namePos: r.namePos,
613
+ nameLine: sf.getLineAndCharacterOfPosition(r.namePos).line + 1,
614
+ hasBody: r.hasBody,
615
+ fullSignature: full,
616
+ ...(r.nameless ? { nameless: true } : {}),
617
+ };
618
+ });
315
619
  }
316
620
  /** Declarations sharing file and qualified name (overloads, merging) are one Symbol. */
317
621
  export function symbolKey(decl) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-axi",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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": [