@crouton-kit/tsym 0.1.0
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/LICENSE +21 -0
- package/README.md +51 -0
- package/bin/tsym.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +89 -0
- package/dist/commands/handlers/check.d.ts +3 -0
- package/dist/commands/handlers/check.js +94 -0
- package/dist/commands/handlers/find.d.ts +3 -0
- package/dist/commands/handlers/find.js +56 -0
- package/dist/commands/handlers/inspect.d.ts +3 -0
- package/dist/commands/handlers/inspect.js +81 -0
- package/dist/commands/handlers/outline.d.ts +3 -0
- package/dist/commands/handlers/outline.js +101 -0
- package/dist/commands/handlers/query.d.ts +3 -0
- package/dist/commands/handlers/query.js +196 -0
- package/dist/commands/handlers/rename.d.ts +3 -0
- package/dist/commands/handlers/rename.js +53 -0
- package/dist/commands/handlers/search.d.ts +5 -0
- package/dist/commands/handlers/search.js +32 -0
- package/dist/commands/handlers/server-reload.d.ts +3 -0
- package/dist/commands/handlers/server-reload.js +7 -0
- package/dist/commands/handlers/server-status.d.ts +3 -0
- package/dist/commands/handlers/server-status.js +7 -0
- package/dist/commands/handlers/server-stop.d.ts +3 -0
- package/dist/commands/handlers/server-stop.js +7 -0
- package/dist/commands/handlers/show.d.ts +3 -0
- package/dist/commands/handlers/show.js +63 -0
- package/dist/commands/handlers/type.d.ts +3 -0
- package/dist/commands/handlers/type.js +45 -0
- package/dist/commands/handlers.d.ts +12 -0
- package/dist/commands/handlers.js +44 -0
- package/dist/commands/help.d.ts +2 -0
- package/dist/commands/help.js +27 -0
- package/dist/commands/rename-apply.d.ts +37 -0
- package/dist/commands/rename-apply.js +115 -0
- package/dist/commands/tree.d.ts +9 -0
- package/dist/commands/tree.js +277 -0
- package/dist/commands/types.d.ts +80 -0
- package/dist/commands/types.js +1 -0
- package/dist/core/declarations.d.ts +48 -0
- package/dist/core/declarations.js +153 -0
- package/dist/core/receipt.d.ts +7 -0
- package/dist/core/receipt.js +9 -0
- package/dist/core/search.d.ts +29 -0
- package/dist/core/search.js +47 -0
- package/dist/core/source.d.ts +32 -0
- package/dist/core/source.js +96 -0
- package/dist/core/workspace.d.ts +28 -0
- package/dist/core/workspace.js +137 -0
- package/dist/index/build.d.ts +22 -0
- package/dist/index/build.js +79 -0
- package/dist/index/classify.d.ts +20 -0
- package/dist/index/classify.js +60 -0
- package/dist/index/commits.d.ts +15 -0
- package/dist/index/commits.js +201 -0
- package/dist/index/descriptor.d.ts +19 -0
- package/dist/index/descriptor.js +295 -0
- package/dist/index/graph.d.ts +82 -0
- package/dist/index/graph.js +1 -0
- package/dist/index/heritage.d.ts +15 -0
- package/dist/index/heritage.js +122 -0
- package/dist/index/refresh.d.ts +28 -0
- package/dist/index/refresh.js +110 -0
- package/dist/index/scope.d.ts +4 -0
- package/dist/index/scope.js +21 -0
- package/dist/index/state.d.ts +22 -0
- package/dist/index/state.js +63 -0
- package/dist/index/walk.d.ts +23 -0
- package/dist/index/walk.js +550 -0
- package/dist/index/watch.d.ts +18 -0
- package/dist/index/watch.js +175 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/output.d.ts +12 -0
- package/dist/output.js +54 -0
- package/dist/read/read.d.ts +32 -0
- package/dist/read/read.js +89 -0
- package/dist/relations/graph.d.ts +23 -0
- package/dist/relations/graph.js +115 -0
- package/dist/relations/mermaid.d.ts +3 -0
- package/dist/relations/mermaid.js +37 -0
- package/dist/relations/store-relations.d.ts +20 -0
- package/dist/relations/store-relations.js +158 -0
- package/dist/relations/types.d.ts +58 -0
- package/dist/relations/types.js +12 -0
- package/dist/server/client.d.ts +5 -0
- package/dist/server/client.js +215 -0
- package/dist/server/context.d.ts +21 -0
- package/dist/server/context.js +12 -0
- package/dist/server/lifecycle.d.ts +28 -0
- package/dist/server/lifecycle.js +62 -0
- package/dist/server/main.d.ts +1 -0
- package/dist/server/main.js +541 -0
- package/dist/server/projects.d.ts +21 -0
- package/dist/server/projects.js +28 -0
- package/dist/server/wire.d.ts +39 -0
- package/dist/server/wire.js +19 -0
- package/dist/store/load.d.ts +67 -0
- package/dist/store/load.js +193 -0
- package/dist/store/meta.d.ts +28 -0
- package/dist/store/meta.js +137 -0
- package/dist/store/query.d.ts +5 -0
- package/dist/store/query.js +4 -0
- package/dist/store/schema.d.ts +4 -0
- package/dist/store/schema.js +19 -0
- package/dist/store/store.d.ts +36 -0
- package/dist/store/store.js +240 -0
- package/dist/ts7/api.d.ts +35 -0
- package/dist/ts7/api.js +164 -0
- package/dist/ts7/ast.d.ts +1 -0
- package/dist/ts7/ast.js +1 -0
- package/dist/ts7/checker.d.ts +55 -0
- package/dist/ts7/checker.js +383 -0
- package/dist/ts7/projects.d.ts +27 -0
- package/dist/ts7/projects.js +94 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Silas Rhyneer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# tsym
|
|
2
|
+
|
|
3
|
+
Symbol-level reading of a TypeScript codebase, from the compiler's own resolution.
|
|
4
|
+
|
|
5
|
+
`tsym` answers questions about TypeScript symbols — where something is declared, what its type resolves to, who calls it, what it calls, what implements it — through a resident bundled TypeScript 7 process and its persisted index rather than by matching text. The answers come from the same program the compiler builds, so they can be acted on without re-reading files to confirm.
|
|
6
|
+
|
|
7
|
+
Output is one named XML block on stdout per call, with a one-line receipt on stderr. It is written for agents first: `-h` on any command prints a machine-readable description of its arguments, flags, and output contract.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @crouton-kit/tsym
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node 22.13 or newer (the resident server locks its per-workspace slot with `node:sqlite`).
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
| Command | What it answers |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `tsym outline` | Structure of a file, a directory, or the whole repository. |
|
|
22
|
+
| `tsym find <name>` | The canonical address of a declaration. |
|
|
23
|
+
| `tsym show <target>` | The declaration's source text. |
|
|
24
|
+
| `tsym type <target>` | Resolved type, signature, and doc comment. |
|
|
25
|
+
| `tsym inspect <target>` | References, callers, calls, and implementations. |
|
|
26
|
+
| `tsym search <pattern>` | Structural syntax search (ast-grep patterns). |
|
|
27
|
+
| `tsym check` | Semantic and syntactic diagnostics. |
|
|
28
|
+
| `tsym rename <target> <new>` | Rename a symbol across the program. |
|
|
29
|
+
| `tsym server` | Status, reload, and stop for the resident process. |
|
|
30
|
+
|
|
31
|
+
Every printed `path:line:col` address is accepted by every symbol command, so an address from `find`, `outline`, or `inspect` can be passed straight to `show` or `type`.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
tsym find reviveNode
|
|
35
|
+
tsym inspect reviveNode --only callers --each 3
|
|
36
|
+
tsym server status
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## The resident server
|
|
40
|
+
|
|
41
|
+
A semantic command starts one resident process per workspace root and leaves it running, so the second and later commands reuse the loaded program and persisted index. It exits on its own after an idle period. `tsym server status` reports the process, memory, bundled TypeScript 7 version, store path, indexed commit, selected-scope counts, files outside configured projects, index state, and last index duration; `tsym server reload` forces a full index rebuild after a branch switch or dependency install; `tsym server stop` ends it.
|
|
42
|
+
|
|
43
|
+
The bundled TypeScript 7 process is always used, so `tsym` works in a checkout with no dependencies installed.
|
|
44
|
+
|
|
45
|
+
## Using it from crtr
|
|
46
|
+
|
|
47
|
+
`tsym` is available inside [crtr](https://www.npmjs.com/package/@north-light/crouter) as `crtr tsym` via the `tsym` plugin in the crouter official marketplace. The plugin passes through to this executable, so it must be on `PATH`.
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
package/bin/tsym.js
ADDED
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { renderHelp } from './commands/help.js';
|
|
2
|
+
import { localSearch } from './commands/handlers/search.js';
|
|
3
|
+
import { applyRenamePlan } from './commands/rename-apply.js';
|
|
4
|
+
import { parseInvocation } from './commands/tree.js';
|
|
5
|
+
import { renderCompletion, renderNode } from './output.js';
|
|
6
|
+
import { invokeResident, serverReload, serverStatus, serverStop } from './server/client.js';
|
|
7
|
+
import { discoverRoot, pathsForRoot, readState } from './server/lifecycle.js';
|
|
8
|
+
import { readMetadata } from './store/meta.js';
|
|
9
|
+
async function main(argv) {
|
|
10
|
+
const parsed = parseInvocation(argv);
|
|
11
|
+
if ('help' in parsed) {
|
|
12
|
+
const state = parsed.help.name === 'search' ? '' : helpState(rootFlag(argv));
|
|
13
|
+
process.stdout.write(renderHelp(parsed.help, state) + '\n');
|
|
14
|
+
process.stderr.write('1 answer, 0 ms\n');
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
if ('category' in parsed)
|
|
18
|
+
return emit(parsed, false);
|
|
19
|
+
return emit(await dispatch(parsed), parsed.json);
|
|
20
|
+
}
|
|
21
|
+
async function dispatch(invocation) {
|
|
22
|
+
switch (invocation.declaration.handler) {
|
|
23
|
+
case 'server-status': return serverStatus(invocation.root);
|
|
24
|
+
case 'server-stop': return serverStop(invocation.root);
|
|
25
|
+
case 'server-reload': return serverReload(invocation.root);
|
|
26
|
+
case 'search': return localSearch(invocation);
|
|
27
|
+
case 'rename': return applyRename(invocation);
|
|
28
|
+
case 'query': return queryFromStdin(invocation);
|
|
29
|
+
default: return invokeResident(invocation);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function queryFromStdin(invocation) {
|
|
33
|
+
if (invocation.input.args[0] !== '-')
|
|
34
|
+
return invokeResident(invocation);
|
|
35
|
+
if (process.stdin.isTTY)
|
|
36
|
+
return { category: 'invocation', code: 'usage', problems: [{ field: '<cypher>', received: '- with no piped input', expected: 'a query on stdin', next: 'Pipe the query or pass it as the positional argument.' }] };
|
|
37
|
+
invocation.input.args[0] = await readStdin();
|
|
38
|
+
return invokeResident(invocation);
|
|
39
|
+
}
|
|
40
|
+
async function readStdin() {
|
|
41
|
+
let text = '';
|
|
42
|
+
for await (const chunk of process.stdin)
|
|
43
|
+
text += chunk;
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
async function applyRename(invocation) {
|
|
47
|
+
const completion = await invokeResident(invocation);
|
|
48
|
+
if (invocation.input.flags['--apply'] !== true || !isRenameAnswer(completion))
|
|
49
|
+
return completion;
|
|
50
|
+
const applied = await applyRenamePlan(completion.renamePlan);
|
|
51
|
+
if ('category' in applied)
|
|
52
|
+
return applied;
|
|
53
|
+
const stamp = (node) => ({ ...node, attributes: { ...node.attributes, applied: true } });
|
|
54
|
+
return { ...completion, data: Array.isArray(completion.data) ? [stamp(completion.data[0]), ...completion.data.slice(1)] : stamp(completion.data) };
|
|
55
|
+
}
|
|
56
|
+
function isRenameAnswer(completion) { return !('category' in completion) && 'renamePlan' in completion; }
|
|
57
|
+
function helpState(rootOverride) {
|
|
58
|
+
const root = discoverRoot(rootOverride);
|
|
59
|
+
const paths = pathsForRoot(root);
|
|
60
|
+
const state = readState(paths);
|
|
61
|
+
if (!state)
|
|
62
|
+
return '<server state="stopped"/>';
|
|
63
|
+
const metadata = readMetadata(paths.store);
|
|
64
|
+
return renderNode({ element: 'server', attributes: { root, state: state.state, indexedCommit: metadata?.indexedCommit, indexDuration: metadata === null ? undefined : `${Math.round(metadata.indexDurationMs)}ms`, index: state.index, declarations: state.declarations } });
|
|
65
|
+
}
|
|
66
|
+
function rootFlag(argv) {
|
|
67
|
+
for (let index = 0; index < argv.length; index++) {
|
|
68
|
+
if (argv[index] === '--root')
|
|
69
|
+
return argv[index + 1];
|
|
70
|
+
if (argv[index]?.startsWith('--root='))
|
|
71
|
+
return argv[index].slice('--root='.length);
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
function emit(completion, json) {
|
|
76
|
+
const rendered = renderCompletion(completion, json);
|
|
77
|
+
if (rendered.stdout)
|
|
78
|
+
process.stdout.write(rendered.stdout);
|
|
79
|
+
process.stderr.write(rendered.stderr.endsWith('\n') ? rendered.stderr : rendered.stderr + '\n');
|
|
80
|
+
return rendered.exitCode;
|
|
81
|
+
}
|
|
82
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
|
|
83
|
+
const completion = { category: 'tool', code: 'internal', message: error instanceof Error ? error.message : String(error) };
|
|
84
|
+
const rendered = renderCompletion(completion, false);
|
|
85
|
+
if (rendered.stdout)
|
|
86
|
+
process.stdout.write(rendered.stdout);
|
|
87
|
+
process.stderr.write(rendered.stderr.endsWith('\n') ? rendered.stderr : rendered.stderr + '\n');
|
|
88
|
+
process.exit(rendered.exitCode);
|
|
89
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { matchesGlob, Workspace } from '../../core/workspace.js';
|
|
4
|
+
import { diagnosticsForFiles } from '../../ts7/checker.js';
|
|
5
|
+
const handler = async (context, invocation) => {
|
|
6
|
+
const started = performance.now();
|
|
7
|
+
try {
|
|
8
|
+
await context.projects.bootstrap();
|
|
9
|
+
const paths = invocation.input.args;
|
|
10
|
+
const files = paths.length > 0 ? await explicitFiles(context, invocation, paths) : undefined;
|
|
11
|
+
const diagnostics = diagnosticsForFiles(context.program, context.root, await diagnosticScopes(context, files));
|
|
12
|
+
const filtered = finish(diagnostics, invocation);
|
|
13
|
+
const limit = invocation.input.flags['--limit'];
|
|
14
|
+
const shown = limit === undefined ? filtered : filtered.slice(0, limit);
|
|
15
|
+
const capped = shown.length < filtered.length;
|
|
16
|
+
const errors = filtered.filter((diagnostic) => diagnostic.severity === 'error').length;
|
|
17
|
+
const warnings = filtered.filter((diagnostic) => diagnostic.severity === 'warning').length;
|
|
18
|
+
const suggestions = filtered.filter((diagnostic) => diagnostic.severity === 'suggestion').length;
|
|
19
|
+
return {
|
|
20
|
+
data: { element: 'check', attributes: { scope: paths.length === 0 ? 'project' : paths.join(' '), count: filtered.length, shown: capped ? shown.length : undefined, errors, warnings, suggestions, ms: Math.round(performance.now() - started) }, children: shown.map(renderDiagnostic) },
|
|
21
|
+
count: filtered.length,
|
|
22
|
+
receipt: capped
|
|
23
|
+
? `${shown.length} of ${filtered.length} check rows shown, limit ${limit}, ${Math.round(performance.now() - started)} ms`
|
|
24
|
+
: `${shown.length} diagnostic${shown.length === 1 ? '' : 's'}, ${Math.round(performance.now() - started)} ms`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return failure(error);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
async function explicitFiles(context, invocation, inputs) {
|
|
32
|
+
const workspace = new Workspace(context.root, null, 'none');
|
|
33
|
+
const candidates = workspace.files().map((file) => path.join(context.root, file));
|
|
34
|
+
const files = [];
|
|
35
|
+
for (const input of inputs) {
|
|
36
|
+
const glob = hasGlob(input);
|
|
37
|
+
const matches = glob
|
|
38
|
+
? candidates.filter((file) => matchesGlob(relative(invocation.cwd, file), input))
|
|
39
|
+
: [path.resolve(invocation.cwd, input)];
|
|
40
|
+
if (matches.length === 0 || !glob && !existsSync(matches[0]))
|
|
41
|
+
throw invocationFailure('no-such-path', `${input} does not exist.`, input, 'an existing TypeScript file or glob with matches');
|
|
42
|
+
for (const file of matches) {
|
|
43
|
+
if (!existsSync(file))
|
|
44
|
+
throw invocationFailure('no-such-path', `${input} does not exist.`, input, 'an existing TypeScript file');
|
|
45
|
+
if (!await context.projects.membership(file))
|
|
46
|
+
throw invocationFailure('not-in-program', `${relative(context.root, file)} exists but is not part of a TypeScript project under ${context.root}.`, input, 'a file listed by a project tsconfig.json');
|
|
47
|
+
if (!files.includes(file))
|
|
48
|
+
files.push(file);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
53
|
+
async function diagnosticScopes(context, files) {
|
|
54
|
+
const candidates = files ?? [...new Set(context.projects.records().flatMap((project) => [...project.files]))];
|
|
55
|
+
const scopes = new Map();
|
|
56
|
+
for (const file of candidates) {
|
|
57
|
+
const project = await context.projects.membership(file);
|
|
58
|
+
if (!project)
|
|
59
|
+
continue;
|
|
60
|
+
const owned = scopes.get(project.config) ?? [];
|
|
61
|
+
owned.push(file);
|
|
62
|
+
scopes.set(project.config, owned);
|
|
63
|
+
}
|
|
64
|
+
return [...scopes].map(([config, owned]) => ({ config, files: owned }));
|
|
65
|
+
}
|
|
66
|
+
function finish(diagnostics, invocation) {
|
|
67
|
+
const errorsOnly = invocation.input.flags['--errors-only'] === true;
|
|
68
|
+
const include = invocation.input.flags['--in'] ?? [];
|
|
69
|
+
const exclude = invocation.input.flags['--exclude'] ?? [];
|
|
70
|
+
return diagnostics
|
|
71
|
+
.filter((diagnostic) => !errorsOnly || diagnostic.severity === 'error')
|
|
72
|
+
.filter((diagnostic) => (include.length === 0 || include.some((glob) => matchesGlob(diagnostic.file, glob))) && !exclude.some((glob) => matchesGlob(diagnostic.file, glob)))
|
|
73
|
+
.sort(compare);
|
|
74
|
+
}
|
|
75
|
+
function renderDiagnostic(diagnostic) {
|
|
76
|
+
return { element: 'diagnostic', attributes: { at: `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}`, severity: diagnostic.severity, code: diagnostic.code }, text: diagnostic.message };
|
|
77
|
+
}
|
|
78
|
+
function compare(left, right) {
|
|
79
|
+
return (left.file < right.file ? -1 : left.file > right.file ? 1 : 0) || left.line - right.line || left.column - right.column || left.code - right.code;
|
|
80
|
+
}
|
|
81
|
+
function hasGlob(value) { return /[*?[{]/.test(value); }
|
|
82
|
+
function relative(root, file) { return path.relative(root, file).split(path.sep).join('/'); }
|
|
83
|
+
function invocationFailure(code, message, _received, expected) {
|
|
84
|
+
return { category: 'invocation', code, problems: [{ field: 'path', received: message, expected, next: 'Pass a file listed by the loaded TypeScript project.' }] };
|
|
85
|
+
}
|
|
86
|
+
function failure(error) {
|
|
87
|
+
if (isCompletionError(error))
|
|
88
|
+
return error;
|
|
89
|
+
return { category: 'tool', code: 'typescript-error', message: 'TypeScript could not answer the diagnostic query.', received: error instanceof Error ? error.message : String(error), expected: 'successful TypeScript Program diagnostics', next: 'Read the server log, then retry.' };
|
|
90
|
+
}
|
|
91
|
+
function isCompletionError(value) {
|
|
92
|
+
return typeof value === 'object' && value !== null && 'category' in value;
|
|
93
|
+
}
|
|
94
|
+
export default handler;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DeclarationError } from '../../core/declarations.js';
|
|
2
|
+
import { matchesPathFilters } from '../../core/receipt.js';
|
|
3
|
+
import { query } from '../../store/query.js';
|
|
4
|
+
const handler = async (context, invocation) => {
|
|
5
|
+
await context.projects.bootstrap();
|
|
6
|
+
const started = performance.now();
|
|
7
|
+
const name = invocation.input.args[0];
|
|
8
|
+
const flags = invocation.input.flags;
|
|
9
|
+
const prefix = flags['--prefix'] === true;
|
|
10
|
+
const qualified = name.includes('.');
|
|
11
|
+
const rows = new Map();
|
|
12
|
+
for (const declaration of declarations(context)) {
|
|
13
|
+
const candidate = qualified ? declaration.qualifiedName : declaration.name;
|
|
14
|
+
if (declaration.kind === 'module' || !(prefix ? candidate.startsWith(name) : candidate === name) || !keep(declaration, flags))
|
|
15
|
+
continue;
|
|
16
|
+
rows.set(declaration.address, declaration);
|
|
17
|
+
}
|
|
18
|
+
for (const alias of aliases(context)) {
|
|
19
|
+
const candidate = alias.qualifiedName;
|
|
20
|
+
if (!(prefix ? candidate.startsWith(name) : candidate === name) || !keep(alias, flags))
|
|
21
|
+
continue;
|
|
22
|
+
rows.set(alias.address, alias);
|
|
23
|
+
}
|
|
24
|
+
const sorted = [...rows.values()].sort((a, b) => a.path.localeCompare(b.path) || numericAddress(a.address, b.address));
|
|
25
|
+
const limit = flags['--limit'];
|
|
26
|
+
const shown = limit === undefined ? sorted : sorted.slice(0, limit);
|
|
27
|
+
const capped = shown.length < sorted.length;
|
|
28
|
+
return {
|
|
29
|
+
data: { element: 'find', attributes: { name, count: sorted.length, shown: capped ? shown.length : undefined }, text: shown.map((row) => `${row.address} ${row.kind}${row.exported ? ' export' : ''} ${row.qualifiedName}`).join('\n') },
|
|
30
|
+
count: sorted.length,
|
|
31
|
+
receipt: capped ? `${shown.length} of ${sorted.length} find rows shown, limit ${limit}, ${Math.round(performance.now() - started)} ms` : `${shown.length} find row${shown.length === 1 ? '' : 's'}, ${Math.round(performance.now() - started)} ms`,
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
function declarations(context) {
|
|
35
|
+
if (!context.store)
|
|
36
|
+
throw new DeclarationError('not-in-program', 'The TypeScript index is not available yet.');
|
|
37
|
+
return query(context.store, 'MATCH (d:Declaration) RETURN d.at AS at, d.file AS file, d.kind AS kind, d.exported AS exported, d.name AS name, d.qname AS qname').map((row) => ({ address: String(row.at), path: String(row.file), kind: String(row.kind), exported: Boolean(row.exported), name: String(row.name), qualifiedName: String(row.qname) }));
|
|
38
|
+
}
|
|
39
|
+
function aliases(context) {
|
|
40
|
+
if (!context.store)
|
|
41
|
+
throw new DeclarationError('not-in-program', 'The TypeScript index is not available yet.');
|
|
42
|
+
return query(context.store, 'MATCH (m:Declaration)-[e:EXPORTS]->(d:Declaration) RETURN m.file AS fromFile, e.name AS name, e.line AS line, e.col AS col, d.file AS file, d.line AS declarationLine, d.col AS declarationCol').flatMap((row) => {
|
|
43
|
+
const fromFile = String(row.fromFile);
|
|
44
|
+
const line = Number(row.line);
|
|
45
|
+
const col = Number(row.col);
|
|
46
|
+
if (fromFile === String(row.file) && line === Number(row.declarationLine) && col === Number(row.declarationCol))
|
|
47
|
+
return [];
|
|
48
|
+
return [{ address: `${fromFile}:${line}:${col}`, path: fromFile, kind: 'alias', exported: true, name: String(row.name), qualifiedName: String(row.name) }];
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function keep(row, flags) {
|
|
52
|
+
const kinds = flags['--kind'];
|
|
53
|
+
return (!kinds || kinds.includes(row.kind)) && matchesPathFilters(row.path, { in: flags['--in'], exclude: flags['--exclude'] });
|
|
54
|
+
}
|
|
55
|
+
function numericAddress(a, b) { const [aLine, aColumn] = a.split(':').slice(-2).map(Number); const [bLine, bColumn] = b.split(':').slice(-2).map(Number); return aLine - bLine || aColumn - bColumn; }
|
|
56
|
+
export default handler;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { matchesPathFilters } from '../../core/receipt.js';
|
|
2
|
+
import { normalKind } from '../../read/read.js';
|
|
3
|
+
import { mergeGraphs } from '../../relations/graph.js';
|
|
4
|
+
import { implementations, references, traverse } from '../../relations/store-relations.js';
|
|
5
|
+
import { renderGraph } from '../../relations/mermaid.js';
|
|
6
|
+
const relationOrder = ['refs', 'callers', 'calls', 'impl'];
|
|
7
|
+
const handler = async (context, invocation) => {
|
|
8
|
+
const started = performance.now();
|
|
9
|
+
const selected = selectedRelations(invocation.input.flags['--only']);
|
|
10
|
+
const each = invocation.input.flags['--each'];
|
|
11
|
+
const depth = invocation.input.flags['--depth'];
|
|
12
|
+
const graphMode = invocation.input.flags['--graph'] === true;
|
|
13
|
+
const filters = { in: strings(invocation.input.flags['--in']), exclude: strings(invocation.input.flags['--exclude']) };
|
|
14
|
+
const seeds = await context.resolver.resolveAll(invocation.input.args);
|
|
15
|
+
if (graphMode)
|
|
16
|
+
return graphAnswer(context, seeds, selected, depth, filters, started);
|
|
17
|
+
const blocks = [];
|
|
18
|
+
let answers = 0;
|
|
19
|
+
const receiptParts = [];
|
|
20
|
+
for (const seed of seeds) {
|
|
21
|
+
const header = await signature(context, seed);
|
|
22
|
+
const relations = [];
|
|
23
|
+
for (const relation of selected) {
|
|
24
|
+
if (relation === 'refs') {
|
|
25
|
+
const rows = await references(context, seed, filters);
|
|
26
|
+
answers += rows.length;
|
|
27
|
+
relations.push(refBlock(rows, each));
|
|
28
|
+
receiptParts.push(`${rows.length} refs`);
|
|
29
|
+
}
|
|
30
|
+
else if (relation === 'impl') {
|
|
31
|
+
const rows = (await implementations(context, seed)).filter((item) => matchesPathFilters(item.location.path, filters));
|
|
32
|
+
answers += rows.length;
|
|
33
|
+
relations.push(implBlock(rows, each));
|
|
34
|
+
receiptParts.push(`${rows.length} impl`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const result = await traverse(context, [seed], relation, depth, filters);
|
|
38
|
+
answers += result.rows.length;
|
|
39
|
+
relations.push(traversalBlock(relation, result, each));
|
|
40
|
+
receiptParts.push(`${result.rows.length} ${relation}${result.excluded ? `, ${result.excluded} excluded` : ''}${result.unresolved ? `, ${result.unresolved} unresolved` : ''}${result.dispatch ? `, ${result.dispatch} dispatch` : ''}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
blocks.push({ element: 'inspect', attributes: { symbol: seed.address, name: seed.name, kind: normalKind(seed.kind), depth }, text: header, children: relations });
|
|
44
|
+
}
|
|
45
|
+
const capped = selected.some((relation) => relation === 'refs' || relation === 'impl' || relation === 'calls' || relation === 'callers') && each > 0;
|
|
46
|
+
const rerun = capped ? `; re-run tsym inspect ${invocation.input.args.join(' ')} --only ${selected[0]} --each 0 for all rows` : '';
|
|
47
|
+
return { data: blocks, count: answers, receipt: `${invocation.input.args.map((input, index) => `${seeds[index].name}: ${receiptParts.slice(index * selected.length, (index + 1) * selected.length).join(', ')}`).join('; ')}, ${Math.round(performance.now() - started)} ms${rerun}` };
|
|
48
|
+
};
|
|
49
|
+
async function graphAnswer(context, seeds, selected, depth, filters, started) {
|
|
50
|
+
const relations = [];
|
|
51
|
+
for (const relation of selected)
|
|
52
|
+
if (relation === 'callers' || relation === 'calls')
|
|
53
|
+
relations.push(await traverse(context, seeds, relation, depth, filters));
|
|
54
|
+
const graph = mergeGraphs(relations.map((result) => result.graph));
|
|
55
|
+
const count = relations.reduce((sum, result) => sum + result.rows.length, 0);
|
|
56
|
+
const unresolved = new Set(graph.unresolved.map((site) => site.siteId)).size;
|
|
57
|
+
const dispatch = new Set([...graph.unresolved.filter((site) => site.dispatch).map((site) => site.siteId), ...graph.edges.filter((edge) => edge.kind === 'impl').flatMap((edge) => edge.siteIds)]).size;
|
|
58
|
+
const excluded = relations.reduce((sum, result) => sum + result.excluded, 0);
|
|
59
|
+
return { data: { element: 'inspect', attributes: { graph: true, count }, text: renderGraph(graph).replace(/\n$/, '') }, count, receipt: `${count} traversal rows${excluded ? `, ${excluded} excluded` : ''}${unresolved ? `, ${unresolved} unresolved` : ''}${dispatch ? `, ${dispatch} dispatch` : ''}, ${Math.round(performance.now() - started)} ms` };
|
|
60
|
+
}
|
|
61
|
+
async function signature(_context, record) { return `${normalKind(record.kind)} ${record.qualifiedName}`; }
|
|
62
|
+
function relationBlock(element, rows, path, each, format) {
|
|
63
|
+
if (!rows.length)
|
|
64
|
+
return { element, attributes: { count: 0 } };
|
|
65
|
+
const shown = shownRows(rows, each);
|
|
66
|
+
return { element, attributes: { count: rows.length, files: new Set(rows.map(path)).size, shown: shown.length < rows.length ? shown.length : undefined }, text: shown.map(format).join('\n') };
|
|
67
|
+
}
|
|
68
|
+
function refBlock(rows, each) {
|
|
69
|
+
return relationBlock('refs', rows, (row) => row.path, each, (row) => `${row.path}:${row.line}:${row.col} ${row.kind} ${row.qualifiedName} ${row.role}`);
|
|
70
|
+
}
|
|
71
|
+
function implBlock(rows, each) {
|
|
72
|
+
return relationBlock('impl', rows, (row) => row.location.path, each, (row) => `${row.address} ${normalKind(row.kind)} ${row.qualifiedName}`);
|
|
73
|
+
}
|
|
74
|
+
function traversalBlock(relation, result, each) {
|
|
75
|
+
return relationBlock(relation, result.rows, (row) => row.path, each, formatTraversal);
|
|
76
|
+
}
|
|
77
|
+
function formatTraversal(row) { return `${row.id} ${row.depth} ${normalKind(row.kind)} ${row.qualifiedName} (${row.callSites} ${row.callSites === 1 ? 'site' : 'sites'}${row.edgeKinds.includes('impl') ? ', impl' : ''})`; }
|
|
78
|
+
function shownRows(rows, each) { return each === 0 ? rows : rows.slice(0, each); }
|
|
79
|
+
function selectedRelations(value) { const values = Array.isArray(value) ? value : relationOrder; return relationOrder.filter((relation) => values.includes(relation)); }
|
|
80
|
+
function strings(value) { return Array.isArray(value) ? value : value === undefined ? [] : [String(value)]; }
|
|
81
|
+
export default handler;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DeclarationError } from '../../core/declarations.js';
|
|
4
|
+
import { matchesPathFilters } from '../../core/receipt.js';
|
|
5
|
+
import { displayPath, scopeFiles } from '../../read/read.js';
|
|
6
|
+
import { query } from '../../store/query.js';
|
|
7
|
+
const containers = new Set(['class', 'interface', 'enum', 'namespace']);
|
|
8
|
+
const handler = async (context, invocation) => {
|
|
9
|
+
await context.projects.bootstrap();
|
|
10
|
+
const started = performance.now();
|
|
11
|
+
const flags = invocation.input.flags;
|
|
12
|
+
const depth = flags['--depth'];
|
|
13
|
+
const scope = await scopeFiles(context, invocation.cwd, invocation.input.args);
|
|
14
|
+
const selected = new Set(scope.files.map((file) => displayPath(context.root, file)).filter((file) => matchesPathFilters(file, { in: flags['--in'], exclude: flags['--exclude'] })));
|
|
15
|
+
const declarations = indexed(context).filter((declaration) => selected.has(declaration.path) && declaration.kind !== 'module');
|
|
16
|
+
const imports = flags['--imports'] === true && flags['--exported'] !== true && !flags['--kind'] ? indexedImports(context).filter((item) => selected.has(item.path)) : [];
|
|
17
|
+
const directory = invocation.input.args.length === 0 ? context.root : invocation.input.args.map((input) => path.resolve(invocation.cwd, input)).find((candidate) => {
|
|
18
|
+
try {
|
|
19
|
+
return statSync(candidate).isDirectory();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
const requestedDirectory = directory !== undefined;
|
|
26
|
+
const files = new Map();
|
|
27
|
+
for (const declaration of declarations)
|
|
28
|
+
files.set(declaration.path, [...(files.get(declaration.path) ?? []), declaration]);
|
|
29
|
+
const importsByFile = new Map();
|
|
30
|
+
for (const item of imports) {
|
|
31
|
+
files.set(item.path, files.get(item.path) ?? []);
|
|
32
|
+
importsByFile.set(item.path, [...(importsByFile.get(item.path) ?? []), item]);
|
|
33
|
+
}
|
|
34
|
+
const rows = requestedDirectory && depth === 1
|
|
35
|
+
? containerRows(context.root, files, importsByFile, flags, directory ?? context.root)
|
|
36
|
+
: [...files.entries()].flatMap(([file, items]) => [...select(items, flags, requestedDirectory ? depth - 1 : depth).map((symbol) => symbolRow(symbol, items, flags)), ...(importsByFile.get(file) ?? []).map(importRow)].sort(byPosition));
|
|
37
|
+
const limit = flags['--limit'];
|
|
38
|
+
const shown = limit === undefined ? rows : rows.slice(0, limit);
|
|
39
|
+
const capped = shown.length < rows.length;
|
|
40
|
+
const symbols = [...files.values()].flatMap((items) => select(items, flags, requestedDirectory ? depth - 1 : depth)).length + imports.length;
|
|
41
|
+
const receipt = `${capped ? `${shown.length} of ${rows.length} outline rows shown, limit ${limit}` : `${shown.length} outline row${shown.length === 1 ? '' : 's'}`}${scope.notInProgram > 0 ? `, ${scope.notInProgram} not in program` : ''}, ${Math.round(performance.now() - started)} ms`;
|
|
42
|
+
return { data: { element: 'outline', attributes: { path: invocation.input.args.join(' ') || '.', depth, symbols, files: files.size, count: rows.length, shown: capped ? shown.length : undefined }, text: shown.map((row) => row.text).join('\n') }, count: rows.length, receipt };
|
|
43
|
+
};
|
|
44
|
+
function indexed(context) {
|
|
45
|
+
if (!context.store)
|
|
46
|
+
throw new DeclarationError('not-in-program', 'The TypeScript index is not available yet.');
|
|
47
|
+
return query(context.store, 'MATCH (d:Declaration) RETURN d.key AS key, d.at AS at, d.name AS name, d.qname AS qname, d.kind AS kind, d.exported AS exported, d.file AS file, d.line AS line, d.col AS col, d.lines AS lines, d.container AS container').map(declaration);
|
|
48
|
+
}
|
|
49
|
+
function declaration(row) {
|
|
50
|
+
return { key: String(row.key), address: String(row.at), path: String(row.file), name: String(row.name), qualifiedName: String(row.qname), kind: String(row.kind), exported: Boolean(row.exported), line: Number(row.line), col: Number(row.col), lines: Number(row.lines), container: row.container === null ? null : String(row.container) };
|
|
51
|
+
}
|
|
52
|
+
function indexedImports(context) {
|
|
53
|
+
if (!context.store)
|
|
54
|
+
throw new DeclarationError('not-in-program', 'The TypeScript index is not available yet.');
|
|
55
|
+
return query(context.store, 'MATCH (m:Declaration)-[e:IMPORTS]->(d:Declaration) RETURN m.file AS file, e.line AS line, e.names AS names, d.file AS target UNION MATCH (m:Declaration)-[e:IMPORTS]->(external:External) RETURN m.file AS file, e.line AS line, e.names AS names, external.module AS target')
|
|
56
|
+
.map((row) => ({ path: String(row.file), line: Number(row.line), address: `${String(row.file)}:${Number(row.line)}:1`, name: String(row.names), target: String(row.target) }));
|
|
57
|
+
}
|
|
58
|
+
function select(items, flags, depth, members = flags['--members'] === true) {
|
|
59
|
+
const byContainer = new Map();
|
|
60
|
+
for (const item of items)
|
|
61
|
+
if (item.container)
|
|
62
|
+
byContainer.set(item.container, [...(byContainer.get(item.container) ?? []), item]);
|
|
63
|
+
const roots = items.filter((item) => item.container === `${item.path}#<module>`);
|
|
64
|
+
const out = [];
|
|
65
|
+
const visit = (item, remaining) => {
|
|
66
|
+
if (keep(item, flags))
|
|
67
|
+
out.push(item);
|
|
68
|
+
if ((containers.has(item.kind) && (members || remaining > 1)) || (item.kind === 'function' && !members && remaining > 1))
|
|
69
|
+
for (const child of byContainer.get(item.key) ?? [])
|
|
70
|
+
visit(child, members ? remaining : remaining - 1);
|
|
71
|
+
};
|
|
72
|
+
for (const item of roots)
|
|
73
|
+
visit(item, depth);
|
|
74
|
+
return out.sort(byPosition);
|
|
75
|
+
}
|
|
76
|
+
function keep(item, flags) {
|
|
77
|
+
const kinds = flags['--kind'];
|
|
78
|
+
return (!kinds || kinds.includes(item.kind)) && (flags['--exported'] !== true || item.exported);
|
|
79
|
+
}
|
|
80
|
+
function symbolRow(symbol, items, flags) {
|
|
81
|
+
const members = items.filter((item) => item.container === symbol.key && keep(item, flags)).length;
|
|
82
|
+
const memberCount = containers.has(symbol.kind) ? `, ${members} members` : '';
|
|
83
|
+
return { text: `${symbol.address} ${symbol.kind}${symbol.exported ? ' export' : ''} ${symbol.qualifiedName} (${symbol.lines}L${memberCount})`, line: symbol.line, col: symbol.col };
|
|
84
|
+
}
|
|
85
|
+
function importRow(item) { return { text: `${item.address} import ${item.name} ${item.target}`, line: item.line, col: 1 }; }
|
|
86
|
+
function containerRows(root, files, imports, flags, directory) {
|
|
87
|
+
const grouped = new Map();
|
|
88
|
+
for (const [file, items] of files) {
|
|
89
|
+
const relative = path.relative(directory, path.join(root, file)).split(path.sep).join('/');
|
|
90
|
+
const parts = relative.split('/');
|
|
91
|
+
const name = parts.length === 1 ? file : `${displayPath(root, path.join(directory, parts[0]))}/`;
|
|
92
|
+
grouped.set(name, [...(grouped.get(name) ?? []), { file, items }]);
|
|
93
|
+
}
|
|
94
|
+
return [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, groups]) => {
|
|
95
|
+
const count = groups.reduce((total, group) => total + select(group.items, flags, 1).length + (imports.get(group.file)?.length ?? 0), 0);
|
|
96
|
+
const fileCount = groups.length;
|
|
97
|
+
return { text: `${name} (${fileCount} ${fileCount === 1 ? 'file' : 'files'}, ${count} symbols)`, line: 0, col: 0 };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function byPosition(a, b) { return a.line - b.line || a.col - b.col; }
|
|
101
|
+
export default handler;
|