clast-ai 0.1.2 → 0.2.1
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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +11 -9
- package/README.md +468 -398
- package/bench/ab/onair-concept.json +22 -0
- package/bench/ab/onair-deadcode.json +7 -0
- package/bench/ab/onair-hard.json +32 -0
- package/bench/ab/onair-impact.json +17 -0
- package/bench/ab/onair-refactors.json +18 -0
- package/bench/ab/onair-survey.json +17 -0
- package/bench/ab/onair-tasks.json +20 -0
- package/bench/ab/onair.json +52 -0
- package/bench/ab/vscode-concept.json +22 -0
- package/bench/ab/vscode-hard.json +32 -0
- package/bench/ab/vscode-impact.json +17 -0
- package/bench/ab/vscode-refactors.json +18 -0
- package/bench/ab/vscode-tasks.json +20 -0
- package/bench/ab/vscode.json +52 -0
- package/bench/ab.mjs +213 -0
- package/bench/bench.mjs +98 -0
- package/bench/resolution.mjs +75 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +266 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/defaults.d.ts +12 -2
- package/dist/config/defaults.js +38 -31
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +26 -14
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +73 -13
- package/dist/db/index.js +481 -39
- package/dist/db/index.js.map +1 -1
- package/dist/engine.d.ts +15 -0
- package/dist/engine.js +28 -0
- package/dist/engine.js.map +1 -0
- package/dist/indexer/imports.d.ts +6 -0
- package/dist/indexer/imports.js +254 -0
- package/dist/indexer/imports.js.map +1 -0
- package/dist/indexer/index.d.ts +63 -0
- package/dist/indexer/index.js +360 -0
- package/dist/indexer/index.js.map +1 -0
- package/dist/indexer/process.d.ts +22 -0
- package/dist/indexer/process.js +42 -0
- package/dist/indexer/process.js.map +1 -0
- package/dist/indexer/scan.d.ts +43 -0
- package/dist/indexer/scan.js +223 -0
- package/dist/indexer/scan.js.map +1 -0
- package/dist/indexer/worker.d.ts +1 -0
- package/dist/indexer/worker.js +23 -0
- package/dist/indexer/worker.js.map +1 -0
- package/dist/llm/comment-generator.d.ts +17 -19
- package/dist/llm/comment-generator.js +46 -80
- package/dist/llm/comment-generator.js.map +1 -1
- package/dist/llm/index.d.ts +0 -1
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/prompts.d.ts +0 -1
- package/dist/llm/prompts.js +16 -16
- package/dist/migrate.d.ts +35 -0
- package/dist/migrate.js +194 -0
- package/dist/migrate.js.map +1 -0
- package/dist/parser/extractor.d.ts +21 -16
- package/dist/parser/extractor.js +700 -228
- package/dist/parser/extractor.js.map +1 -1
- package/dist/parser/index.d.ts +17 -21
- package/dist/parser/index.js +115 -171
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/langs/c.d.ts +3 -0
- package/dist/parser/langs/c.js +879 -0
- package/dist/parser/langs/c.js.map +1 -0
- package/dist/parser/langs/csharp.d.ts +2 -0
- package/dist/parser/langs/csharp.js +548 -0
- package/dist/parser/langs/csharp.js.map +1 -0
- package/dist/parser/langs/dart.d.ts +2 -0
- package/dist/parser/langs/dart.js +893 -0
- package/dist/parser/langs/dart.js.map +1 -0
- package/dist/parser/langs/go.d.ts +2 -0
- package/dist/parser/langs/go.js +455 -0
- package/dist/parser/langs/go.js.map +1 -0
- package/dist/parser/langs/java.d.ts +2 -0
- package/dist/parser/langs/java.js +390 -0
- package/dist/parser/langs/java.js.map +1 -0
- package/dist/parser/langs/php.d.ts +2 -0
- package/dist/parser/langs/php.js +486 -0
- package/dist/parser/langs/php.js.map +1 -0
- package/dist/parser/langs/python.d.ts +2 -0
- package/dist/parser/langs/python.js +216 -0
- package/dist/parser/langs/python.js.map +1 -0
- package/dist/parser/langs/ruby.d.ts +2 -0
- package/dist/parser/langs/ruby.js +602 -0
- package/dist/parser/langs/ruby.js.map +1 -0
- package/dist/parser/langs/rust.d.ts +2 -0
- package/dist/parser/langs/rust.js +931 -0
- package/dist/parser/langs/rust.js.map +1 -0
- package/dist/parser/langs/swift.d.ts +2 -0
- package/dist/parser/langs/swift.js +616 -0
- package/dist/parser/langs/swift.js.map +1 -0
- package/dist/parser/langs/typescript.d.ts +4 -0
- package/dist/parser/langs/typescript.js +535 -0
- package/dist/parser/langs/typescript.js.map +1 -0
- package/dist/parser/registry.d.ts +16 -0
- package/dist/parser/registry.js +87 -0
- package/dist/parser/registry.js.map +1 -0
- package/dist/parser/returns.d.ts +9 -0
- package/dist/parser/returns.js +152 -0
- package/dist/parser/returns.js.map +1 -0
- package/dist/parser/spec.d.ts +165 -0
- package/dist/parser/spec.js +188 -0
- package/dist/parser/spec.js.map +1 -0
- package/dist/query/arity.d.ts +27 -0
- package/dist/query/arity.js +187 -0
- package/dist/query/arity.js.map +1 -0
- package/dist/query/callgraph.d.ts +113 -0
- package/dist/query/callgraph.js +332 -0
- package/dist/query/callgraph.js.map +1 -0
- package/dist/query/format.d.ts +36 -0
- package/dist/query/format.js +136 -0
- package/dist/query/format.js.map +1 -0
- package/dist/query/hierarchy.d.ts +44 -0
- package/dist/query/hierarchy.js +441 -0
- package/dist/query/hierarchy.js.map +1 -0
- package/dist/query/resolve.d.ts +265 -0
- package/dist/query/resolve.js +1957 -0
- package/dist/query/resolve.js.map +1 -0
- package/dist/query/search.d.ts +35 -0
- package/dist/query/search.js +379 -0
- package/dist/query/search.js.map +1 -0
- package/dist/query/words.d.ts +30 -0
- package/dist/query/words.js +201 -0
- package/dist/query/words.js.map +1 -0
- package/dist/server/index.d.ts +1 -3
- package/dist/server/index.js +313 -73
- package/dist/server/index.js.map +1 -1
- package/dist/tools/context.d.ts +35 -0
- package/dist/tools/context.js +79 -0
- package/dist/tools/context.js.map +1 -0
- package/dist/tools/explore.d.ts +51 -0
- package/dist/tools/explore.js +779 -0
- package/dist/tools/explore.js.map +1 -0
- package/dist/tools/impact.d.ts +21 -0
- package/dist/tools/impact.js +743 -0
- package/dist/tools/impact.js.map +1 -0
- package/dist/tools/search.d.ts +59 -0
- package/dist/tools/search.js +843 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/stats.d.ts +26 -0
- package/dist/tools/stats.js +604 -0
- package/dist/tools/stats.js.map +1 -0
- package/dist/types.d.ts +95 -40
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/watcher/index.d.ts +18 -16
- package/dist/watcher/index.js +94 -77
- package/dist/watcher/index.js.map +1 -1
- package/package.json +21 -24
- package/plugin/server.mjs +72 -0
- package/skills/audit/SKILL.md +37 -0
- package/skills/deadcode/SKILL.md +33 -0
- package/skills/explore/SKILL.md +54 -0
- package/skills/impact/SKILL.md +24 -0
- package/skills/status/SKILL.md +11 -0
- package/src/cli.ts +265 -0
- package/src/config/defaults.ts +50 -32
- package/src/config/index.ts +23 -14
- package/src/db/index.ts +540 -43
- package/src/engine.ts +30 -0
- package/src/indexer/imports.ts +241 -0
- package/src/indexer/index.ts +369 -0
- package/src/indexer/process.ts +63 -0
- package/src/indexer/scan.ts +226 -0
- package/src/indexer/worker.ts +24 -0
- package/src/llm/comment-generator.ts +52 -103
- package/src/llm/index.ts +80 -80
- package/src/llm/prompts.ts +95 -95
- package/src/migrate.ts +213 -0
- package/src/parser/extractor.ts +643 -256
- package/src/parser/index.ts +105 -205
- package/src/parser/langs/c.ts +860 -0
- package/src/parser/langs/csharp.ts +519 -0
- package/src/parser/langs/dart.ts +820 -0
- package/src/parser/langs/go.ts +437 -0
- package/src/parser/langs/java.ts +361 -0
- package/src/parser/langs/php.ts +450 -0
- package/src/parser/langs/python.ts +206 -0
- package/src/parser/langs/ruby.ts +554 -0
- package/src/parser/langs/rust.ts +904 -0
- package/src/parser/langs/swift.ts +590 -0
- package/src/parser/langs/typescript.ts +499 -0
- package/src/parser/registry.ts +88 -0
- package/src/parser/returns.ts +137 -0
- package/src/parser/spec.ts +309 -0
- package/src/query/arity.ts +173 -0
- package/src/query/callgraph.ts +473 -0
- package/src/query/format.ts +136 -0
- package/src/query/hierarchy.ts +447 -0
- package/src/query/resolve.ts +1972 -0
- package/src/query/search.ts +430 -0
- package/src/query/words.ts +183 -0
- package/src/server/index.ts +390 -79
- package/src/tools/context.ts +82 -0
- package/src/tools/explore.ts +860 -0
- package/src/tools/impact.ts +748 -0
- package/src/tools/search.ts +886 -0
- package/src/tools/stats.ts +633 -0
- package/src/types.ts +113 -45
- package/src/version.ts +2 -0
- package/src/watcher/index.ts +82 -88
- package/tsconfig.json +6 -3
- package/.mcp.json +0 -8
- package/commands/clast-status.md +0 -9
- package/dist/config/defaults.d.ts.map +0 -1
- package/dist/config/index.d.ts.map +0 -1
- package/dist/db/index.d.ts.map +0 -1
- package/dist/db/queries.d.ts +0 -44
- package/dist/db/queries.d.ts.map +0 -1
- package/dist/db/queries.js +0 -251
- package/dist/db/queries.js.map +0 -1
- package/dist/db/schema.d.ts +0 -3
- package/dist/db/schema.d.ts.map +0 -1
- package/dist/db/schema.js +0 -94
- package/dist/db/schema.js.map +0 -1
- package/dist/db/types.d.ts +0 -44
- package/dist/db/types.d.ts.map +0 -1
- package/dist/db/types.js +0 -2
- package/dist/db/types.js.map +0 -1
- package/dist/llm/comment-generator.d.ts.map +0 -1
- package/dist/llm/index.d.ts.map +0 -1
- package/dist/llm/prompts.d.ts.map +0 -1
- package/dist/parser/call-resolver.d.ts +0 -15
- package/dist/parser/call-resolver.d.ts.map +0 -1
- package/dist/parser/call-resolver.js +0 -16
- package/dist/parser/call-resolver.js.map +0 -1
- package/dist/parser/extractor.d.ts.map +0 -1
- package/dist/parser/index.d.ts.map +0 -1
- package/dist/parser/languages.d.ts +0 -11
- package/dist/parser/languages.d.ts.map +0 -1
- package/dist/parser/languages.js +0 -74
- package/dist/parser/languages.js.map +0 -1
- package/dist/server/index.d.ts.map +0 -1
- package/dist/server/register-tools.d.ts +0 -7
- package/dist/server/register-tools.d.ts.map +0 -1
- package/dist/server/register-tools.js +0 -17
- package/dist/server/register-tools.js.map +0 -1
- package/dist/server/tools/clast-call-graph.d.ts +0 -4
- package/dist/server/tools/clast-call-graph.d.ts.map +0 -1
- package/dist/server/tools/clast-call-graph.js +0 -70
- package/dist/server/tools/clast-call-graph.js.map +0 -1
- package/dist/server/tools/clast-comment.d.ts +0 -5
- package/dist/server/tools/clast-comment.d.ts.map +0 -1
- package/dist/server/tools/clast-comment.js +0 -64
- package/dist/server/tools/clast-comment.js.map +0 -1
- package/dist/server/tools/clast-file-summary.d.ts +0 -4
- package/dist/server/tools/clast-file-summary.d.ts.map +0 -1
- package/dist/server/tools/clast-file-summary.js +0 -42
- package/dist/server/tools/clast-file-summary.js.map +0 -1
- package/dist/server/tools/clast-get-context.d.ts +0 -4
- package/dist/server/tools/clast-get-context.d.ts.map +0 -1
- package/dist/server/tools/clast-get-context.js +0 -80
- package/dist/server/tools/clast-get-context.js.map +0 -1
- package/dist/server/tools/clast-reindex.d.ts +0 -5
- package/dist/server/tools/clast-reindex.d.ts.map +0 -1
- package/dist/server/tools/clast-reindex.js +0 -36
- package/dist/server/tools/clast-reindex.js.map +0 -1
- package/dist/server/tools/clast-search.d.ts +0 -4
- package/dist/server/tools/clast-search.d.ts.map +0 -1
- package/dist/server/tools/clast-search.js +0 -29
- package/dist/server/tools/clast-search.js.map +0 -1
- package/dist/server/tools/clast-status.d.ts +0 -5
- package/dist/server/tools/clast-status.d.ts.map +0 -1
- package/dist/server/tools/clast-status.js +0 -23
- package/dist/server/tools/clast-status.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/watcher/gitignore.d.ts +0 -9
- package/dist/watcher/gitignore.d.ts.map +0 -1
- package/dist/watcher/gitignore.js +0 -38
- package/dist/watcher/gitignore.js.map +0 -1
- package/dist/watcher/index.d.ts.map +0 -1
- package/skills/clast-explore/SKILL.md +0 -41
- package/src/db/queries.ts +0 -332
- package/src/db/schema.ts +0 -106
- package/src/db/types.ts +0 -47
- package/src/parser/call-resolver.ts +0 -17
- package/src/parser/languages.ts +0 -86
- package/src/server/register-tools.ts +0 -30
- package/src/server/tools/clast-call-graph.ts +0 -82
- package/src/server/tools/clast-comment.ts +0 -76
- package/src/server/tools/clast-file-summary.ts +0 -59
- package/src/server/tools/clast-get-context.ts +0 -97
- package/src/server/tools/clast-reindex.ts +0 -52
- package/src/server/tools/clast-search.ts +0 -38
- package/src/server/tools/clast-status.ts +0 -37
- package/src/watcher/gitignore.ts +0 -42
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { SYMBOL_COLUMNS, SYMBOL_FROM } from '../db/index.js';
|
|
5
|
+
import { callersOf, groupCallers, type CallerGroup } from '../query/callgraph.js';
|
|
6
|
+
import { firstLine, isTestPath, isTestSite, isVendoredPath, loc, plural, symLine } from '../query/format.js';
|
|
7
|
+
import { directSubtypes, directSupertypes } from '../query/hierarchy.js';
|
|
8
|
+
import { findSymbols, langFamily, parseSymbolRef, pathMatches } from '../query/resolve.js';
|
|
9
|
+
import { CONTAINER_KINDS, type SymbolRow } from '../types.js';
|
|
10
|
+
import { ToolContext } from './context.js';
|
|
11
|
+
import { otherTextFiles } from './explore.js';
|
|
12
|
+
import { containerNote, disambiguate, notFound } from './search.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// clast_impact: what has to change with a symbol, who reaches it, which tests to run
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface ImpactArgs {
|
|
19
|
+
name: string;
|
|
20
|
+
path?: string;
|
|
21
|
+
/** How many caller levels to follow. Default 3. */
|
|
22
|
+
depth?: number;
|
|
23
|
+
/** Max lines per section. Default 30. */
|
|
24
|
+
limit?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const IMPACT_BUDGET_MS = 8000;
|
|
28
|
+
const IMPACT_MAX_EXPANSIONS = 400;
|
|
29
|
+
|
|
30
|
+
function dirOf(path: string, depth = 3): string {
|
|
31
|
+
const parts = path.split('/');
|
|
32
|
+
parts.pop();
|
|
33
|
+
return parts.slice(0, depth).join('/') || '.';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `a.ts: f :12, g :40 (x2)` lines, one per file, in the order of the groups. */
|
|
37
|
+
function byFile(groups: CallerGroup[], limit: number, out: string[]): void {
|
|
38
|
+
const files = new Map<string, string[]>();
|
|
39
|
+
for (const g of groups) {
|
|
40
|
+
const list = files.get(g.path) ?? [];
|
|
41
|
+
list.push(`${g.caller ? g.caller.qname : '<file level>'} :${g.line}${g.sites > 1 ? ` (x${g.sites})` : ''}${g.certain ? '' : ' ?'}`);
|
|
42
|
+
files.set(g.path, list);
|
|
43
|
+
}
|
|
44
|
+
let n = 0;
|
|
45
|
+
for (const [path, list] of files) {
|
|
46
|
+
if (n++ >= limit) {
|
|
47
|
+
out.push(` ... ${files.size - limit} more files`);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
out.push(` ${path}: ${list.slice(0, 8).join(', ')}${list.length > 8 ? `, +${list.length - 8} more` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function impact(ctx: ToolContext, args: ImpactArgs): Promise<string> {
|
|
55
|
+
const note = await ctx.ready();
|
|
56
|
+
if (!args.name || !args.name.trim()) return 'error: name is required';
|
|
57
|
+
const matches = findSymbols(ctx.db, args.name, { path: args.path });
|
|
58
|
+
if (matches.length === 0) return note + notFound(ctx, args.name, args.path).join('\n');
|
|
59
|
+
const amb = disambiguate(ctx, args.name, args.path, matches);
|
|
60
|
+
if (amb.stop) return note + amb.lines.join('\n');
|
|
61
|
+
const sym = matches[0]!;
|
|
62
|
+
const depth = Math.max(1, Math.min(args.depth ?? 3, 6));
|
|
63
|
+
const limit = Math.max(1, Math.min(args.limit ?? 30, 500));
|
|
64
|
+
const started = performance.now();
|
|
65
|
+
const out: string[] = [symLine(sym)];
|
|
66
|
+
if (sym.signature) out.push(` ${firstLine(sym.signature, 160)}`);
|
|
67
|
+
out.push(...amb.lines);
|
|
68
|
+
out.push(...containerNote(args.name, sym));
|
|
69
|
+
|
|
70
|
+
const isType = CONTAINER_KINDS.has(sym.kind) || sym.kind === 'type';
|
|
71
|
+
const overloads = matches.filter(
|
|
72
|
+
m => m.id !== sym.id && m.file_id === sym.file_id && m.qname === sym.qname && m.kind === sym.kind
|
|
73
|
+
);
|
|
74
|
+
const oneOverload = parseSymbolRef(args.name).line !== undefined;
|
|
75
|
+
|
|
76
|
+
// 1. What shares the contract: the same member on related types, or the subtypes of a type.
|
|
77
|
+
if (isType) {
|
|
78
|
+
const seen = new Set<number>([sym.id]);
|
|
79
|
+
const subs: SymbolRow[] = [];
|
|
80
|
+
let frontier = [sym];
|
|
81
|
+
while (frontier.length && subs.length < 5000) {
|
|
82
|
+
const next: SymbolRow[] = [];
|
|
83
|
+
for (const s of frontier) {
|
|
84
|
+
for (const sub of directSubtypes(ctx.db, ctx.resolver, s)) {
|
|
85
|
+
if (seen.has(sub.sym.id)) continue;
|
|
86
|
+
seen.add(sub.sym.id);
|
|
87
|
+
subs.push(sub.sym);
|
|
88
|
+
next.push(sub.sym);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
frontier = next;
|
|
92
|
+
}
|
|
93
|
+
if (subs.length) {
|
|
94
|
+
out.push(`subtypes that inherit a change to it (${subs.length}):`);
|
|
95
|
+
for (const s of subs.slice(0, Math.min(limit, 12))) out.push(` ${s.kind} ${s.qname} ${s.path}:${s.start_line}`);
|
|
96
|
+
if (subs.length > 12) out.push(` ... ${subs.length - Math.min(limit, 12)} more (clast_hierarchy lists them)`);
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
const family = [...ctx.resolver.familyOf(sym)]
|
|
100
|
+
.filter(id => id !== sym.id)
|
|
101
|
+
.map(id => ctx.db.symbolById(id))
|
|
102
|
+
.filter((s): s is SymbolRow => !!s);
|
|
103
|
+
if (family.length) {
|
|
104
|
+
family.sort((a, b) => Number(isTestPath(a.path)) - Number(isTestPath(b.path)) || a.path.localeCompare(b.path));
|
|
105
|
+
out.push(`change together - declarations, overrides, implementations and overloads of the same member (${family.length}):`);
|
|
106
|
+
for (const f of family.slice(0, limit)) out.push(` ${f.kind} ${f.qname} ${loc(f)}`);
|
|
107
|
+
if (family.length > limit) out.push(` ... ${family.length - limit} more (clast_hierarchy lists them)`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. Direct callers (for a type: the places that construct it).
|
|
112
|
+
const stats = { examined: 0, total: 0 };
|
|
113
|
+
const direct = groupCallers(callersOf(ctx.db, ctx.resolver, sym, { overloads: oneOverload ? undefined : overloads }, stats));
|
|
114
|
+
const isTest = (g: CallerGroup): boolean => isTestSite(g.path, g.caller?.qname);
|
|
115
|
+
const prod = direct.filter(g => !isTest(g));
|
|
116
|
+
const certain = prod.filter(g => g.certain);
|
|
117
|
+
const unsure = prod.filter(g => !g.certain);
|
|
118
|
+
const what = isType ? 'direct uses as a call (construction)' : 'direct callers';
|
|
119
|
+
const files = new Set(certain.map(g => g.path));
|
|
120
|
+
out.push(
|
|
121
|
+
`${what}: ${certain.length} resolved in ${plural(files.size, 'file')}` +
|
|
122
|
+
(unsure.length ? `, ${unsure.length} unconfirmed "?"` : '') + ' (each must be checked when the signature changes):'
|
|
123
|
+
);
|
|
124
|
+
if (prod.length === 0) out.push(' none in production code');
|
|
125
|
+
byFile(certain, limit, out);
|
|
126
|
+
if (unsure.length) byFile(unsure, Math.min(limit, certain.length ? 6 : limit), out);
|
|
127
|
+
if (stats.total > stats.examined) {
|
|
128
|
+
out.push(` note: "${sym.name}" is called in ${stats.total} places; ${stats.examined} were examined.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 3. Everything that reaches it through those callers, and the tests among them.
|
|
132
|
+
interface TestHit { path: string; via: string | null; level: number; tests: Set<string> }
|
|
133
|
+
const tests = new Map<string, TestHit>();
|
|
134
|
+
const addTest = (g: CallerGroup, level: number, via: string | null): void => {
|
|
135
|
+
let hit = tests.get(g.path);
|
|
136
|
+
if (!hit) tests.set(g.path, (hit = { path: g.path, via, level, tests: new Set() }));
|
|
137
|
+
if (g.caller) hit.tests.add(g.caller.name);
|
|
138
|
+
};
|
|
139
|
+
for (const g of direct) if (isTest(g)) addTest(g, 1, null);
|
|
140
|
+
|
|
141
|
+
const reached = new Map<number, SymbolRow>();
|
|
142
|
+
for (const g of certain) if (g.caller) reached.set(g.caller.id, g.caller);
|
|
143
|
+
const perLevel: number[] = [];
|
|
144
|
+
let frontier = [...reached.values()];
|
|
145
|
+
let expansions = 0;
|
|
146
|
+
let cut = false;
|
|
147
|
+
for (let level = 2; level <= depth && frontier.length; level++) {
|
|
148
|
+
const next: SymbolRow[] = [];
|
|
149
|
+
for (const s of frontier) {
|
|
150
|
+
if (expansions >= IMPACT_MAX_EXPANSIONS || performance.now() - started > IMPACT_BUDGET_MS) {
|
|
151
|
+
cut = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
expansions++;
|
|
155
|
+
for (const g of groupCallers(callersOf(ctx.db, ctx.resolver, s, { quick: true }))) {
|
|
156
|
+
if (!g.certain) continue;
|
|
157
|
+
if (isTest(g)) {
|
|
158
|
+
addTest(g, level, s.qname);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!g.caller || g.caller.id === sym.id || reached.has(g.caller.id)) continue;
|
|
162
|
+
reached.set(g.caller.id, g.caller);
|
|
163
|
+
next.push(g.caller);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
perLevel.push(next.length);
|
|
167
|
+
if (cut) break;
|
|
168
|
+
frontier = next;
|
|
169
|
+
}
|
|
170
|
+
const indirect = [...reached.values()].filter(s => !certain.some(g => g.caller?.id === s.id));
|
|
171
|
+
if (indirect.length) {
|
|
172
|
+
const dirs = new Map<string, number>();
|
|
173
|
+
for (const s of indirect) dirs.set(dirOf(s.path), (dirs.get(dirOf(s.path)) ?? 0) + 1);
|
|
174
|
+
const top = [...dirs.entries()].sort((a, b) => b[1] - a[1]);
|
|
175
|
+
out.push(
|
|
176
|
+
`indirect callers (through the direct ones, up to depth ${depth}): ${plural(indirect.length, 'function')} in ` +
|
|
177
|
+
`${plural(new Set(indirect.map(s => s.path)).size, 'file')}${cut ? ' (search cut short; the real number is higher)' : ''}`
|
|
178
|
+
);
|
|
179
|
+
out.push(` by area: ${top.slice(0, 8).map(([d, n]) => `${d} ${n}`).join(', ')}${top.length > 8 ? ', ...' : ''}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 4. Tests to run.
|
|
183
|
+
const hits = [...tests.values()].sort((a, b) => a.level - b.level || a.path.localeCompare(b.path));
|
|
184
|
+
if (hits.length) {
|
|
185
|
+
const directN = hits.filter(h => h.level === 1).length;
|
|
186
|
+
out.push(`tests to run (${plural(hits.length, 'file')}: ${directN} call it directly, ${hits.length - directN} reach it through callers):`);
|
|
187
|
+
for (const h of hits.slice(0, limit)) {
|
|
188
|
+
const names = [...h.tests];
|
|
189
|
+
out.push(
|
|
190
|
+
` ${h.path} ${h.level === 1 ? 'direct' : `via ${h.via}`}` +
|
|
191
|
+
(names.length ? ` (${names.slice(0, 3).join(', ')}${names.length > 3 ? `, +${names.length - 3}` : ''})` : '')
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
if (hits.length > limit) out.push(` ... ${hits.length - limit} more`);
|
|
195
|
+
} else {
|
|
196
|
+
// No test calls it: fall back to test files that mention the name at all.
|
|
197
|
+
const nameId = ctx.db.nameId(sym.name, false);
|
|
198
|
+
const mention = nameId === undefined ? [] : ctx.db
|
|
199
|
+
.all<{ path: string }>(`SELECT f.path FROM occ o JOIN files f ON f.id = o.file_id WHERE o.name_id = ?`, nameId)
|
|
200
|
+
.map(r => r.path)
|
|
201
|
+
.filter(p => isTestPath(p));
|
|
202
|
+
out.push(
|
|
203
|
+
mention.length
|
|
204
|
+
? `tests: none call it (directly or through its callers); ${plural(mention.length, 'test file')} mention the name: ` +
|
|
205
|
+
`${mention.slice(0, 6).join(', ')}${mention.length > 6 ? ', ...' : ''}`
|
|
206
|
+
: `tests: none found that call it or mention its name (depth ${depth}).`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (out.some(l => / \?(,|$)/.test(l))) {
|
|
210
|
+
out.push('("?" = matched by name only; the receiver type is unknown and another definition could be the real target)');
|
|
211
|
+
}
|
|
212
|
+
return note + out.join('\n');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// clast_unused: dead code
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
export interface UnusedArgs {
|
|
220
|
+
path?: string;
|
|
221
|
+
lang?: string;
|
|
222
|
+
/** Kinds to judge, comma separated. Default: function, method, class, struct, interface, trait, enum, type. */
|
|
223
|
+
kind?: string;
|
|
224
|
+
/** Include exported / public symbols (dead only if nothing outside the project uses them). Default true. */
|
|
225
|
+
exported?: boolean;
|
|
226
|
+
/** Max symbols listed. Default 60. */
|
|
227
|
+
limit?: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const DEFAULT_UNUSED_KINDS = ['function', 'method', 'class', 'struct', 'interface', 'trait', 'enum', 'type'];
|
|
231
|
+
const MAX_REFS = 12;
|
|
232
|
+
/** `getattr(x, "as_" + vendor)`, `send("on_#{e}")`, `obj["handle" + kind]`, obj[`on${Name}`]: the literal prefix of a computed member name. */
|
|
233
|
+
const DYNAMIC =
|
|
234
|
+
/(?:getattr|hasattr|send|public_send|method)\(\s*[^,()\n]{0,60},?\s*f?["'](\w{2,})(?:["']\s*[+%]|[{#])|\[\s*["'`](\w{2,})(?:["']\s*\+|\$\{)/g;
|
|
235
|
+
const EXTENSION_LANGS = new Set(['csharp', 'kotlin', 'swift', 'dart']);
|
|
236
|
+
const HASH_COMMENT = new Set(['python', 'ruby', 'bash', 'shell', 'perl', 'r']);
|
|
237
|
+
const NOMINAL = new Set(['java', 'csharp', 'cpp', 'c', 'rust', 'kotlin']);
|
|
238
|
+
const UNUSED_BUDGET_MS = 30_000;
|
|
239
|
+
|
|
240
|
+
/** Names the runtime, the language or a common framework calls without the project naming them. */
|
|
241
|
+
const IMPLICIT = new Set([
|
|
242
|
+
'main', 'Main', 'wmain', 'WinMain', 'wWinMain', 'DllMain', '_start', 'app_main', 'setup', 'loop', 'init', 'constructor',
|
|
243
|
+
'toString', 'ToString', 'equals', 'Equals', 'hashCode', 'GetHashCode', 'compareTo', 'CompareTo', 'Dispose', 'finalize',
|
|
244
|
+
'clone', 'valueOf', 'toJSON', 'setUp', 'tearDown', 'SetUp', 'TearDown', 'setUpClass', 'tearDownClass',
|
|
245
|
+
'String', 'Error', 'ServeHTTP', 'MarshalJSON', 'UnmarshalJSON', 'MarshalText', 'UnmarshalText', 'Len', 'Less', 'Swap',
|
|
246
|
+
'fmt', 'drop', 'default', 'from', 'into', 'try_from', 'deref', 'deref_mut', 'next', 'eq', 'ne', 'hash', 'cmp',
|
|
247
|
+
'partial_cmp', 'poll', 'as_ref', 'as_mut', 'from_str', 'index', 'call',
|
|
248
|
+
]);
|
|
249
|
+
const EXPORT_MARKER =
|
|
250
|
+
/dllexport|JNIEXPORT|JNICALL|__attribute__|EMSCRIPTEN_KEEPALIVE|extern\s+"C"|\b(WINAPI|CALLBACK|APIENTRY|STDMETHODCALLTYPE|IRAM_ATTR|ISR|Q_INVOKABLE|UFUNCTION)\b/;
|
|
251
|
+
/** Decorators / attributes that do not make something reachable. */
|
|
252
|
+
const HARMLESS_MARK =
|
|
253
|
+
/^(@(staticmethod|classmethod|property|abstractmethod|override|Override|Deprecated|deprecated|SuppressWarnings|final|Nullable|NonNull|NotNull|VisibleForTesting|dataclass|overload|cached_property|functools\.\w+|lru_cache|contextmanager|inline|internal|private|protected|public|param|returns?|see|throws|type|template)\b|\[(Obsolete|Serializable|DebuggerDisplay|MethodImpl|Pure|NotNull|CanBeNull|EditorBrowsable|SuppressMessage)\b|#!?\[(derive|inline|allow|warn|deny|cfg|cfg_attr|must_use|doc|repr|non_exhaustive|deprecated|track_caller|cold|automatically_derived|rustfmt|clippy)\b)/;
|
|
254
|
+
|
|
255
|
+
interface Cand extends SymbolRow {
|
|
256
|
+
exported: number;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface Ref {
|
|
260
|
+
fileId: number;
|
|
261
|
+
path: string;
|
|
262
|
+
line: number;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function unused(ctx: ToolContext, args: UnusedArgs = {}): Promise<string> {
|
|
266
|
+
const gate = await ctx.gate();
|
|
267
|
+
if (gate) return gate;
|
|
268
|
+
const db = ctx.db;
|
|
269
|
+
const started = performance.now();
|
|
270
|
+
const limit = Math.max(1, Math.min(args.limit ?? 60, 1000));
|
|
271
|
+
const kinds = (args.kind ? args.kind.split(',').map(k => k.trim()).filter(Boolean) : DEFAULT_UNUSED_KINDS);
|
|
272
|
+
const showExported = args.exported !== false;
|
|
273
|
+
|
|
274
|
+
const all = db.all<Cand>(
|
|
275
|
+
`SELECT ${SYMBOL_COLUMNS} FROM ${SYMBOL_FROM} WHERE s.kind IN (${kinds.map(() => '?').join(',')})`, ...kinds
|
|
276
|
+
);
|
|
277
|
+
const cands = all.filter(
|
|
278
|
+
s =>
|
|
279
|
+
!isTestSite(s.path, s.qname) && !isVendoredPath(s.path) && (!args.path || pathMatches(s.path, args.path)) &&
|
|
280
|
+
(!args.lang || s.lang === args.lang) && (showExported || !s.exported)
|
|
281
|
+
);
|
|
282
|
+
if (cands.length === 0) return 'no symbols of these kinds in production code match the filters.';
|
|
283
|
+
|
|
284
|
+
// --- 1. Names: where is each one referenced outside its own definitions? ---
|
|
285
|
+
const pathOf = new Map<number, string>();
|
|
286
|
+
for (const f of db.all<{ id: number; path: string }>(`SELECT id, path FROM files`)) pathOf.set(f.id, f.path);
|
|
287
|
+
const names = new Set(cands.map(c => c.name));
|
|
288
|
+
/** null = referenced too widely to look at (alive); else the references outside the definitions. */
|
|
289
|
+
const refs = new Map<string, Ref[] | null>();
|
|
290
|
+
const headLine = new Map<number, number>();
|
|
291
|
+
const srcCache = new Map<string, string[] | null>();
|
|
292
|
+
const srcOf = (path: string): string[] | null => {
|
|
293
|
+
let src = srcCache.get(path);
|
|
294
|
+
if (src === undefined) {
|
|
295
|
+
if (srcCache.size > 4000) srcCache.clear();
|
|
296
|
+
try {
|
|
297
|
+
src = readFileSync(join(ctx.clast.root, path), 'utf-8').split(/\r?\n/);
|
|
298
|
+
} catch {
|
|
299
|
+
src = null;
|
|
300
|
+
}
|
|
301
|
+
srcCache.set(path, src);
|
|
302
|
+
}
|
|
303
|
+
return src;
|
|
304
|
+
};
|
|
305
|
+
/**
|
|
306
|
+
* Is an occurrence inside a definition's range part of that definition? For a type: always
|
|
307
|
+
* (constructors and self references do not make it used). For a callable: the line that declares
|
|
308
|
+
* it, and calls of the same name on itself (recursion, one overload delegating to another). A call
|
|
309
|
+
* on another object (`return inner.name()`) or a plain mention further down is a real use - the
|
|
310
|
+
* range may also be stretched over an #if / #else pair of definitions.
|
|
311
|
+
*/
|
|
312
|
+
const isOwn = (d: SymbolRow, nameId: number, line: number): boolean => {
|
|
313
|
+
if (CONTAINER_KINDS.has(d.kind)) return true;
|
|
314
|
+
let head = headLine.get(d.id);
|
|
315
|
+
if (head === undefined) {
|
|
316
|
+
head = d.start_line;
|
|
317
|
+
const src = line === d.start_line ? null : srcOf(d.path);
|
|
318
|
+
const word = new RegExp(`(^|[^\\w$])${d.name.replace(/[.*+?^${}()|[\]\\]/g, '\\ const refs = new Map<string, Ref[] | null>();')}($|[^\\w$])`);
|
|
319
|
+
for (let n = d.start_line; src && n <= Math.min(d.end_line, d.start_line + 8); n++) {
|
|
320
|
+
if (word.test(src[n - 1] ?? '')) {
|
|
321
|
+
head = n;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (src) headLine.set(d.id, head);
|
|
326
|
+
}
|
|
327
|
+
if (line < head) return true;
|
|
328
|
+
const calls = db.all<{ receiver: string | null }>(
|
|
329
|
+
`SELECT receiver FROM calls WHERE name_id = ? AND file_id = ? AND line = ?`, nameId, d.file_id, line
|
|
330
|
+
);
|
|
331
|
+
const onSelf = calls.every(c => c.receiver === null || /^(this|self|Self|cls|super|base)$/.test(c.receiver));
|
|
332
|
+
// The declaring line: its own unless it also forwards to another object (`int n() { return inner.n(); }`).
|
|
333
|
+
return line === head ? onSelf : calls.length > 0 && onSelf;
|
|
334
|
+
};
|
|
335
|
+
const defsOf = new Map<string, SymbolRow[]>();
|
|
336
|
+
for (const name of names) {
|
|
337
|
+
const nameId = db.nameId(name, false);
|
|
338
|
+
const defs = db.symbolsByName(name);
|
|
339
|
+
defsOf.set(name, defs);
|
|
340
|
+
if (nameId === undefined) {
|
|
341
|
+
refs.set(name, []);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const rows = db.all<{ file_id: number; lines: string }>(`SELECT file_id, lines FROM occ WHERE name_id = ?`, nameId);
|
|
345
|
+
const list: Ref[] = [];
|
|
346
|
+
let alive = false;
|
|
347
|
+
outer: for (const r of rows) {
|
|
348
|
+
const inFile = defs.filter(d => d.file_id === r.file_id);
|
|
349
|
+
for (const part of r.lines.split(',')) {
|
|
350
|
+
const line = Number(part);
|
|
351
|
+
if (inFile.some(d => line >= d.start_line && line <= d.end_line && isOwn(d, nameId, line))) continue;
|
|
352
|
+
list.push({ fileId: r.file_id, path: pathOf.get(r.file_id) ?? '', line });
|
|
353
|
+
if (list.length > MAX_REFS) {
|
|
354
|
+
alive = true;
|
|
355
|
+
break outer;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
refs.set(name, alive ? null : list);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- 2. What something outside the code can reach: never unused, and what it uses is used. ---
|
|
363
|
+
const skipped = { implicit: 0, marked: 0, library: 0, shared: 0, members: 0, structural: 0 };
|
|
364
|
+
const externalAncestor = new Map<number, boolean>();
|
|
365
|
+
/** True when the type extends or implements something that is not defined in this project. */
|
|
366
|
+
const hasExternalAncestor = (type: SymbolRow, depthLeft = 4): boolean => {
|
|
367
|
+
const cached = externalAncestor.get(type.id);
|
|
368
|
+
if (cached !== undefined) return cached;
|
|
369
|
+
externalAncestor.set(type.id, false);
|
|
370
|
+
let ext = false;
|
|
371
|
+
for (const sup of directSupertypes(db, ctx.resolver, type)) {
|
|
372
|
+
if (!sup.sym || (depthLeft > 0 && hasExternalAncestor(sup.sym, depthLeft - 1))) {
|
|
373
|
+
ext = true;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
externalAncestor.set(type.id, ext);
|
|
378
|
+
return ext;
|
|
379
|
+
};
|
|
380
|
+
const containersOf = (s: SymbolRow, container: string): SymbolRow[] => {
|
|
381
|
+
const parent = s.parent_id !== null ? db.symbolById(s.parent_id) : undefined;
|
|
382
|
+
if (parent) return [parent];
|
|
383
|
+
return db.symbolsByName(container).filter(c => CONTAINER_KINDS.has(c.kind) && c.lang === s.lang).slice(0, 4);
|
|
384
|
+
};
|
|
385
|
+
const hasMain = new Map<number, boolean>();
|
|
386
|
+
const isProgram = (type: SymbolRow): boolean => {
|
|
387
|
+
let hit = hasMain.get(type.id);
|
|
388
|
+
if (hit === undefined) {
|
|
389
|
+
hit = db.get(`SELECT 1 AS x FROM symbols WHERE parent_id = ? AND name IN ('main', 'Main') LIMIT 1`, type.id) !== undefined;
|
|
390
|
+
hasMain.set(type.id, hit);
|
|
391
|
+
}
|
|
392
|
+
return hit;
|
|
393
|
+
};
|
|
394
|
+
const marked = (s: SymbolRow): boolean => {
|
|
395
|
+
if (EXPORT_MARKER.test(s.signature)) return true;
|
|
396
|
+
// `override` of a member that only this project's one definition has: the base is a library type.
|
|
397
|
+
if (/(^|\s)override\b|@Override\b/.test(s.signature) && new Set((defsOf.get(s.name) ?? []).map(d => d.qname)).size <= 1) return true;
|
|
398
|
+
const src = srcOf(s.path);
|
|
399
|
+
if (!src) return false;
|
|
400
|
+
// The lines above the definition, as far as they are attributes, decorators or comments.
|
|
401
|
+
let from = s.doc_line > 0 ? Math.min(s.doc_line, s.start_line) : s.start_line;
|
|
402
|
+
while (from > 1 && s.start_line - from < 12 && /^(@|\[[A-Za-z]|#!?\[|\/\/|\/\*|\*|#\s)/.test((src[from - 2] ?? '').trim())) from--;
|
|
403
|
+
for (let n = from; n <= s.start_line; n++) {
|
|
404
|
+
const t = (src[n - 1] ?? '').trim();
|
|
405
|
+
if (/^@Override\b/.test(t) && new Set((defsOf.get(s.name) ?? []).map(d => d.qname)).size <= 1) return true;
|
|
406
|
+
if (!/^(@|\[[A-Za-z]|#!?\[)/.test(t) || HARMLESS_MARK.test(t)) continue;
|
|
407
|
+
// JSDoc tags and C++ lambdas / array literals are not attributes.
|
|
408
|
+
if (t.startsWith('[') && (s.lang !== 'csharp' || /^\[.*\]\s*[=;(]/.test(t))) continue;
|
|
409
|
+
if (t.startsWith('@') && langFamily(s.lang) === 'c') continue;
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const membersInUse = (type: SymbolRow): boolean =>
|
|
416
|
+
db.all<{ name: string }>(
|
|
417
|
+
`SELECT DISTINCT name FROM symbols WHERE parent_id = ? AND kind IN ('method', 'function', 'property')`, type.id
|
|
418
|
+
).some(m => m.name !== type.name && refs.get(m.name)?.length !== 0 && memberRefs(m.name));
|
|
419
|
+
/** Is this member name referenced anywhere outside its definitions (also for names that were not candidates)? */
|
|
420
|
+
const memberRefs = (name: string): boolean => {
|
|
421
|
+
if (refs.has(name)) return true; // candidates with zero references were excluded by the caller
|
|
422
|
+
const id = db.nameId(name, false);
|
|
423
|
+
if (id === undefined) return false;
|
|
424
|
+
const defLines = new Set(db.symbolsByName(name).map(d => d.file_id + ':' + d.start_line));
|
|
425
|
+
for (const r of db.all<{ file_id: number; lines: string }>(`SELECT file_id, lines FROM occ WHERE name_id = ?`, id)) {
|
|
426
|
+
for (const l of r.lines.split(',')) if (!defLines.has(r.file_id + ':' + l)) return true;
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
};
|
|
430
|
+
const verdictOf = new Map<number, keyof typeof skipped | 'ok'>();
|
|
431
|
+
const verdict = (s: Cand): keyof typeof skipped | 'ok' => {
|
|
432
|
+
let v = verdictOf.get(s.id);
|
|
433
|
+
if (v === undefined) verdictOf.set(s.id, (v = decide(s)));
|
|
434
|
+
return v;
|
|
435
|
+
};
|
|
436
|
+
const decide = (s: Cand): keyof typeof skipped | 'ok' => {
|
|
437
|
+
const container = s.qname.includes('.') ? s.qname.split('.').slice(-2, -1)[0]! : null;
|
|
438
|
+
if (
|
|
439
|
+
IMPLICIT.has(s.name) || /^__\w+__$/.test(s.name) || s.name.startsWith('~') || s.name.startsWith('operator') ||
|
|
440
|
+
s.name === container || (s.lang === 'go' && /^(Test|Benchmark|Example|Fuzz)/.test(s.name))
|
|
441
|
+
) return 'implicit';
|
|
442
|
+
if (marked(s)) return 'marked';
|
|
443
|
+
if (
|
|
444
|
+
s.kind === 'method' && container && s.lang !== 'go' && s.lang !== 'rust' &&
|
|
445
|
+
containersOf(s, container).some(c => hasExternalAncestor(c))
|
|
446
|
+
) return 'library';
|
|
447
|
+
// A subclass of a library type that nothing names is usually found by the library (Django apps,
|
|
448
|
+
// migrations, commands, servlets, plugins).
|
|
449
|
+
if ((s.kind === 'class' || s.kind === 'struct') && hasExternalAncestor(s)) return 'library';
|
|
450
|
+
// An exported Go method may be what makes its type satisfy an interface of a library (afero.Fs,
|
|
451
|
+
// http.Handler): nothing in the project has to name it.
|
|
452
|
+
if (s.lang === 'go' && s.kind === 'method' && s.exported) return 'structural';
|
|
453
|
+
if (
|
|
454
|
+
s.lang === 'go' && s.kind === 'method' &&
|
|
455
|
+
(defsOf.get(s.name) ?? []).some(d => d.parent_id !== null && db.symbolById(d.parent_id)?.kind === 'interface')
|
|
456
|
+
) return 'structural';
|
|
457
|
+
// A member of an object literal: the object is handed to something that calls it by shape.
|
|
458
|
+
if ((s.lang === 'typescript' || s.lang === 'javascript') && s.kind === 'method') {
|
|
459
|
+
const parent = s.parent_id !== null ? db.symbolById(s.parent_id) : undefined;
|
|
460
|
+
if (!parent || !CONTAINER_KINDS.has(parent.kind)) return 'structural';
|
|
461
|
+
}
|
|
462
|
+
// A class with a main method is a program: something outside the code starts it.
|
|
463
|
+
if (CONTAINER_KINDS.has(s.kind) && isProgram(s)) return 'implicit';
|
|
464
|
+
// Extension methods are used without naming the class that holds them.
|
|
465
|
+
if (CONTAINER_KINDS.has(s.kind) && EXTENSION_LANGS.has(s.lang) && membersInUse(s)) return 'members';
|
|
466
|
+
return 'ok';
|
|
467
|
+
};
|
|
468
|
+
const candsByName = new Map<string, Cand[]>();
|
|
469
|
+
for (const c of cands) {
|
|
470
|
+
const l = candsByName.get(c.name);
|
|
471
|
+
if (l) l.push(c);
|
|
472
|
+
else candsByName.set(c.name, [c]);
|
|
473
|
+
}
|
|
474
|
+
/** Counts what is left out, once per symbol. */
|
|
475
|
+
const judge = (s: Cand): boolean => {
|
|
476
|
+
const v = verdict(s);
|
|
477
|
+
if (v !== 'ok') skipped[v]++;
|
|
478
|
+
return v === 'ok';
|
|
479
|
+
};
|
|
480
|
+
/** A name only counts as unused when none of its definitions is reachable from outside. */
|
|
481
|
+
const reachable = (name: string): boolean => (candsByName.get(name) ?? []).some(c => verdict(c) !== 'ok');
|
|
482
|
+
|
|
483
|
+
// --- 3. Fixpoint: a name used only inside unused code is unused too. ---
|
|
484
|
+
const dead = new Map<string, string | null>(); // name -> the unused name that holds its only uses
|
|
485
|
+
for (const [name, list] of refs) if (list && list.length === 0 && !reachable(name)) dead.set(name, null);
|
|
486
|
+
const enclosingNames = new Map<string, string[]>();
|
|
487
|
+
const enclosing = (r: Ref): string[] => {
|
|
488
|
+
const key = r.fileId + ':' + r.line;
|
|
489
|
+
let hit = enclosingNames.get(key);
|
|
490
|
+
if (!hit) {
|
|
491
|
+
hit = [];
|
|
492
|
+
let s = db.enclosingSymbol(r.fileId, r.line);
|
|
493
|
+
for (let i = 0; s && i < 8; i++) {
|
|
494
|
+
hit.push(s.name);
|
|
495
|
+
s = s.parent_id !== null ? db.symbolById(s.parent_id) : undefined;
|
|
496
|
+
}
|
|
497
|
+
enclosingNames.set(key, hit);
|
|
498
|
+
}
|
|
499
|
+
return hit;
|
|
500
|
+
};
|
|
501
|
+
const testOnly = new Set<string>();
|
|
502
|
+
for (let round = 0; round < 6; round++) {
|
|
503
|
+
let grew = false;
|
|
504
|
+
for (const [name, list] of refs) {
|
|
505
|
+
if (!list || list.length === 0 || dead.has(name) || reachable(name)) continue;
|
|
506
|
+
let holder: string | null = null;
|
|
507
|
+
const ok = list.every(r => {
|
|
508
|
+
const h = enclosing(r).find(n => n !== name && dead.has(n));
|
|
509
|
+
if (h) holder = h;
|
|
510
|
+
return h !== undefined;
|
|
511
|
+
});
|
|
512
|
+
if (ok) {
|
|
513
|
+
dead.set(name, holder);
|
|
514
|
+
grew = true;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (!grew) break;
|
|
518
|
+
}
|
|
519
|
+
for (const [name, list] of refs) {
|
|
520
|
+
if (!list || list.length === 0 || dead.has(name)) continue;
|
|
521
|
+
if (list.every(r => isTestPath(r.path) || enclosing(r).some(n => n !== name && dead.has(n)))) testOnly.add(name);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
interface Found { sym: Cand; why: 'unused' | 'tests' | 'unbound'; holder?: string | null; mention?: string; members?: number }
|
|
525
|
+
const found: Found[] = [];
|
|
526
|
+
for (const s of cands) {
|
|
527
|
+
if (dead.has(s.name)) {
|
|
528
|
+
found.push({ sym: s, why: 'unused', holder: dead.get(s.name) });
|
|
529
|
+
} else if (refs.get(s.name)?.length === 0) {
|
|
530
|
+
judge(s);
|
|
531
|
+
} else if (testOnly.has(s.name)) {
|
|
532
|
+
if (judge(s)) found.push({ sym: s, why: 'tests' });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// --- 4. Members whose name is used, but never on them: every use is a call bound to another definition. ---
|
|
537
|
+
let unboundCut = false;
|
|
538
|
+
const hasCall = (nameId: number, r: Ref): boolean =>
|
|
539
|
+
db.get(`SELECT 1 AS x FROM calls WHERE name_id = ? AND file_id = ? AND line = ? LIMIT 1`, nameId, r.fileId, r.line) !== undefined;
|
|
540
|
+
for (const s of cands) {
|
|
541
|
+
if (s.kind !== 'method' && s.kind !== 'function') continue;
|
|
542
|
+
// Only where types are nominal: a structurally typed object (TypeScript, Go, Python) can stand in for
|
|
543
|
+
// another definition without declaring it.
|
|
544
|
+
if (!NOMINAL.has(s.lang)) continue;
|
|
545
|
+
const list = refs.get(s.name);
|
|
546
|
+
if (!list || list.length === 0 || dead.has(s.name) || testOnly.has(s.name)) continue;
|
|
547
|
+
const defs = defsOf.get(s.name) ?? [];
|
|
548
|
+
if (defs.length < 2) continue;
|
|
549
|
+
if (performance.now() - started > UNUSED_BUDGET_MS / 2) {
|
|
550
|
+
unboundCut = true;
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
const nameId = db.nameId(s.name, false)!;
|
|
554
|
+
if (!list.every(r => hasCall(nameId, r))) continue;
|
|
555
|
+
// A member used inside another definition of the same name (an override calling super) is still a use.
|
|
556
|
+
const inside = defs.some(d => d.id !== s.id && d.end_line > d.start_line && db.get(
|
|
557
|
+
`SELECT 1 AS x FROM calls WHERE name_id = ? AND file_id = ? AND line BETWEEN ? AND ? LIMIT 1`,
|
|
558
|
+
nameId, d.file_id, d.start_line, d.end_line
|
|
559
|
+
) !== undefined);
|
|
560
|
+
if (inside) continue;
|
|
561
|
+
if (ctx.resolver.familyOf(s).size > 1) continue;
|
|
562
|
+
if (callersOf(db, ctx.resolver, s, { quick: true }).length > 0) continue;
|
|
563
|
+
if (judge(s)) found.push({ sym: s, why: 'unbound' });
|
|
564
|
+
}
|
|
565
|
+
for (const s of cands) if (refs.get(s.name) === null) skipped.shared++;
|
|
566
|
+
|
|
567
|
+
// Members of an unused container are part of it, not findings of their own.
|
|
568
|
+
const foundIds = new Map<number, Found>();
|
|
569
|
+
for (const f of found) if (f.why === 'unused') foundIds.set(f.sym.id, f);
|
|
570
|
+
const kept: Found[] = [];
|
|
571
|
+
for (const f of found) {
|
|
572
|
+
let p = f.sym.parent_id;
|
|
573
|
+
let owner: Found | undefined;
|
|
574
|
+
for (let i = 0; p !== null && i < 8 && !owner; i++) {
|
|
575
|
+
owner = foundIds.get(p);
|
|
576
|
+
p = db.symbolById(p)?.parent_id ?? null;
|
|
577
|
+
}
|
|
578
|
+
if (owner && owner !== f) owner.members = (owner.members ?? 0) + 1;
|
|
579
|
+
else kept.push(f);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// --- 5. Strings, comments, templates and config can name a symbol too. ---
|
|
583
|
+
const wanted = new Map<string, Found[]>();
|
|
584
|
+
for (const f of kept) {
|
|
585
|
+
const list = wanted.get(f.sym.name) ?? [];
|
|
586
|
+
list.push(f);
|
|
587
|
+
wanted.set(f.sym.name, list);
|
|
588
|
+
}
|
|
589
|
+
let scanCut = false;
|
|
590
|
+
/** Name prefixes that some code completes at run time -> where. */
|
|
591
|
+
const dynamic = new Map<string, string>();
|
|
592
|
+
if (wanted.size) {
|
|
593
|
+
const indexed = db.all<{ path: string; lang: string }>(`SELECT path, lang FROM files`);
|
|
594
|
+
const langOf = new Map(indexed.map(f => [f.path, f.lang]));
|
|
595
|
+
const work = [...indexed.map(f => f.path), ...otherTextFiles(ctx)];
|
|
596
|
+
const word = /[A-Za-z_$][\w$]*/g;
|
|
597
|
+
const BATCH = 96;
|
|
598
|
+
for (let i = 0; i < work.length; i += BATCH) {
|
|
599
|
+
if (performance.now() - started > UNUSED_BUDGET_MS) {
|
|
600
|
+
scanCut = true;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
const batch = work.slice(i, i + BATCH);
|
|
604
|
+
const bufs = await Promise.all(batch.map(p => readFile(join(ctx.clast.root, p)).catch(() => null)));
|
|
605
|
+
for (let k = 0; k < batch.length; k++) {
|
|
606
|
+
const buf = bufs[k];
|
|
607
|
+
if (!buf || buf.length > 4 * 1024 * 1024 || buf.subarray(0, 1024).includes(0)) continue;
|
|
608
|
+
const text = buf.toString('latin1');
|
|
609
|
+
const path = batch[k]!;
|
|
610
|
+
if (text.includes('getattr(') || text.includes('send(') || /\[\s*["'`]\w/.test(text)) {
|
|
611
|
+
DYNAMIC.lastIndex = 0;
|
|
612
|
+
let dm: RegExpExecArray | null;
|
|
613
|
+
while ((dm = DYNAMIC.exec(text)) !== null) {
|
|
614
|
+
const prefix = dm[1] ?? dm[2]!;
|
|
615
|
+
if (!dynamic.has(prefix)) dynamic.set(prefix, `${batch[k]!}:${text.slice(0, dm.index).split('\n').length}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
let lineNo = 1;
|
|
619
|
+
let pos = 0;
|
|
620
|
+
let m: RegExpExecArray | null;
|
|
621
|
+
word.lastIndex = 0;
|
|
622
|
+
while ((m = word.exec(text)) !== null) {
|
|
623
|
+
const hits = wanted.get(m[0]);
|
|
624
|
+
if (!hits) continue;
|
|
625
|
+
for (let j = text.indexOf('\n', pos); j !== -1 && j < m.index; j = text.indexOf('\n', j + 1)) {
|
|
626
|
+
lineNo++;
|
|
627
|
+
pos = j + 1;
|
|
628
|
+
}
|
|
629
|
+
// Its own definition (and the comment above it) does not count.
|
|
630
|
+
const own = (defsOf.get(m[0]) ?? []).some(
|
|
631
|
+
d => d.path === path && lineNo >= (d.doc_line > 0 ? Math.min(d.doc_line, d.start_line) : d.start_line) && lineNo <= d.end_line
|
|
632
|
+
);
|
|
633
|
+
if (own || refs.get(m[0])?.some(r => r.path === path && r.line === lineNo)) continue;
|
|
634
|
+
// A comment line in source code that names it (`{@link Foo}`, "see foo()") does not keep it alive.
|
|
635
|
+
const lang = langOf.get(path);
|
|
636
|
+
if (lang !== undefined) {
|
|
637
|
+
const head = text.slice(pos, pos + 200).trimStart();
|
|
638
|
+
if (/^(\/\/|\/\*|\*)/.test(head) || (head.startsWith('#') && HASH_COMMENT.has(lang))) continue;
|
|
639
|
+
}
|
|
640
|
+
// What only tests use may be mentioned by tests; anything else that names it counts.
|
|
641
|
+
const live = hits.filter(h => h.why !== 'tests' || !isTestPath(path));
|
|
642
|
+
if (live.length === 0) continue;
|
|
643
|
+
for (const h of live) h.mention ??= `${path}:${lineNo}`;
|
|
644
|
+
if (live.length === hits.length) wanted.delete(m[0]);
|
|
645
|
+
else wanted.set(m[0], hits.filter(h => !live.includes(h)));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
for (const f of kept) {
|
|
652
|
+
if (f.mention) continue;
|
|
653
|
+
for (const [prefix, at] of dynamic) {
|
|
654
|
+
if (f.sym.name.length > prefix.length && f.sym.name.startsWith(prefix)) {
|
|
655
|
+
f.mention = `${at} builds "${prefix}..." names at run time`;
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// What is used only by something that turned out to be reachable after all is reachable too.
|
|
662
|
+
const saved = new Set(kept.filter(f => f.mention).map(f => f.sym.name));
|
|
663
|
+
for (let round = 0; round < 6; round++) {
|
|
664
|
+
let grew = false;
|
|
665
|
+
for (const f of kept) {
|
|
666
|
+
if (f.mention || !f.holder || !saved.has(f.holder)) continue;
|
|
667
|
+
f.mention = `used by ${f.holder}, listed here`;
|
|
668
|
+
saved.add(f.sym.name);
|
|
669
|
+
grew = true;
|
|
670
|
+
}
|
|
671
|
+
if (!grew) break;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// --- 6. Report ---
|
|
675
|
+
const span = (s: SymbolRow): number => s.end_line - s.start_line + 1;
|
|
676
|
+
const sure = kept.filter(f => f.why === 'unused' && !f.mention);
|
|
677
|
+
const unbound = kept.filter(f => f.why === 'unbound' && !f.mention);
|
|
678
|
+
const mentioned = kept.filter(f => f.mention);
|
|
679
|
+
const tests = kept.filter(f => f.why === 'tests' && !f.mention);
|
|
680
|
+
const out: string[] = [];
|
|
681
|
+
const lines = (list: Found[]): number => list.reduce((n, f) => n + span(f.sym), 0);
|
|
682
|
+
const priv = sure.filter(f => !f.sym.exported);
|
|
683
|
+
out.push(
|
|
684
|
+
`unused code: ${plural(sure.length, 'symbol')}, ${lines(sure)} lines in ${plural(new Set(sure.map(f => f.sym.path)).size, 'file')}` +
|
|
685
|
+
` - the name is never referenced outside its own definition, or only from other unused code` +
|
|
686
|
+
` (${cands.length} symbols judged${args.path ? ` under ${args.path}` : ''})`
|
|
687
|
+
);
|
|
688
|
+
if (sure.length) {
|
|
689
|
+
out.push(
|
|
690
|
+
` ${priv.length} not exported (${lines(priv)} lines), ${sure.length - priv.length} exported or public ` +
|
|
691
|
+
`(marked "exported": unused inside this project, dead unless something outside uses them)`
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
const section = (list: Found[], cap: number, tail: (f: Found) => string): void => {
|
|
695
|
+
const byPath = new Map<string, Found[]>();
|
|
696
|
+
for (const f of list) {
|
|
697
|
+
const l = byPath.get(f.sym.path) ?? [];
|
|
698
|
+
l.push(f);
|
|
699
|
+
byPath.set(f.sym.path, l);
|
|
700
|
+
}
|
|
701
|
+
const order = [...byPath.entries()].sort((a, b) => lines(b[1]) - lines(a[1]));
|
|
702
|
+
let shown = 0;
|
|
703
|
+
for (const [path, fs] of order) {
|
|
704
|
+
if (shown >= cap) break;
|
|
705
|
+
out.push(`${path} (${plural(fs.length, 'symbol')}, ${lines(fs)} lines)`);
|
|
706
|
+
fs.sort((a, b) => a.sym.start_line - b.sym.start_line);
|
|
707
|
+
for (const f of fs) {
|
|
708
|
+
if (shown >= cap) break;
|
|
709
|
+
shown++;
|
|
710
|
+
out.push(
|
|
711
|
+
` ${f.sym.kind} ${f.sym.qname} :${f.sym.start_line}${f.sym.end_line > f.sym.start_line ? '-' + f.sym.end_line : ''}` +
|
|
712
|
+
(f.sym.exported ? ' exported' : '') + (f.members ? ` (+${plural(f.members, 'unused member')})` : '') + tail(f)
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (list.length > shown) out.push(`... ${list.length - shown} more (raise limit, or narrow with path)`);
|
|
717
|
+
};
|
|
718
|
+
section(sure, limit, f => (f.holder ? ` (used only by unused ${f.holder})` : ''));
|
|
719
|
+
if (unbound.length) {
|
|
720
|
+
out.push(`probably unused (${unbound.length}): the name is called elsewhere, but every call binds to another definition:`);
|
|
721
|
+
section(unbound, Math.min(limit, 25), () => '');
|
|
722
|
+
}
|
|
723
|
+
if (tests.length) {
|
|
724
|
+
out.push(`used only by tests (${tests.length}, ${lines(tests)} lines):`);
|
|
725
|
+
section(tests, Math.min(limit, 25), () => '');
|
|
726
|
+
}
|
|
727
|
+
if (mentioned.length) {
|
|
728
|
+
out.push(
|
|
729
|
+
`no code reference, but the name appears in a string, trailing comment or non-code file (${mentioned.length}; ` +
|
|
730
|
+
`reflection, templates, config, docs - look before deleting):`
|
|
731
|
+
);
|
|
732
|
+
section(mentioned, Math.min(limit, 25), f => ` <- ${f.mention}`);
|
|
733
|
+
}
|
|
734
|
+
const notes: string[] = [];
|
|
735
|
+
if (skipped.marked) notes.push(`${skipped.marked} with a decorator, attribute or export marker (routes, tests, exported C symbols)`);
|
|
736
|
+
if (skipped.library) notes.push(`${skipped.library} subclasses of library types and their methods (the library may find and call them)`);
|
|
737
|
+
if (skipped.structural) notes.push(`${skipped.structural} members used by shape (object-literal members, exported Go methods that may satisfy a library interface)`);
|
|
738
|
+
if (skipped.implicit) notes.push(`${skipped.implicit} entry points, constructors and conventional names (main, toString, ...)`);
|
|
739
|
+
if (skipped.members) notes.push(`${skipped.members} classes never named but whose members are used (extension methods)`);
|
|
740
|
+
if (notes.length) out.push(`not judged: ${notes.join('; ')}.`);
|
|
741
|
+
out.push(
|
|
742
|
+
`limits: judged by name - a member whose name is widely used elsewhere (${skipped.shared} symbols, e.g. "get", "run") ` +
|
|
743
|
+
`is never listed; check one with clast_call_graph. Calls built from strings at run time are invisible.` +
|
|
744
|
+
(scanCut ? ' The string / comment scan hit its time budget: verify with clast_grep before deleting.' : '') +
|
|
745
|
+
(unboundCut ? ' The "probably unused" pass hit its time budget; narrow with path for full coverage.' : '')
|
|
746
|
+
);
|
|
747
|
+
return out.join('\n');
|
|
748
|
+
}
|