ts-axi 0.1.6 → 0.1.8

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
@@ -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`. 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`.
178
+ An edit inside `CartCtrl` is reported by `impact` as `CartCtrl body`, not as a file-level change. Names registered by a string have no identifier the checker can follow; `refs` says so and offers `--grep`. ui-router and ngRoute states are Declarations of kind `state` (`states.cart`, with callbacks as `states.cart.resolve.items`), so an edit inside a `resolve` names that callback; state objects in exported arrays (`` module.exports = [{ name: `${base}.init`, url, resolve }] ``) count too, their template-literal names filled from same-file consts. Directives, components, their `scope`/`bindings` keys, controllers and filters also get `templates: <n> usages in <m> files` (kebab-case elements and attributes, `ng-controller`, `| filter` in `.html`/`.htm`/`.ejs`/`.hbs`/`.njk`/`.pug`), a `templates` column in `impact`, and a changed template that uses a changed directive or binding is listed under `template_changes` instead of `skipped`.
179
179
 
180
180
  ## Monorepos
181
181
 
@@ -29,7 +29,7 @@ flags[7]:
29
29
  output:
30
30
  base, head, changes (count), changed[n]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
31
31
  change = signature | removed | type | added | body | modified (before unavailable) - riskiest first
32
- textual = column added only when an untyped (JavaScript; implicit project) Symbol has more whole-word matches outside the change set than refs_outside - those are uses the checker cannot resolve; - for typed rows; such a Change is never local_only
32
+ textual = column added only when an untyped (JavaScript; implicit project) Symbol has more whole-word matches outside the change set than refs_outside - those are uses the checker cannot resolve (declaring file only for a non-exported local); - for typed rows; such a Change is never local_only
33
33
  refs_* / files / tests count direct References only; via = References outside the change set that reach the Symbol through an interface or base member it implements (or name an override of it) - they MAY call it
34
34
  refs[n]{symbol,file,line,context} = References outside the change set; truncated + refs_shown + refs_outside_total when cut
35
35
  notes[n]{symbol,note} = a signature change of a member that implements an interface or base member (the contract may break)
@@ -231,22 +231,27 @@ function blindSpots(semantic, names, changeSet) {
231
231
  function walkExtra(dir, outFiles) {
232
232
  outFiles.push(...walkFiles(dir, { accept: (name) => GREP_EXTRA.some((ext) => name.endsWith(ext)) }).files);
233
233
  }
234
- /** Whole-word matches of `names` in Project files and templates that are not `known` References. */
235
- export function textualMatches(opened, semantic, names, known, skipLines, changeSet) {
234
+ /**
235
+ * Whole-word matches of `names` in Project files and templates that are not `known` References;
236
+ * a name in `localTo` (name → absolute file) is searched in that file only.
237
+ */
238
+ export function textualMatches(opened, semantic, names, known, skipLines, changeSet, localTo = new Map()) {
236
239
  const files = [...semantic.projectFiles];
237
240
  const projectSet = new Set(files);
238
241
  walkExtra(opened.root, files);
239
242
  const rows = [];
240
243
  for (const abs of files) {
241
244
  const text = opened.index.text(abs);
242
- if (text === undefined || !names.some((n) => text.includes(n)))
245
+ if (text === undefined || !names.some((n) => text.includes(n.text)))
243
246
  continue;
244
247
  const rel = opened.index.rel(abs);
245
248
  // A Declaration of another Symbol with the same name is provably not a use.
246
249
  const declared = new Set(projectSet.has(abs) ? opened.index.declarations(abs).map((d) => d.nameLine) : []);
247
250
  const lines = new Set();
248
- for (const name of names) {
249
- for (const m of wordMatches(text, name)) {
251
+ for (const { text: name, token } of names) {
252
+ if (localTo.has(name) && localTo.get(name) !== abs)
253
+ continue;
254
+ for (const m of wordMatches(text, name, token)) {
250
255
  if (known.has(`${rel}:${m.line}`) || skipLines.has(`${rel}:${m.line}`) || declared.has(m.line) || inRanges(changeSet.get(rel), m.line))
251
256
  continue;
252
257
  if (lines.has(m.line))
@@ -258,34 +263,65 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
258
263
  }
259
264
  return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
260
265
  }
266
+ /** Whether a whole-word count of the name says anything: not for router-called or by-name-injected members (`injected`). */
267
+ export function isTextualCandidate(decl) {
268
+ return decl?.injected !== true;
269
+ }
261
270
  /**
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.
271
+ * The textual needle of a Symbol: a state's full name without the `states.`/`routes.` prefix (a token, so
272
+ * `ui-sref="a.b"` counts and `a.b.c` does not match `a.b`; none when it still holds `*`), else the last
273
+ * segment of the qualified name.
264
274
  */
275
+ export function textualNeedle(subject, kind) {
276
+ const symbol = typeof subject === "string" ? subject : subject.qualifiedName;
277
+ if ((kind ?? (typeof subject === "string" ? undefined : subject.kind)) === "state") {
278
+ const name = symbol.replace(/^(states|routes)\./, "");
279
+ return name.includes("*") || name === "" ? undefined : { text: name, token: true };
280
+ }
281
+ return { text: symbol.split(".").pop(), token: false };
282
+ }
283
+ /** Whether `any`-typed property accesses of the name can be uses of the Symbol: same rule as `isTextualCandidate`. */
284
+ export function isBlindSpotCandidate(decl) {
285
+ return decl?.injected !== true;
286
+ }
287
+ /** The checker may miss uses: JS declaring file, a string-registered name, or no tsconfig at all. */
265
288
  export function isUntyped(opened, decl, file) {
266
- if (decl?.parentName !== undefined && /^(states|routes)\./.test(decl.parentName) && !decl.parentName.endsWith(".resolve"))
289
+ if (!isTextualCandidate(decl))
267
290
  return false;
268
291
  return decl?.nameless === true || opened.index.project.implicit || (file !== undefined && /\.[mc]?jsx?$/.test(file));
269
292
  }
270
- /** Whole-word occurrences of `name` in Project files outside `changeSet`; lines declaring a `name` excluded. */
271
- export function textualCount(opened, name, changeSet = new Map()) {
293
+ /** A non-exported, non-member, identifier-named Declaration: only its own file can name it. */
294
+ export function isPlainLocal(decl) {
295
+ return decl !== undefined && !decl.exported && !decl.nameless && decl.parentName === undefined;
296
+ }
297
+ /**
298
+ * Whole-word occurrences of `name` in Project files outside `changeSet`; lines declaring a `name` excluded.
299
+ * With `onlyFile` (absolute), that file alone.
300
+ */
301
+ export function textualCount(opened, needle, changeSet = new Map(), onlyFile) {
302
+ const { text: name, token } = needle;
272
303
  let matches = 0;
273
304
  let files = 0;
274
- for (const abs of opened.index.files) {
305
+ const project = new Set(opened.index.files);
306
+ // State names are also written in templates (`ui-sref`), which the Project does not list.
307
+ const candidates = onlyFile !== undefined ? [onlyFile] : [...project];
308
+ if (token && onlyFile === undefined)
309
+ walkExtra(opened.root, candidates);
310
+ for (const abs of new Set(candidates)) {
275
311
  const text = opened.index.text(abs);
276
312
  if (text === undefined || !text.includes(name))
277
313
  continue;
278
314
  const rel = opened.index.rel(abs);
279
- const declared = new Set(opened.index.declarations(abs).filter((d) => d.name === name).map((d) => d.nameLine));
280
- const n = wordMatches(text, name).filter((m) => !declared.has(m.line) && !inRanges(changeSet.get(rel), m.line)).length;
315
+ const declared = new Set(project.has(abs) ? opened.index.declarations(abs).filter((d) => d.name === name).map((d) => d.nameLine) : []);
316
+ const n = wordMatches(text, name, token).filter((m) => !declared.has(m.line) && !inRanges(changeSet.get(rel), m.line)).length;
281
317
  matches += n;
282
318
  if (n > 0)
283
319
  files++;
284
320
  }
285
- return { matches, files };
321
+ return { matches, files, ...(onlyFile !== undefined ? { fileScoped: true } : {}) };
286
322
  }
287
323
  export function textualLine(count) {
288
- return `${count.matches} matches in ${count.files} files`;
324
+ return count.fileScoped ? `${count.matches} matches in this file` : `${count.matches} matches in ${count.files} files`;
289
325
  }
290
326
  export function textualHint(command, gap) {
291
327
  return `Run \`${command}\` for the ${gap} textual matches the checker cannot resolve (untyped JavaScript)`;
@@ -529,9 +565,13 @@ export async function impactCommand(args, ctx) {
529
565
  for (const row of rows) {
530
566
  if (!row.live || !isUntyped(opened, row.live, row.live.file))
531
567
  continue;
532
- row.textual = textualCount(opened, row.live.name, changeSet);
568
+ const localTo = isPlainLocal(row.live) ? new Map([[row.live.name, opened.index.abs(row.live.file)]]) : undefined;
569
+ const needle = textualNeedle(row.live);
570
+ if (!needle)
571
+ continue;
572
+ row.textual = textualCount(opened, needle, changeSet, localTo?.get(row.live.name));
533
573
  if (row.textual.matches > 0)
534
- row.unresolved = textualMatches(opened, semantic, [row.live.name], known, new Set(), changeSet).length;
574
+ row.unresolved = textualMatches(opened, semantic, [needle], known, new Set(), changeSet, localTo).length;
535
575
  }
536
576
  // Template usages: directives, components, bindings, controllers and filters named in .html and friends.
537
577
  let templateFiles;
@@ -585,14 +625,31 @@ export async function impactCommand(args, ctx) {
585
625
  };
586
626
  // --- Blind spots, textual matches, deeper callers ---
587
627
  const names = new Map();
628
+ const textNames = new Map();
629
+ // A name is file-scoped only when every changed Declaration carrying it is a plain local of one file.
630
+ const localTo = new Map();
631
+ const wide = new Set();
588
632
  for (const r of rows) {
589
- const name = (r.change.after ?? r.change.before).name;
590
- if (!names.has(name))
633
+ const decl = r.change.after ?? r.change.before;
634
+ const name = decl.name;
635
+ if (isBlindSpotCandidate(decl) && !names.has(name))
591
636
  names.set(name, r.symbol);
637
+ const needle = isTextualCandidate(decl) ? textualNeedle(decl) : undefined;
638
+ if (!needle)
639
+ continue;
640
+ if (!textNames.has(needle.text))
641
+ textNames.set(needle.text, needle);
642
+ const abs = opened.index.abs(decl.file);
643
+ if (!isPlainLocal(decl) || (localTo.has(name) && localTo.get(name) !== abs))
644
+ wide.add(name);
645
+ else
646
+ localTo.set(name, abs);
592
647
  }
648
+ for (const name of wide)
649
+ localTo.delete(name);
593
650
  const spots = blindSpots(semantic, names, changeSet);
594
651
  const textRows = grep
595
- ? textualMatches(opened, semantic, [...names.keys()], known, new Set(spots.map((s) => `${s.file}:${s.line}`)), changeSet)
652
+ ? textualMatches(opened, semantic, [...textNames.values()], known, new Set(spots.map((s) => `${s.file}:${s.line}`)), changeSet, localTo)
596
653
  : [];
597
654
  const unresolved = grep ? withTemplateRows(textRows, rows, templateFiles ?? []) : [];
598
655
  const touched = [...new Set([...analysed.filter((f) => f.status !== "deleted").map((f) => f.path), ...listed.flatMap((r) => r.outside.map((e) => e.file))])];
@@ -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 { isUntyped, textualCount, textualHint, textualLine, textualMatches } from "./impact.js";
4
+ import { isPlainLocal, isTextualCandidate, isUntyped, textualCount, textualNeedle, textualHint, textualLine, textualMatches } from "./impact.js";
5
5
  import { findOutsideProject, nearestTsconfig } from "../project.js";
6
6
  import { viaLine } from "../references.js";
7
7
  import { block, helpBlock, out } from "../render.js";
@@ -23,7 +23,7 @@ const VIA_DOC = `via_interface: n (Iface.member) = References through an interfa
23
23
  refs_total: 0 under -P in a workspace lists found_outside_project[n]{file,line} = importers outside that Project
24
24
  scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
25
25
  tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references
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
26
+ textual: n matches in m files = whole-word count of the name in Project files (JavaScript declarations and implicit projects; a state counts its full dotted name as a whole token, templates included; not for a state's resolve keys and hooks - injected by name, not imported); 'n matches in this file' for a non-exported local; above refs_total = uses the checker cannot resolve - see refs --grep
27
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`;
28
28
  export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
29
29
  flags[12]:
@@ -150,10 +150,12 @@ async function run(args, ctx, invoked) {
150
150
  const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
151
151
  const explicit = Boolean(project || opened.settings.project);
152
152
  const declaredFile = decl?.file ?? (def && def.line > 0 ? def.file : undefined);
153
- const textual = declaredFile !== undefined && isUntyped(opened, decl, declaredFile) ? textualCount(opened, symbol.split(".").pop()) : undefined;
153
+ const localFile = isPlainLocal(decl) ? opened.index.abs(decl.file) : undefined;
154
+ const needle = textualNeedle(decl ?? symbol, kind);
155
+ const textual = declaredFile !== undefined && needle && isUntyped(opened, decl, declaredFile) ? textualCount(opened, needle, new Map(), localFile) : undefined;
154
156
  const tplTarget = decl ? templateTarget(decl, opened.index.declarations(opened.index.abs(decl.file))) : undefined;
155
157
  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 } : {}) };
158
+ const o = { started, symbol, kind, declaredAt, show, scope, explicit, label, name, absFile: resolved.absFile, ...(decl ? { decl } : {}), ...(textual ? { textual } : {}), ...(templates ? { templates } : {}) };
157
159
  return mode === "calls"
158
160
  ? renderCallers(semantic, opened, resolved, header, { ...o, limit, depth })
159
161
  : renderRefs(semantic, opened, resolved, header, { ...o, limit, kind });
@@ -196,7 +198,9 @@ function outsideImporters(opened, o) {
196
198
  const inProject = new Set(opened.index.files);
197
199
  const barrels = new Set(closure.barrels);
198
200
  const targets = [o.absFile, ...closure.barrels];
199
- const needle = o.symbol.split(".").pop();
201
+ const needle = textualNeedle(o.symbol, o.kind)?.text;
202
+ if (!needle)
203
+ return { fields: {}, help: [] };
200
204
  const rows = closure.importers
201
205
  .filter((f) => !inProject.has(f) && !barrels.has(f) && (opened.index.text(f) ?? "").includes(needle))
202
206
  .map((f) => {
@@ -241,7 +245,10 @@ function viaFields(via, bases) {
241
245
  }
242
246
  /** Whole-word matches of the name on lines that hold no known position (what `--grep` lists). */
243
247
  function unresolvedOf(opened, semantic, o, known) {
244
- return textualMatches(opened, semantic, [o.symbol.split(".").pop()], new Set(known.map((r) => `${r.file}:${r.line}`)), new Set(o.decl ? [`${o.decl.file}:${o.decl.line}`, `${o.decl.file}:${o.decl.nameLine}`] : []), new Map());
248
+ const needle = isTextualCandidate(o.decl) ? textualNeedle(o.decl ?? o.symbol, o.kind) : undefined;
249
+ if (!needle)
250
+ return [];
251
+ return textualMatches(opened, semantic, [needle], new Set(known.map((r) => `${r.file}:${r.line}`)), new Set(o.decl ? [`${o.decl.file}:${o.decl.line}`, `${o.decl.file}:${o.decl.nameLine}`] : []), new Map(), isPlainLocal(o.decl) ? new Map([[o.decl.name, opened.index.abs(o.decl.file)]]) : new Map());
245
252
  }
246
253
  /**
247
254
  * `textual` field and, when the name occurs more often than refs_total and some matches sit on no
@@ -281,7 +288,7 @@ function renderRefs(semantic, opened, resolved, header, o) {
281
288
  help.push(`Run \`ts-axi refs ${o.declaredAt} --implementations\` to list the ${impls.length} implementations`);
282
289
  const outside = rows.length === 0 ? outsideImporters(opened, o) : { fields: {}, help: [] };
283
290
  help.unshift(...outside.help);
284
- const word = o.symbol.split(".").pop();
291
+ const word = textualNeedle(o.symbol, o.kind)?.text ?? "";
285
292
  const unresolved = o.show.grep || o.textual || o.templates ? unresolvedOf(opened, semantic, o, [...rows, ...via, ...impls]) : [];
286
293
  const grepRows = withTemplates(unresolved, o.templates);
287
294
  // The --grep hint counts what --grep lists: textual and template rows together.
@@ -289,7 +296,7 @@ function renderRefs(semantic, opened, resolved, header, o) {
289
296
  const tpl = templateFields(o, o.show.grep || text.help.length > 0);
290
297
  if (text.help.length > 0)
291
298
  help.unshift(...text.help);
292
- else if (o.decl?.nameless && !o.show.grep && tpl.help.length === 0)
299
+ else if (o.decl?.nameless && word && !o.show.grep && tpl.help.length === 0)
293
300
  help.unshift(`Run \`ts-axi refs ${o.declaredAt} --grep\` for textual matches of '${word}'`);
294
301
  help.unshift(...tpl.help);
295
302
  if (isFunctionLike(o.kind))
@@ -222,10 +222,14 @@ export function localOnlyLine(entries, max = 10) {
222
222
  const rest = entries.length - shown.length;
223
223
  return `${entries.length} (${[...shown, ...(rest > 0 ? [`+${rest}`] : [])].join("; ")})`;
224
224
  }
225
- /** Whole-word occurrences of `name` in `text`: 1-based line and column. */
226
- export function wordMatches(text, name) {
227
- const escaped = name.replace(/[$]/g, "\\$");
228
- const re = new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`, "g");
225
+ /**
226
+ * Whole-word occurrences of `name` in `text`: 1-based line and column. With `token`, `name` is a dotted
227
+ * path (a state name): escaped literally, and neither `.` nor an identifier character may touch it.
228
+ */
229
+ export function wordMatches(text, name, token = false) {
230
+ const re = token
231
+ ? new RegExp(`(?<![\\w$.])${name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}(?![\\w$.])`, "g")
232
+ : new RegExp(`(?<![\\w$])${name.replace(/[$]/g, "\\$")}(?![\\w$])`, "g");
229
233
  const result = [];
230
234
  const lines = text.split("\n");
231
235
  lines.forEach((lineText, i) => {
@@ -232,6 +232,8 @@ const CONSTRUCTOR_VERBS = new Set(["controller", "service", "provider"]);
232
232
  /** Router providers whose `.state(...)` / `.when(...)` calls declare a state (ui-router, ngRoute). */
233
233
  const STATE_PROVIDER = /^\$?stateProvider$/i;
234
234
  const ROUTE_PROVIDER = /^\$?routeProvider$/i;
235
+ /** Keys that, next to a string `name`, make an array element a ui-router state object. */
236
+ const STATE_KEYS = new Set(["url", "resolve", "component", "controller", "template", "templateUrl", "views", "abstract"]);
235
237
  /** Directive definition keys whose object literal lists the bindings. */
236
238
  const BINDING_KEYS = new Set(["scope", "bindToController", "bindings"]);
237
239
  /** `Template.x.<hook>(fn)` lifecycle callbacks. */
@@ -448,52 +450,111 @@ export function collectDeclarations(sf, file) {
448
450
  if (!name || !prop.name)
449
451
  continue;
450
452
  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) });
453
+ nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported: true, signature: fullSignatureOf(prop, sf), namePos: prop.name.getStart(sf), injected: true });
452
454
  }
453
455
  else if (ts.isPropertyAssignment(prop)) {
454
456
  const value = unwrap(prop.initializer);
455
457
  const fn = registeredFunction(value);
456
458
  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) });
459
+ nested({ start: prop.getStart(sf), end: prop.end, kind: "method", name, parentName: holder, exported: true, signature: `${name}${paramsText(fn, sf)}`, namePos: prop.name.getStart(sf), injected: true });
458
460
  else if (ts.isObjectLiteralExpression(value) && depth < 3)
459
461
  stateMembers(value, `${holder}.${name}`, depth + 1);
460
462
  }
461
463
  }
462
464
  };
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");
465
+ /** Same-file string consts (`const base = 'main.x'`), for state names built from them; ambiguous names dropped. */
466
+ let stringConsts;
467
+ const constValue = (id, depth) => {
468
+ if (!stringConsts) {
469
+ stringConsts = new Map();
470
+ const visit = (node) => {
471
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
472
+ const init = unwrap(node.initializer);
473
+ if (isStringName(init) || ts.isTemplateExpression(init))
474
+ stringConsts.set(node.name.text, stringConsts.has(node.name.text) ? null : init);
475
+ }
476
+ ts.forEachChild(node, visit);
477
+ };
478
+ visit(sf);
479
+ }
480
+ const init = stringConsts.get(id);
481
+ return init && depth < 3 ? stateNameOf(init, depth + 1) : undefined;
482
+ };
483
+ /** A literal, a template literal (`${...}` = a same-file string const, else `*`) or a const identifier. */
484
+ const stateNameOf = (expr, depth = 0) => {
485
+ const e = unwrap(expr);
486
+ if (isStringName(e))
487
+ return e.text;
488
+ if (ts.isTemplateExpression(e)) {
489
+ return e.head.text + e.templateSpans.map((s) => (ts.isIdentifier(s.expression) ? (constValue(s.expression.text, depth) ?? "*") : "*") + s.literal.text).join("");
490
+ }
491
+ if (ts.isIdentifier(e))
492
+ return constValue(e.text, depth) ?? e.text;
493
+ return undefined;
494
+ };
495
+ const propOf = (obj, key) => obj.properties.find((p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && nameText(p.name, sf) === key);
496
+ /** `name` of a state object literal: `name: 'x'`, `` name: `${base}.x` ``, `name: base` or `{ name }`. */
497
+ const stateObjectName = (obj) => {
498
+ const prop = propOf(obj, "name");
499
+ if (prop && ts.isPropertyAssignment(prop))
500
+ return stateNameOf(prop.initializer);
501
+ if (prop && ts.isShorthandPropertyAssignment(prop))
502
+ return stateNameOf(prop.name);
503
+ return undefined;
504
+ };
505
+ /** A ui-router state object (`.state()` argument or array element) or an ngRoute config as a `state` Declaration plus its callbacks. */
506
+ const stateFromObject = (obj, name, start, end, route) => {
507
+ const url = propOf(obj, "url");
508
+ const urlText = url && ts.isPropertyAssignment(url) && isStringName(url.initializer) ? url.initializer.text : undefined;
477
509
  const parent = route ? "routes" : "states";
478
510
  // Plain-literal options are compared, not displayed: a new controller or template is a contract change.
479
511
  const options = obj.properties
480
512
  .filter((p) => ts.isPropertyAssignment(p) && nameText(p.name, sf) !== "name" && nameText(p.name, sf) !== "url")
481
513
  .filter((p) => isStringName(p.initializer) || p.initializer.kind === ts.SyntaxKind.TrueKeyword || p.initializer.kind === ts.SyntaxKind.FalseKeyword)
482
514
  .map((p) => tokenText(p, sf, p.getStart(sf), p.end));
483
- const callee = call.expression;
484
515
  nested({
485
- start: callee.name.getStart(sf),
486
- end: call.end,
516
+ start,
517
+ end,
487
518
  kind: "state",
488
519
  name,
489
520
  parentName: parent,
490
521
  exported: true,
491
- signature: `${route ? "route" : "state"} ${name}${url !== undefined && !route ? ` (url ${url})` : ""}${options.length > 0 ? `\n${options.join("; ")}` : ""}`,
522
+ signature: `${route ? "route" : "state"} ${name}${urlText !== undefined && !route ? ` (url ${urlText})` : ""}${options.length > 0 ? `\n${options.join("; ")}` : ""}`,
492
523
  namePos: obj.getStart(sf),
493
524
  nameless: true,
494
525
  });
495
526
  stateMembers(obj, `${parent}.${name}`, 1);
496
527
  };
528
+ /** One `.state('name', {...})`, `.state({ name, ... })` or `.when('/path', {...})` call. */
529
+ const stateCall = (call, route) => {
530
+ const [first, second] = call.arguments;
531
+ const obj = first && ts.isObjectLiteralExpression(first) && !route ? first : second && ts.isObjectLiteralExpression(second) ? second : undefined;
532
+ if (!obj)
533
+ return;
534
+ const name = isStringName(first) ? first.text : route ? undefined : stateObjectName(obj);
535
+ if (name === undefined)
536
+ return;
537
+ stateFromObject(obj, name, call.expression.name.getStart(sf), call.end, route);
538
+ };
539
+ /** State objects among the elements of an array literal, or of `[...].concat([...], ...)`; other values ignored. */
540
+ const stateArray = (expr) => {
541
+ const e = unwrap(expr);
542
+ if (ts.isArrayLiteralExpression(e)) {
543
+ for (const el of e.elements) {
544
+ const obj = unwrap(el);
545
+ if (!ts.isObjectLiteralExpression(obj) || !obj.properties.some((p) => STATE_KEYS.has(nameText(p.name, sf) ?? "")))
546
+ continue;
547
+ const name = stateObjectName(obj);
548
+ if (name !== undefined)
549
+ stateFromObject(obj, name, obj.getStart(sf), obj.end, false);
550
+ }
551
+ }
552
+ else if (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === "concat") {
553
+ stateArray(e.expression.expression);
554
+ for (const arg of e.arguments)
555
+ stateArray(arg);
556
+ }
557
+ };
497
558
  /** `$stateProvider.state(...).state(...)` and `$routeProvider.when(...)` chains; `.otherwise()` ignored. */
498
559
  const routerChain = (expr) => {
499
560
  const calls = [];
@@ -567,8 +628,12 @@ export function collectDeclarations(sf, file) {
567
628
  if (target === "module.exports") {
568
629
  if (ts.isObjectLiteralExpression(right))
569
630
  objectMembers(right, "exports", true);
631
+ else
632
+ stateArray(right);
570
633
  return;
571
634
  }
635
+ if (target === `exports.${left.name.text}` || target === `module.exports.${left.name.text}`)
636
+ stateArray(right);
572
637
  const owner = left.expression;
573
638
  if (ts.isPropertyAccessExpression(owner) && owner.name.text === "prototype" && ts.isIdentifier(owner.expression)) {
574
639
  const cls = owner.expression.text;
@@ -614,6 +679,7 @@ export function collectDeclarations(sf, file) {
614
679
  routerBody(fn.body);
615
680
  const exported = inIife ? false : topLevelExported(name);
616
681
  const init = unwrap(decl.initializer);
682
+ stateArray(init);
617
683
  if (ts.isObjectLiteralExpression(init))
618
684
  objectMembers(init, name, exported);
619
685
  else if (fn && isConstructorName(name))
@@ -637,6 +703,9 @@ export function collectDeclarations(sf, file) {
637
703
  else
638
704
  callChain(expr);
639
705
  }
706
+ else if (ts.isExportAssignment(statement)) {
707
+ stateArray(statement.expression);
708
+ }
640
709
  else if (ts.isIfStatement(statement)) {
641
710
  scriptStatement(statement.thenStatement, inIife);
642
711
  if (statement.elseStatement)
@@ -739,6 +808,7 @@ export function collectDeclarations(sf, file) {
739
808
  hasBody: r.hasBody,
740
809
  fullSignature: full,
741
810
  ...(r.nameless ? { nameless: true } : {}),
811
+ ...(r.injected ? { injected: true } : {}),
742
812
  };
743
813
  });
744
814
  }
@@ -53,6 +53,46 @@ export function loadTemplates(root) {
53
53
  }
54
54
  return files;
55
55
  }
56
+ const TAG_SPAN_LINES = 40;
57
+ /**
58
+ * Text of the tag open at `offset` (from its `<`), or undefined when `offset` is outside a tag or
59
+ * inside a quoted value. Forward scan from up to 40 lines back; `>` inside quotes does not close.
60
+ * Tolerant of broken markup: never throws, an unclosed quote just ends the tag at the scan point.
61
+ */
62
+ function openTagBefore(text, offset, lineStarts) {
63
+ let lo = 0;
64
+ let hi = lineStarts.length - 1;
65
+ while (lo < hi) {
66
+ const mid = (lo + hi + 1) >> 1;
67
+ if (lineStarts[mid] <= offset)
68
+ lo = mid;
69
+ else
70
+ hi = mid - 1;
71
+ }
72
+ let tagStart = -1;
73
+ let quote = "";
74
+ for (let i = lineStarts[Math.max(0, lo - TAG_SPAN_LINES)]; i < offset; i++) {
75
+ const c = text[i];
76
+ if (tagStart < 0) {
77
+ if (c === "<" && /[A-Za-z]/.test(text[i + 1] ?? ""))
78
+ tagStart = i;
79
+ }
80
+ else if (quote) {
81
+ if (c === quote)
82
+ quote = "";
83
+ }
84
+ else if (c === '"' || c === "'") {
85
+ quote = c;
86
+ }
87
+ else if (c === ">") {
88
+ tagStart = -1;
89
+ }
90
+ else if (c === "<" && /[A-Za-z]/.test(text[i + 1] ?? "")) {
91
+ tagStart = i;
92
+ }
93
+ }
94
+ return tagStart < 0 || quote ? undefined : text.slice(tagStart, offset);
95
+ }
56
96
  /** Usages of `target` in one template text: at most one per line. */
57
97
  export function usagesIn(text, target) {
58
98
  const result = [];
@@ -66,12 +106,11 @@ export function usagesIn(text, target) {
66
106
  const offset = (m.index ?? 0) + (/^[<\s"'|]/.test(m[0]) ? 1 : 0);
67
107
  if (target.owner) {
68
108
  // The attribute counts only inside a tag opened by (or carrying) the owner directive.
69
- const tagStart = text.lastIndexOf("<", offset);
70
- if (tagStart < 0)
109
+ const tag = openTagBefore(text, offset, lineStarts);
110
+ if (tag === undefined)
71
111
  continue;
72
- const tag = text.slice(tagStart, offset);
73
112
  target.owner.lastIndex = 0;
74
- if (tag.includes(">") || !target.owner.test(tag))
113
+ if (!target.owner.test(tag))
75
114
  continue;
76
115
  }
77
116
  let lo = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-axi",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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": [