ts-axi 0.1.11 → 0.1.12
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 +2 -2
- package/dist/src/commands/common.js +47 -21
- package/dist/src/commands/def.js +4 -2
- package/dist/src/commands/impact.js +83 -5
- package/dist/src/commands/refs.js +27 -8
- package/dist/src/symbols.js +93 -24
- package/dist/src/templates.js +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,7 +78,7 @@ The hook runs `ts-axi` with no arguments: it reads only the file tree and git st
|
|
|
78
78
|
| Command | What it does |
|
|
79
79
|
| --- | --- |
|
|
80
80
|
| `ts-axi` | Home view: project root, tsconfig used (or implicit), source file count, test-file glob and match counts, git branch and dirty count, next steps. Under a second, exit 0 everywhere |
|
|
81
|
-
| `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` |
|
|
81
|
+
| `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` and, when the name still occurs in the code, the `refs <name> --grep` that lists those textual matches |
|
|
82
82
|
| `refs <target>` | References of one symbol, grouped by file, with test counts. A class member that implements an interface (or overrides a base) member counts only its direct References; calls through the interface are `via_interface: n (Iface.member)`, sibling implementations are dropped, and an interface member reports `implementations: n`. `--limit`, `--kind calls`, `--via` (via rows with a `via` column), `--implementations` |
|
|
83
83
|
| `callers <target>` | Incoming calls of a function-like symbol, split the same way (`via_interface`, `--via`). `--depth 1..3` |
|
|
84
84
|
| `outline <file>` | Top-level declarations in source order, class members nested: `{line,kind,name,signature}` (`{at,kind,name,signature}` over several files). `--exports`, `--fields exported` |
|
|
@@ -180,7 +180,7 @@ decls[4]{line,kind,name,exported,signature}:
|
|
|
180
180
|
17,function,cartBadge,true,directive cartBadge()
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
-
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`.
|
|
183
|
+
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`. A registration that names a same-file function (`.directive('fancyBox', fancyBox)`, `['$uibModal', fancyBox]`) links the two, so `refs` on the function reports the registration's template usages too. Function-valued members of an object a function returns (`return { canReadItem: async () => ... }`) are Declarations (`makeItemDeps.canReadItem`), so `def canReadItem` finds them; their `textual:` count covers the whole project, while a real local (a parameter, an IIFE helper, a CommonJS binding no `module.exports` hands out) says `in this file`. 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`.
|
|
184
184
|
|
|
185
185
|
## Monorepos
|
|
186
186
|
|
|
@@ -217,9 +217,27 @@ function identifiers(node, sf, outIds) {
|
|
|
217
217
|
}
|
|
218
218
|
ts.forEachChild(node, (child) => identifiers(child, sf, outIds));
|
|
219
219
|
}
|
|
220
|
+
function isFunctionValue(expr) {
|
|
221
|
+
let e = expr;
|
|
222
|
+
while (ts.isParenthesizedExpression(e))
|
|
223
|
+
e = e.expression;
|
|
224
|
+
return ts.isArrowFunction(e) || ts.isFunctionExpression(e);
|
|
225
|
+
}
|
|
226
|
+
/** Declares a name: a Declaration kind, a parameter, a binding element, or a function-valued property. */
|
|
220
227
|
function isDeclarationName(id) {
|
|
221
228
|
const parent = id.parent;
|
|
222
|
-
|
|
229
|
+
if (parent === undefined || parent.name !== id)
|
|
230
|
+
return false;
|
|
231
|
+
if (ts.isPropertyAssignment(parent))
|
|
232
|
+
return isFunctionValue(parent.initializer);
|
|
233
|
+
return kindOf(parent) !== undefined || ts.isParameter(parent) || ts.isBindingElement(parent);
|
|
234
|
+
}
|
|
235
|
+
/** The node a declaration name owns, for nesting: `this.x = fn` → the whole assignment. */
|
|
236
|
+
function declarationNode(id) {
|
|
237
|
+
const parent = id.parent;
|
|
238
|
+
if (ts.isPropertyAccessExpression(parent) && parent.name === id && ts.isBinaryExpression(parent.parent) && parent.parent.left === parent)
|
|
239
|
+
return parent.parent;
|
|
240
|
+
return parent;
|
|
223
241
|
}
|
|
224
242
|
/** The identifier a `path:line[:col]` target points at (see PLAN.md Targets). */
|
|
225
243
|
export function resolvePosition(index, cwd, target) {
|
|
@@ -252,33 +270,41 @@ export function resolvePosition(index, cwd, target) {
|
|
|
252
270
|
return hit ? pick(hit) : { kind: "none", abs, file, reason: `no identifier at ${file}:${target.line}:${target.col}` };
|
|
253
271
|
}
|
|
254
272
|
// A string-registered name has no identifier: its line stands for it.
|
|
255
|
-
|
|
273
|
+
// The registration line too: `.directive('x', fancyBox)` has its name far from the function.
|
|
274
|
+
const nameless = decls.find((d) => d.nameless && d.nameLine === target.line) ?? decls.find((d) => d.nameless && d.line === target.line);
|
|
256
275
|
if (nameless)
|
|
257
276
|
return { kind: "decl", decl: nameless, abs };
|
|
258
277
|
if (onLine.length === 0)
|
|
259
278
|
return { kind: "none", abs, file, reason: `no identifier on ${file}:${target.line}` };
|
|
279
|
+
const ambiguous = (ids) => {
|
|
280
|
+
const distinct = new Map();
|
|
281
|
+
for (const id of ids)
|
|
282
|
+
if (!distinct.has(id.text))
|
|
283
|
+
distinct.set(id.text, id);
|
|
284
|
+
if (distinct.size === 1)
|
|
285
|
+
return pick(ids[0]);
|
|
286
|
+
return {
|
|
287
|
+
kind: "ambiguous",
|
|
288
|
+
abs,
|
|
289
|
+
file,
|
|
290
|
+
candidates: [...distinct.values()].map((id) => ({ name: id.text, line: target.line, col: id.getStart(sf) - lineStart + 1 })),
|
|
291
|
+
};
|
|
292
|
+
};
|
|
260
293
|
const constructorDecl = decls.find((d) => d.nameLine === target.line && d.name === "constructor");
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
294
|
+
// The declaration that starts the line: names nested in another one's declaration (its parameters, its members) rank after it.
|
|
295
|
+
const declaring = onLine.filter((id) => declFor(id) !== undefined || isDeclarationName(id));
|
|
296
|
+
const owned = (inner, outer) => {
|
|
297
|
+
const node = declarationNode(outer);
|
|
298
|
+
const at = inner.getStart(sf);
|
|
299
|
+
return node !== declarationNode(inner) && node.getStart(sf) <= at && at < node.end;
|
|
300
|
+
};
|
|
301
|
+
const outer = declaring.filter((id) => !(constructorDecl && ts.isParameter(id.parent) && ts.isConstructorDeclaration(id.parent.parent)) &&
|
|
302
|
+
!declaring.some((other) => other !== id && owned(id, other)));
|
|
303
|
+
if (outer.length > 0)
|
|
304
|
+
return ambiguous(outer);
|
|
264
305
|
if (constructorDecl)
|
|
265
306
|
return { kind: "decl", decl: constructorDecl, abs };
|
|
266
|
-
|
|
267
|
-
for (const id of onLine)
|
|
268
|
-
if (!distinct.has(id.text))
|
|
269
|
-
distinct.set(id.text, id);
|
|
270
|
-
if (distinct.size === 1)
|
|
271
|
-
return pick(onLine[0]);
|
|
272
|
-
return {
|
|
273
|
-
kind: "ambiguous",
|
|
274
|
-
abs,
|
|
275
|
-
file,
|
|
276
|
-
candidates: [...distinct.values()].map((id) => ({
|
|
277
|
-
name: id.text,
|
|
278
|
-
line: target.line,
|
|
279
|
-
col: id.getStart(sf) - lineStart + 1,
|
|
280
|
-
})),
|
|
281
|
-
};
|
|
307
|
+
return ambiguous(onLine);
|
|
282
308
|
}
|
|
283
309
|
/** A Declaration for an arbitrary declaration name position (locals, parameters) not in the index. */
|
|
284
310
|
export function declarationAt(index, abs, pos, fallbackKind) {
|
package/dist/src/commands/def.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bool, expectPositionals, flag, parseArgs } from "../args.js";
|
|
2
2
|
import { semanticFor } from "../scope.js";
|
|
3
|
+
import { textualScan, textualScanHint } from "./impact.js";
|
|
3
4
|
import { block, fenced, helpBlock, out } from "../render.js";
|
|
4
5
|
import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
|
|
5
6
|
import { BODY_LINES, declarationAt, filterSuffix, MATCH_ROWS, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, useRef, } from "./common.js";
|
|
@@ -9,7 +10,7 @@ flags[8]:
|
|
|
9
10
|
output:
|
|
10
11
|
1 match = file, line, kind, exported, signature + body fenced with file line numbers
|
|
11
12
|
several = matches[n]{at,kind,signature} (at = path:line) (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms (--in, --kind, Class.member, --exported) with how many each keeps
|
|
12
|
-
none = matches: 0 + did_you_mean
|
|
13
|
+
none = matches: 0 + did_you_mean; when the name still occurs in Project files, help gives the refs --grep that lists those textual matches
|
|
13
14
|
examples[5]:
|
|
14
15
|
ts-axi def User
|
|
15
16
|
ts-axi def User.save
|
|
@@ -78,7 +79,8 @@ export async function defCommand(args, ctx) {
|
|
|
78
79
|
return detail(opened, groups[0].decls, full, label);
|
|
79
80
|
if (groups.length === 0) {
|
|
80
81
|
const suggested = suggestions(opened, target);
|
|
81
|
-
const
|
|
82
|
+
const text = f.fuzzy ? 0 : textualScan(opened, target.member ?? target.name).length;
|
|
83
|
+
const help = [...(text > 0 ? [textualScanHint(label, text)] : []), ...suggested.slice(0, 2).map((s) => `Run \`ts-axi def ${s}\``)];
|
|
82
84
|
if (!f.fuzzy)
|
|
83
85
|
help.push(`Run \`ts-axi def ${label} --fuzzy\` for substring matches`);
|
|
84
86
|
if (f.kind || f.in.length > 0 || f.exported)
|
|
@@ -270,6 +270,27 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
|
|
|
270
270
|
}
|
|
271
271
|
return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
272
272
|
}
|
|
273
|
+
/** Whole-word matches of `name` in the Project files, one row per line: the fallback when no Declaration matches. */
|
|
274
|
+
export function textualScan(opened, name) {
|
|
275
|
+
const rows = [];
|
|
276
|
+
sourceFs().prefetch(opened.index.files);
|
|
277
|
+
for (const abs of opened.index.files) {
|
|
278
|
+
const text = opened.index.text(abs);
|
|
279
|
+
if (text === undefined || !text.includes(name))
|
|
280
|
+
continue;
|
|
281
|
+
let last = 0;
|
|
282
|
+
for (const m of wordMatches(text, name)) {
|
|
283
|
+
if (m.line === last)
|
|
284
|
+
continue;
|
|
285
|
+
last = m.line;
|
|
286
|
+
rows.push({ file: opened.index.rel(abs), line: m.line, context: contextOf(lineOf(text, m.line), m.col) });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
290
|
+
}
|
|
291
|
+
export function textualScanHint(label, matches) {
|
|
292
|
+
return `Run \`ts-axi refs ${label} --grep\` for ${matches} textual matches`;
|
|
293
|
+
}
|
|
273
294
|
/** Whether a whole-word count of the name says anything: not for router-called or by-name-injected members (`injected`). */
|
|
274
295
|
export function isTextualCandidate(decl) {
|
|
275
296
|
return decl?.injected !== true;
|
|
@@ -297,9 +318,66 @@ export function isUntyped(opened, decl, file) {
|
|
|
297
318
|
return false;
|
|
298
319
|
return decl?.nameless === true || opened.index.project.implicit || (file !== undefined && /\.[mc]?jsx?$/.test(file));
|
|
299
320
|
}
|
|
300
|
-
/**
|
|
301
|
-
|
|
302
|
-
|
|
321
|
+
/** The identifier starting at `pos`. */
|
|
322
|
+
function identifierAt(node, pos, sf) {
|
|
323
|
+
if (ts.isIdentifier(node) && node.getStart(sf) === pos)
|
|
324
|
+
return node;
|
|
325
|
+
return ts.forEachChild(node, (child) => (child.pos <= pos && pos < child.end ? identifierAt(child, pos, sf) : undefined));
|
|
326
|
+
}
|
|
327
|
+
/** Names a CommonJS file hands out (`module.exports = { a, b: c }`, `exports.x = d`); undefined when not CommonJS. */
|
|
328
|
+
function commonJsExports(sf) {
|
|
329
|
+
const names = new Set();
|
|
330
|
+
let cjs = /\brequire\s*\(/.test(sf.text);
|
|
331
|
+
// Values only: identifiers used inside exported function bodies do not leave the file.
|
|
332
|
+
const collect = (expr) => {
|
|
333
|
+
let e = expr;
|
|
334
|
+
while (ts.isParenthesizedExpression(e))
|
|
335
|
+
e = e.expression;
|
|
336
|
+
if (ts.isIdentifier(e))
|
|
337
|
+
names.add(e.text);
|
|
338
|
+
else if (ts.isObjectLiteralExpression(e)) {
|
|
339
|
+
for (const p of e.properties) {
|
|
340
|
+
if (ts.isShorthandPropertyAssignment(p))
|
|
341
|
+
names.add(p.name.text);
|
|
342
|
+
else if (ts.isPropertyAssignment(p))
|
|
343
|
+
collect(p.initializer);
|
|
344
|
+
else if (ts.isSpreadAssignment(p))
|
|
345
|
+
collect(p.expression);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
for (const st of sf.statements) {
|
|
350
|
+
if (!ts.isExpressionStatement(st) || !ts.isBinaryExpression(st.expression) || st.expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
|
|
351
|
+
continue;
|
|
352
|
+
const left = st.expression.left.getText(sf).replace(/\s+/g, "");
|
|
353
|
+
if (!/^(module\.exports|exports)(\.|$)/.test(left))
|
|
354
|
+
continue;
|
|
355
|
+
cjs = true;
|
|
356
|
+
collect(st.expression.right);
|
|
357
|
+
}
|
|
358
|
+
return cjs ? names : undefined;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* A Declaration only its own file can name: a parameter or function-scoped binding, or a non-exported top-level
|
|
362
|
+
* binding of an ES module, or of a CommonJS file whose exports never mention it. Members, object properties,
|
|
363
|
+
* string registrations and script globals are not.
|
|
364
|
+
*/
|
|
365
|
+
export function isPlainLocal(index, decl) {
|
|
366
|
+
if (decl === undefined || decl.exported || decl.nameless || decl.parentName !== undefined)
|
|
367
|
+
return false;
|
|
368
|
+
const sf = index.sourceFile(index.abs(decl.file));
|
|
369
|
+
const id = sf && identifierAt(sf, decl.namePos, sf);
|
|
370
|
+
if (!sf || !id)
|
|
371
|
+
return false;
|
|
372
|
+
const owner = id.parent;
|
|
373
|
+
if (ts.isPropertyAssignment(owner) || ts.isShorthandPropertyAssignment(owner) || ts.isMethodDeclaration(owner) || ts.isPropertyAccessExpression(owner))
|
|
374
|
+
return false;
|
|
375
|
+
for (let n = owner.parent; !ts.isSourceFile(n); n = n.parent)
|
|
376
|
+
if (ts.isFunctionLike(n))
|
|
377
|
+
return true;
|
|
378
|
+
if (ts.isExternalModule(sf))
|
|
379
|
+
return true;
|
|
380
|
+
return commonJsExports(sf)?.has(decl.name) === false;
|
|
303
381
|
}
|
|
304
382
|
/**
|
|
305
383
|
* Whole-word occurrences of `name` in Project files outside `changeSet`; lines declaring a `name` excluded.
|
|
@@ -592,7 +670,7 @@ export async function impactCommand(args, ctx) {
|
|
|
592
670
|
for (const row of rows) {
|
|
593
671
|
if (!row.live || !isUntyped(opened, row.live, row.live.file))
|
|
594
672
|
continue;
|
|
595
|
-
const localTo = isPlainLocal(row.live) ? new Map([[row.live.name, opened.index.abs(row.live.file)]]) : undefined;
|
|
673
|
+
const localTo = isPlainLocal(opened.index, row.live) ? new Map([[row.live.name, opened.index.abs(row.live.file)]]) : undefined;
|
|
596
674
|
const needle = textualNeedle(row.live);
|
|
597
675
|
if (!needle)
|
|
598
676
|
continue;
|
|
@@ -667,7 +745,7 @@ export async function impactCommand(args, ctx) {
|
|
|
667
745
|
if (!textNames.has(needle.text))
|
|
668
746
|
textNames.set(needle.text, needle);
|
|
669
747
|
const abs = opened.index.abs(decl.file);
|
|
670
|
-
if (!isPlainLocal(decl) || (localTo.has(name) && localTo.get(name) !== abs))
|
|
748
|
+
if (!isPlainLocal(opened.index, decl) || (localTo.has(name) && localTo.get(name) !== abs))
|
|
671
749
|
wide.add(name);
|
|
672
750
|
else
|
|
673
751
|
localTo.set(name, abs);
|
|
@@ -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 } from "./impact.js";
|
|
4
|
+
import { isPlainLocal, isTextualCandidate, 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";
|
|
@@ -10,7 +10,7 @@ import { loadTemplates, templatesLine, templateTarget, templateUsages } from "..
|
|
|
10
10
|
import { byDepthThenPath } from "../walk.js";
|
|
11
11
|
import { detectWorkspace, isMultiProject } from "../workspace.js";
|
|
12
12
|
import { isTestFile } from "../settings.js";
|
|
13
|
-
import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
|
|
13
|
+
import { isFunctionLike, kindFromScriptElement, parseTarget, registrationOf } from "../symbols.js";
|
|
14
14
|
import { contextOf, declarationAt, filterSuffix, lineOf, MATCH_ROWS, matchRows, narrowHints, open, positionStartDir, projectFields, readFilters, useRef, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
|
|
15
15
|
const DEFAULT_LIMIT = 50;
|
|
16
16
|
const MAX_LIMIT = 500;
|
|
@@ -23,8 +23,8 @@ 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; 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
|
|
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
|
+
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' only for a real local (a parameter, a binding inside a function or IIFE, a non-exported top-level binding of an ES module or of a CommonJS file whose module.exports never hands it out) - members, object properties and script globals count Project-wide; 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) - also for a function registered by identifier (.directive('fancyBox', fancyBox)), under the registered name; 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[13]:
|
|
30
30
|
${SHARED_FLAGS}
|
|
@@ -34,7 +34,9 @@ flags[13]:
|
|
|
34
34
|
output:
|
|
35
35
|
symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,context} (Declarations excluded), elapsed_ms
|
|
36
36
|
${VIA_DOC}
|
|
37
|
-
note = the name is registered by a string ('Name' in .controller('Name', fn)) - its uses are textual; add --grep
|
|
37
|
+
note = the name is registered by a string ('Name' in .controller('Name', fn), or fn a same-file function named by identifier) - its uses are textual; add --grep
|
|
38
|
+
matches: 0 = no Declaration of the name; help counts its whole-word matches in Project files, and --grep lists them in unresolved with note 'no declaration found - textual matches only'
|
|
39
|
+
path:line = the declaration that starts the line (a function before its parameters, a property before its value's parameters; siblings like 'const a = 1, b = 2' list identifiers - add :col)
|
|
38
40
|
an ambiguous name lists matches[n] (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms; nothing is analysed
|
|
39
41
|
examples[4]:
|
|
40
42
|
ts-axi refs User.save
|
|
@@ -111,8 +113,23 @@ async function run(args, ctx, invoked) {
|
|
|
111
113
|
`Run \`ts-axi ${name} ${outside[0].file}:${outside[0].nameLine}\` to use the nearest tsconfig of that file`,
|
|
112
114
|
]));
|
|
113
115
|
}
|
|
116
|
+
// No Declaration, but the name may still be written: an untyped property set up elsewhere.
|
|
117
|
+
const text = f.fuzzy ? [] : textualScan(opened, target.member ?? target.name);
|
|
118
|
+
if (show.grep) {
|
|
119
|
+
return out(block({
|
|
120
|
+
...warning,
|
|
121
|
+
matches: 0,
|
|
122
|
+
note: "no declaration found - textual matches only",
|
|
123
|
+
unresolved: text.length === 0 ? 0 : text.slice(0, limit),
|
|
124
|
+
...(text.length > limit ? { unresolved_total: text.length } : {}),
|
|
125
|
+
}), helpBlock([
|
|
126
|
+
...(text.length > limit ? [`Showing ${limit} of ${text.length}; run \`ts-axi refs ${label} --grep --limit ${Math.min(MAX_LIMIT, text.length)}\` for more`] : []),
|
|
127
|
+
`Run \`ts-axi def ${label} --fuzzy\` for substring matches`,
|
|
128
|
+
]));
|
|
129
|
+
}
|
|
114
130
|
const suggested = suggestions(opened, target);
|
|
115
131
|
return out(block({ ...warning, matches: 0, ...(suggested.length > 0 ? { did_you_mean: suggested } : {}) }), helpBlock([
|
|
132
|
+
...(text.length > 0 ? [textualScanHint(label, text.length)] : []),
|
|
116
133
|
...suggested.slice(0, 2).map((s) => `Run \`ts-axi ${name} ${s}\``),
|
|
117
134
|
...(f.fuzzy ? [] : [`Run \`ts-axi def ${label} --fuzzy\` for substring matches`]),
|
|
118
135
|
]));
|
|
@@ -143,15 +160,17 @@ async function run(args, ctx, invoked) {
|
|
|
143
160
|
}
|
|
144
161
|
const { semantic, scope } = built;
|
|
145
162
|
const def = semantic.definitionAt(resolved.absFile, resolved.pos);
|
|
146
|
-
const
|
|
163
|
+
const found = resolved.decl ??
|
|
147
164
|
(def && def.line > 0 ? declarationAt(opened.index, def.absFile, def.pos, kindFromScriptElement(def.kind)) : undefined);
|
|
165
|
+
// A function registered by identifier is its registration: templates, note and textual use the registered name.
|
|
166
|
+
const decl = (found && registrationOf(found, opened.index.declarations(opened.index.abs(found.file)))) ?? found;
|
|
148
167
|
const kind = decl?.kind ?? kindFromScriptElement(def?.kind ?? semantic.quickInfo(resolved.absFile, resolved.pos)?.kind ?? "");
|
|
149
168
|
const symbol = decl?.qualifiedName ?? def?.name ?? label;
|
|
150
169
|
const declaredAt = decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
|
|
151
170
|
const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
|
|
152
171
|
const explicit = Boolean(project || opened.settings.project);
|
|
153
172
|
const declaredFile = decl?.file ?? (def && def.line > 0 ? def.file : undefined);
|
|
154
|
-
const localFile = isPlainLocal(decl) ? opened.index.abs(decl.file) : undefined;
|
|
173
|
+
const localFile = isPlainLocal(opened.index, decl) ? opened.index.abs(decl.file) : undefined;
|
|
155
174
|
const needle = textualNeedle(decl ?? symbol, kind);
|
|
156
175
|
const textual = declaredFile !== undefined && needle && isUntyped(opened, decl, declaredFile) ? textualCount(opened, needle, new Map(), localFile) : undefined;
|
|
157
176
|
const tplTarget = decl ? templateTarget(decl, opened.index.declarations(opened.index.abs(decl.file))) : undefined;
|
|
@@ -249,7 +268,7 @@ function unresolvedOf(opened, semantic, o, known) {
|
|
|
249
268
|
const needle = isTextualCandidate(o.decl) ? textualNeedle(o.decl ?? o.symbol, o.kind) : undefined;
|
|
250
269
|
if (!needle)
|
|
251
270
|
return [];
|
|
252
|
-
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());
|
|
271
|
+
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(opened.index, o.decl) ? new Map([[o.decl.name, opened.index.abs(o.decl.file)]]) : new Map());
|
|
253
272
|
}
|
|
254
273
|
/**
|
|
255
274
|
* `textual` field and, when the name occurs more often than refs_total and some matches sit on no
|
package/dist/src/symbols.js
CHANGED
|
@@ -278,6 +278,38 @@ function registeredFunction(arg) {
|
|
|
278
278
|
return asFunction(arg.elements[arg.elements.length - 1]);
|
|
279
279
|
return asFunction(arg);
|
|
280
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* A registration's callback given by identifier (`.directive('x', fancyBox)`, `['$a', fancyBox]`): the
|
|
283
|
+
* function declaration or function-valued variable of that name among the statements of the call's scope.
|
|
284
|
+
*/
|
|
285
|
+
function functionByIdentifier(call, arg) {
|
|
286
|
+
const ref = arg && ts.isArrayLiteralExpression(arg) ? arg.elements[arg.elements.length - 1] : arg;
|
|
287
|
+
if (!ref || !ts.isIdentifier(ref))
|
|
288
|
+
return undefined;
|
|
289
|
+
let scope = call;
|
|
290
|
+
while (!ts.isSourceFile(scope) && !(ts.isBlock(scope) && ts.isFunctionLike(scope.parent)))
|
|
291
|
+
scope = scope.parent;
|
|
292
|
+
const statements = scope.statements;
|
|
293
|
+
let found;
|
|
294
|
+
let inject;
|
|
295
|
+
for (const st of statements) {
|
|
296
|
+
if (ts.isFunctionDeclaration(st) && st.name?.text === ref.text && st.body)
|
|
297
|
+
found = { fn: st, name: st.name };
|
|
298
|
+
else if (ts.isVariableStatement(st)) {
|
|
299
|
+
for (const d of st.declarationList.declarations) {
|
|
300
|
+
const fn = asFunction(d.initializer);
|
|
301
|
+
if (fn && ts.isIdentifier(d.name) && d.name.text === ref.text)
|
|
302
|
+
found = { fn, name: d.name };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else if (ts.isExpressionStatement(st) && ts.isBinaryExpression(st.expression) && ts.isPropertyAccessExpression(st.expression.left)) {
|
|
306
|
+
const { left, right } = st.expression;
|
|
307
|
+
if (left.name.text === "$inject" && ts.isIdentifier(left.expression) && left.expression.text === ref.text && ts.isArrayLiteralExpression(right))
|
|
308
|
+
inject = right;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return found && (inject ? { ...found, inject } : found);
|
|
312
|
+
}
|
|
281
313
|
function isStringName(node) {
|
|
282
314
|
return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node));
|
|
283
315
|
}
|
|
@@ -303,6 +335,8 @@ function headUntil(node, fn, sf) {
|
|
|
303
335
|
export function collectDeclarations(sf, file) {
|
|
304
336
|
const raws = [];
|
|
305
337
|
const local = localExportNames(sf);
|
|
338
|
+
/** Body spans of functions registered by identifier. */
|
|
339
|
+
const registeredBodies = [];
|
|
306
340
|
const add = (node, nameNode, name, exported, parent) => {
|
|
307
341
|
const kind = kindOf(node);
|
|
308
342
|
if (!kind)
|
|
@@ -353,23 +387,22 @@ export function collectDeclarations(sf, file) {
|
|
|
353
387
|
if (!ts.isExpressionStatement(statement) || !ts.isBinaryExpression(statement.expression))
|
|
354
388
|
continue;
|
|
355
389
|
const { left, operatorToken, right } = statement.expression;
|
|
390
|
+
if (operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isPropertyAccessExpression(left) || left.expression.kind !== ts.SyntaxKind.ThisKeyword)
|
|
391
|
+
continue;
|
|
392
|
+
const value = unwrap(right);
|
|
393
|
+
if (ts.isObjectLiteralExpression(value))
|
|
394
|
+
objectMembers(value, `${owner}.${left.name.text}`, exported);
|
|
356
395
|
const fn = asFunction(right);
|
|
357
|
-
if (
|
|
396
|
+
if (!fn)
|
|
358
397
|
continue;
|
|
359
398
|
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) });
|
|
360
399
|
}
|
|
361
400
|
};
|
|
362
|
-
/**
|
|
363
|
-
const returnedMembers = (body, owner) => {
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
return;
|
|
368
|
-
for (const statement of body.statements) {
|
|
369
|
-
if (ts.isReturnStatement(statement) && statement.expression && ts.isObjectLiteralExpression(unwrap(statement.expression))) {
|
|
370
|
-
objectMembers(unwrap(statement.expression), owner, true);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
401
|
+
/** Object literals a function returns at its top level (a factory's API, a deps object): their function-valued members. */
|
|
402
|
+
const returnedMembers = (body, owner, exported) => {
|
|
403
|
+
if (body)
|
|
404
|
+
for (const obj of returnedObjects(body))
|
|
405
|
+
objectMembers(obj, owner, exported);
|
|
373
406
|
};
|
|
374
407
|
const topLevelExported = (name) => raws.find((r) => r.qualifiedName === name && r.parentName === undefined)?.exported ?? false;
|
|
375
408
|
const isConstructorName = (name) => /^[A-Z]/.test(name);
|
|
@@ -389,10 +422,14 @@ export function collectDeclarations(sf, file) {
|
|
|
389
422
|
bindingMembers(arg, name);
|
|
390
423
|
return;
|
|
391
424
|
}
|
|
392
|
-
const
|
|
393
|
-
|
|
425
|
+
const inline = registeredFunction(arg);
|
|
426
|
+
// By identifier: namePos is the function's own name, which links the two Declarations (`registrationOf`).
|
|
427
|
+
const byName = inline ? undefined : functionByIdentifier(call, arg);
|
|
428
|
+
const fn = inline ?? byName?.fn;
|
|
429
|
+
if (!fn || !fn.body)
|
|
394
430
|
return;
|
|
395
|
-
const
|
|
431
|
+
const injectList = arg && ts.isArrayLiteralExpression(arg) ? arg.elements.slice(0, -1) : (byName?.inject?.elements ?? []);
|
|
432
|
+
const inject = injectList.length > 0 || (arg && ts.isArrayLiteralExpression(arg)) ? ` [${injectList.map((e) => e.getText(sf)).join(", ")}]` : "";
|
|
396
433
|
nested({
|
|
397
434
|
start,
|
|
398
435
|
end: arg.end,
|
|
@@ -402,21 +439,27 @@ export function collectDeclarations(sf, file) {
|
|
|
402
439
|
parentName: verb,
|
|
403
440
|
exported: true,
|
|
404
441
|
signature: `${verb} ${name}${paramsText(fn, sf)}`,
|
|
405
|
-
namePos: (ts.isFunctionExpression(fn) && fn.name ? fn.name : fn).getStart(sf),
|
|
442
|
+
namePos: (byName ? byName.name : ts.isFunctionExpression(fn) && fn.name ? fn.name : fn).getStart(sf),
|
|
406
443
|
nameless: true,
|
|
407
444
|
});
|
|
408
445
|
// The DI array is compared, not displayed: reordering injections is a contract change.
|
|
409
446
|
if (inject)
|
|
410
447
|
raws[raws.length - 1].signature += `\n${inject.trim()}`;
|
|
448
|
+
const from = raws.length;
|
|
411
449
|
if (CONSTRUCTOR_VERBS.has(verb))
|
|
412
450
|
thisMembers(fn.body, name, true);
|
|
413
451
|
if (verb === "factory" || verb === "service")
|
|
414
|
-
returnedMembers(fn.body, name);
|
|
452
|
+
returnedMembers(fn.body, name, true);
|
|
415
453
|
if (verb === "directive")
|
|
416
454
|
for (const obj of returnedObjects(fn.body))
|
|
417
455
|
bindingMembers(obj, name);
|
|
418
456
|
if (verb === "config" || verb === "run")
|
|
419
457
|
routerBody(fn.body);
|
|
458
|
+
if (byName) {
|
|
459
|
+
for (const r of raws.slice(from))
|
|
460
|
+
r.registered = true;
|
|
461
|
+
registeredBodies.push([fn.body.getStart(sf), fn.body.end]);
|
|
462
|
+
}
|
|
420
463
|
};
|
|
421
464
|
/** Object literals a function returns at its top level (a directive definition object). */
|
|
422
465
|
const returnedObjects = (body) => {
|
|
@@ -628,18 +671,26 @@ export function collectDeclarations(sf, file) {
|
|
|
628
671
|
if (target === "module.exports") {
|
|
629
672
|
if (ts.isObjectLiteralExpression(right))
|
|
630
673
|
objectMembers(right, "exports", true);
|
|
674
|
+
else if (asFunction(right))
|
|
675
|
+
returnedMembers(asFunction(right).body, "exports", true);
|
|
631
676
|
else
|
|
632
677
|
stateArray(right);
|
|
633
678
|
return;
|
|
634
679
|
}
|
|
635
|
-
if (target === `exports.${left.name.text}` || target === `module.exports.${left.name.text}`)
|
|
636
|
-
|
|
680
|
+
if (target === `exports.${left.name.text}` || target === `module.exports.${left.name.text}`) {
|
|
681
|
+
if (ts.isObjectLiteralExpression(right))
|
|
682
|
+
objectMembers(right, left.name.text, true);
|
|
683
|
+
else
|
|
684
|
+
stateArray(right);
|
|
685
|
+
}
|
|
637
686
|
const owner = left.expression;
|
|
638
687
|
if (ts.isPropertyAccessExpression(owner) && owner.name.text === "prototype" && ts.isIdentifier(owner.expression)) {
|
|
639
688
|
const cls = owner.expression.text;
|
|
640
689
|
const fn = asFunction(right);
|
|
641
|
-
if (fn)
|
|
690
|
+
if (fn) {
|
|
642
691
|
nested({ start: statement.getStart(sf), end: statement.end, kind: "method", name: left.name.text, parentName: cls, exported: topLevelExported(cls), signature: headUntil(expr, fn, sf), namePos: left.name.getStart(sf) });
|
|
692
|
+
returnedMembers(fn.body, left.name.text, topLevelExported(cls));
|
|
693
|
+
}
|
|
643
694
|
return;
|
|
644
695
|
}
|
|
645
696
|
if (ts.isIdentifier(left.name) && left.name.text === "prototype" && ts.isIdentifier(owner) && ts.isObjectLiteralExpression(right)) {
|
|
@@ -654,6 +705,7 @@ export function collectDeclarations(sf, file) {
|
|
|
654
705
|
return;
|
|
655
706
|
const exported = holder === "exports" || topLevelExported(holder);
|
|
656
707
|
nested({ start: statement.getStart(sf), end: statement.end, kind: "method", name: left.name.text, parentName: holder, exported, signature: headUntil(expr, fn, sf), namePos: left.name.getStart(sf) });
|
|
708
|
+
returnedMembers(fn.body, left.name.text, exported);
|
|
657
709
|
};
|
|
658
710
|
/** Script-style patterns of one module-level statement; `inIife` adds the IIFE's own functions. */
|
|
659
711
|
const scriptStatement = (statement, inIife) => {
|
|
@@ -662,8 +714,10 @@ export function collectDeclarations(sf, file) {
|
|
|
662
714
|
return;
|
|
663
715
|
if (inIife)
|
|
664
716
|
add(statement, statement.name, statement.name.text, false);
|
|
717
|
+
const exported = inIife ? false : topLevelExported(statement.name.text);
|
|
665
718
|
if (isConstructorName(statement.name.text))
|
|
666
|
-
thisMembers(statement.body, statement.name.text,
|
|
719
|
+
thisMembers(statement.body, statement.name.text, exported);
|
|
720
|
+
returnedMembers(statement.body, statement.name.text, exported);
|
|
667
721
|
if (takesRouter(statement))
|
|
668
722
|
routerBody(statement.body);
|
|
669
723
|
}
|
|
@@ -680,6 +734,8 @@ export function collectDeclarations(sf, file) {
|
|
|
680
734
|
const exported = inIife ? false : topLevelExported(name);
|
|
681
735
|
const init = unwrap(decl.initializer);
|
|
682
736
|
stateArray(init);
|
|
737
|
+
if (fn)
|
|
738
|
+
returnedMembers(fn.body, name, exported);
|
|
683
739
|
if (ts.isObjectLiteralExpression(init))
|
|
684
740
|
objectMembers(init, name, exported);
|
|
685
741
|
else if (fn && isConstructorName(name))
|
|
@@ -704,7 +760,11 @@ export function collectDeclarations(sf, file) {
|
|
|
704
760
|
callChain(expr);
|
|
705
761
|
}
|
|
706
762
|
else if (ts.isExportAssignment(statement)) {
|
|
707
|
-
|
|
763
|
+
const value = unwrap(statement.expression);
|
|
764
|
+
if (ts.isObjectLiteralExpression(value))
|
|
765
|
+
objectMembers(value, "default", true);
|
|
766
|
+
else
|
|
767
|
+
stateArray(statement.expression);
|
|
708
768
|
}
|
|
709
769
|
else if (ts.isIfStatement(statement)) {
|
|
710
770
|
scriptStatement(statement.thenStatement, inIife);
|
|
@@ -787,8 +847,11 @@ export function collectDeclarations(sf, file) {
|
|
|
787
847
|
}
|
|
788
848
|
};
|
|
789
849
|
visitStatements(sf.statements, undefined, true);
|
|
790
|
-
//
|
|
791
|
-
const
|
|
850
|
+
// A registered function's members belong to its registered name; the generic pass (returned objects, `this.x`) is dropped.
|
|
851
|
+
const kept = registeredBodies.length === 0
|
|
852
|
+
? raws
|
|
853
|
+
: 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);
|
|
792
855
|
return ordered.map(({ r }) => {
|
|
793
856
|
const startLc = sf.getLineAndCharacterOfPosition(r.start);
|
|
794
857
|
const full = r.signature;
|
|
@@ -812,6 +875,12 @@ export function collectDeclarations(sf, file) {
|
|
|
812
875
|
};
|
|
813
876
|
});
|
|
814
877
|
}
|
|
878
|
+
/** The string registration of a function registered by its identifier (`.directive('fancyBox', fancyBox)`): same namePos. */
|
|
879
|
+
export function registrationOf(decl, fileDecls) {
|
|
880
|
+
if (decl.nameless)
|
|
881
|
+
return undefined;
|
|
882
|
+
return fileDecls.find((d) => d.nameless === true && d.namePos === decl.namePos && d.file === decl.file && d.kind === "function");
|
|
883
|
+
}
|
|
815
884
|
/** Declarations sharing file and qualified name (overloads, merging) are one Symbol. */
|
|
816
885
|
export function symbolKey(decl) {
|
|
817
886
|
return `${decl.file}#${decl.qualifiedName}`;
|
package/dist/src/templates.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// elements/attributes, their bindings as attributes, controllers in `ng-controller`, filters
|
|
3
3
|
// after `|`. One regex pass per template line, no HTML parsing.
|
|
4
4
|
import { sourceFs } from "./fs.js";
|
|
5
|
+
import { registrationOf } from "./symbols.js";
|
|
5
6
|
import { listFiles, toRel } from "./walk.js";
|
|
6
7
|
export const TEMPLATE_EXTENSIONS = [".html", ".htm", ".ejs", ".hbs", ".njk", ".pug"];
|
|
7
8
|
export function isTemplatePath(path) {
|
|
@@ -20,6 +21,12 @@ function elementOrAttribute(name) {
|
|
|
20
21
|
}
|
|
21
22
|
/** A Declaration's template form; undefined when templates cannot name it. */
|
|
22
23
|
export function templateTarget(decl, fileDecls) {
|
|
24
|
+
const registered = registrationOf(decl, fileDecls);
|
|
25
|
+
// A function registered by identifier uses the registered name; the row keeps the function's name.
|
|
26
|
+
if (registered) {
|
|
27
|
+
const target = templateTarget(registered, fileDecls);
|
|
28
|
+
return target && { ...target, symbol: decl.qualifiedName };
|
|
29
|
+
}
|
|
23
30
|
const isComponent = (d) => d !== undefined && d.nameless === true && d.signature.startsWith("component ");
|
|
24
31
|
if (decl.nameless && (decl.parentName === "directive" || isComponent(decl)))
|
|
25
32
|
return { symbol: decl.qualifiedName, pattern: elementOrAttribute(decl.name) };
|
|
@@ -29,7 +36,8 @@ export function templateTarget(decl, fileDecls) {
|
|
|
29
36
|
if (decl.nameless && decl.parentName === "filter")
|
|
30
37
|
return { symbol: decl.qualifiedName, pattern: new RegExp(`(?<!\\|)\\|\\s*${escape(decl.name)}(?![\\w$])`, "g") };
|
|
31
38
|
if (decl.kind === "property" && decl.parentName !== undefined) {
|
|
32
|
-
const
|
|
39
|
+
const owners = fileDecls.filter((d) => d.qualifiedName === decl.parentName && d.parentName !== decl.parentName);
|
|
40
|
+
const owner = owners.find((d) => d.nameless) ?? owners[0];
|
|
33
41
|
if (owner && owner.nameless && (owner.parentName === "directive" || isComponent(owner))) {
|
|
34
42
|
return {
|
|
35
43
|
symbol: decl.qualifiedName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-axi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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": [
|