clast-ai 0.1.2 → 0.2.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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +11 -9
- package/README.md +447 -401
- 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 +257 -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/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 +50 -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 +256 -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/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 +7 -4
- 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,473 @@
|
|
|
1
|
+
import type { ClastDb } from '../db/index.js';
|
|
2
|
+
import { CONTAINER_KINDS, type SymbolRow } from '../types.js';
|
|
3
|
+
import { isTestPath } from './format.js';
|
|
4
|
+
import { goInterfaceMethods } from './hierarchy.js';
|
|
5
|
+
import { langFamily, type Resolver } from './resolve.js';
|
|
6
|
+
|
|
7
|
+
export interface CallerEdge {
|
|
8
|
+
/** Calling symbol, or null for a call at file level. */
|
|
9
|
+
caller: SymbolRow | null;
|
|
10
|
+
path: string;
|
|
11
|
+
line: number;
|
|
12
|
+
count: number;
|
|
13
|
+
/** False when name-based resolution could not rule out another target. */
|
|
14
|
+
certain: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* True for call sites whose receiver type is unknown and that could not be
|
|
17
|
+
* attributed to any definition: they MAY call the target (same method
|
|
18
|
+
* name), but there is no evidence either way.
|
|
19
|
+
*/
|
|
20
|
+
weak?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CalleeEdge {
|
|
24
|
+
name: string;
|
|
25
|
+
line: number;
|
|
26
|
+
count: number;
|
|
27
|
+
receiver: string | null;
|
|
28
|
+
/** Resolved definition, or null for external / unknown callees. */
|
|
29
|
+
target: SymbolRow | null;
|
|
30
|
+
certain: boolean;
|
|
31
|
+
/** Number of other plausible definitions. */
|
|
32
|
+
alternatives: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CallRow {
|
|
36
|
+
file_id: number;
|
|
37
|
+
caller_id: number | null;
|
|
38
|
+
line: number;
|
|
39
|
+
receiver: string | null;
|
|
40
|
+
rtype: string | null;
|
|
41
|
+
via: number | null;
|
|
42
|
+
/** Arguments at the call site; -1 or absent when unknown. */
|
|
43
|
+
argc?: number;
|
|
44
|
+
count: number;
|
|
45
|
+
path: string;
|
|
46
|
+
lang: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MAX_CALL_ROWS = 4000;
|
|
50
|
+
const MAX_NEAR_ROWS = 20000;
|
|
51
|
+
const QUICK_NEAR_ROWS = 5000;
|
|
52
|
+
const QUICK_FAR_ROWS = 1500;
|
|
53
|
+
const MAX_WEAK = 1500;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The definition that best stands for a symbol's family member with the same
|
|
57
|
+
* qualified name: the function body rather than its prototype, the
|
|
58
|
+
* out-of-line C++ method rather than the in-class declaration.
|
|
59
|
+
*/
|
|
60
|
+
export function representative(db: ClastDb, resolver: Resolver, sym: SymbolRow): SymbolRow {
|
|
61
|
+
// Only C-family code separates declarations from definitions across files.
|
|
62
|
+
if (langFamily(sym.lang) !== 'c' && sym.kind !== 'declaration') return sym;
|
|
63
|
+
let best = sym;
|
|
64
|
+
let bestScore = (sym.kind === 'declaration' ? 0 : 1000) + (sym.end_line - sym.start_line);
|
|
65
|
+
for (const id of resolver.familyOf(sym)) {
|
|
66
|
+
if (id === sym.id) continue;
|
|
67
|
+
const other = db.symbolById(id);
|
|
68
|
+
// The same member in another file (header vs source); overloads in the same file are different functions.
|
|
69
|
+
if (!other || other.qname !== sym.qname || (other.file_id === sym.file_id && sym.kind !== 'declaration')) continue;
|
|
70
|
+
const score = (other.kind === 'declaration' ? 0 : 1000) + (other.end_line - other.start_line);
|
|
71
|
+
if (score > bestScore) {
|
|
72
|
+
best = other;
|
|
73
|
+
bestScore = score;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return best;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Ids of the symbol itself plus (for containers) its direct members. */
|
|
80
|
+
function bodyIds(db: ClastDb, sym: SymbolRow): number[] {
|
|
81
|
+
if (CONTAINER_KINDS.has(sym.kind)) {
|
|
82
|
+
const members = db.all<{ id: number }>(`SELECT id FROM symbols WHERE parent_id = ?`, sym.id);
|
|
83
|
+
return [sym.id, ...members.map(m => m.id)];
|
|
84
|
+
}
|
|
85
|
+
// A function's body includes the closures and helpers nested inside it.
|
|
86
|
+
const ids = [sym.id];
|
|
87
|
+
let frontier = [sym.id];
|
|
88
|
+
for (let depth = 0; depth < 4 && frontier.length && ids.length < 200; depth++) {
|
|
89
|
+
const next: number[] = [];
|
|
90
|
+
for (const id of frontier) {
|
|
91
|
+
for (const row of db.all<{ id: number }>(
|
|
92
|
+
`SELECT id FROM symbols WHERE parent_id = ? AND kind IN ('function', 'method')`, id
|
|
93
|
+
)) {
|
|
94
|
+
ids.push(row.id);
|
|
95
|
+
next.push(row.id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
frontier = next;
|
|
99
|
+
}
|
|
100
|
+
return ids;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Who calls `target`? Call sites are matched by name and then filtered by
|
|
105
|
+
* resolving each site back to its most plausible definition.
|
|
106
|
+
*/
|
|
107
|
+
export interface CallerQuery {
|
|
108
|
+
/** Only call sites in files whose path contains this text (pushed down to SQL). */
|
|
109
|
+
pathContains?: string;
|
|
110
|
+
/** Examine fewer call sites (summaries that only show the first few callers). */
|
|
111
|
+
quick?: boolean;
|
|
112
|
+
/** Other overloads of the target whose call sites count too (a bare name means "any overload"). */
|
|
113
|
+
overloads?: SymbolRow[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Set by callersOf when a name has more call sites than were examined. */
|
|
117
|
+
export interface CallerStats {
|
|
118
|
+
examined: number;
|
|
119
|
+
total: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function callersOf(
|
|
123
|
+
db: ClastDb,
|
|
124
|
+
resolver: Resolver,
|
|
125
|
+
target: SymbolRow,
|
|
126
|
+
query: CallerQuery = {},
|
|
127
|
+
stats?: CallerStats
|
|
128
|
+
): CallerEdge[] {
|
|
129
|
+
const nameId = db.nameId(target.name, false);
|
|
130
|
+
if (nameId === undefined) return [];
|
|
131
|
+
const cols = `c.rowid AS id, c.file_id, c.caller_id, c.line, c.receiver, c.rtype, c.via, c.argc, c.count, f.path, f.lang`;
|
|
132
|
+
const pathClause = query.pathContains ? ` AND f.path LIKE ? ESCAPE '\\'` : '';
|
|
133
|
+
const pathArg = query.pathContains ? ['%' + query.pathContains.replace(/[\\%_]/g, c => '\\' + c) + '%'] : [];
|
|
134
|
+
|
|
135
|
+
// Very common names (`dispose`, `push`, `get`) have more call sites than can be examined.
|
|
136
|
+
// Sites that can see the target - its own file and the files that import it - always go first.
|
|
137
|
+
const near = db.all<CallRow & { id: number }>(
|
|
138
|
+
`SELECT ${cols} FROM calls c JOIN files f ON f.id = c.file_id
|
|
139
|
+
WHERE c.name_id = ? AND (c.file_id = ? OR c.file_id IN (SELECT file_id FROM imports WHERE resolved_id = ?))${pathClause}
|
|
140
|
+
LIMIT ${query.quick ? QUICK_NEAR_ROWS : MAX_NEAR_ROWS}`,
|
|
141
|
+
nameId, target.file_id, target.file_id, ...pathArg
|
|
142
|
+
);
|
|
143
|
+
const seenRows = new Set(near.map(r => r.id));
|
|
144
|
+
const far = db
|
|
145
|
+
.all<CallRow & { id: number }>(
|
|
146
|
+
`SELECT ${cols} FROM calls c JOIN files f ON f.id = c.file_id
|
|
147
|
+
WHERE c.name_id = ?${pathClause} LIMIT ${MAX_CALL_ROWS + MAX_NEAR_ROWS}`,
|
|
148
|
+
nameId, ...pathArg
|
|
149
|
+
)
|
|
150
|
+
.filter(r => !seenRows.has(r.id))
|
|
151
|
+
.slice(0, query.quick ? QUICK_FAR_ROWS : MAX_CALL_ROWS);
|
|
152
|
+
const rows: CallRow[] = [...near, ...far];
|
|
153
|
+
if (stats) {
|
|
154
|
+
stats.examined = rows.length;
|
|
155
|
+
stats.total = rows.length < (query.quick ? QUICK_FAR_ROWS : MAX_CALL_ROWS)
|
|
156
|
+
? rows.length
|
|
157
|
+
: db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM calls WHERE name_id = ?`, nameId)?.n ?? rows.length;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let family = resolver.familyOf(target);
|
|
161
|
+
// A constructor is called by naming its class: `new Foo(...)` resolves to the class.
|
|
162
|
+
if (target.parent_id !== null && target.kind === 'method') {
|
|
163
|
+
const parent = db.symbolById(target.parent_id);
|
|
164
|
+
if (parent && parent.name === target.name && CONTAINER_KINDS.has(parent.kind)) family = new Set([...family, parent.id]);
|
|
165
|
+
}
|
|
166
|
+
const arityTargets = query.overloads?.length ? [target, ...query.overloads] : [target];
|
|
167
|
+
/** Go interface methods the target satisfies structurally: calls through them are possible, not certain. */
|
|
168
|
+
let structural: Set<number> | null = null;
|
|
169
|
+
if (target.lang === 'go' && target.kind === 'method') {
|
|
170
|
+
// Go interfaces are satisfied structurally: a call through an interface this method's
|
|
171
|
+
// type implements may reach it.
|
|
172
|
+
const viaInterface = goInterfaceMethods(db, target);
|
|
173
|
+
if (viaInterface.length) {
|
|
174
|
+
family = new Set([...family, ...viaInterface]);
|
|
175
|
+
structural = new Set(viaInterface);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const edges: CallerEdge[] = [];
|
|
179
|
+
const weak: CallerEdge[] = [];
|
|
180
|
+
const targetIsMember = target.kind === 'method' || target.qname.includes('.');
|
|
181
|
+
// `get_or_create`, `maybeAutoCommit`: a name like that on an unknown receiver is worth listing as possible
|
|
182
|
+
// even from files that never mention the class.
|
|
183
|
+
const distinctive = target.name.length >= 8 && /[a-z0-9][A-Z_]|_[a-z]/.test(target.name);
|
|
184
|
+
// `@property def count` / `get count()`: read as an attribute, so `.count(...)` calls elsewhere are something else.
|
|
185
|
+
const propertyLike = /^@(cached_)?property\b|^(public |private |protected |static |override )*get \w/.test(target.signature);
|
|
186
|
+
const callerCache = new Map<number, SymbolRow | undefined>();
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
let caller: SymbolRow | null = null;
|
|
189
|
+
if (row.caller_id !== null) {
|
|
190
|
+
if (!callerCache.has(row.caller_id)) callerCache.set(row.caller_id, db.symbolById(row.caller_id));
|
|
191
|
+
caller = callerCache.get(row.caller_id) ?? null;
|
|
192
|
+
}
|
|
193
|
+
const site = {
|
|
194
|
+
fileId: row.file_id, path: row.path, lang: row.lang, receiver: row.receiver, rtype: row.rtype, via: row.via,
|
|
195
|
+
argc: row.argc, caller,
|
|
196
|
+
};
|
|
197
|
+
// A call with another number of arguments is a call of another overload (or of something else).
|
|
198
|
+
if (!arityTargets.some(t => resolver.arityFits(t, site))) continue;
|
|
199
|
+
// A symbol "calling itself" by name from inside its own definition line is recursion: keep it.
|
|
200
|
+
const res = resolver.resolve(target.name, site);
|
|
201
|
+
if (res.candidates.length === 0) {
|
|
202
|
+
// Nothing could be attributed. If the receiver's type is simply unknown,
|
|
203
|
+
// keep the site as a possible caller instead of silently dropping it.
|
|
204
|
+
if (targetIsMember && !propertyLike && weak.length < MAX_WEAK && (distinctive || resolver.canSee(row.file_id, target)) && resolver.isOpaqueReceiver(site) && langFamily(row.lang) === langFamily(target.lang) && !(isTestPath(target.path) && !isTestPath(row.path))) {
|
|
205
|
+
weak.push({ caller, path: row.path, line: row.line, count: row.count, certain: false, weak: true });
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const top = res.candidates[0]!;
|
|
210
|
+
let certain: boolean;
|
|
211
|
+
if (family.has(top.sym.id)) {
|
|
212
|
+
// Resolved to the target or to its interface/override/overload twin.
|
|
213
|
+
certain = res.confident;
|
|
214
|
+
// Any type with these methods satisfies a Go interface: a call through it may reach the
|
|
215
|
+
// target, but equally one of the other implementers.
|
|
216
|
+
if (structural && structural.has(top.sym.id)) certain = false;
|
|
217
|
+
// Bound to an override in a subclass: that call does not run the target.
|
|
218
|
+
if (top.sym.id !== target.id && resolver.overriddenBelow(target, top.sym)) {
|
|
219
|
+
if (certain) continue;
|
|
220
|
+
}
|
|
221
|
+
// Bound to a supertype's declaration, but on a receiver that cannot be the target's class
|
|
222
|
+
// (a sibling subclass, or an explicit super call).
|
|
223
|
+
if (certain && top.sym.id !== target.id && !resolver.reaches(target, top.sym, site)) continue;
|
|
224
|
+
} else {
|
|
225
|
+
const mine = res.candidates.find(c => family.has(c.sym.id));
|
|
226
|
+
// Keep near-ties as uncertain edges; drop sites that clearly mean another definition.
|
|
227
|
+
if (!mine || top.score - mine.score > 10) continue;
|
|
228
|
+
// The site binds to another copy of this definition (a vendored fork) that its file can see better.
|
|
229
|
+
if (
|
|
230
|
+
top.sym.qname === target.qname && top.sym.kind === target.kind && top.sym.file_id !== target.file_id &&
|
|
231
|
+
top.score - mine.score >= 4
|
|
232
|
+
) continue;
|
|
233
|
+
certain = false;
|
|
234
|
+
}
|
|
235
|
+
edges.push({ caller, path: row.path, line: row.line, count: row.count, certain });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Call sites that use a local alias of the target: `import { X as Y }`, default imports, require bindings.
|
|
239
|
+
for (const use of resolver.aliasesOf(target)) {
|
|
240
|
+
const aliasId = db.nameId(use.alias, false);
|
|
241
|
+
if (aliasId === undefined) continue;
|
|
242
|
+
const aliasRows = db.all<CallRow>(
|
|
243
|
+
`SELECT c.file_id, c.caller_id, c.line, c.receiver, c.rtype, c.via, c.count, f.path, f.lang
|
|
244
|
+
FROM calls c JOIN files f ON f.id = c.file_id
|
|
245
|
+
WHERE c.name_id = ? AND c.file_id = ? AND c.receiver IS NULL`,
|
|
246
|
+
aliasId, use.fileId
|
|
247
|
+
);
|
|
248
|
+
for (const row of aliasRows) {
|
|
249
|
+
const caller = row.caller_id !== null ? db.symbolById(row.caller_id) ?? null : null;
|
|
250
|
+
edges.push({ caller, path: row.path, line: row.line, count: row.count, certain: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Unattributed sites only matter when they are not already explained.
|
|
254
|
+
const known = new Set(edges.map(e => e.path + ':' + e.line));
|
|
255
|
+
for (const w of weak) if (!known.has(w.path + ':' + w.line)) edges.push(w);
|
|
256
|
+
|
|
257
|
+
// Production code first, then resolved before name-only, then by location.
|
|
258
|
+
edges.sort(
|
|
259
|
+
(a, b) =>
|
|
260
|
+
Number(isTestPath(a.path)) - Number(isTestPath(b.path)) ||
|
|
261
|
+
Number(b.certain) - Number(a.certain) ||
|
|
262
|
+
Number(a.weak ?? false) - Number(b.weak ?? false) ||
|
|
263
|
+
a.path.localeCompare(b.path) ||
|
|
264
|
+
a.line - b.line
|
|
265
|
+
);
|
|
266
|
+
return edges;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** What does `sym` call? For containers, the calls of all direct members. */
|
|
270
|
+
export function calleesOf(db: ClastDb, resolver: Resolver, sym: SymbolRow): CalleeEdge[] {
|
|
271
|
+
const ids = bodyIds(db, sym);
|
|
272
|
+
const byName = new Map<string, CalleeEdge>();
|
|
273
|
+
for (const id of ids) {
|
|
274
|
+
const caller = id === sym.id ? sym : db.symbolById(id) ?? sym;
|
|
275
|
+
const rows = db.all<{ name: string; line: number; receiver: string | null; rtype: string | null; via: number | null; argc: number; count: number }>(
|
|
276
|
+
`SELECT n.name, c.line, c.receiver, c.rtype, c.via, c.argc, c.count
|
|
277
|
+
FROM calls c JOIN names n ON n.id = c.name_id
|
|
278
|
+
WHERE c.caller_id = ? ORDER BY c.line`,
|
|
279
|
+
id
|
|
280
|
+
);
|
|
281
|
+
for (const row of rows) {
|
|
282
|
+
const res = resolver.resolve(row.name, {
|
|
283
|
+
fileId: sym.file_id, path: sym.path, lang: sym.lang, receiver: row.receiver, rtype: row.rtype, via: row.via,
|
|
284
|
+
argc: row.argc, caller,
|
|
285
|
+
});
|
|
286
|
+
const first = res.candidates[0];
|
|
287
|
+
// Follow the call to the implementation, not to a prototype / in-class declaration.
|
|
288
|
+
const top = first ? { ...first, sym: representative(db, resolver, first.sym) } : undefined;
|
|
289
|
+
const key = row.name + '\0' + (top ? top.sym.id : '');
|
|
290
|
+
const existing = byName.get(key);
|
|
291
|
+
if (existing) {
|
|
292
|
+
existing.count += row.count;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
byName.set(key, {
|
|
296
|
+
name: row.name,
|
|
297
|
+
line: row.line,
|
|
298
|
+
count: row.count,
|
|
299
|
+
receiver: row.receiver,
|
|
300
|
+
target: top ? top.sym : null,
|
|
301
|
+
certain: res.confident,
|
|
302
|
+
alternatives: top
|
|
303
|
+
? Math.max(0, res.candidates.length - 1)
|
|
304
|
+
// Unresolved, but the project does define methods with this name: not "external".
|
|
305
|
+
: resolver.isOpaqueReceiver({
|
|
306
|
+
fileId: sym.file_id, path: sym.path, lang: sym.lang, receiver: row.receiver, rtype: row.rtype, via: row.via, caller,
|
|
307
|
+
})
|
|
308
|
+
? resolver.candidates(row.name).filter(c => langFamily(c.lang) === langFamily(sym.lang)).length
|
|
309
|
+
: 0,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return [...byName.values()].sort((a, b) => a.line - b.line);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** One entry per calling symbol (call sites inside the same caller are merged). */
|
|
317
|
+
export interface CallerGroup {
|
|
318
|
+
caller: SymbolRow | null;
|
|
319
|
+
path: string;
|
|
320
|
+
/** First call line inside the caller. */
|
|
321
|
+
line: number;
|
|
322
|
+
/** Number of call sites in this caller. */
|
|
323
|
+
sites: number;
|
|
324
|
+
certain: boolean;
|
|
325
|
+
/** All call sites in this caller are unattributed (receiver type unknown). */
|
|
326
|
+
weak: boolean;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function groupCallers(edges: CallerEdge[]): CallerGroup[] {
|
|
330
|
+
const groups = new Map<string, CallerGroup>();
|
|
331
|
+
for (const e of edges) {
|
|
332
|
+
const key = e.caller ? 's' + e.caller.id : 'f' + e.path;
|
|
333
|
+
const g = groups.get(key);
|
|
334
|
+
if (g) {
|
|
335
|
+
g.sites += e.count;
|
|
336
|
+
if (e.certain) g.certain = true;
|
|
337
|
+
if (!e.weak) g.weak = false;
|
|
338
|
+
if (e.line < g.line) g.line = e.line;
|
|
339
|
+
} else {
|
|
340
|
+
groups.set(key, {
|
|
341
|
+
caller: e.caller, path: e.path, line: e.line, sites: e.count, certain: e.certain, weak: e.weak ?? false,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return [...groups.values()];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Call sites of a name that has no definition in the project (an external
|
|
350
|
+
* library function, a dynamically defined method).
|
|
351
|
+
*/
|
|
352
|
+
export function externalCallSites(db: ClastDb, name: string, limit = 2000): CallerEdge[] {
|
|
353
|
+
const nameId = db.nameId(name, false);
|
|
354
|
+
if (nameId === undefined) return [];
|
|
355
|
+
const rows = db.all<CallRow>(
|
|
356
|
+
`SELECT c.file_id, c.caller_id, c.line, c.receiver, c.rtype, c.via, c.count, f.path, f.lang
|
|
357
|
+
FROM calls c JOIN files f ON f.id = c.file_id WHERE c.name_id = ? LIMIT ?`,
|
|
358
|
+
nameId, limit
|
|
359
|
+
);
|
|
360
|
+
const edges: CallerEdge[] = rows.map(row => ({
|
|
361
|
+
caller: row.caller_id !== null ? db.symbolById(row.caller_id) ?? null : null,
|
|
362
|
+
path: row.path, line: row.line, count: row.count, certain: false,
|
|
363
|
+
}));
|
|
364
|
+
edges.sort(
|
|
365
|
+
(a, b) => Number(isTestPath(a.path)) - Number(isTestPath(b.path)) || a.path.localeCompare(b.path) || a.line - b.line
|
|
366
|
+
);
|
|
367
|
+
return edges;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export interface GraphNode {
|
|
371
|
+
sym: SymbolRow | null;
|
|
372
|
+
/** For callers: where the call happens. For callees: call line in the parent. */
|
|
373
|
+
path: string;
|
|
374
|
+
line: number;
|
|
375
|
+
certain: boolean;
|
|
376
|
+
/** Call sites merged into this node. */
|
|
377
|
+
sites: number;
|
|
378
|
+
/** Unattributed call site (receiver type unknown). */
|
|
379
|
+
weak?: boolean;
|
|
380
|
+
label?: string;
|
|
381
|
+
children: GraphNode[];
|
|
382
|
+
/** Set when this symbol was already expanded elsewhere in the tree. */
|
|
383
|
+
repeated?: boolean;
|
|
384
|
+
/** Children omitted because of the node budget. */
|
|
385
|
+
omitted?: number;
|
|
386
|
+
/** Set when the node has too many edges to expand at this depth. */
|
|
387
|
+
hub?: number;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface GraphOptions {
|
|
391
|
+
depth: number;
|
|
392
|
+
/** Total node budget. */
|
|
393
|
+
budget: number;
|
|
394
|
+
includeTests: boolean;
|
|
395
|
+
/** Only edges whose file matches this path filter. */
|
|
396
|
+
path?: (path: string) => boolean;
|
|
397
|
+
/** Text every call-site path must contain (lets the database do the first cut for `within`). */
|
|
398
|
+
pathContains?: string;
|
|
399
|
+
/** Filled in for the root symbol: how many call sites of the name exist vs were examined. */
|
|
400
|
+
stats?: CallerStats;
|
|
401
|
+
/** Other overloads of the root whose callers are included. */
|
|
402
|
+
overloads?: SymbolRow[];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Symbols with more direct edges than this are not expanded below the first level. */
|
|
406
|
+
const HUB_LIMIT = 30;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Breadth-first expansion of callers or callees, bounded by a total node
|
|
410
|
+
* budget so the output stays small. Hubs (symbols with dozens of callers)
|
|
411
|
+
* are reported but not expanded beyond the first level: their fan-out would
|
|
412
|
+
* drown the rest of the tree.
|
|
413
|
+
*/
|
|
414
|
+
export function buildGraph(
|
|
415
|
+
db: ClastDb,
|
|
416
|
+
resolver: Resolver,
|
|
417
|
+
root: SymbolRow,
|
|
418
|
+
direction: 'callers' | 'callees',
|
|
419
|
+
opts: GraphOptions
|
|
420
|
+
): GraphNode {
|
|
421
|
+
const rootNode: GraphNode = { sym: root, path: root.path, line: root.start_line, certain: true, sites: 1, children: [] };
|
|
422
|
+
const expanded = new Set<number>([root.id]);
|
|
423
|
+
let frontier: GraphNode[] = [rootNode];
|
|
424
|
+
let remaining = opts.budget;
|
|
425
|
+
|
|
426
|
+
for (let level = 0; level < opts.depth && frontier.length && remaining > 0; level++) {
|
|
427
|
+
const next: GraphNode[] = [];
|
|
428
|
+
// Split the remaining budget fairly across this level's nodes.
|
|
429
|
+
const perNode = level === 0 ? remaining : Math.max(3, Math.ceil(remaining / frontier.length));
|
|
430
|
+
for (const node of frontier) {
|
|
431
|
+
if (!node.sym || remaining <= 0) continue;
|
|
432
|
+
let children: GraphNode[];
|
|
433
|
+
if (direction === 'callers') {
|
|
434
|
+
children = groupCallers(
|
|
435
|
+
callersOf(
|
|
436
|
+
db, resolver, node.sym,
|
|
437
|
+
{ pathContains: opts.pathContains, overloads: level === 0 ? opts.overloads : undefined },
|
|
438
|
+
level === 0 ? opts.stats : undefined
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
.filter(g => (opts.includeTests || !isTestPath(g.path)) && (!opts.path || opts.path(g.path)))
|
|
442
|
+
.map(g => ({
|
|
443
|
+
sym: g.caller, path: g.path, line: g.line, certain: g.certain, sites: g.sites, weak: g.weak,
|
|
444
|
+
label: g.caller ? undefined : '<file level>', children: [],
|
|
445
|
+
}));
|
|
446
|
+
} else {
|
|
447
|
+
children = calleesOf(db, resolver, node.sym)
|
|
448
|
+
.filter(e => e.target !== null && (opts.includeTests || !isTestPath(e.target.path)))
|
|
449
|
+
.map(e => ({ sym: e.target, path: e.target!.path, line: e.line, certain: e.certain, sites: e.count, children: [] }));
|
|
450
|
+
}
|
|
451
|
+
if (level > 0 && children.length > HUB_LIMIT) {
|
|
452
|
+
node.hub = children.length;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const take = Math.min(children.length, perNode, remaining);
|
|
456
|
+
node.omitted = children.length - take;
|
|
457
|
+
node.children = children.slice(0, take);
|
|
458
|
+
remaining -= take;
|
|
459
|
+
for (const child of node.children) {
|
|
460
|
+
// Only confirmed edges are worth following further.
|
|
461
|
+
if (!child.sym || child.weak || (direction === 'callers' && !child.certain)) continue;
|
|
462
|
+
if (expanded.has(child.sym.id)) {
|
|
463
|
+
child.repeated = true;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
expanded.add(child.sym.id);
|
|
467
|
+
next.push(child);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
frontier = next;
|
|
471
|
+
}
|
|
472
|
+
return rootNode;
|
|
473
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { SymbolRow } from '../types.js';
|
|
4
|
+
|
|
5
|
+
/** `path:start-end` (or `path:line` for one-liners). */
|
|
6
|
+
export function loc(s: Pick<SymbolRow, 'path' | 'start_line' | 'end_line'>): string {
|
|
7
|
+
return s.start_line === s.end_line
|
|
8
|
+
? `${s.path}:${s.start_line}`
|
|
9
|
+
: `${s.path}:${s.start_line}-${s.end_line}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** `kind qname path:start-end` */
|
|
13
|
+
export function symLine(s: SymbolRow): string {
|
|
14
|
+
return `${s.kind} ${s.qname} ${loc(s)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function firstLine(text: string, max = 160): string {
|
|
18
|
+
const nl = text.indexOf('\n');
|
|
19
|
+
let line = nl === -1 ? text : text.slice(0, nl);
|
|
20
|
+
line = line.trim();
|
|
21
|
+
return line.length > max ? line.slice(0, max - 3) + '...' : line;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function plural(n: number, word: string): string {
|
|
25
|
+
if (n === 1) return `1 ${word}`;
|
|
26
|
+
return `${n} ${word}${/(ch|sh|s|x)$/.test(word) ? 'es' : 's'}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TEST_PATH_RE = /(^|\/)(tests?|__tests__|spec|specs|testdata|fixtures|__mocks__|mocks?|e2e|benchmarks?|bench|examples?|samples?|demos?|docs?|docs_src|documentation|tutorials?|playground|stubs?)(\/|$)|(\.|_|-)(test|spec|tests|bench)\.[a-z0-9]+$|(^|\/)test_[^/]+\.py$|(^|\/)(testassets|test-assets|testfixtures|test-fixtures|perf|benchmarkapps)(\/|$)|(^|\/)tests?\.rs$|Tests?\.(java|cs|kt|swift)$/i;
|
|
30
|
+
|
|
31
|
+
const testPathCache = new Map<string, boolean>();
|
|
32
|
+
|
|
33
|
+
/** Tests, fixtures, examples and benchmarks rank below production code. */
|
|
34
|
+
export function isTestPath(path: string): boolean {
|
|
35
|
+
let hit = testPathCache.get(path);
|
|
36
|
+
if (hit === undefined) {
|
|
37
|
+
// Also .NET / Java style project directories: FunctionalTests/, Foo.Tests/, UnitTest/ (case matters here).
|
|
38
|
+
// Below a Maven / Gradle main source root the directories are package names:
|
|
39
|
+
// `src/main/java/org/springframework/samples/petclinic` is production code, not a sample.
|
|
40
|
+
const main = /^(.*?)src\/main\/(?:java|kotlin|scala|groovy|resources)\/(?:.*\/)?([^/]+)$/.exec(path);
|
|
41
|
+
const probe = main ? `${main[1]}${main[2]}` : path;
|
|
42
|
+
hit = TEST_PATH_RE.test(probe) || /(^|\/)[^/]*[a-z.](Tests?|Specs)\//.test(probe);
|
|
43
|
+
if (testPathCache.size > 100_000) testPathCache.clear();
|
|
44
|
+
testPathCache.set(path, hit);
|
|
45
|
+
}
|
|
46
|
+
return hit;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Test code by path, or by enclosing symbol for languages that keep unit tests
|
|
51
|
+
* next to the code (Rust `mod tests`).
|
|
52
|
+
*/
|
|
53
|
+
export function isTestSite(path: string, callerQname: string | null | undefined): boolean {
|
|
54
|
+
if (isTestPath(path)) return true;
|
|
55
|
+
return !!callerQname && path.endsWith('.rs') && /(^|\.)tests?(\.|$)/.test(callerQname);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const GENERATED_RE = /(^|\/)(generated|gen|third_party|thirdparty|external|deps|vendor)(\/|$)|\.(pb|gen|generated)\.[a-z]+$|\.d\.ts$/i;
|
|
59
|
+
|
|
60
|
+
const vendoredPathCache = new Map<string, boolean>();
|
|
61
|
+
|
|
62
|
+
export function isVendoredPath(path: string): boolean {
|
|
63
|
+
let hit = vendoredPathCache.get(path);
|
|
64
|
+
if (hit === undefined) {
|
|
65
|
+
hit = GENERATED_RE.test(path);
|
|
66
|
+
if (vendoredPathCache.size > 100_000) vendoredPathCache.clear();
|
|
67
|
+
vendoredPathCache.set(path, hit);
|
|
68
|
+
}
|
|
69
|
+
return hit;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Small LRU of file contents split into lines, so several tools in one
|
|
74
|
+
* request (or consecutive requests) do not re-read the same file.
|
|
75
|
+
*/
|
|
76
|
+
export class LineCache {
|
|
77
|
+
private cache = new Map<string, string[]>();
|
|
78
|
+
constructor(private readonly root: string, private readonly max = 64) {}
|
|
79
|
+
|
|
80
|
+
lines(rel: string): string[] | null {
|
|
81
|
+
const hit = this.cache.get(rel);
|
|
82
|
+
if (hit) {
|
|
83
|
+
this.cache.delete(rel);
|
|
84
|
+
this.cache.set(rel, hit);
|
|
85
|
+
return hit;
|
|
86
|
+
}
|
|
87
|
+
let text: string;
|
|
88
|
+
try {
|
|
89
|
+
text = readFileSync(join(this.root, rel), 'utf-8');
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
94
|
+
const lines = text.split(/\r?\n/);
|
|
95
|
+
this.cache.set(rel, lines);
|
|
96
|
+
if (this.cache.size > this.max) {
|
|
97
|
+
const oldest = this.cache.keys().next().value;
|
|
98
|
+
if (oldest !== undefined) this.cache.delete(oldest);
|
|
99
|
+
}
|
|
100
|
+
return lines;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
invalidate(rel?: string): void {
|
|
104
|
+
if (rel) this.cache.delete(rel);
|
|
105
|
+
else this.cache.clear();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One trimmed source line (1-based), or ''. */
|
|
109
|
+
line(rel: string, n: number, max = 180): string {
|
|
110
|
+
const lines = this.lines(rel);
|
|
111
|
+
const raw = lines?.[n - 1];
|
|
112
|
+
if (raw === undefined) return '';
|
|
113
|
+
const t = raw.trim();
|
|
114
|
+
return t.length > max ? t.slice(0, max - 3) + '...' : t;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Numbered excerpt of lines [start, end] (1-based, inclusive). */
|
|
118
|
+
excerpt(rel: string, start: number, end: number): string | null {
|
|
119
|
+
const lines = this.lines(rel);
|
|
120
|
+
if (!lines) return null;
|
|
121
|
+
const from = Math.max(1, start);
|
|
122
|
+
const to = Math.min(lines.length, end);
|
|
123
|
+
const width = String(to).length;
|
|
124
|
+
const out: string[] = [];
|
|
125
|
+
for (let i = from; i <= to; i++) {
|
|
126
|
+
out.push(`${String(i).padStart(width)}| ${lines[i - 1]}`);
|
|
127
|
+
}
|
|
128
|
+
return out.join('\n');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Cap a list and say how many were dropped. */
|
|
133
|
+
export function capped<T>(items: T[], limit: number): { shown: T[]; more: number } {
|
|
134
|
+
if (items.length <= limit) return { shown: items, more: 0 };
|
|
135
|
+
return { shown: items.slice(0, limit), more: items.length - limit };
|
|
136
|
+
}
|