@kodus/kodus-graph 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +62 -0
- package/src/analysis/blast-radius.ts +54 -0
- package/src/analysis/communities.ts +135 -0
- package/src/analysis/diff.ts +120 -0
- package/src/analysis/flows.ts +112 -0
- package/src/analysis/review-context.ts +141 -0
- package/src/analysis/risk-score.ts +62 -0
- package/src/analysis/search.ts +76 -0
- package/src/analysis/test-gaps.ts +21 -0
- package/src/cli.ts +192 -0
- package/src/commands/analyze.ts +66 -0
- package/src/commands/communities.ts +19 -0
- package/src/commands/context.ts +69 -0
- package/src/commands/diff.ts +96 -0
- package/src/commands/flows.ts +19 -0
- package/src/commands/parse.ts +100 -0
- package/src/commands/search.ts +41 -0
- package/src/commands/update.ts +166 -0
- package/src/graph/builder.ts +170 -0
- package/src/graph/edges.ts +101 -0
- package/src/graph/loader.ts +100 -0
- package/src/graph/merger.ts +25 -0
- package/src/graph/types.ts +218 -0
- package/src/parser/batch.ts +74 -0
- package/src/parser/discovery.ts +42 -0
- package/src/parser/extractor.ts +37 -0
- package/src/parser/extractors/generic.ts +87 -0
- package/src/parser/extractors/python.ts +127 -0
- package/src/parser/extractors/ruby.ts +142 -0
- package/src/parser/extractors/typescript.ts +329 -0
- package/src/parser/languages.ts +122 -0
- package/src/resolver/call-resolver.ts +179 -0
- package/src/resolver/import-map.ts +27 -0
- package/src/resolver/import-resolver.ts +72 -0
- package/src/resolver/languages/csharp.ts +7 -0
- package/src/resolver/languages/go.ts +7 -0
- package/src/resolver/languages/java.ts +7 -0
- package/src/resolver/languages/php.ts +7 -0
- package/src/resolver/languages/python.ts +35 -0
- package/src/resolver/languages/ruby.ts +21 -0
- package/src/resolver/languages/rust.ts +7 -0
- package/src/resolver/languages/typescript.ts +168 -0
- package/src/resolver/symbol-table.ts +53 -0
- package/src/shared/file-hash.ts +7 -0
- package/src/shared/filters.ts +243 -0
- package/src/shared/logger.ts +14 -0
- package/src/shared/qualified-name.ts +5 -0
- package/src/shared/safe-path.ts +31 -0
- package/src/shared/schemas.ts +31 -0
- package/src/shared/temp.ts +17 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// ── Node kinds (aligned with Postgres ast_nodes.kind) ──
|
|
2
|
+
export type NodeKind = 'Function' | 'Method' | 'Constructor' | 'Class' | 'Interface' | 'Enum' | 'Test';
|
|
3
|
+
|
|
4
|
+
// ── Edge kinds (aligned with Postgres ast_edges.kind) ──
|
|
5
|
+
export type EdgeKind = 'CALLS' | 'IMPORTS' | 'INHERITS' | 'IMPLEMENTS' | 'TESTED_BY' | 'CONTAINS';
|
|
6
|
+
|
|
7
|
+
// ── Graph node (matches ast_nodes table) ──
|
|
8
|
+
export interface GraphNode {
|
|
9
|
+
kind: NodeKind;
|
|
10
|
+
name: string;
|
|
11
|
+
qualified_name: string;
|
|
12
|
+
file_path: string;
|
|
13
|
+
line_start: number;
|
|
14
|
+
line_end: number;
|
|
15
|
+
language: string;
|
|
16
|
+
parent_name?: string;
|
|
17
|
+
params?: string;
|
|
18
|
+
return_type?: string;
|
|
19
|
+
modifiers?: string;
|
|
20
|
+
is_test: boolean;
|
|
21
|
+
file_hash: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Graph edge (matches ast_edges table) ──
|
|
25
|
+
export interface GraphEdge {
|
|
26
|
+
kind: EdgeKind;
|
|
27
|
+
source_qualified: string;
|
|
28
|
+
target_qualified: string;
|
|
29
|
+
file_path: string;
|
|
30
|
+
line: number;
|
|
31
|
+
confidence?: number; // 0.0-1.0, only for CALLS
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Full graph data ──
|
|
35
|
+
export interface GraphData {
|
|
36
|
+
nodes: GraphNode[];
|
|
37
|
+
edges: GraphEdge[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Parse command output ──
|
|
41
|
+
export interface ParseMetadata {
|
|
42
|
+
repo_dir: string;
|
|
43
|
+
files_parsed: number;
|
|
44
|
+
total_nodes: number;
|
|
45
|
+
total_edges: number;
|
|
46
|
+
duration_ms: number;
|
|
47
|
+
parse_errors: number;
|
|
48
|
+
extract_errors: number;
|
|
49
|
+
files_unchanged?: number;
|
|
50
|
+
incremental?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ParseOutput {
|
|
54
|
+
metadata: ParseMetadata;
|
|
55
|
+
nodes: GraphNode[];
|
|
56
|
+
edges: GraphEdge[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Analyze command output ──
|
|
60
|
+
export interface BlastRadiusResult {
|
|
61
|
+
total_functions: number;
|
|
62
|
+
total_files: number;
|
|
63
|
+
by_depth: Record<string, string[]>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RiskFactor {
|
|
67
|
+
weight: number;
|
|
68
|
+
value: number;
|
|
69
|
+
detail: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface RiskScoreResult {
|
|
73
|
+
level: 'LOW' | 'MEDIUM' | 'HIGH';
|
|
74
|
+
score: number;
|
|
75
|
+
factors: {
|
|
76
|
+
blast_radius: RiskFactor;
|
|
77
|
+
test_gaps: RiskFactor;
|
|
78
|
+
complexity: RiskFactor;
|
|
79
|
+
inheritance: RiskFactor;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TestGap {
|
|
84
|
+
function: string;
|
|
85
|
+
file_path: string;
|
|
86
|
+
line_start: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AnalysisOutput {
|
|
90
|
+
blast_radius: BlastRadiusResult;
|
|
91
|
+
risk_score: RiskScoreResult;
|
|
92
|
+
test_gaps: TestGap[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Context command output ──
|
|
96
|
+
export interface ContextMetadata {
|
|
97
|
+
changed_functions: number;
|
|
98
|
+
caller_count: number;
|
|
99
|
+
callee_count: number;
|
|
100
|
+
untested_count: number;
|
|
101
|
+
blast_radius: { functions: number; files: number };
|
|
102
|
+
risk_level: 'LOW' | 'MEDIUM' | 'HIGH';
|
|
103
|
+
risk_score: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ContextOutput {
|
|
107
|
+
text: string;
|
|
108
|
+
metadata: ContextMetadata;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Main graph JSON (input --graph, from Postgres) ──
|
|
112
|
+
export interface MainGraphInput {
|
|
113
|
+
repo_id: string;
|
|
114
|
+
sha: string;
|
|
115
|
+
nodes: GraphNode[];
|
|
116
|
+
edges: GraphEdge[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Internal types used during parsing pipeline ──
|
|
120
|
+
export interface RawFunction {
|
|
121
|
+
name: string;
|
|
122
|
+
file: string;
|
|
123
|
+
line_start: number;
|
|
124
|
+
line_end: number;
|
|
125
|
+
params: string;
|
|
126
|
+
returnType: string;
|
|
127
|
+
kind: 'Function' | 'Method' | 'Constructor';
|
|
128
|
+
className: string;
|
|
129
|
+
qualified: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface RawClass {
|
|
133
|
+
name: string;
|
|
134
|
+
file: string;
|
|
135
|
+
line_start: number;
|
|
136
|
+
line_end: number;
|
|
137
|
+
extends: string;
|
|
138
|
+
implements: string;
|
|
139
|
+
qualified: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface RawInterface {
|
|
143
|
+
name: string;
|
|
144
|
+
file: string;
|
|
145
|
+
line_start: number;
|
|
146
|
+
line_end: number;
|
|
147
|
+
methods: string[];
|
|
148
|
+
qualified: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface RawEnum {
|
|
152
|
+
name: string;
|
|
153
|
+
file: string;
|
|
154
|
+
line_start: number;
|
|
155
|
+
line_end: number;
|
|
156
|
+
qualified: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface RawTest {
|
|
160
|
+
name: string;
|
|
161
|
+
file: string;
|
|
162
|
+
line_start: number;
|
|
163
|
+
line_end: number;
|
|
164
|
+
qualified: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface RawImport {
|
|
168
|
+
module: string;
|
|
169
|
+
file: string;
|
|
170
|
+
line: number;
|
|
171
|
+
names: string[];
|
|
172
|
+
lang: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface RawReExport {
|
|
176
|
+
module: string;
|
|
177
|
+
file: string;
|
|
178
|
+
line: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface RawCallSite {
|
|
182
|
+
source: string; // relative file path
|
|
183
|
+
callName: string; // function or method name being called
|
|
184
|
+
line: number; // line number of the call
|
|
185
|
+
diField?: string; // if DI pattern (this.field.method), the field name
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface RawCallEdge {
|
|
189
|
+
source: string; // file path of the caller
|
|
190
|
+
target: string; // qualified name of the callee
|
|
191
|
+
callName: string;
|
|
192
|
+
line: number;
|
|
193
|
+
confidence: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface ImportEdge {
|
|
197
|
+
source: string; // source file
|
|
198
|
+
target: string; // resolved target file or unresolved module
|
|
199
|
+
resolved: boolean;
|
|
200
|
+
line: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface RawGraph {
|
|
204
|
+
functions: RawFunction[];
|
|
205
|
+
classes: RawClass[];
|
|
206
|
+
interfaces: RawInterface[];
|
|
207
|
+
enums: RawEnum[];
|
|
208
|
+
tests: RawTest[];
|
|
209
|
+
imports: RawImport[];
|
|
210
|
+
reExports: RawReExport[];
|
|
211
|
+
rawCalls: RawCallSite[];
|
|
212
|
+
diMaps: Map<string, Map<string, string>>; // file -> Map<fieldName, typeName>
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface ParseBatchResult extends RawGraph {
|
|
216
|
+
parseErrors: number;
|
|
217
|
+
extractErrors: number;
|
|
218
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { SgRoot } from '@ast-grep/napi';
|
|
2
|
+
import { parseAsync } from '@ast-grep/napi';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { extname, relative } from 'path';
|
|
5
|
+
import type { ParseBatchResult, RawGraph } from '../graph/types';
|
|
6
|
+
import { log } from '../shared/logger';
|
|
7
|
+
import { extractCallsFromFile, extractFromFile } from './extractor';
|
|
8
|
+
import { getLanguage } from './languages';
|
|
9
|
+
|
|
10
|
+
const BATCH_SIZE = 50;
|
|
11
|
+
|
|
12
|
+
export async function parseBatch(files: string[], repoRoot: string): Promise<ParseBatchResult> {
|
|
13
|
+
const graph: RawGraph = {
|
|
14
|
+
functions: [],
|
|
15
|
+
classes: [],
|
|
16
|
+
interfaces: [],
|
|
17
|
+
enums: [],
|
|
18
|
+
tests: [],
|
|
19
|
+
imports: [],
|
|
20
|
+
reExports: [],
|
|
21
|
+
rawCalls: [],
|
|
22
|
+
diMaps: new Map(),
|
|
23
|
+
};
|
|
24
|
+
const seen = new Set<string>();
|
|
25
|
+
let parseErrors = 0;
|
|
26
|
+
let extractErrors = 0;
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
29
|
+
const batch = files.slice(i, i + BATCH_SIZE);
|
|
30
|
+
|
|
31
|
+
const promises = batch.map(async (filePath) => {
|
|
32
|
+
const lang = getLanguage(extname(filePath));
|
|
33
|
+
if (!lang) return;
|
|
34
|
+
|
|
35
|
+
let source: string;
|
|
36
|
+
try {
|
|
37
|
+
source = readFileSync(filePath, 'utf-8');
|
|
38
|
+
} catch (err) {
|
|
39
|
+
log.warn('Failed to read file', { file: filePath, error: String(err) });
|
|
40
|
+
parseErrors++;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let root: SgRoot;
|
|
45
|
+
try {
|
|
46
|
+
root = await parseAsync(lang, source);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.warn('Failed to parse file', { file: filePath, error: String(err) });
|
|
49
|
+
parseErrors++;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fp = relative(repoRoot, filePath);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
extractFromFile(root, fp, lang, seen, graph);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
log.error('Extraction crashed', { file: fp, error: String(err) });
|
|
59
|
+
extractErrors++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
extractCallsFromFile(root, fp, lang, graph.rawCalls);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log.error('Call extraction crashed', { file: fp, error: String(err) });
|
|
66
|
+
extractErrors++;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await Promise.all(promises);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { ...graph, parseErrors, extractErrors };
|
|
74
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readdirSync } from 'fs';
|
|
2
|
+
import { extname, join, resolve } from 'path';
|
|
3
|
+
import { isSkippableFile, SKIP_DIRS } from '../shared/filters';
|
|
4
|
+
import { log } from '../shared/logger';
|
|
5
|
+
import { ensureWithinRoot } from '../shared/safe-path';
|
|
6
|
+
import { getLanguage } from './languages';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Walk the filesystem and find all supported source files.
|
|
10
|
+
* If `filterFiles` is provided, only return those specific files (resolved to absolute paths).
|
|
11
|
+
*/
|
|
12
|
+
export function discoverFiles(repoDir: string, filterFiles?: string[]): string[] {
|
|
13
|
+
const absRepoDir = resolve(repoDir);
|
|
14
|
+
|
|
15
|
+
if (filterFiles) {
|
|
16
|
+
return filterFiles
|
|
17
|
+
.map((f) => (f.startsWith('/') ? f : join(absRepoDir, f)))
|
|
18
|
+
.filter((f) => {
|
|
19
|
+
try {
|
|
20
|
+
ensureWithinRoot(f, absRepoDir);
|
|
21
|
+
return getLanguage(extname(f)) !== null;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
log.warn('Skipping file outside repository root', { file: f, error: String(err) });
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const files: string[] = [];
|
|
30
|
+
walkFiles(absRepoDir, files);
|
|
31
|
+
return files;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function walkFiles(dir: string, files: string[]): void {
|
|
35
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
36
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
37
|
+
walkFiles(join(dir, entry.name), files);
|
|
38
|
+
} else if (entry.isFile() && getLanguage(extname(entry.name)) !== null && !isSkippableFile(entry.name)) {
|
|
39
|
+
files.push(join(dir, entry.name));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Lang, SgRoot } from '@ast-grep/napi';
|
|
2
|
+
import type { RawCallSite, RawGraph } from '../graph/types';
|
|
3
|
+
import { extractCallsFromGeneric, extractGeneric } from './extractors/generic';
|
|
4
|
+
import { extractCallsFromPython, extractPython } from './extractors/python';
|
|
5
|
+
import { extractCallsFromRuby, extractRuby } from './extractors/ruby';
|
|
6
|
+
import { extractCallsFromTypeScript, extractTypeScript } from './extractors/typescript';
|
|
7
|
+
import { isTypeScriptLike } from './languages';
|
|
8
|
+
|
|
9
|
+
export function extractFromFile(
|
|
10
|
+
root: SgRoot,
|
|
11
|
+
filePath: string,
|
|
12
|
+
lang: Lang | string,
|
|
13
|
+
seen: Set<string>,
|
|
14
|
+
graph: RawGraph,
|
|
15
|
+
): void {
|
|
16
|
+
if (isTypeScriptLike(lang)) {
|
|
17
|
+
extractTypeScript(root, filePath, seen, graph, lang);
|
|
18
|
+
} else if (lang === 'python') {
|
|
19
|
+
extractPython(root, filePath, seen, graph);
|
|
20
|
+
} else if (lang === 'ruby') {
|
|
21
|
+
extractRuby(root, filePath, seen, graph);
|
|
22
|
+
} else {
|
|
23
|
+
extractGeneric(root, filePath, lang as string, seen, graph);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function extractCallsFromFile(root: SgRoot, filePath: string, lang: Lang | string, calls: RawCallSite[]): void {
|
|
28
|
+
if (isTypeScriptLike(lang)) {
|
|
29
|
+
extractCallsFromTypeScript(root, filePath, calls);
|
|
30
|
+
} else if (lang === 'python') {
|
|
31
|
+
extractCallsFromPython(root, filePath, calls);
|
|
32
|
+
} else if (lang === 'ruby') {
|
|
33
|
+
extractCallsFromRuby(root, filePath, calls);
|
|
34
|
+
} else {
|
|
35
|
+
extractCallsFromGeneric(root, filePath, calls);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { SgNode, SgRoot } from '@ast-grep/napi';
|
|
2
|
+
import type { RawCallSite, RawGraph } from '../../graph/types';
|
|
3
|
+
import { NOISE } from '../../shared/filters';
|
|
4
|
+
import { log } from '../../shared/logger';
|
|
5
|
+
import { LANG_KINDS } from '../languages';
|
|
6
|
+
|
|
7
|
+
export function extractGeneric(root: SgRoot, fp: string, lang: string, seen: Set<string>, graph: RawGraph): void {
|
|
8
|
+
const kinds = LANG_KINDS[lang];
|
|
9
|
+
if (!kinds) return;
|
|
10
|
+
const rootNode = root.root();
|
|
11
|
+
|
|
12
|
+
// Try to extract classes
|
|
13
|
+
for (const classKind of [kinds.class, kinds.struct, kinds.interface].filter(Boolean)) {
|
|
14
|
+
try {
|
|
15
|
+
for (const node of rootNode.findAll({ rule: { kind: classKind } })) {
|
|
16
|
+
const name = node.field('name')?.text();
|
|
17
|
+
if (!name || seen.has(`c:${fp}:${name}`)) continue;
|
|
18
|
+
seen.add(`c:${fp}:${name}`);
|
|
19
|
+
graph.classes.push({
|
|
20
|
+
name,
|
|
21
|
+
file: fp,
|
|
22
|
+
line_start: node.range().start.line,
|
|
23
|
+
line_end: node.range().end.line,
|
|
24
|
+
extends: '',
|
|
25
|
+
implements: '',
|
|
26
|
+
qualified: `${fp}::${name}`,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
} catch (err) {
|
|
30
|
+
log.debug('Generic extraction failed', { file: fp, error: String(err) });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Try to extract functions/methods
|
|
35
|
+
// biome-ignore lint/complexity/useLiteralKeys: 'constructor' must use bracket notation to avoid Object.prototype.constructor
|
|
36
|
+
for (const funcKind of [kinds.function, kinds.method, kinds['constructor'] as string | undefined].filter(Boolean)) {
|
|
37
|
+
try {
|
|
38
|
+
for (const node of rootNode.findAll({ rule: { kind: funcKind } })) {
|
|
39
|
+
const name = node.field('name')?.text();
|
|
40
|
+
if (!name) continue;
|
|
41
|
+
const line = node.range().start.line;
|
|
42
|
+
if (seen.has(`f:${fp}:${name}:${line}`)) continue;
|
|
43
|
+
seen.add(`f:${fp}:${name}:${line}`);
|
|
44
|
+
|
|
45
|
+
const classAncestor = node.ancestors().find((a: SgNode) => {
|
|
46
|
+
const k = String(a.kind());
|
|
47
|
+
return k.includes('class') || k.includes('struct') || k.includes('impl');
|
|
48
|
+
});
|
|
49
|
+
const className = classAncestor?.field('name')?.text() || '';
|
|
50
|
+
|
|
51
|
+
graph.functions.push({
|
|
52
|
+
name,
|
|
53
|
+
file: fp,
|
|
54
|
+
line_start: line,
|
|
55
|
+
line_end: node.range().end.line,
|
|
56
|
+
params: node.field('parameters')?.text() || '()',
|
|
57
|
+
returnType: node.field('return_type')?.text() || '',
|
|
58
|
+
kind: className ? 'Method' : 'Function',
|
|
59
|
+
className,
|
|
60
|
+
qualified: className ? `${fp}::${className}.${name}` : `${fp}::${name}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
log.debug('Generic extraction failed', { file: fp, error: String(err) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract raw call sites from a generic language AST.
|
|
71
|
+
* Direct calls only.
|
|
72
|
+
*/
|
|
73
|
+
export function extractCallsFromGeneric(root: SgRoot, fp: string, calls: RawCallSite[]): void {
|
|
74
|
+
const rootNode = root.root();
|
|
75
|
+
|
|
76
|
+
for (const m of rootNode.findAll('$CALLEE($$$ARGS)')) {
|
|
77
|
+
const callee = m.getMatch('CALLEE')?.text();
|
|
78
|
+
if (!callee) continue;
|
|
79
|
+
const callName = callee.includes('.') ? callee.split('.').pop()! : callee;
|
|
80
|
+
if (NOISE.has(callName)) continue;
|
|
81
|
+
calls.push({
|
|
82
|
+
source: fp,
|
|
83
|
+
callName,
|
|
84
|
+
line: m.range().start.line,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { SgNode, SgRoot } from '@ast-grep/napi';
|
|
2
|
+
import type { RawCallSite, RawGraph } from '../../graph/types';
|
|
3
|
+
import { NOISE } from '../../shared/filters';
|
|
4
|
+
import { LANG_KINDS } from '../languages';
|
|
5
|
+
|
|
6
|
+
export function extractPython(root: SgRoot, fp: string, seen: Set<string>, graph: RawGraph): void {
|
|
7
|
+
const kinds = LANG_KINDS.python;
|
|
8
|
+
const rootNode = root.root();
|
|
9
|
+
|
|
10
|
+
// ── Classes ──
|
|
11
|
+
for (const node of rootNode.findAll({ rule: { kind: kinds.class } })) {
|
|
12
|
+
const name = node.field('name')?.text();
|
|
13
|
+
if (!name || seen.has(`c:${fp}:${name}`)) continue;
|
|
14
|
+
seen.add(`c:${fp}:${name}`);
|
|
15
|
+
|
|
16
|
+
const argList = node.field('superclasses') || node.children().find((c: SgNode) => c.kind() === 'argument_list');
|
|
17
|
+
const extendsName =
|
|
18
|
+
argList
|
|
19
|
+
?.children()
|
|
20
|
+
.find((c: SgNode) => c.kind() === 'identifier')
|
|
21
|
+
?.text() || '';
|
|
22
|
+
|
|
23
|
+
graph.classes.push({
|
|
24
|
+
name,
|
|
25
|
+
file: fp,
|
|
26
|
+
line_start: node.range().start.line,
|
|
27
|
+
line_end: node.range().end.line,
|
|
28
|
+
extends: extendsName,
|
|
29
|
+
implements: '',
|
|
30
|
+
qualified: `${fp}::${name}`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Functions / Methods ──
|
|
35
|
+
for (const node of rootNode.findAll({ rule: { kind: kinds.function } })) {
|
|
36
|
+
const name = node.field('name')?.text();
|
|
37
|
+
if (!name) continue;
|
|
38
|
+
const line = node.range().start.line;
|
|
39
|
+
if (seen.has(`m:${fp}:${name}:${line}`)) continue;
|
|
40
|
+
seen.add(`m:${fp}:${name}:${line}`);
|
|
41
|
+
|
|
42
|
+
const classAncestor = node.ancestors().find((a: SgNode) => a.kind() === kinds.class);
|
|
43
|
+
const className = classAncestor?.field('name')?.text() || '';
|
|
44
|
+
const retType =
|
|
45
|
+
node
|
|
46
|
+
.field('return_type')
|
|
47
|
+
?.text()
|
|
48
|
+
?.replace(/^->\s*/, '') || '';
|
|
49
|
+
|
|
50
|
+
const isTest = name.startsWith('test_');
|
|
51
|
+
if (isTest) {
|
|
52
|
+
graph.tests.push({
|
|
53
|
+
name,
|
|
54
|
+
file: fp,
|
|
55
|
+
line_start: line,
|
|
56
|
+
line_end: node.range().end.line,
|
|
57
|
+
qualified: `${fp}::test:${name}`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
graph.functions.push({
|
|
62
|
+
name,
|
|
63
|
+
file: fp,
|
|
64
|
+
line_start: line,
|
|
65
|
+
line_end: node.range().end.line,
|
|
66
|
+
params: node.field('parameters')?.text() || '()',
|
|
67
|
+
returnType: retType,
|
|
68
|
+
kind: name === '__init__' ? 'Constructor' : className ? 'Method' : 'Function',
|
|
69
|
+
className,
|
|
70
|
+
qualified: className ? `${fp}::${className}.${name}` : `${fp}::${name}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Imports (from X import Y) ──
|
|
75
|
+
for (const node of rootNode.findAll({ rule: { kind: kinds.import } })) {
|
|
76
|
+
const modNode = node.children().find((c: SgNode) => c.kind() === 'dotted_name' || c.kind() === 'relative_import');
|
|
77
|
+
const modulePath = modNode?.text() || '';
|
|
78
|
+
if (!modulePath) continue;
|
|
79
|
+
|
|
80
|
+
const names: string[] = [];
|
|
81
|
+
for (const child of node.children()) {
|
|
82
|
+
if (child.kind() === 'dotted_name' && child !== modNode) names.push(child.text());
|
|
83
|
+
if (child.kind() === 'identifier' && child !== modNode) names.push(child.text());
|
|
84
|
+
}
|
|
85
|
+
graph.imports.push({
|
|
86
|
+
module: modulePath,
|
|
87
|
+
file: fp,
|
|
88
|
+
line: node.range().start.line,
|
|
89
|
+
names,
|
|
90
|
+
lang: 'python',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Regular imports (import X) ──
|
|
95
|
+
for (const node of rootNode.findAll({ rule: { kind: kinds.importRegular } })) {
|
|
96
|
+
const modNode = node.children().find((c: SgNode) => c.kind() === 'dotted_name');
|
|
97
|
+
if (modNode) {
|
|
98
|
+
graph.imports.push({
|
|
99
|
+
module: modNode.text(),
|
|
100
|
+
file: fp,
|
|
101
|
+
line: node.range().start.line,
|
|
102
|
+
names: [modNode.text()],
|
|
103
|
+
lang: 'python',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract raw call sites from a Python AST.
|
|
111
|
+
* Direct calls only — Python has no DI pattern.
|
|
112
|
+
*/
|
|
113
|
+
export function extractCallsFromPython(root: SgRoot, fp: string, calls: RawCallSite[]): void {
|
|
114
|
+
const rootNode = root.root();
|
|
115
|
+
|
|
116
|
+
for (const m of rootNode.findAll('$CALLEE($$$ARGS)')) {
|
|
117
|
+
const callee = m.getMatch('CALLEE')?.text();
|
|
118
|
+
if (!callee) continue;
|
|
119
|
+
const callName = callee.includes('.') ? callee.split('.').pop()! : callee;
|
|
120
|
+
if (NOISE.has(callName)) continue;
|
|
121
|
+
calls.push({
|
|
122
|
+
source: fp,
|
|
123
|
+
callName,
|
|
124
|
+
line: m.range().start.line,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|