ts-axi 0.1.13 → 0.1.15

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
@@ -50,6 +50,37 @@ Early, but already used in real reviews. What the numbers say so far:
50
50
 
51
51
  Known limits: `.astro` / `.vue` / `.svelte` files are not analysed (they appear under `skipped`, and `--grep` searches them textually); every semantic command starts cold (0.3 s on a small project, 2-5 s on the monorepo, dominated by the library `.d.ts` files that barrels pull in); import specifiers are extracted textually, so computed specifiers and bundler-only aliases are missed.
52
52
 
53
+ ## Token cost
54
+
55
+ `test/integration/token-compare.test.ts` does six agent tasks twice: with ts-axi (plus one followed hint where that is the normal next step), and the way a careful agent does them without it - at most 3 `git grep -n` calls, then a ±40-line numbered read around each relevant match (overlaps merged), with `git diff` first for impact. Tokens are characters / 4. A recall gate fails when a grep hit in code (not a comment, a string or a doc file) is neither listed by ts-axi nor covered by its `textual` / `templates` count.
56
+
57
+ <!-- token-compare:start -->
58
+ | task | repo | ts-axi tokens | grep tokens | ratio | recall |
59
+ | --- | --- | ---: | ---: | ---: | --- |
60
+ | def of a class method | astro sample | 117 | 266 | 0.44 | 100% (1/1) |
61
+ | callers of a function | astro sample | 227 | 1169 | 0.19 | 100% (8/8) |
62
+ | refs of a class member | astro sample | 216 | 1133 | 0.19 | 100% (8/8) |
63
+ | impact of a signature change | astro sample | 737 | 1304 | 0.56 | 100% (14/14) |
64
+ | template uses of a directive | legacy-js | 409 | 289 | 1.42 | 100% (5/5) |
65
+ | refs of an untyped CommonJS member | legacy-js | 387 | 401 | 0.96 | 100% (5/5) |
66
+
67
+ - def of a class method is over 0.3 - the class file has 36 lines, so the grep side reads all of it once.
68
+ - impact of a signature change is over 0.3 - the followed hint repeats the first output with the extra rows (refs on the changed function); impact alone is 0.33.
69
+ - template uses of a directive is over 0.3 - the followed hint repeats the first output with the extra rows (--grep); the two matching files have 21 and 4 lines.
70
+ - refs of an untyped CommonJS member is over 0.3 - the followed hint repeats the first output with the extra rows (--grep); the two matching files have 24 and 12 lines.
71
+ <!-- token-compare:end -->
72
+
73
+ The fixture files are 5 to 40 lines long, so a ±40-line read is the whole file: the cheapest case for grep, and where a followed hint repeating the first output weighs most. On a real Nx monorepo (143 projects, ~5.4k source files; `scripts/token-compare.ts`, run locally):
74
+
75
+ | task | ts-axi tokens | grep tokens | ratio | recall |
76
+ | --- | ---: | ---: | ---: | --- |
77
+ | def of a service method | 496 | 1256 | 0.40 | 1/1 |
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
+
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
+
53
84
  ## Install
54
85
 
55
86
  Requires Node.js 20+.
@@ -274,7 +305,7 @@ One dependency: the `typescript` compiler API (ADR in `docs/adr/0001-typescript-
274
305
  - `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.
275
306
  - `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.
276
307
  - 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.
277
- - 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.
308
+ - 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.
278
309
  - Type errors in the analysed project never block anything; they are reported as a `diagnostics` count.
279
310
 
280
311
  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 };
@@ -251,7 +251,9 @@ function walkExtra(dir, outFiles) {
251
251
  * a name in `localTo` (name → absolute file) is searched in that file only.
252
252
  */
253
253
  export function textualMatches(opened, semantic, names, known, skipLines, changeSet, localTo = new Map()) {
254
- const files = [...semantic.projectFiles];
254
+ // The whole Project, like `textualCount`: a subgraph program misses DI uses, which import nothing.
255
+ const byRel = new Map([...semantic.projectFiles, ...opened.index.files].map((f) => [opened.index.rel(f), f]));
256
+ const files = [...byRel.values()];
255
257
  const projectSet = new Set(files);
256
258
  walkExtra(opened.root, files);
257
259
  sourceFs().prefetch(files);
@@ -262,7 +264,7 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
262
264
  continue;
263
265
  const rel = opened.index.rel(abs);
264
266
  // 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) : []);
267
+ const declared = new Set(projectSet.has(abs) ? opened.index.declarations(abs).flatMap((d) => (d.aliasLine ? [d.nameLine, d.aliasLine] : [d.nameLine])) : []);
266
268
  const lines = new Set();
267
269
  for (const { text: name, token } of names) {
268
270
  if (localTo.has(name) && localTo.get(name) !== abs)
@@ -317,6 +319,10 @@ export function textualNeedle(subject, kind) {
317
319
  }
318
320
  return { text: symbol.split(".").pop(), token: false };
319
321
  }
322
+ /** A Reference on a member's aliasing line (`self.x = x`) is part of its Declaration, not a use. */
323
+ export function onAliasLine(decl, e) {
324
+ return decl?.aliasLine !== undefined && e.file === decl.file && e.line === decl.aliasLine;
325
+ }
320
326
  /** Whether `any`-typed property accesses of the name can be uses of the Symbol: same rule as `isTextualCandidate`. */
321
327
  export function isBlindSpotCandidate(decl) {
322
328
  return decl?.injected !== true;
@@ -407,7 +413,7 @@ export function textualCount(opened, needle, changeSet = new Map(), onlyFile) {
407
413
  if (text === undefined || !text.includes(name))
408
414
  continue;
409
415
  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) : []);
416
+ 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
417
  const n = wordMatches(text, name, token).filter((m) => !declared.has(m.line) && !inRanges(changeSet.get(rel), m.line)).length;
412
418
  matches += n;
413
419
  if (n > 0)
@@ -683,7 +689,7 @@ export async function impactCommand(args, ctx) {
683
689
  }
684
690
  for (const e of entries)
685
691
  known.add(`${e.file}:${e.line}`);
686
- row.all = entries.filter((e) => !e.isDefinition);
692
+ row.all = entries.filter((e) => !e.isDefinition && !onAliasLine(row.live, e));
687
693
  row.outside = row.all.filter((e) => !inRanges(changeSet.get(e.file), e.line)).sort(byPosition);
688
694
  }
689
695
  for (const row of rows) {
@@ -1,7 +1,7 @@
1
1
  import { relative, sep } from "node:path";
2
2
  import { bool, expectPositionals, flag, intFlag, parseArgs } from "../args.js";
3
3
  import { importerClosure } from "../importers.js";
4
- import { 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";
@@ -289,7 +289,7 @@ function mergedRows(direct, via, show) {
289
289
  }
290
290
  function renderRefs(semantic, opened, resolved, header, o) {
291
291
  const split = semantic.referenceSplit(resolved.absFile, resolved.pos);
292
- const rows = sortRows([...split.direct]);
292
+ const rows = sortRows(split.direct.filter((e) => !onAliasLine(o.decl, e)));
293
293
  const via = sortRows([...split.via]);
294
294
  const impls = sortRows([...split.implementations]);
295
295
  const { files, tests, fileList } = summary(opened, rows);
@@ -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)
@@ -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.13",
3
+ "version": "0.1.15",
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": [