ts-axi 0.1.10 → 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 +30 -4
- package/dist/src/args.js +13 -0
- package/dist/src/cli.js +15 -4
- package/dist/src/commands/common.js +74 -26
- package/dist/src/commands/def.js +8 -5
- package/dist/src/commands/home.js +6 -12
- package/dist/src/commands/impact.js +89 -7
- package/dist/src/commands/outline.js +9 -5
- package/dist/src/commands/refs.js +32 -12
- package/dist/src/fs.js +68 -0
- package/dist/src/git.js +2 -1
- package/dist/src/gittree.js +277 -0
- package/dist/src/project.js +67 -32
- package/dist/src/render.js +14 -1
- package/dist/src/scope.js +4 -3
- package/dist/src/symbols.js +93 -24
- package/dist/src/templates.js +18 -9
- package/dist/src/walk.js +12 -30
- package/dist/src/workspace.js +21 -26
- 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` |
|
|
@@ -86,7 +86,7 @@ The hook runs `ts-axi` with no arguments: it reads only the file tree and git st
|
|
|
86
86
|
| `config` | Show effective settings with their source; `config set <key> <value>` and `config unset <key>` write `.ts-axi.json` |
|
|
87
87
|
| `setup <hooks\|status\|uninstall>` | Opt-in SessionStart hook for Claude Code, Codex and OpenCode that runs the home view at session start. `--project` for the current repository only |
|
|
88
88
|
|
|
89
|
-
`ts-axi <command> --help` prints a concise, complete flag reference. `-P <tsconfig>` selects a project on any command that may analyse types; `--json` prints the decoded payload.
|
|
89
|
+
`ts-axi <command> --help` prints a concise, complete flag reference. `-P <tsconfig>` selects a project on any command that may analyse types; `--ref <rev>` answers `def`, `refs`, `callers` and `outline` at a git revision ([below](#reading-another-revision---ref)); `--json` prints the decoded payload.
|
|
90
90
|
|
|
91
91
|
### A review, end to end
|
|
92
92
|
|
|
@@ -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
|
|
|
@@ -196,6 +196,31 @@ scope: nx workspace - 30 of 5433 files (seeds 1; importers 29)
|
|
|
196
196
|
|
|
197
197
|
Files are listed with `git ls-files` (tracked plus untracked, gitignore applied), so nested worktrees and ignored copies are never analysed. Outside git, a directory walk skips `node_modules`, build output, `.claude`, `.cursor`, `.idea`, `.vscode` and any nested checkout.
|
|
198
198
|
|
|
199
|
+
## Reading another revision (`--ref`)
|
|
200
|
+
|
|
201
|
+
`def`, `refs`, `callers` and `outline` take `--ref <rev>` (a branch, tag, `origin/main`, `HEAD~3`, a SHA) and answer from that commit's tree, read straight from git objects. Nothing is checked out, written or fetched, and the working tree has no effect on the answer: a triage agent can ask about the deployed code while the checkout is on another branch or dirty.
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
ts-axi callers Cart.total --ref origin/main
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
ref: origin/main @ 5474e0f
|
|
209
|
+
symbol: Cart.total
|
|
210
|
+
...
|
|
211
|
+
help[1]:
|
|
212
|
+
- Run `ts-axi refs src/lib/cart.ts:32 --ref origin/main` for every Reference (not only calls)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- The rev is resolved once to a SHA (`ref: <rev> @ <sha>`), so a `git fetch` during the run cannot mix versions. Paths in the output look as they do without `--ref`, and every follow-up hint keeps `--ref`.
|
|
216
|
+
- A file that is not in the revision is absent even if it exists locally, and a file deleted locally is still there: `file not in origin/main: src/new.ts`.
|
|
217
|
+
- `node_modules` is not in git, so dependency types come from the working tree; the output says `node_modules: working tree (not in git)` when it read any.
|
|
218
|
+
- A partial clone never fetches: a blob that is not available locally is a `GIT_ERROR` (`git fetch` or a full clone fixes it). Fetch the ref yourself first if it may be stale.
|
|
219
|
+
- `.ts-axi.json` is read from the working tree, like `--test-glob`.
|
|
220
|
+
- `impact` already compares git revisions (`ts-axi impact origin/main...HEAD`) and rejects `--ref`, as do `config` and `setup`.
|
|
221
|
+
|
|
222
|
+
Cost: the tree listing and one batched blob read add about 0.1 s on a small repo and 0.1-0.3 s on a ~5k-file Nx monorepo.
|
|
223
|
+
|
|
199
224
|
## Test files
|
|
200
225
|
|
|
201
226
|
A file counts as a test by path pattern. Defaults: `**/*.test.*`, `**/*.spec.*`, `**/__tests__/**`, `**/test/**`, `**/tests/**`, `**/e2e/**`, `**/spec/**`, `**/test.*`, `**/*_test.*`. The home view shows `test_globs` and `test_files` so a wrong rule is visible.
|
|
@@ -236,7 +261,7 @@ corepack pnpm install
|
|
|
236
261
|
corepack pnpm run check # typecheck, tests, build, skill freshness
|
|
237
262
|
```
|
|
238
263
|
|
|
239
|
-
`skills/ts-axi/SKILL.md` is generated: `corepack pnpm run build:skill`. See `PLAN.md` for the spec, `GLOSSARY.md` for the vocabulary and `docs/adr/` for the
|
|
264
|
+
`skills/ts-axi/SKILL.md` is generated: `corepack pnpm run build:skill`. See `PLAN.md` for the spec, `GLOSSARY.md` for the vocabulary and `docs/adr/` for the design decisions (TypeScript LanguageService over ts-morph / tree-sitter; importer subgraph over a configured project list; `--ref` over git objects instead of a worktree).
|
|
240
265
|
|
|
241
266
|
### Tests
|
|
242
267
|
|
|
@@ -244,6 +269,7 @@ corepack pnpm run check # typecheck, tests, build, skill freshness
|
|
|
244
269
|
| --- | --- | --- |
|
|
245
270
|
| `test/unit.test.ts`, `test/workspace.test.ts` | `pnpm test` | target parsing, signatures, change classification, diff parsing, settings, glob matching, reference classification, workspace detection, import resolution |
|
|
246
271
|
| `test/commands.test.ts` | `pnpm test` | the whole CLI through `main()` over five fixtures copied to temp git repos: `test/fixtures/project` (TS, interface + implementations, barrel, JS consumer, `any` access), `test/fixtures/workspace` (Nx + pnpm workspace, `paths`, references-only tsconfigs), `test/fixtures/legacy-js` (AngularJS, Meteor, prototype, IIFE, CommonJS), `test/fixtures/commonjs` (`require('..')` shim, expando methods, `.d.ts` twin), `test/fixtures/esm-js` (`"type": "module"`, `index.js` + `index.d.ts`) |
|
|
272
|
+
| `test/ref.test.ts`, `test/ref-leak.test.ts`, `test/gittree.test.ts` | `pnpm test` | `--ref`: answers on a poisoned working tree equal a clean checkout (four fixtures), no read below the repo root bypasses the git tree (`node:fs` and `ts.sys` spied), tree listing, blob batches, symlinks, submodules, bad revisions |
|
|
247
273
|
| `test/integration/*.test.ts` | `pnpm run test:integration` | the built binary against the vendored Astro + React sample in `test/integration/sample` (installs its own dependencies once; copied to a temp git repo per run, see `test/integration/README.md`) |
|
|
248
274
|
|
|
249
275
|
CI runs the first two on Node 22 and 24, the built CLI on Node 20, and the integration suite on Node 24.
|
package/dist/src/args.js
CHANGED
|
@@ -19,6 +19,14 @@ const POSITIONAL = {
|
|
|
19
19
|
"--symbol": "pass the symbol as a positional argument",
|
|
20
20
|
"--name": "pass the symbol as a positional argument",
|
|
21
21
|
};
|
|
22
|
+
/** Commands that take `--ref <rev>`. */
|
|
23
|
+
export const REF_COMMANDS = ["def", "refs", "callers", "outline"];
|
|
24
|
+
function refHint(command, rev) {
|
|
25
|
+
const value = rev && !rev.startsWith("-") ? rev : "<ref>";
|
|
26
|
+
if (command === "ts-axi impact")
|
|
27
|
+
return `Run \`ts-axi impact ${value}...HEAD\` - impact takes git revisions as its range`;
|
|
28
|
+
return `--ref works with ${REF_COMMANDS.join(", ")} only`;
|
|
29
|
+
}
|
|
22
30
|
/**
|
|
23
31
|
* Strict declarative parser: every flag must be declared, unknown flags fail
|
|
24
32
|
* loud with the valid set inline (AXI principle 6). Supports `--flag value`,
|
|
@@ -38,8 +46,11 @@ export function parseArgs(args, specs, command) {
|
|
|
38
46
|
positionals: [],
|
|
39
47
|
};
|
|
40
48
|
const unknown = [];
|
|
49
|
+
let refValue;
|
|
41
50
|
for (let i = 0; i < args.length; i++) {
|
|
42
51
|
const token = args[i];
|
|
52
|
+
if (token === "--ref" || token.startsWith("--ref="))
|
|
53
|
+
refValue = token.startsWith("--ref=") ? token.slice(6) : args[i + 1];
|
|
43
54
|
if (token === "--") {
|
|
44
55
|
parsed.positionals.push(...args.slice(i + 1));
|
|
45
56
|
break;
|
|
@@ -98,7 +109,9 @@ export function parseArgs(args, specs, command) {
|
|
|
98
109
|
const positional = unknown
|
|
99
110
|
.filter((flag) => POSITIONAL[flag])
|
|
100
111
|
.map((flag) => `${flag} is not supported; ${POSITIONAL[flag]}`);
|
|
112
|
+
const ref = unknown.includes("--ref") ? [refHint(command, refValue)] : [];
|
|
101
113
|
throw usageError(`unknown flag${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} for \`${command}\``, [
|
|
114
|
+
...ref,
|
|
102
115
|
...renamed,
|
|
103
116
|
...positional,
|
|
104
117
|
`valid flags for \`${command}\`: ${valid} (--help always allowed)`,
|
package/dist/src/cli.js
CHANGED
|
@@ -7,7 +7,9 @@ import { outlineCommand, OUTLINE_HELP } from "./commands/outline.js";
|
|
|
7
7
|
import { callersCommand, CALLERS_HELP, refsCommand, REFS_HELP } from "./commands/refs.js";
|
|
8
8
|
import { setupCommand, SETUP_HELP } from "./commands/setup.js";
|
|
9
9
|
import { AxiError, exitCodeForError } from "./errors.js";
|
|
10
|
-
import {
|
|
10
|
+
import { refHeader } from "./commands/common.js";
|
|
11
|
+
import { diskFs, setSourceFs } from "./fs.js";
|
|
12
|
+
import { out, renderErrorOutput, setHintRef, setJsonMode, toJson } from "./render.js";
|
|
11
13
|
import { VERSION } from "./version.js";
|
|
12
14
|
export const DESCRIPTION = "TypeScript/JavaScript symbol CLI for agents - definitions, references, callers, file outlines and change impact (what a diff affects) from the TypeScript compiler. Prefer it over grep for 'who uses this'.";
|
|
13
15
|
export const TOP_HELP = `usage: ts-axi [command] [args] [flags]
|
|
@@ -15,13 +17,14 @@ commands[8]:
|
|
|
15
17
|
(none)=project overview, def, refs, callers, outline, impact, config, setup (opt-in session hooks)
|
|
16
18
|
targets:
|
|
17
19
|
name (save), member (User.save) or position (src/a.ts:42 or src/a.ts:42:7)
|
|
18
|
-
flags[
|
|
19
|
-
-P <tsconfig> (after the command; default nearest tsconfig.json), --json (anywhere), --help, -v/-V/--version
|
|
20
|
+
flags[5]:
|
|
21
|
+
-P <tsconfig> (after the command; default nearest tsconfig.json), --ref <rev> (def/refs/callers/outline; read a git revision instead of the working tree), --json (anywhere), --help, -v/-V/--version
|
|
20
22
|
examples:
|
|
21
23
|
ts-axi
|
|
22
24
|
ts-axi def User.save
|
|
23
25
|
ts-axi refs src/models/user.ts:12
|
|
24
26
|
ts-axi callers runSync --depth 2
|
|
27
|
+
ts-axi refs User.save --ref origin/main
|
|
25
28
|
ts-axi outline src/models/user.ts
|
|
26
29
|
ts-axi impact main...HEAD
|
|
27
30
|
ts-axi refs --help
|
|
@@ -36,7 +39,13 @@ const COMMAND_HELP = {
|
|
|
36
39
|
setup: SETUP_HELP,
|
|
37
40
|
};
|
|
38
41
|
function command(name, handler, ctx) {
|
|
39
|
-
return async (args) =>
|
|
42
|
+
return async (args) => {
|
|
43
|
+
if (args.includes("-h"))
|
|
44
|
+
return (COMMAND_HELP[name] ?? "").trimEnd();
|
|
45
|
+
const result = await handler(args, ctx);
|
|
46
|
+
// After the handler: the node_modules note depends on what it read.
|
|
47
|
+
return out(refHeader(), result);
|
|
48
|
+
};
|
|
40
49
|
}
|
|
41
50
|
function formatError(error) {
|
|
42
51
|
const axi = error instanceof AxiError
|
|
@@ -84,6 +93,8 @@ export async function main(options = {}) {
|
|
|
84
93
|
}
|
|
85
94
|
finally {
|
|
86
95
|
setJsonMode(false);
|
|
96
|
+
setSourceFs(diskFs);
|
|
97
|
+
setHintRef(undefined);
|
|
87
98
|
}
|
|
88
99
|
if (!json)
|
|
89
100
|
return;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
1
|
+
import { setSourceFs, sourceFs } from "../fs.js";
|
|
2
|
+
import { openGitTree } from "../gittree.js";
|
|
3
|
+
import { block, setHintRef } from "../render.js";
|
|
4
4
|
import { dirname, posix, relative, resolve, sep } from "node:path";
|
|
5
5
|
import ts from "typescript";
|
|
6
6
|
import { enumFlag, flag, flags, bool } from "../args.js";
|
|
@@ -18,7 +18,28 @@ export const TARGET_FLAGS = [
|
|
|
18
18
|
{ name: "--exported", boolean: true },
|
|
19
19
|
{ name: "--fuzzy", boolean: true },
|
|
20
20
|
{ name: "-P" },
|
|
21
|
+
{ name: "--ref" },
|
|
21
22
|
];
|
|
23
|
+
/**
|
|
24
|
+
* `--ref <rev>`: read the repo from that commit's git tree for the rest of the run. Returns the
|
|
25
|
+
* context with the cwd mapped into the tree (no disk realpath of the working tree).
|
|
26
|
+
*/
|
|
27
|
+
export function useRef(ctx, parsed) {
|
|
28
|
+
const rev = flag(parsed, "--ref");
|
|
29
|
+
if (rev === undefined)
|
|
30
|
+
return ctx;
|
|
31
|
+
const fs = openGitTree(ctx.cwd, rev);
|
|
32
|
+
setSourceFs(fs);
|
|
33
|
+
setHintRef(rev);
|
|
34
|
+
return { ...ctx, cwd: fs.ref.cwd };
|
|
35
|
+
}
|
|
36
|
+
/** The header block of a `--ref` run: `ref: <rev> @ <short sha>`, plus where node_modules came from. */
|
|
37
|
+
export function refHeader() {
|
|
38
|
+
const ref = sourceFs().ref;
|
|
39
|
+
if (!ref)
|
|
40
|
+
return undefined;
|
|
41
|
+
return block({ ref: `${ref.rev} @ ${ref.sha.slice(0, 7)}`, ...(ref.nodeModulesRead ? { node_modules: "working tree (not in git)" } : {}) });
|
|
42
|
+
}
|
|
22
43
|
export function readFilters(parsed, extraKinds = []) {
|
|
23
44
|
const raw = flag(parsed, "--kind");
|
|
24
45
|
const kind = raw === undefined ? undefined : enumFlag(parsed, "--kind", [...KINDS, ...extraKinds], KINDS[0]);
|
|
@@ -180,8 +201,9 @@ function filterSuffixOf(label, target) {
|
|
|
180
201
|
}
|
|
181
202
|
export function resolvePositionFile(cwd, file) {
|
|
182
203
|
const abs = resolve(cwd, file);
|
|
183
|
-
if (!
|
|
184
|
-
|
|
204
|
+
if (!sourceFs().exists(abs)) {
|
|
205
|
+
const ref = sourceFs().ref;
|
|
206
|
+
throw usageError(ref ? `file not in ${ref.rev}: ${file}` : `file not found: ${file}`, ["paths are relative to the current directory"]);
|
|
185
207
|
}
|
|
186
208
|
return abs;
|
|
187
209
|
}
|
|
@@ -195,9 +217,27 @@ function identifiers(node, sf, outIds) {
|
|
|
195
217
|
}
|
|
196
218
|
ts.forEachChild(node, (child) => identifiers(child, sf, outIds));
|
|
197
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. */
|
|
198
227
|
function isDeclarationName(id) {
|
|
199
228
|
const parent = id.parent;
|
|
200
|
-
|
|
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;
|
|
201
241
|
}
|
|
202
242
|
/** The identifier a `path:line[:col]` target points at (see PLAN.md Targets). */
|
|
203
243
|
export function resolvePosition(index, cwd, target) {
|
|
@@ -230,33 +270,41 @@ export function resolvePosition(index, cwd, target) {
|
|
|
230
270
|
return hit ? pick(hit) : { kind: "none", abs, file, reason: `no identifier at ${file}:${target.line}:${target.col}` };
|
|
231
271
|
}
|
|
232
272
|
// A string-registered name has no identifier: its line stands for it.
|
|
233
|
-
|
|
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);
|
|
234
275
|
if (nameless)
|
|
235
276
|
return { kind: "decl", decl: nameless, abs };
|
|
236
277
|
if (onLine.length === 0)
|
|
237
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
|
+
};
|
|
238
293
|
const constructorDecl = decls.find((d) => d.nameLine === target.line && d.name === "constructor");
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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);
|
|
242
305
|
if (constructorDecl)
|
|
243
306
|
return { kind: "decl", decl: constructorDecl, abs };
|
|
244
|
-
|
|
245
|
-
for (const id of onLine)
|
|
246
|
-
if (!distinct.has(id.text))
|
|
247
|
-
distinct.set(id.text, id);
|
|
248
|
-
if (distinct.size === 1)
|
|
249
|
-
return pick(onLine[0]);
|
|
250
|
-
return {
|
|
251
|
-
kind: "ambiguous",
|
|
252
|
-
abs,
|
|
253
|
-
file,
|
|
254
|
-
candidates: [...distinct.values()].map((id) => ({
|
|
255
|
-
name: id.text,
|
|
256
|
-
line: target.line,
|
|
257
|
-
col: id.getStart(sf) - lineStart + 1,
|
|
258
|
-
})),
|
|
259
|
-
};
|
|
307
|
+
return ambiguous(onLine);
|
|
260
308
|
}
|
|
261
309
|
/** A Declaration for an arbitrary declaration name position (locals, parameters) not in the index. */
|
|
262
310
|
export function declarationAt(index, abs, pos, fallbackKind) {
|
package/dist/src/commands/def.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
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
|
-
import { BODY_LINES, declarationAt, filterSuffix, MATCH_ROWS, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, } from "./common.js";
|
|
6
|
+
import { BODY_LINES, declarationAt, filterSuffix, MATCH_ROWS, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, useRef, } from "./common.js";
|
|
6
7
|
export const DEF_HELP = `usage: ts-axi def <name | Class.member | path:line[:col]> [flags]
|
|
7
|
-
flags[
|
|
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
|
|
8
|
+
flags[8]:
|
|
9
|
+
--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>, --ref <rev> (read the code at a git revision; working tree ignored), --json
|
|
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
|
|
@@ -49,6 +50,7 @@ export async function defCommand(args, ctx) {
|
|
|
49
50
|
const target = parseTarget(raw, { fuzzy: f.fuzzy });
|
|
50
51
|
const full = bool(parsed, "--full");
|
|
51
52
|
const project = flag(parsed, "-P");
|
|
53
|
+
ctx = useRef(ctx, parsed);
|
|
52
54
|
const opened = open(ctx.cwd, parsed, positionStartDir(ctx.cwd, target));
|
|
53
55
|
const label = targetLabel(target);
|
|
54
56
|
if (target.kind === "position") {
|
|
@@ -77,7 +79,8 @@ export async function defCommand(args, ctx) {
|
|
|
77
79
|
return detail(opened, groups[0].decls, full, label);
|
|
78
80
|
if (groups.length === 0) {
|
|
79
81
|
const suggested = suggestions(opened, target);
|
|
80
|
-
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}\``)];
|
|
81
84
|
if (!f.fuzzy)
|
|
82
85
|
help.push(`Run \`ts-axi def ${label} --fuzzy\` for substring matches`);
|
|
83
86
|
if (f.kind || f.in.length > 0 || f.exported)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sourceFs } from "../fs.js";
|
|
2
2
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { expectPositionals, flag, flags, parseArgs } from "../args.js";
|
|
4
4
|
import { AxiError, usageError } from "../errors.js";
|
|
@@ -18,20 +18,14 @@ const HOME_SPECS = [
|
|
|
18
18
|
];
|
|
19
19
|
/** Does `dir` hold a TS/JS source within `depth` levels (shared skip rule)? */
|
|
20
20
|
function hasSources(dir, depth, root = dir) {
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
21
|
+
const entries = sourceFs().readdir(dir);
|
|
28
22
|
for (const entry of entries) {
|
|
29
|
-
if (entry.isFile
|
|
23
|
+
if (entry.isFile && SOURCE_EXT.test(entry.name))
|
|
30
24
|
return true;
|
|
31
25
|
}
|
|
32
26
|
if (depth === 0)
|
|
33
27
|
return false;
|
|
34
|
-
return entries.some((e) => e.isDirectory
|
|
28
|
+
return entries.some((e) => e.isDirectory && !skipDir(e.name, join(dir, e.name), root) && hasSources(join(dir, e.name), depth - 1, root));
|
|
35
29
|
}
|
|
36
30
|
/** Implicit project: every source below `root` from the shared listing (git, else a bounded fs walk). */
|
|
37
31
|
function walkSources(root) {
|
|
@@ -41,7 +35,7 @@ function findTsconfig(cwd) {
|
|
|
41
35
|
let dir = cwd;
|
|
42
36
|
for (;;) {
|
|
43
37
|
const candidate = join(dir, "tsconfig.json");
|
|
44
|
-
if (
|
|
38
|
+
if (sourceFs().exists(candidate))
|
|
45
39
|
return candidate;
|
|
46
40
|
const parent = dirname(dir);
|
|
47
41
|
if (parent === dir)
|
|
@@ -79,7 +73,7 @@ function resolveTsconfig(cwd, settings) {
|
|
|
79
73
|
// -P is relative to cwd, the .ts-axi.json value to the repo root.
|
|
80
74
|
const base = settings.sources.project === "flag" ? cwd : settings.root;
|
|
81
75
|
const path = resolve(base, settings.project);
|
|
82
|
-
if (!
|
|
76
|
+
if (!sourceFs().isFile(path)) {
|
|
83
77
|
throw usageError(`tsconfig not found: ${settings.project}`, [
|
|
84
78
|
settings.sources.project === "flag"
|
|
85
79
|
? "Pass an existing file to -P"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { sourceFs } from "../fs.js";
|
|
2
3
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
3
4
|
import ts from "typescript";
|
|
4
5
|
import { bool, flag, intFlag, parseArgs, parseFields } from "../args.js";
|
|
@@ -69,6 +70,7 @@ async function readStdin() {
|
|
|
69
70
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
70
71
|
return Buffer.concat(chunks).toString("utf8");
|
|
71
72
|
}
|
|
73
|
+
// Always the working tree: it is the "after" side of the diff (impact rejects --ref).
|
|
72
74
|
function readText(abs) {
|
|
73
75
|
try {
|
|
74
76
|
return readFileSync(abs, "utf8");
|
|
@@ -243,6 +245,7 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
|
|
|
243
245
|
const files = [...semantic.projectFiles];
|
|
244
246
|
const projectSet = new Set(files);
|
|
245
247
|
walkExtra(opened.root, files);
|
|
248
|
+
sourceFs().prefetch(files);
|
|
246
249
|
const rows = [];
|
|
247
250
|
for (const abs of files) {
|
|
248
251
|
const text = opened.index.text(abs);
|
|
@@ -267,6 +270,27 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
|
|
|
267
270
|
}
|
|
268
271
|
return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
269
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
|
+
}
|
|
270
294
|
/** Whether a whole-word count of the name says anything: not for router-called or by-name-injected members (`injected`). */
|
|
271
295
|
export function isTextualCandidate(decl) {
|
|
272
296
|
return decl?.injected !== true;
|
|
@@ -294,9 +318,66 @@ export function isUntyped(opened, decl, file) {
|
|
|
294
318
|
return false;
|
|
295
319
|
return decl?.nameless === true || opened.index.project.implicit || (file !== undefined && /\.[mc]?jsx?$/.test(file));
|
|
296
320
|
}
|
|
297
|
-
/**
|
|
298
|
-
|
|
299
|
-
|
|
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;
|
|
300
381
|
}
|
|
301
382
|
/**
|
|
302
383
|
* Whole-word occurrences of `name` in Project files outside `changeSet`; lines declaring a `name` excluded.
|
|
@@ -311,6 +392,7 @@ export function textualCount(opened, needle, changeSet = new Map(), onlyFile) {
|
|
|
311
392
|
const candidates = onlyFile !== undefined ? [onlyFile] : [...project];
|
|
312
393
|
if (token && onlyFile === undefined)
|
|
313
394
|
walkExtra(opened.root, candidates);
|
|
395
|
+
sourceFs().prefetch(candidates);
|
|
314
396
|
for (const abs of new Set(candidates)) {
|
|
315
397
|
const text = opened.index.text(abs);
|
|
316
398
|
if (text === undefined || !text.includes(name))
|
|
@@ -432,7 +514,7 @@ function specFilesHint(cwd, opened, skipped, noTests) {
|
|
|
432
514
|
const dir = dirname(tsconfig);
|
|
433
515
|
const name = basename(tsconfig);
|
|
434
516
|
const order = name === "tsconfig.lib.json" || name === "tsconfig.app.json" ? ["tsconfig.json", "tsconfig.spec.json"] : ["tsconfig.spec.json", "tsconfig.json"];
|
|
435
|
-
const candidates = order.map((n) => join(dir, n)).filter((c) => c !== tsconfig &&
|
|
517
|
+
const candidates = order.map((n) => join(dir, n)).filter((c) => c !== tsconfig && sourceFs().exists(c));
|
|
436
518
|
// Prefer the one that really includes a skipped spec file (an Nx tsconfig.json may only hold references).
|
|
437
519
|
const includes = (c) => {
|
|
438
520
|
try {
|
|
@@ -588,7 +670,7 @@ export async function impactCommand(args, ctx) {
|
|
|
588
670
|
for (const row of rows) {
|
|
589
671
|
if (!row.live || !isUntyped(opened, row.live, row.live.file))
|
|
590
672
|
continue;
|
|
591
|
-
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;
|
|
592
674
|
const needle = textualNeedle(row.live);
|
|
593
675
|
if (!needle)
|
|
594
676
|
continue;
|
|
@@ -663,7 +745,7 @@ export async function impactCommand(args, ctx) {
|
|
|
663
745
|
if (!textNames.has(needle.text))
|
|
664
746
|
textNames.set(needle.text, needle);
|
|
665
747
|
const abs = opened.index.abs(decl.file);
|
|
666
|
-
if (!isPlainLocal(decl) || (localTo.has(name) && localTo.get(name) !== abs))
|
|
748
|
+
if (!isPlainLocal(opened.index, decl) || (localTo.has(name) && localTo.get(name) !== abs))
|
|
667
749
|
wide.add(name);
|
|
668
750
|
else
|
|
669
751
|
localTo.set(name, abs);
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sourceFs } from "../fs.js";
|
|
2
2
|
import { posix, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { bool, flag, parseArgs, parseFields } from "../args.js";
|
|
4
4
|
import { usageError } from "../errors.js";
|
|
5
5
|
import { hasGlobMagic, matchesGlob } from "../glob.js";
|
|
6
6
|
import { createSyntacticIndex, findRepoRoot, openProject, SOURCE_EXTENSIONS } from "../project.js";
|
|
7
7
|
import { block, helpBlock, out } from "../render.js";
|
|
8
|
+
import { useRef } from "./common.js";
|
|
8
9
|
const MAX_ROWS = 300;
|
|
9
10
|
export const OUTLINE_HELP = `usage: ts-axi outline <file | glob>... [flags]
|
|
10
|
-
flags[
|
|
11
|
-
--exported (alias --exports; exported Declarations only), --fields exported (adds an exported column), -P <tsconfig> (file list for globs), --json
|
|
11
|
+
flags[5]:
|
|
12
|
+
--exported (alias --exports; exported Declarations only), --fields exported (adds an exported column), -P <tsconfig> (file list for globs), --ref <rev> (the file at a git revision), --json
|
|
12
13
|
output:
|
|
13
14
|
one file = file + decls[n]{line,kind,name,signature}; several = decls[n]{at,kind,name,signature} (at = path:line); --exported keeps only exported Declarations, --fields exported adds the column
|
|
14
15
|
class members follow their class as Class.member; at most ${MAX_ROWS} rows
|
|
@@ -27,6 +28,7 @@ export async function outlineCommand(args, ctx) {
|
|
|
27
28
|
{ name: "--exported", alias: "--exports", boolean: true },
|
|
28
29
|
{ name: "--fields" },
|
|
29
30
|
{ name: "-P" },
|
|
31
|
+
{ name: "--ref" },
|
|
30
32
|
], COMMAND);
|
|
31
33
|
if (parsed.positionals.length === 0) {
|
|
32
34
|
throw usageError("missing argument", [`usage: ${COMMAND} <file | glob>... [--exported]`]);
|
|
@@ -34,14 +36,16 @@ export async function outlineCommand(args, ctx) {
|
|
|
34
36
|
const exportedOnly = bool(parsed, "--exported");
|
|
35
37
|
const showExported = parseFields(flag(parsed, "--fields"), ["exported"]).length > 0;
|
|
36
38
|
const override = flag(parsed, "-P");
|
|
39
|
+
ctx = useRef(ctx, parsed);
|
|
37
40
|
const root = findRepoRoot(ctx.cwd);
|
|
38
41
|
const cwdPrefix = relative(root, resolve(ctx.cwd)).split(sep).join("/");
|
|
39
42
|
const files = [];
|
|
40
43
|
const globs = parsed.positionals.filter(hasGlobMagic);
|
|
41
44
|
for (const raw of parsed.positionals.filter((p) => !hasGlobMagic(p))) {
|
|
42
45
|
const abs = resolve(ctx.cwd, raw);
|
|
43
|
-
if (!
|
|
44
|
-
|
|
46
|
+
if (!sourceFs().isFile(abs)) {
|
|
47
|
+
const ref = sourceFs().ref;
|
|
48
|
+
throw usageError(ref ? `file not in ${ref.rev}: ${raw}` : `file not found: ${raw}`, ["pass a source file or a quoted glob such as \"src/**/*.ts\""]);
|
|
45
49
|
}
|
|
46
50
|
if (!isSource(abs))
|
|
47
51
|
throw usageError(`not a TypeScript/JavaScript source file: ${raw}`);
|