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