ts-axi 0.1.14 → 0.1.16

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
@@ -75,11 +75,24 @@ The fixture files are 5 to 40 lines long, so a ±40-line read is the whole file:
75
75
  | task | ts-axi tokens | grep tokens | ratio | recall |
76
76
  | --- | ---: | ---: | ---: | --- |
77
77
  | def of a service method | 496 | 1256 | 0.40 | 1/1 |
78
- | callers of a service method (17 call sites) | 873 | 54314 | 0.02 | 17/96 |
79
- | refs of a service method with a common name | 715 | 147944 | 0.01 | 18/77 (244 hits are other declarations of the name) |
80
- | impact of a two-commit fix (4 changed symbols) | 1395 | 23950 | 0.06 | 28/44 (5 in the diff) |
78
+ | callers of a service method (96 call sites) | 6587 | 54314 | 0.12 | 96/96 |
79
+ | refs of a service method with a common name | 4653 | 147944 | 0.03 | 77/77 (244 more hits are other declarations of the name) |
80
+ | impact of a two-commit fix (4 changed symbols) | 2255 | 23948 | 0.09 | 44/44 (5 in the diff) |
81
81
 
82
- Every hit those three rows miss is a member access through a deep-mock mapped type (`DeepMocked<Service>`) in a test file: the checker resolves it to the method, `findReferences` does not return it, and nothing reports it as a blind spot yet.
82
+ The ts-axi side follows the `--limit` hint when a list is cut at 50 rows, as an agent would. Most rows past the first 50 are member accesses through a deep-mock mapped type (`DeepMocked<Service>`) in test files: `findReferences` returns them as extra groups defined at the method itself, and they count as References (before 0.1.15 they were dropped, and recall was 17/96 and 18/77).
83
+
84
+ ### Speed
85
+
86
+ The same runs count tool calls (each ts-axi command, `git grep`, `git diff` and file read is one call) and time the commands. An agent's task time is mostly model round-trips, so the estimate adds an **assumed** 3 s per call to the measured command time (`TURN_MS` in `test/compare.ts`). On the Nx monorepo:
87
+
88
+ | task | ts-axi calls | grep calls | ts-axi command time | grep command time | est. task time ts-axi | est. task time grep |
89
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: |
90
+ | def of a service method | 1 | 3 | 1.4 s | 0.94 s | 4.4 s | 9.9 s |
91
+ | callers of a service method | 2 | 28 | 11.1 s | 0.20 s | 17.1 s | 84.2 s |
92
+ | refs of a service method with a common name | 2 | 100 | 6.3 s | 0.27 s | 12.3 s | 300.3 s |
93
+ | impact of a two-commit fix | 2 | 19 | 8.1 s | 0.22 s | 14.1 s | 57.2 s |
94
+
95
+ Each ts-axi command is slower than a `git grep` (every call starts cold and builds a program: 3-6 s per semantic call here), but it replaces dozens of reads. The integration test checks that ts-axi never needs more tool calls than the grep workflow; times are printed, not asserted.
83
96
 
84
97
  ## Install
85
98
 
@@ -109,7 +122,7 @@ The hook runs `ts-axi` with no arguments: it reads only the file tree and git st
109
122
  | Command | What it does |
110
123
  | --- | --- |
111
124
  | `ts-axi` | Home view: project root, tsconfig used (or implicit), source file count, test-file glob and match counts, git branch and dirty count, next steps. Under a second, exit 0 everywhere |
112
- | `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` and, when the name still occurs in the code, the `refs <name> --grep` that lists those textual matches |
125
+ | `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` and, when the name still occurs in the code, the `refs <name> --grep` that lists those textual matches. Before saying none, a name that is only a function-local Declaration (`local_to: Comp`) or only declared in a file the tsconfig leaves out (`project: outside (Deno edge/deno.json)`) is found syntactically and shown like a match |
113
126
  | `refs <target>` | References of one symbol, grouped by file, with test counts. A class member that implements an interface (or overrides a base) member counts only its direct References; calls through the interface are `via_interface: n (Iface.member)`, sibling implementations are dropped, and an interface member reports `implementations: n`. `--limit`, `--kind calls`, `--via` (via rows with a `via` column), `--implementations` |
114
127
  | `callers <target>` | Incoming calls of a function-like symbol, split the same way (`via_interface`, `--via`). `--depth 1..3` |
115
128
  | `outline <file>` | Top-level declarations in source order, class members nested: `{line,kind,name,signature}` (`{at,kind,name,signature}` over several files). `--exports`, `--fields exported` |
@@ -305,7 +318,7 @@ One dependency: the `typescript` compiler API (ADR in `docs/adr/0001-typescript-
305
318
  - `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.
306
319
  - `impact` reads the "before" version of touched files with `git show` and compares signatures to classify each change (`added`, `removed`, `signature`, `body`, `type`, `modified`). Uses it cannot follow (access through `any`, dynamic string keys) are reported as `blind_spots`, never silently dropped.
307
320
  - Module resolution keeps TypeScript's rules with two substitutions so References reach implementations: a `.d.ts` that has a JavaScript twin in the Project (`index.d.ts` beside `index.js`) resolves to the twin, and a CommonJS shim whose only statement is `module.exports = require('./x')` resolves to `./x`, so `require('..')` reaches the real function. A `.d.ts` twin that still ends up in the program is queried too and its References merged.
308
- - 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.
321
+ - Script-style JavaScript (AngularJS registrations with their `this` / `var self = this` members and factory objects, ui-router states, Meteor methods and helpers, `module.exports` objects, prototype methods, IIFE helpers) gets nested declarations with qualified names, so `outline` lists them and `impact` names the function an edit touches instead of a file-level change.
309
322
  - Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
310
323
 
311
324
  Every semantic command starts cold and prints `elapsed_ms`.
@@ -259,7 +259,8 @@ export function resolvePosition(index, cwd, target) {
259
259
  return start >= lineStart && start < lineEnd;
260
260
  });
261
261
  const decls = index.declarations(abs);
262
- const declFor = (id) => decls.find((d) => d.namePos === id.getStart(sf));
262
+ // `self.loadItem = loadItem`: the member name there stands for the nested function's member too.
263
+ const declFor = (id) => decls.find((d) => d.namePos === id.getStart(sf)) ?? decls.find((d) => d.aliasPos === id.getStart(sf));
263
264
  const pick = (id) => {
264
265
  const decl = declFor(id);
265
266
  return decl ? { kind: "decl", decl, abs } : { kind: "ident", abs, pos: id.getStart(sf), name: id.text };
@@ -2,6 +2,7 @@ import { bool, expectPositionals, flag, parseArgs } from "../args.js";
2
2
  import { semanticFor } from "../scope.js";
3
3
  import { textualScan, textualScanHint } from "./impact.js";
4
4
  import { block, fenced, helpBlock, out } from "../render.js";
5
+ import { isOutside, missFallback, missRows, notSearched, outsideReason } from "./miss.js";
5
6
  import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
6
7
  import { BODY_LINES, declarationAt, filterSuffix, MATCH_ROWS, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, useRef, } from "./common.js";
7
8
  export const DEF_HELP = `usage: ts-axi def <name | Class.member | path:line[:col]> [flags]
@@ -10,7 +11,8 @@ flags[8]:
10
11
  output:
11
12
  1 match = file, line, kind, exported, signature + body fenced with file line numbers
12
13
  several = matches[n]{at,kind,signature} (at = path:line) (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms (--in, --kind, Class.member, --exported) with how many each keeps
13
- none = matches: 0 + did_you_mean; when the name still occurs in Project files, help gives the refs --grep that lists those textual matches
14
+ not indexed = a function-local Declaration (local_to: Comp) or one in a file the tsconfig leaves out (project: outside (not in tsconfig.json | own tsconfig x | Deno x/deno.json)) shows the same way; several = matches[n]{at,kind,name,where} (at = path:line:col)
15
+ none = matches: 0 + did_you_mean; when the name still occurs in source files (left-out files too), help gives the refs --grep that lists those textual matches
14
16
  examples[5]:
15
17
  ts-axi def User
16
18
  ts-axi def User.save
@@ -19,13 +21,13 @@ examples[5]:
19
21
  ts-axi def sav --fuzzy --exported
20
22
  `;
21
23
  const COMMAND = "ts-axi def";
22
- function detail(ctx, decls, full, label, extra = {}) {
24
+ function detail(ctx, decls, full, label, extra = {}, at) {
23
25
  const primary = decls.find((d) => d.hasBody) ?? decls[0];
24
26
  const first = decls[0];
25
27
  const from = Math.min(...decls.map((d) => d.line));
26
28
  const to = Math.max(...decls.map((d) => d.endLine));
27
29
  const body = numberedLines(ctx.index.text(ctx.index.abs(primary.file)) ?? "", from, to, full ? undefined : BODY_LINES);
28
- const position = `${first.file}:${first.nameLine}`;
30
+ const position = at ?? `${first.file}:${first.nameLine}`;
29
31
  const help = [`Run \`ts-axi refs ${position}\` for its references`];
30
32
  if (isFunctionLike(primary.kind))
31
33
  help.push(`Run \`ts-axi callers ${position}\` for its call sites`);
@@ -40,6 +42,7 @@ function detail(ctx, decls, full, label, extra = {}) {
40
42
  exported: primary.exported,
41
43
  signature: first.signature,
42
44
  ...(decls.filter((d) => !d.hasBody).length > 0 ? { overloads: decls.filter((d) => !d.hasBody).length } : {}),
45
+ ...(isOutside(ctx, ctx.index.abs(primary.file)) ? { project: outsideReason(ctx, ctx.index.abs(primary.file)) } : {}),
43
46
  ...extra,
44
47
  }), fenced("body", body), helpBlock(help));
45
48
  }
@@ -78,9 +81,19 @@ export async function defCommand(args, ctx) {
78
81
  if (groups.length === 1)
79
82
  return detail(opened, groups[0].decls, full, label);
80
83
  if (groups.length === 0) {
84
+ const miss = missFallback(opened, target, f);
85
+ if (miss.hits.length === 1) {
86
+ const hit = miss.hits[0];
87
+ return detail(opened, [hit.decl], full, hit.at, hit.localTo !== undefined ? { local_to: hit.localTo } : {}, hit.at);
88
+ }
89
+ if (miss.hits.length > 1) {
90
+ return out(block({ ...(opened.warning ? { warning: opened.warning } : {}), symbol: label, ...missRows(miss, MATCH_ROWS) }), helpBlock(miss.hits.slice(0, 3).map((h) => `Run \`ts-axi def ${h.at}\` for the one ${h.localTo !== undefined ? `in ${h.localTo}` : `in ${h.decl.file}`}`)));
91
+ }
81
92
  const suggested = suggestions(opened, target);
82
- const text = f.fuzzy ? 0 : textualScan(opened, target.member ?? target.name).length;
83
- const help = [...(text > 0 ? [textualScanHint(label, text)] : []), ...suggested.slice(0, 2).map((s) => `Run \`ts-axi def ${s}\``)];
93
+ const rows = f.fuzzy ? [] : textualScan(opened, target.member ?? target.name);
94
+ const text = rows.length;
95
+ const outsideCount = rows.filter((r) => isOutside(opened, opened.index.abs(r.file))).length;
96
+ const help = [...(text > 0 ? [textualScanHint(label, text, outsideCount)] : []), ...suggested.slice(0, 2).map((s) => `Run \`ts-axi def ${s}\``)];
84
97
  if (!f.fuzzy)
85
98
  help.push(`Run \`ts-axi def ${label} --fuzzy\` for substring matches`);
86
99
  if (f.kind || f.in.length > 0 || f.exported)
@@ -89,6 +102,7 @@ export async function defCommand(args, ctx) {
89
102
  ...(opened.warning ? { warning: opened.warning } : {}),
90
103
  matches: 0,
91
104
  ...(suggested.length > 0 ? { did_you_mean: suggested } : {}),
105
+ ...notSearched(miss),
92
106
  }), helpBlock(help));
93
107
  }
94
108
  return out(block({
@@ -15,6 +15,7 @@ import { IMPACT_HOPS, scopeFields, scopeHelp, semanticFor } from "../scope.js";
15
15
  import { isTemplatePath, loadTemplates, templateTarget, templateUsages, usagesIn } from "../templates.js";
16
16
  import { walkFiles } from "../walk.js";
17
17
  import { contextOf, lineOf, open, projectFields } from "./common.js";
18
+ import { outsideFiles } from "./miss.js";
18
19
  const DEFAULT_LIMIT = 50;
19
20
  const MAX_LIMIT = 500;
20
21
  const SIDE_ROWS = 20;
@@ -251,7 +252,9 @@ function walkExtra(dir, outFiles) {
251
252
  * a name in `localTo` (name → absolute file) is searched in that file only.
252
253
  */
253
254
  export function textualMatches(opened, semantic, names, known, skipLines, changeSet, localTo = new Map()) {
254
- const files = [...semantic.projectFiles];
255
+ // The whole Project, like `textualCount`: a subgraph program misses DI uses, which import nothing.
256
+ const byRel = new Map([...semantic.projectFiles, ...opened.index.files].map((f) => [opened.index.rel(f), f]));
257
+ const files = [...byRel.values()];
255
258
  const projectSet = new Set(files);
256
259
  walkExtra(opened.root, files);
257
260
  sourceFs().prefetch(files);
@@ -262,7 +265,7 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
262
265
  continue;
263
266
  const rel = opened.index.rel(abs);
264
267
  // A Declaration of another Symbol with the same name is provably not a use.
265
- const declared = new Set(projectSet.has(abs) ? opened.index.declarations(abs).map((d) => d.nameLine) : []);
268
+ const declared = new Set(projectSet.has(abs) ? opened.index.declarations(abs).flatMap((d) => (d.aliasLine ? [d.nameLine, d.aliasLine] : [d.nameLine])) : []);
266
269
  const lines = new Set();
267
270
  for (const { text: name, token } of names) {
268
271
  if (localTo.has(name) && localTo.get(name) !== abs)
@@ -279,11 +282,15 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
279
282
  }
280
283
  return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
281
284
  }
282
- /** Whole-word matches of `name` in the Project files, one row per line: the fallback when no Declaration matches. */
285
+ /**
286
+ * Whole-word matches of `name` in the Project files and the source files it leaves out, one row per line:
287
+ * the fallback when no Declaration matches.
288
+ */
283
289
  export function textualScan(opened, name) {
284
290
  const rows = [];
285
- sourceFs().prefetch(opened.index.files);
286
- for (const abs of opened.index.files) {
291
+ const files = [...opened.index.files, ...outsideFiles(opened)];
292
+ sourceFs().prefetch(files);
293
+ for (const abs of files) {
287
294
  const text = opened.index.text(abs);
288
295
  if (text === undefined || !text.includes(name))
289
296
  continue;
@@ -297,8 +304,8 @@ export function textualScan(opened, name) {
297
304
  }
298
305
  return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
299
306
  }
300
- export function textualScanHint(label, matches) {
301
- return `Run \`ts-axi refs ${label} --grep\` for ${matches} textual matches`;
307
+ export function textualScanHint(label, matches, outside = 0) {
308
+ return `Run \`ts-axi refs ${label} --grep\` for ${matches} textual matches${outside > 0 ? ` (${outside} in files outside the Project)` : ""}`;
302
309
  }
303
310
  /** Whether a whole-word count of the name says anything: not for router-called or by-name-injected members (`injected`). */
304
311
  export function isTextualCandidate(decl) {
@@ -317,6 +324,10 @@ export function textualNeedle(subject, kind) {
317
324
  }
318
325
  return { text: symbol.split(".").pop(), token: false };
319
326
  }
327
+ /** A Reference on a member's aliasing line (`self.x = x`) is part of its Declaration, not a use. */
328
+ export function onAliasLine(decl, e) {
329
+ return decl?.aliasLine !== undefined && e.file === decl.file && e.line === decl.aliasLine;
330
+ }
320
331
  /** Whether `any`-typed property accesses of the name can be uses of the Symbol: same rule as `isTextualCandidate`. */
321
332
  export function isBlindSpotCandidate(decl) {
322
333
  return decl?.injected !== true;
@@ -407,7 +418,7 @@ export function textualCount(opened, needle, changeSet = new Map(), onlyFile) {
407
418
  if (text === undefined || !text.includes(name))
408
419
  continue;
409
420
  const rel = opened.index.rel(abs);
410
- const declared = new Set(project.has(abs) ? opened.index.declarations(abs).filter((d) => d.name === name).map((d) => d.nameLine) : []);
421
+ const declared = new Set(project.has(abs) ? opened.index.declarations(abs).filter((d) => d.name === name).flatMap((d) => (d.aliasLine ? [d.nameLine, d.aliasLine] : [d.nameLine])) : []);
411
422
  const n = wordMatches(text, name, token).filter((m) => !declared.has(m.line) && !inRanges(changeSet.get(rel), m.line)).length;
412
423
  matches += n;
413
424
  if (n > 0)
@@ -683,7 +694,7 @@ export async function impactCommand(args, ctx) {
683
694
  }
684
695
  for (const e of entries)
685
696
  known.add(`${e.file}:${e.line}`);
686
- row.all = entries.filter((e) => !e.isDefinition);
697
+ row.all = entries.filter((e) => !e.isDefinition && !onAliasLine(row.live, e));
687
698
  row.outside = row.all.filter((e) => !inRanges(changeSet.get(e.file), e.line)).sort(byPosition);
688
699
  }
689
700
  for (const row of rows) {
@@ -0,0 +1,225 @@
1
+ // The miss fallback: when a name target matches no indexed Declaration, look for it syntactically in
2
+ // source files the tsconfig leaves out and in function-local Declarations, so a miss is never silent.
3
+ import { dirname, join, posix, relative, sep } from "node:path";
4
+ import ts from "typescript";
5
+ import { sourceFs } from "../fs.js";
6
+ import { matchesGlob } from "../glob.js";
7
+ import { declMatches, isSourceFile, nearestTsconfig } from "../project.js";
8
+ import { byDepthThenPath, listFiles } from "../walk.js";
9
+ import { declarationAt } from "./common.js";
10
+ /** At most this many files that pass the name prefilter are parsed; the rest are counted in `not_searched`. */
11
+ export const MISS_PARSE_CAP = 2_000;
12
+ const ANON = "(anonymous)";
13
+ function nameOf(node) {
14
+ return node && (ts.isIdentifier(node) || ts.isPrivateIdentifier(node) || ts.isStringLiteral(node)) ? node.text : undefined;
15
+ }
16
+ /** Name of a function-like node: its own, or the variable / property / member it is assigned to. */
17
+ function functionName(fn) {
18
+ const parent = fn.parent;
19
+ const cls = parent && (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) ? nameOf(parent.name) ?? ANON : undefined;
20
+ if (ts.isConstructorDeclaration(fn))
21
+ return `${cls ?? ANON}.constructor`;
22
+ const own = nameOf(fn.name);
23
+ if (own !== undefined)
24
+ return cls !== undefined ? `${cls}.${own}` : own;
25
+ if (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent))
26
+ return nameOf(parent.name);
27
+ if (ts.isPropertyDeclaration(parent)) {
28
+ const owner = parent.parent;
29
+ return `${nameOf(owner.name) ?? ANON}.${nameOf(parent.name) ?? ANON}`;
30
+ }
31
+ return undefined;
32
+ }
33
+ /** Nearest named enclosing function; anonymous callbacks (`useEffect(() => ...)`) are skipped. */
34
+ function ownerOf(node) {
35
+ for (let n = node.parent; n && !ts.isSourceFile(n); n = n.parent) {
36
+ if (ts.isFunctionLike(n)) {
37
+ const name = functionName(n);
38
+ if (name !== undefined)
39
+ return name;
40
+ }
41
+ }
42
+ return "(top-level block)";
43
+ }
44
+ /**
45
+ * Function, class and plain `const/let/var x` Declarations named `name` that sit in a function or block
46
+ * body (not top level, not a namespace). Parameters, destructured and loop bindings are left out.
47
+ */
48
+ export function findLocals(sf, name) {
49
+ const found = [];
50
+ const nested = (statement) => !ts.isSourceFile(statement.parent) && !ts.isModuleBlock(statement.parent);
51
+ const visit = (node) => {
52
+ if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name?.text === name && nested(node)) {
53
+ found.push({ pos: node.name.getStart(sf), name, owner: ownerOf(node) });
54
+ }
55
+ else if (ts.isVariableDeclaration(node) &&
56
+ ts.isIdentifier(node.name) &&
57
+ node.name.text === name &&
58
+ ts.isVariableStatement(node.parent.parent) &&
59
+ nested(node.parent.parent)) {
60
+ found.push({ pos: node.name.getStart(sf), name, owner: ownerOf(node) });
61
+ }
62
+ ts.forEachChild(node, visit);
63
+ };
64
+ visit(sf);
65
+ return found;
66
+ }
67
+ const outsideCache = new WeakMap();
68
+ /**
69
+ * Source files below the repo root the Project does not list (gitignored, node_modules and `.d.ts`
70
+ * dropped). Empty for a workspace or implicit Project: those already list every source file.
71
+ */
72
+ export function outsideFiles(opened) {
73
+ const cached = outsideCache.get(opened);
74
+ if (cached)
75
+ return cached.files;
76
+ const { project } = opened.index;
77
+ let files = [];
78
+ let capped = false;
79
+ if (!project.scope && !project.implicit) {
80
+ const known = new Set(opened.index.files);
81
+ // node_modules and build dirs are already dropped by the shared skip rule.
82
+ const listing = listFiles(opened.root, isSourceFile, { maxFiles: 20_000, deadline: Date.now() + 600 });
83
+ files = listing.files.filter((f) => !known.has(f));
84
+ capped = listing.capped;
85
+ }
86
+ outsideCache.set(opened, { files, set: new Set(files), capped });
87
+ return files;
88
+ }
89
+ const projectSets = new WeakMap();
90
+ /** Lists the left-out files only for a file the Project does not hold, so hit paths stay listing-free. */
91
+ export function isOutside(opened, abs) {
92
+ let known = projectSets.get(opened);
93
+ if (!known)
94
+ projectSets.set(opened, (known = new Set(opened.index.files)));
95
+ if (known.has(abs))
96
+ return false;
97
+ outsideFiles(opened);
98
+ return outsideCache.get(opened).set.has(abs);
99
+ }
100
+ /** `outside (Deno edge/deno.json)`, `outside (own tsconfig x/tsconfig.json)` or `outside (not in tsconfig.json)`. */
101
+ export function outsideReason(opened, abs) {
102
+ const root = opened.root;
103
+ for (let dir = dirname(abs);; dir = dirname(dir)) {
104
+ for (const name of ["deno.json", "deno.jsonc"]) {
105
+ if (sourceFs().exists(join(dir, name)))
106
+ return `outside (Deno ${opened.index.rel(join(dir, name))})`;
107
+ }
108
+ if (dir === root || dirname(dir) === dir)
109
+ break;
110
+ }
111
+ const own = nearestTsconfig(abs, root);
112
+ const projectTsconfig = opened.index.project.tsconfig;
113
+ if (own && own !== projectTsconfig)
114
+ return `outside (own tsconfig ${opened.index.rel(own)})`;
115
+ return projectTsconfig ? `outside (not in ${relative(opened.cwd, projectTsconfig).split(sep).join("/")})` : "outside (not in the Project)";
116
+ }
117
+ /** `--in` globs are cwd-relative; files are root-relative. */
118
+ function rootGlobs(opened, globs) {
119
+ const prefix = relative(opened.root, opened.cwd).split(sep).join("/");
120
+ return globs.map((g) => {
121
+ const clean = g.replace(/^\.\//, "");
122
+ return prefix && !prefix.startsWith("..") ? posix.join(prefix, clean) : clean;
123
+ });
124
+ }
125
+ function inGlobs(rel, globs) {
126
+ return globs.length === 0 || globs.some((g) => matchesGlob(rel, g));
127
+ }
128
+ function nameCol(sf, pos) {
129
+ const lc = sf.getLineAndCharacterOfPosition(pos);
130
+ return { line: lc.line + 1, col: lc.character + 1 };
131
+ }
132
+ /** Declarations of the name outside the Project, plus function-local ones anywhere (see the header). */
133
+ export function missFallback(opened, target, f) {
134
+ if (f.fuzzy)
135
+ return { hits: [], skipped: 0, walkCapped: false };
136
+ const { index } = opened;
137
+ const globs = rootGlobs(opened, f.in);
138
+ const outside = outsideFiles(opened);
139
+ const word = target.member ?? target.name;
140
+ const pick = (list) => (globs.length === 0 ? list : list.filter((abs) => inGlobs(index.rel(abs), globs)));
141
+ const outsidePicked = pick(outside);
142
+ const candidates = [...pick(index.files), ...outsidePicked];
143
+ sourceFs().prefetch(outsidePicked);
144
+ const hits = [];
145
+ let parsed = 0;
146
+ let skipped = 0;
147
+ for (const abs of candidates) {
148
+ const text = index.text(abs);
149
+ if (text === undefined || !text.includes(word))
150
+ continue;
151
+ if (++parsed > MISS_PARSE_CAP) {
152
+ skipped++;
153
+ continue;
154
+ }
155
+ const sf = index.sourceFile(abs);
156
+ if (!sf)
157
+ continue;
158
+ const reason = isOutside(opened, abs) ? outsideReason(opened, abs) : undefined;
159
+ const add = (decl, localTo) => {
160
+ if (f.kind !== undefined && decl.kind !== f.kind)
161
+ return;
162
+ if (f.exported && !decl.exported)
163
+ return;
164
+ const { line, col } = nameCol(sf, decl.namePos);
165
+ hits.push({ decl, abs, at: `${decl.file}:${line}:${col}`, ...(localTo !== undefined ? { localTo } : {}), ...(reason ? { outside: reason } : {}) });
166
+ };
167
+ const indexed = index.declarations(abs);
168
+ if (reason)
169
+ for (const d of indexed)
170
+ if (declMatches(d, { name: target.name, ...(target.member !== undefined ? { member: target.member } : {}) }))
171
+ add(d);
172
+ if (target.member !== undefined)
173
+ continue;
174
+ const known = new Set(indexed.map((d) => d.namePos));
175
+ for (const local of findLocals(sf, target.name)) {
176
+ if (known.has(local.pos))
177
+ continue;
178
+ const decl = declarationAt(index, abs, local.pos, "variable");
179
+ if (decl)
180
+ add(decl, local.owner);
181
+ }
182
+ }
183
+ hits.sort((a, b) => byDepthThenPath(a.decl.file, b.decl.file) || a.decl.namePos - b.decl.namePos);
184
+ return { hits, skipped, walkCapped: outsideCache.get(opened).capped };
185
+ }
186
+ /** `local to Comp`, `outside (...)` or both: the `where` cell and the single-hit fields. */
187
+ export function whereOf(hit) {
188
+ return [hit.localTo !== undefined ? `local to ${hit.localTo}` : undefined, hit.outside].filter(Boolean).join("; ");
189
+ }
190
+ /** Fields a single fallback hit adds to a detail or refs header. */
191
+ export function missFields(hit) {
192
+ return { ...(hit.localTo !== undefined ? { local_to: hit.localTo } : {}), ...(hit.outside ? { project: hit.outside } : {}) };
193
+ }
194
+ /** `matches[n]{at,kind,name,where}` for several fallback hits. */
195
+ export function missRows(result, max) {
196
+ const rows = result.hits.slice(0, max).map((h) => ({ at: h.at, kind: h.decl.kind, name: h.decl.qualifiedName, where: whereOf(h) }));
197
+ return {
198
+ matches: rows,
199
+ ...(result.hits.length > max ? { matches_total: result.hits.length, truncated: true } : {}),
200
+ ...notSearched(result),
201
+ };
202
+ }
203
+ /** `not_searched` when a bound stopped the fallback. */
204
+ export function notSearched(result) {
205
+ const parts = [
206
+ ...(result.skipped > 0 ? [`${result.skipped} files past the ${MISS_PARSE_CAP}-file cap`] : []),
207
+ ...(result.walkCapped ? ["files past the 20000-file or 0.6 s walk bound (no git)"] : []),
208
+ ];
209
+ return parts.length > 0 ? { not_searched: parts.join("; ") } : {};
210
+ }
211
+ /** Outside-Project source files whose text holds `name`: extra roots so a semantic run sees their uses. */
212
+ export function outsideSeeds(opened, name) {
213
+ return outsideFiles(opened).filter((abs) => opened.index.text(abs)?.includes(name));
214
+ }
215
+ /** `3 matches in 2 files (edge/fn; tools)`: textual rows from outside-Project files, at most 3 dirs. */
216
+ export function outsideLine(opened, rows) {
217
+ const outside = new Set(outsideFiles(opened).map((abs) => opened.index.rel(abs)));
218
+ const mine = rows.filter((r) => outside.has(r.file));
219
+ if (mine.length === 0)
220
+ return undefined;
221
+ const files = [...new Set(mine.map((r) => r.file))];
222
+ const dirs = [...new Set(files.map((f) => posix.dirname(f)))];
223
+ const shown = dirs.slice(0, 3).join("; ") + (dirs.length > 3 ? `; +${dirs.length - 3} more` : "");
224
+ return `${mine.length} matches in ${files.length} files (${shown})`;
225
+ }
@@ -3,7 +3,8 @@ import { posix, relative, resolve, sep } from "node:path";
3
3
  import { bool, flag, parseArgs, parseFields } from "../args.js";
4
4
  import { usageError } from "../errors.js";
5
5
  import { hasGlobMagic, matchesGlob } from "../glob.js";
6
- import { createSyntacticIndex, findRepoRoot, openProject, SOURCE_EXTENSIONS } from "../project.js";
6
+ import { createSyntacticIndex, findRepoRoot, isSourceFile, openProject, SOURCE_EXTENSIONS } from "../project.js";
7
+ import { listFiles } from "../walk.js";
7
8
  import { block, helpBlock, out } from "../render.js";
8
9
  import { useRef } from "./common.js";
9
10
  const MAX_ROWS = 300;
@@ -55,13 +56,17 @@ export async function outlineCommand(args, ctx) {
55
56
  const index = globs.length > 0
56
57
  ? openProject(ctx.cwd, override ? { override } : {}).index
57
58
  : createSyntacticIndex(root, { root, tsconfig: null, implicit: true, fileNames: files, options: {} });
59
+ let outsideCount = 0;
58
60
  if (globs.length > 0) {
59
61
  const rooted = globs.map((g) => (cwdPrefix && !cwdPrefix.startsWith("..") ? posix.join(cwdPrefix, g.replace(/^\.\//, "")) : g));
60
- for (const file of index.files)
61
- if (rooted.some((g) => matchesGlob(index.rel(file), g)))
62
- files.push(file);
62
+ const matching = (list) => list.filter((file) => rooted.some((g) => matchesGlob(index.rel(file), g)));
63
+ const inProject = matching(index.files);
64
+ // Files the tsconfig leaves out parse alone, like a plain path.
65
+ const outside = inProject.length === 0 && files.length === 0 ? matching(listFiles(root, isSourceFile, { maxFiles: 20_000, deadline: Date.now() + 600 }).files) : [];
66
+ outsideCount = outside.length;
67
+ files.push(...inProject, ...outside);
63
68
  if (files.length === 0) {
64
- return out(block({ files: 0, decls: 0 }), helpBlock([`No Project file matches ${globs.join(" ")}; paths are relative to the current directory`]));
69
+ return out(block({ files: 0, decls: 0 }), helpBlock([`No source file matches ${globs.join(" ")}; paths are relative to the current directory`]));
65
70
  }
66
71
  }
67
72
  const unique = [...new Set(files)];
@@ -97,6 +102,7 @@ export async function outlineCommand(args, ctx) {
97
102
  ...(single ? { file: index.rel(unique[0]) } : { files: unique.length }),
98
103
  decls: rows.length === 0 ? 0 : rows,
99
104
  ...(decls.length > MAX_ROWS ? { truncated: true, decls_total: decls.length } : {}),
105
+ ...(outsideCount > 0 ? { note: `${outsideCount} files outside the Project` } : {}),
100
106
  }), helpBlock(help));
101
107
  }
102
108
  function isInterfaceMember(fileDecls, d) {
@@ -1,13 +1,14 @@
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 { isPlainLocal, isTextualCandidate, isUntyped, textualCount, textualNeedle, textualHint, textualLine, textualMatches, textualScan, textualScanHint } from "./impact.js";
4
+ import { isPlainLocal, isTextualCandidate, onAliasLine, isUntyped, textualCount, textualNeedle, textualHint, textualLine, textualMatches, textualScan, textualScanHint } 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
9
  import { loadTemplates, templatesLine, templateTarget, templateUsages } from "../templates.js";
10
10
  import { byDepthThenPath } from "../walk.js";
11
+ import { isOutside, missFallback, missRows, notSearched, outsideLine, outsideReason, outsideSeeds } from "./miss.js";
11
12
  import { detectWorkspace, isMultiProject } from "../workspace.js";
12
13
  import { isTestFile } from "../settings.js";
13
14
  import { isFunctionLike, kindFromScriptElement, parseTarget, registrationOf } from "../symbols.js";
@@ -20,6 +21,9 @@ const VIA_DOC = `via_interface: n (Iface.member) = References through an interfa
20
21
  via_implementations: n (Impl.member) = References to implementations or overrides of the Symbol; not in refs_total
21
22
  implementations: n = members that implement or override the Symbol (their Declarations are never refs)
22
23
  matches: 0 under -P lists found_outside_project[n]{file,line} when the name is declared outside that Project
24
+ local_to: Comp = the name is a function-local Declaration (not indexed): its References from the declaring file
25
+ matches: 0 + project: outside (...) = declared only in a file the tsconfig leaves out: outside_project + unresolved list its textual matches; the help position target runs the checker with the left-out files added (project: outside on that output)
26
+ matches[n]{at,kind,name,where} = several function-local or left-out Declarations; pick one by its path:line:col
23
27
  refs_total: 0 under -P in a workspace lists found_outside_project[n]{file,line} = importers outside that Project
24
28
  scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
25
29
  tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references
@@ -35,7 +39,7 @@ output:
35
39
  symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,context} (Declarations excluded), elapsed_ms
36
40
  ${VIA_DOC}
37
41
  note = the name is registered by a string ('Name' in .controller('Name', fn), or fn a same-file function named by identifier) - its uses are textual; add --grep
38
- matches: 0 = no Declaration of the name; help counts its whole-word matches in Project files, and --grep lists them in unresolved with note 'no declaration found - textual matches only'
42
+ matches: 0 = no Declaration of the name; help counts its whole-word matches in source files (left-out files too), and --grep lists them in unresolved with note 'no declaration found - textual matches only' (outside_project = the share in files the tsconfig leaves out)
39
43
  path:line = the declaration that starts the line (a function before its parameters, a property before its value's parameters; siblings like 'const a = 1, b = 2' list identifiers - add :col)
40
44
  an ambiguous name lists matches[n] (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms; nothing is analysed
41
45
  examples[4]:
@@ -93,6 +97,8 @@ async function run(args, ctx, invoked) {
93
97
  const warning = opened.warning ? { warning: opened.warning } : {};
94
98
  // Resolve to exactly one Symbol before building a program.
95
99
  let resolved;
100
+ let local;
101
+ let miss;
96
102
  if (target.kind === "name") {
97
103
  const groups = resolveNameTarget(opened, target, f);
98
104
  if (groups.length === 0) {
@@ -113,13 +119,33 @@ async function run(args, ctx, invoked) {
113
119
  `Run \`ts-axi ${name} ${outside[0].file}:${outside[0].nameLine}\` to use the nearest tsconfig of that file`,
114
120
  ]));
115
121
  }
122
+ miss = missFallback(opened, target, f);
123
+ if (miss.hits.length > 1) {
124
+ return out(block({ ...warning, symbol: label, ambiguous: `${miss.hits.length} Declarations outside the index - pick one`, ...missRows(miss, MATCH_ROWS) }), helpBlock(miss.hits.slice(0, 3).map((h) => `Run \`ts-axi ${name} ${h.at}\` for the one ${h.localTo !== undefined ? `in ${h.localTo}` : `in ${h.decl.file}`}`)));
125
+ }
126
+ const hit = miss.hits[0];
127
+ if (hit && hit.localTo !== undefined) {
128
+ // A local is file-scoped: the normal position path finds every Reference.
129
+ local = hit;
130
+ resolved = { absFile: hit.abs, pos: hit.decl.namePos, decl: hit.decl };
131
+ }
132
+ else if (hit) {
133
+ return outsideMiss(opened, hit, { warning, name, limit, label: target.member ?? target.name });
134
+ }
135
+ }
136
+ if (resolved) {
137
+ // A local found by the miss fallback.
138
+ }
139
+ else if (groups.length === 0) {
116
140
  // No Declaration, but the name may still be written: an untyped property set up elsewhere.
117
141
  const text = f.fuzzy ? [] : textualScan(opened, target.member ?? target.name);
142
+ const outsideText = outsideLine(opened, text);
118
143
  if (show.grep) {
119
144
  return out(block({
120
145
  ...warning,
121
146
  matches: 0,
122
147
  note: "no declaration found - textual matches only",
148
+ ...(outsideText ? { outside_project: outsideText } : {}),
123
149
  unresolved: text.length === 0 ? 0 : text.slice(0, limit),
124
150
  ...(text.length > limit ? { unresolved_total: text.length } : {}),
125
151
  }), helpBlock([
@@ -128,17 +154,19 @@ async function run(args, ctx, invoked) {
128
154
  ]));
129
155
  }
130
156
  const suggested = suggestions(opened, target);
131
- return out(block({ ...warning, matches: 0, ...(suggested.length > 0 ? { did_you_mean: suggested } : {}) }), helpBlock([
132
- ...(text.length > 0 ? [textualScanHint(label, text.length)] : []),
157
+ return out(block({ ...warning, matches: 0, ...(suggested.length > 0 ? { did_you_mean: suggested } : {}), ...(miss ? notSearched(miss) : {}) }), helpBlock([
158
+ ...(text.length > 0 ? [textualScanHint(label, text.length, text.filter((r) => isOutside(opened, opened.index.abs(r.file))).length)] : []),
133
159
  ...suggested.slice(0, 2).map((s) => `Run \`ts-axi ${name} ${s}\``),
134
160
  ...(f.fuzzy ? [] : [`Run \`ts-axi def ${label} --fuzzy\` for substring matches`]),
135
161
  ]));
136
162
  }
137
- if (groups.length > 1) {
163
+ else if (groups.length > 1) {
138
164
  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)));
139
165
  }
140
- const decl = groups[0].decls[0];
141
- resolved = { absFile: opened.index.abs(decl.file), pos: decl.namePos, decl, seeds: [...new Set(groups[0].decls.map((d) => opened.index.abs(d.file)))] };
166
+ else {
167
+ const decl = groups[0].decls[0];
168
+ resolved = { absFile: opened.index.abs(decl.file), pos: decl.namePos, decl, seeds: [...new Set(groups[0].decls.map((d) => opened.index.abs(d.file)))] };
169
+ }
142
170
  }
143
171
  else {
144
172
  const at = resolvePosition(opened.index, ctx.cwd, target);
@@ -150,13 +178,21 @@ async function run(args, ctx, invoked) {
150
178
  }
151
179
  resolved = at.kind === "decl" ? { absFile: at.abs, pos: at.decl.namePos, decl: at.decl } : { absFile: at.abs, pos: at.pos };
152
180
  }
153
- let built = semanticFor(opened, resolved.seeds ?? [resolved.absFile], resolved.decl ? REFS_HOPS : 0);
154
- if (!resolved.decl && built.scope) {
181
+ // A file the tsconfig leaves out brings the other left-out files that name the identifier.
182
+ const seedsOf = (files, abs, pos) => {
183
+ if (!isOutside(opened, abs))
184
+ return files;
185
+ const sf = opened.index.sourceFile(abs);
186
+ const word = sf ? /^[\w$]+/.exec(sf.text.slice(pos))?.[0] : undefined;
187
+ return [...new Set([...files, ...(word ? outsideSeeds(opened, word) : [])])];
188
+ };
189
+ let built = semanticFor(opened, seedsOf(resolved.seeds ?? [resolved.absFile], resolved.absFile, resolved.pos), resolved.decl ? REFS_HOPS : 0);
190
+ if (!resolved.decl && (built.scope || isOutside(opened, resolved.absFile))) {
155
191
  // A use position: find its Declaration in a small program, then scope around the declaring file.
156
192
  const at = built.semantic.definitionAt(resolved.absFile, resolved.pos);
157
193
  if (at && at.line > 0)
158
194
  resolved = { absFile: at.absFile, pos: at.pos };
159
- built = semanticFor(opened, [resolved.absFile], REFS_HOPS);
195
+ built = semanticFor(opened, seedsOf([resolved.absFile], resolved.absFile, resolved.pos), REFS_HOPS);
160
196
  }
161
197
  const { semantic, scope } = built;
162
198
  const def = semantic.definitionAt(resolved.absFile, resolved.pos);
@@ -166,8 +202,17 @@ async function run(args, ctx, invoked) {
166
202
  const decl = (found && registrationOf(found, opened.index.declarations(opened.index.abs(found.file)))) ?? found;
167
203
  const kind = decl?.kind ?? kindFromScriptElement(def?.kind ?? semantic.quickInfo(resolved.absFile, resolved.pos)?.kind ?? "");
168
204
  const symbol = decl?.qualifiedName ?? def?.name ?? label;
169
- const declaredAt = decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
170
- const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
205
+ const declaredAt = local ? local.at : decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
206
+ const declaredAbs = decl ? opened.index.abs(decl.file) : def && def.line > 0 ? def.absFile : undefined;
207
+ const header = {
208
+ ...warning,
209
+ ...projectFields(opened),
210
+ symbol,
211
+ kind,
212
+ declared_at: declaredAt,
213
+ ...(local ? { local_to: local.localTo } : {}),
214
+ ...(declaredAbs && isOutside(opened, declaredAbs) ? { project: outsideReason(opened, declaredAbs) } : {}),
215
+ };
171
216
  const explicit = Boolean(project || opened.settings.project);
172
217
  const declaredFile = decl?.file ?? (def && def.line > 0 ? def.file : undefined);
173
218
  const localFile = isPlainLocal(opened.index, decl) ? opened.index.abs(decl.file) : undefined;
@@ -180,6 +225,26 @@ async function run(args, ctx, invoked) {
180
225
  ? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
181
226
  : renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
182
227
  }
228
+ /**
229
+ * One Declaration in a file the tsconfig leaves out: no semantic program for a name target, so the
230
+ * textual rows of the name (Project + left-out files) and the position target that does run one.
231
+ */
232
+ function outsideMiss(opened, hit, o) {
233
+ const rows = textualScan(opened, o.label).filter((r) => !(r.file === hit.decl.file && r.line === hit.decl.nameLine));
234
+ const outsideText = outsideLine(opened, rows);
235
+ return out(block({
236
+ ...o.warning,
237
+ matches: 0,
238
+ declared_at: `${hit.decl.file}:${hit.decl.nameLine}`,
239
+ project: hit.outside,
240
+ ...(outsideText ? { outside_project: outsideText } : {}),
241
+ unresolved: rows.length === 0 ? 0 : rows.slice(0, o.limit),
242
+ ...(rows.length > o.limit ? { unresolved_total: rows.length } : {}),
243
+ }), helpBlock([
244
+ `Run \`ts-axi ${o.name} ${hit.at}\` for semantic ${o.name === "callers" ? "call sites" : "References"} - a position target adds the left-out files to the program`,
245
+ `Run \`ts-axi def ${hit.at}\` for the declaration body`,
246
+ ]));
247
+ }
183
248
  /** Template usages as `--grep` rows. */
184
249
  export function templateRows(files, target) {
185
250
  const texts = new Map(files.map((f) => [f.rel, f.text]));
@@ -289,7 +354,7 @@ function mergedRows(direct, via, show) {
289
354
  }
290
355
  function renderRefs(semantic, opened, resolved, header, o) {
291
356
  const split = semantic.referenceSplit(resolved.absFile, resolved.pos);
292
- const rows = sortRows([...split.direct]);
357
+ const rows = sortRows(split.direct.filter((e) => !onAliasLine(o.decl, e)));
293
358
  const via = sortRows([...split.via]);
294
359
  const impls = sortRows([...split.implementations]);
295
360
  const { files, tests, fileList } = summary(opened, rows);
@@ -102,7 +102,7 @@ export function discoverTsconfig(startDir, options = {}) {
102
102
  dir = parent;
103
103
  }
104
104
  }
105
- function isSourceFile(name) {
105
+ export function isSourceFile(name) {
106
106
  return SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)) && !/\.d\.[mc]?ts$/.test(name);
107
107
  }
108
108
  /** Every source file below `root` from the shared listing (`src/walk.ts`: git, else fs walk), minus `.d.ts`. */
@@ -204,7 +204,7 @@ export function parseSource(file, text) {
204
204
  export function relPath(root, abs) {
205
205
  return relative(root, abs).split(sep).join("/");
206
206
  }
207
- function declMatches(decl, query) {
207
+ export function declMatches(decl, query) {
208
208
  const needle = query.fuzzy ? query.name.toLowerCase() : query.name;
209
209
  if (query.member !== undefined) {
210
210
  if (decl.parentName === undefined)
@@ -7,7 +7,8 @@ const RANK = { direct: 0, declaration: 1, implementation: 2, via: 3, dropped: 4
7
7
  * Rules: the target group is direct. When the target is a class/interface member, a group it
8
8
  * implements/overrides (a base) is via, minus Declarations and `super` calls; a group that
9
9
  * implements/overrides the target is an implementation (its Declaration) or via (its uses);
10
- * any other member group is a sibling and dropped. Non-member groups (import aliases) are direct.
10
+ * any other member group is a sibling and dropped. Non-member groups (import aliases) and groups
11
+ * defined at the target itself (mock mapped types) are direct.
11
12
  */
12
13
  export function classifyReferenceGroups(groups, targetIndex) {
13
14
  const target = groups[targetIndex];
@@ -19,7 +20,8 @@ export function classifyReferenceGroups(groups, targetIndex) {
19
20
  };
20
21
  const bases = new Set(target?.bases ?? []);
21
22
  groups.forEach((group, i) => {
22
- const isTarget = i === targetIndex;
23
+ // Mock shapes (`{ [K in keyof T]: … } & T`) come back as extra groups defined at the target itself.
24
+ const isTarget = i === targetIndex || (target !== undefined && group.key === target.key);
23
25
  const relation = isTarget || !target?.member || !group.member
24
26
  ? "direct"
25
27
  : bases.has(group.key)
package/dist/src/scope.js CHANGED
@@ -99,8 +99,21 @@ export function planScope(index, seeds, hops, extra = []) {
99
99
  }
100
100
  /** A LanguageService over the Project, or over the importer subgraph of `seeds` when the Project is scoped. */
101
101
  export function semanticFor(ctx, seeds, hops, extra = []) {
102
- if (!ctx.index.project.scope)
103
- return { semantic: createSemanticProject(ctx.index) };
102
+ if (!ctx.index.project.scope) {
103
+ // A seed the tsconfig leaves out (an excluded dir) joins the Project's roots, or the service cannot see it.
104
+ const known = new Set(ctx.index.files);
105
+ const outside = seeds.filter((s) => !known.has(s) && sourceFs().exists(s));
106
+ if (outside.length === 0)
107
+ return { semantic: createSemanticProject(ctx.index) };
108
+ const { project } = ctx.index;
109
+ return {
110
+ semantic: createSemanticProject(ctx.index, {
111
+ rootNames: [...project.fileNames, ...new Set(outside)],
112
+ options: project.options,
113
+ currentDirectory: project.tsconfig ? dirname(project.tsconfig) : ctx.index.root,
114
+ }),
115
+ };
116
+ }
104
117
  const { program, info } = planScope(ctx.index, seeds, hops, extra);
105
118
  return { semantic: createSemanticProject(ctx.index, program), scope: info };
106
119
  }
@@ -289,27 +289,57 @@ function functionByIdentifier(call, arg) {
289
289
  let scope = call;
290
290
  while (!ts.isSourceFile(scope) && !(ts.isBlock(scope) && ts.isFunctionLike(scope.parent)))
291
291
  scope = scope.parent;
292
- const statements = scope.statements;
292
+ return functionInStatements(scope.statements, ref.text);
293
+ }
294
+ /** The function declaration or function-valued variable `name` among `statements` (any position: hoisting), with its `$inject`. */
295
+ function functionInStatements(statements, name) {
293
296
  let found;
294
297
  let inject;
295
298
  for (const st of statements) {
296
- if (ts.isFunctionDeclaration(st) && st.name?.text === ref.text && st.body)
299
+ if (ts.isFunctionDeclaration(st) && st.name?.text === name && st.body)
297
300
  found = { fn: st, name: st.name };
298
301
  else if (ts.isVariableStatement(st)) {
299
302
  for (const d of st.declarationList.declarations) {
300
303
  const fn = asFunction(d.initializer);
301
- if (fn && ts.isIdentifier(d.name) && d.name.text === ref.text)
304
+ if (fn && ts.isIdentifier(d.name) && d.name.text === name)
302
305
  found = { fn, name: d.name };
303
306
  }
304
307
  }
305
308
  else if (ts.isExpressionStatement(st) && ts.isBinaryExpression(st.expression) && ts.isPropertyAccessExpression(st.expression.left)) {
306
309
  const { left, right } = st.expression;
307
- if (left.name.text === "$inject" && ts.isIdentifier(left.expression) && left.expression.text === ref.text && ts.isArrayLiteralExpression(right))
310
+ if (left.name.text === "$inject" && ts.isIdentifier(left.expression) && left.expression.text === name && ts.isArrayLiteralExpression(right))
308
311
  inject = right;
309
312
  }
310
313
  }
311
314
  return found && (inject ? { ...found, inject } : found);
312
315
  }
316
+ /** Names bound to `this` at the top level of a body (`var self = this`, `const vm = this`). */
317
+ function thisAliases(statements) {
318
+ const names = new Set();
319
+ for (const st of statements) {
320
+ if (!ts.isVariableStatement(st))
321
+ continue;
322
+ for (const d of st.declarationList.declarations) {
323
+ if (ts.isIdentifier(d.name) && d.initializer && unwrap(d.initializer).kind === ts.SyntaxKind.ThisKeyword)
324
+ names.add(d.name.text);
325
+ }
326
+ }
327
+ return names;
328
+ }
329
+ /** The object literal of a top-level `var <name> = {...}` among `statements`. */
330
+ function objectVariable(statements, name) {
331
+ let found;
332
+ for (const st of statements) {
333
+ if (!ts.isVariableStatement(st))
334
+ continue;
335
+ for (const d of st.declarationList.declarations) {
336
+ const init = d.initializer && unwrap(d.initializer);
337
+ if (ts.isIdentifier(d.name) && d.name.text === name && init && ts.isObjectLiteralExpression(init))
338
+ found = init;
339
+ }
340
+ }
341
+ return found;
342
+ }
313
343
  function isStringName(node) {
314
344
  return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node));
315
345
  }
@@ -363,8 +393,21 @@ export function collectDeclarations(sf, file) {
363
393
  const nested = (raw) => {
364
394
  raws.push({ ...raw, qualifiedName: raw.qualifiedName ?? (raw.parentName ? `${raw.parentName}.${raw.name}` : raw.name), hasBody: true });
365
395
  };
366
- /** Method and function-valued properties of an object literal as `<holder>.<name>`. */
367
- const objectMembers = (obj, holder, exported) => {
396
+ /**
397
+ * `<holder>.<name>` for a nested function named by identifier (`self.x = loadItem`, `{ x: loadItem }`): the
398
+ * function's own span, signature and name, so its line and the aliasing line both resolve to the member.
399
+ */
400
+ const aliasedMember = (scope, ref, name, holder, exported, alias) => {
401
+ const target = functionInStatements(scope, ref.text);
402
+ if (!target)
403
+ return;
404
+ const decl = ts.isFunctionDeclaration(target.fn) ? target.fn : target.name.parent;
405
+ const list = decl.parent;
406
+ const node = ts.isVariableDeclaration(decl) && ts.isVariableDeclarationList(list) && ts.isVariableStatement(list.parent) && list.declarations[0] === decl ? list.parent : decl;
407
+ nested({ start: node.getStart(sf), end: node.end, kind: "method", name, parentName: holder, exported, signature: fullSignatureOf(decl, sf), namePos: target.name.getStart(sf), aliasPos: alias.getStart(sf) });
408
+ };
409
+ /** Method and function-valued properties of an object literal as `<holder>.<name>`; with `scope`, also identifiers naming its functions. */
410
+ const objectMembers = (obj, holder, exported, scope) => {
368
411
  for (const prop of obj.properties) {
369
412
  const name = nameText(prop.name, sf);
370
413
  if (!name || !prop.name)
@@ -374,35 +417,60 @@ export function collectDeclarations(sf, file) {
374
417
  }
375
418
  else if (ts.isPropertyAssignment(prop)) {
376
419
  const fn = asFunction(prop.initializer);
420
+ const value = unwrap(prop.initializer);
377
421
  if (fn)
378
422
  nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported, signature: headUntil(prop, fn, sf), namePos: prop.name.getStart(sf) });
423
+ else if (scope && ts.isIdentifier(value))
424
+ aliasedMember(scope, value, name, holder, exported, prop.name);
425
+ }
426
+ else if (scope && ts.isShorthandPropertyAssignment(prop)) {
427
+ aliasedMember(scope, prop.name, name, holder, exported, prop.name);
379
428
  }
380
429
  }
381
430
  };
382
- /** `this.x = function` statements at the top level of a constructor-like body. */
383
- const thisMembers = (body, owner, exported) => {
384
- if (!body || !ts.isBlock(body))
385
- return;
386
- for (const statement of body.statements) {
431
+ /** `<holder>.x = function | <nested function>` at the top level of a body; `isHolder` matches `this`, its aliases or a returned object. */
432
+ const memberAssignments = (statements, isHolder, owner, exported) => {
433
+ for (const statement of statements) {
387
434
  if (!ts.isExpressionStatement(statement) || !ts.isBinaryExpression(statement.expression))
388
435
  continue;
389
436
  const { left, operatorToken, right } = statement.expression;
390
- if (operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isPropertyAccessExpression(left) || left.expression.kind !== ts.SyntaxKind.ThisKeyword)
437
+ if (operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isPropertyAccessExpression(left) || !isHolder(left.expression))
391
438
  continue;
392
439
  const value = unwrap(right);
393
440
  if (ts.isObjectLiteralExpression(value))
394
441
  objectMembers(value, `${owner}.${left.name.text}`, exported);
442
+ if (ts.isIdentifier(value))
443
+ aliasedMember(statements, value, left.name.text, owner, exported, left.name);
395
444
  const fn = asFunction(right);
396
445
  if (!fn)
397
446
  continue;
398
447
  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) });
399
448
  }
400
449
  };
401
- /** Object literals a function returns at its top level (a factory's API, a deps object): their function-valued members. */
450
+ /** `this.x = ...` (also through `var self = this`) at the top level of a constructor-like body. */
451
+ const thisMembers = (body, owner, exported) => {
452
+ if (!body || !ts.isBlock(body))
453
+ return;
454
+ const aliases = thisAliases(body.statements);
455
+ memberAssignments(body.statements, (e) => e.kind === ts.SyntaxKind.ThisKeyword || (ts.isIdentifier(e) && aliases.has(e.text)), owner, exported);
456
+ };
457
+ /** Object literals a function returns at its top level (a factory's API, a deps object), directly or as `var service = {...}; return service`. */
402
458
  const returnedMembers = (body, owner, exported) => {
403
- if (body)
404
- for (const obj of returnedObjects(body))
405
- objectMembers(obj, owner, exported);
459
+ if (!body)
460
+ return;
461
+ const scope = ts.isBlock(body) ? body.statements : undefined;
462
+ for (const obj of returnedObjects(body))
463
+ objectMembers(obj, owner, exported, scope);
464
+ for (const st of scope ?? []) {
465
+ const ret = ts.isReturnStatement(st) && st.expression ? unwrap(st.expression) : undefined;
466
+ if (!ret || !ts.isIdentifier(ret))
467
+ continue;
468
+ const obj = objectVariable(scope, ret.text);
469
+ if (!obj)
470
+ continue;
471
+ objectMembers(obj, owner, exported, scope);
472
+ memberAssignments(scope, (e) => ts.isIdentifier(e) && e.text === ret.text, owner, exported);
473
+ }
406
474
  };
407
475
  const topLevelExported = (name) => raws.find((r) => r.qualifiedName === name && r.parentName === undefined)?.exported ?? false;
408
476
  const isConstructorName = (name) => /^[A-Z]/.test(name);
@@ -851,7 +919,18 @@ export function collectDeclarations(sf, file) {
851
919
  const kept = registeredBodies.length === 0
852
920
  ? raws
853
921
  : raws.filter((r) => r.registered || !registeredBodies.some(([s, e]) => r.namePos >= s && r.namePos < e));
854
- const ordered = kept.map((r, i) => ({ r, i })).sort((a, b) => a.r.start - b.r.start || a.i - b.i);
922
+ // One function named twice for the same holder (`this.x = x` and `return { x }`) is one member.
923
+ const seen = new Set();
924
+ const unique = kept.filter((r) => {
925
+ if (r.aliasPos === undefined)
926
+ return true;
927
+ const key = `${r.qualifiedName}@${r.namePos}`;
928
+ if (seen.has(key))
929
+ return false;
930
+ seen.add(key);
931
+ return true;
932
+ });
933
+ const ordered = unique.map((r, i) => ({ r, i })).sort((a, b) => a.r.start - b.r.start || a.i - b.i);
855
934
  return ordered.map(({ r }) => {
856
935
  const startLc = sf.getLineAndCharacterOfPosition(r.start);
857
936
  const full = r.signature;
@@ -872,6 +951,7 @@ export function collectDeclarations(sf, file) {
872
951
  fullSignature: full,
873
952
  ...(r.nameless ? { nameless: true } : {}),
874
953
  ...(r.injected ? { injected: true } : {}),
954
+ ...(r.aliasPos !== undefined ? { aliasPos: r.aliasPos, aliasLine: sf.getLineAndCharacterOfPosition(r.aliasPos).line + 1 } : {}),
875
955
  };
876
956
  });
877
957
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-axi",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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": [