@kodus/kodus-graph 0.2.5 → 0.2.7
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 +1 -1
- package/src/analysis/diff.ts +11 -12
- package/src/analysis/prompt-formatter.ts +47 -7
- package/src/graph/types.ts +1 -0
- package/src/parser/extractor.ts +1 -1
- package/src/parser/extractors/generic.ts +58 -16
- package/src/parser/extractors/python.ts +17 -15
- package/src/parser/extractors/ruby.ts +15 -15
- package/src/parser/extractors/typescript.ts +24 -12
- package/src/resolver/call-resolver.ts +36 -0
- package/src/resolver/symbol-table.ts +18 -4
- package/src/shared/extract-calls.ts +75 -0
package/package.json
CHANGED
package/src/analysis/diff.ts
CHANGED
|
@@ -74,18 +74,17 @@ export function computeStructuralDiff(
|
|
|
74
74
|
} else {
|
|
75
75
|
const newN = newNodesMap.get(qn)!;
|
|
76
76
|
const changes: string[] = [];
|
|
77
|
-
// Detect real changes vs. pure displacement
|
|
78
|
-
// content_hash
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (n.content_hash
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
77
|
+
// Detect real content changes vs. pure displacement.
|
|
78
|
+
// content_hash = SHA256 of the node's source text (position-independent).
|
|
79
|
+
if (n.content_hash && newN.content_hash) {
|
|
80
|
+
// Definitive: hash comparison catches ALL content changes,
|
|
81
|
+
// even same-line-count edits (e.g. `return 1` → `return 2`).
|
|
82
|
+
if (n.content_hash !== newN.content_hash) changes.push('body');
|
|
83
|
+
} else if (n.line_start !== newN.line_start || n.line_end !== newN.line_end) {
|
|
84
|
+
// Fallback (legacy data without content_hash): size heuristic.
|
|
85
|
+
const oldSize = n.line_end - n.line_start;
|
|
86
|
+
const newSize = newN.line_end - newN.line_start;
|
|
87
|
+
if (oldSize !== newSize) changes.push('line_range');
|
|
89
88
|
}
|
|
90
89
|
if ((n.params || '') !== (newN.params || '')) changes.push('params');
|
|
91
90
|
if ((n.return_type || '') !== (newN.return_type || '')) changes.push('return_type');
|
|
@@ -118,16 +118,56 @@ export function formatPrompt(output: ContextV2Output): string {
|
|
|
118
118
|
lines.push('');
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// Structural diff
|
|
121
|
+
// Structural diff
|
|
122
122
|
const diff = analysis.structural_diff;
|
|
123
|
-
|
|
123
|
+
const hasNodeChanges = diff.summary.added > 0 || diff.summary.removed > 0 || diff.summary.modified > 0;
|
|
124
|
+
const hasEdgeChanges = diff.edges.added.length > 0 || diff.edges.removed.length > 0;
|
|
125
|
+
|
|
126
|
+
if (hasNodeChanges || hasEdgeChanges) {
|
|
124
127
|
lines.push('## Structural Changes');
|
|
125
128
|
lines.push('');
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
|
|
130
|
+
if (hasNodeChanges) {
|
|
131
|
+
const parts: string[] = [];
|
|
132
|
+
if (diff.summary.added > 0) parts.push(`${diff.summary.added} added`);
|
|
133
|
+
if (diff.summary.removed > 0) parts.push(`${diff.summary.removed} removed`);
|
|
134
|
+
if (diff.summary.modified > 0) parts.push(`${diff.summary.modified} modified`);
|
|
135
|
+
lines.push(parts.join(', '));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (diff.nodes.removed.length > 0) {
|
|
139
|
+
lines.push('');
|
|
140
|
+
lines.push('Removed:');
|
|
141
|
+
for (const n of diff.nodes.removed) {
|
|
142
|
+
const name = n.qualified_name.split('::').pop();
|
|
143
|
+
lines.push(` - [${n.kind}] ${name} [${n.file_path}:${n.line_start}]`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (diff.nodes.modified.length > 0) {
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push('Modified:');
|
|
150
|
+
for (const m of diff.nodes.modified) {
|
|
151
|
+
const name = m.qualified_name.split('::').pop();
|
|
152
|
+
lines.push(` - ${name} (${m.changes.join(', ')})`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (hasEdgeChanges) {
|
|
157
|
+
lines.push('');
|
|
158
|
+
lines.push('Dependency changes:');
|
|
159
|
+
for (const e of diff.edges.added) {
|
|
160
|
+
const src = e.source_qualified.split('::').pop();
|
|
161
|
+
const tgt = e.target_qualified.split('::').pop();
|
|
162
|
+
lines.push(` + ${e.kind}: ${src} → ${tgt}`);
|
|
163
|
+
}
|
|
164
|
+
for (const e of diff.edges.removed) {
|
|
165
|
+
const src = e.source_qualified.split('::').pop();
|
|
166
|
+
const tgt = e.target_qualified.split('::').pop();
|
|
167
|
+
lines.push(` - ${e.kind}: ${src} → ${tgt}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
131
171
|
lines.push('');
|
|
132
172
|
}
|
|
133
173
|
|
package/src/graph/types.ts
CHANGED
|
@@ -247,6 +247,7 @@ export interface RawCallSite {
|
|
|
247
247
|
callName: string; // function or method name being called
|
|
248
248
|
line: number; // line number of the call
|
|
249
249
|
diField?: string; // if DI pattern (this.field.method), the field name
|
|
250
|
+
resolveInClass?: string; // class to resolve in: current class for self.X(), parent for super().X()
|
|
250
251
|
}
|
|
251
252
|
|
|
252
253
|
export interface RawCallEdge {
|
package/src/parser/extractor.ts
CHANGED
|
@@ -32,6 +32,6 @@ export function extractCallsFromFile(root: SgRoot, filePath: string, lang: Lang
|
|
|
32
32
|
} else if (lang === 'ruby') {
|
|
33
33
|
extractCallsFromRuby(root, filePath, calls);
|
|
34
34
|
} else {
|
|
35
|
-
extractCallsFromGeneric(root, filePath, calls);
|
|
35
|
+
extractCallsFromGeneric(root, filePath, lang as string, calls);
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SgNode, SgRoot } from '@ast-grep/napi';
|
|
2
2
|
import type { RawCallSite, RawGraph } from '../../graph/types';
|
|
3
|
+
import { type CallExtractionConfig, extractCalls } from '../../shared/extract-calls';
|
|
3
4
|
import { computeContentHash } from '../../shared/file-hash';
|
|
4
|
-
import { NOISE } from '../../shared/filters';
|
|
5
5
|
import { log } from '../../shared/logger';
|
|
6
6
|
import { LANG_KINDS } from '../languages';
|
|
7
7
|
|
|
@@ -69,22 +69,64 @@ export function extractGeneric(root: SgRoot, fp: string, lang: string, seen: Set
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** Shared class-finder for languages using class/struct/impl AST kinds. */
|
|
73
|
+
function findEnclosingClassGeneric(node: import('@ast-grep/napi').SgNode): import('@ast-grep/napi').SgNode | null {
|
|
74
|
+
return node.ancestors().find((a) => {
|
|
75
|
+
const k = String(a.kind());
|
|
76
|
+
return k.includes('class') || k.includes('struct') || k.includes('impl');
|
|
77
|
+
}) ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Per-language call extraction configs for self/super detection. */
|
|
81
|
+
const GENERIC_CONFIGS: Record<string, CallExtractionConfig> = {
|
|
82
|
+
java: {
|
|
83
|
+
selfPrefixes: ['this.'],
|
|
84
|
+
superPrefixes: ['super.'],
|
|
85
|
+
findEnclosingClass: findEnclosingClassGeneric,
|
|
86
|
+
getParentClass: (classNode) => {
|
|
87
|
+
const sc = classNode.children().find((c) => c.kind() === 'superclass');
|
|
88
|
+
return sc?.children().find((c) => c.kind() === 'type_identifier')?.text();
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
csharp: {
|
|
92
|
+
selfPrefixes: ['this.'],
|
|
93
|
+
superPrefixes: ['base.'],
|
|
94
|
+
findEnclosingClass: findEnclosingClassGeneric,
|
|
95
|
+
getParentClass: (classNode) => {
|
|
96
|
+
const bl = classNode.children().find((c) => c.kind() === 'base_list');
|
|
97
|
+
return bl?.children().find((c) => c.kind() === 'identifier' || c.kind() === 'type_identifier')?.text();
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
rust: {
|
|
101
|
+
selfPrefixes: ['self.'],
|
|
102
|
+
superPrefixes: [],
|
|
103
|
+
findEnclosingClass: (node) =>
|
|
104
|
+
node.ancestors().find((a) => a.kind() === 'impl_item') ?? null,
|
|
105
|
+
},
|
|
106
|
+
go: {
|
|
107
|
+
selfPrefixes: [],
|
|
108
|
+
superPrefixes: [],
|
|
109
|
+
findEnclosingClass: findEnclosingClassGeneric,
|
|
110
|
+
},
|
|
111
|
+
php: {
|
|
112
|
+
selfPrefixes: [],
|
|
113
|
+
superPrefixes: [],
|
|
114
|
+
findEnclosingClass: findEnclosingClassGeneric,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Fallback config for unknown languages — no self/super detection. */
|
|
119
|
+
const FALLBACK_CONFIG: CallExtractionConfig = {
|
|
120
|
+
selfPrefixes: [],
|
|
121
|
+
superPrefixes: [],
|
|
122
|
+
findEnclosingClass: findEnclosingClassGeneric,
|
|
123
|
+
};
|
|
124
|
+
|
|
72
125
|
/**
|
|
73
126
|
* Extract raw call sites from a generic language AST.
|
|
74
|
-
*
|
|
127
|
+
* Uses per-language config for self/super detection.
|
|
75
128
|
*/
|
|
76
|
-
export function extractCallsFromGeneric(root: SgRoot, fp: string, calls: RawCallSite[]): void {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
for (const m of rootNode.findAll('$CALLEE($$$ARGS)')) {
|
|
80
|
-
const callee = m.getMatch('CALLEE')?.text();
|
|
81
|
-
if (!callee) continue;
|
|
82
|
-
const callName = callee.includes('.') ? callee.split('.').pop()! : callee;
|
|
83
|
-
if (NOISE.has(callName)) continue;
|
|
84
|
-
calls.push({
|
|
85
|
-
source: fp,
|
|
86
|
-
callName,
|
|
87
|
-
line: m.range().start.line,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
129
|
+
export function extractCallsFromGeneric(root: SgRoot, fp: string, lang: string, calls: RawCallSite[]): void {
|
|
130
|
+
const config = GENERIC_CONFIGS[lang] ?? FALLBACK_CONFIG;
|
|
131
|
+
extractCalls(root.root(), fp, config, calls);
|
|
90
132
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SgNode, SgRoot } from '@ast-grep/napi';
|
|
2
2
|
import type { RawCallSite, RawGraph } from '../../graph/types';
|
|
3
|
+
import { type CallExtractionConfig, extractCalls } from '../../shared/extract-calls';
|
|
3
4
|
import { computeContentHash } from '../../shared/file-hash';
|
|
4
|
-
import { NOISE } from '../../shared/filters';
|
|
5
5
|
import { LANG_KINDS } from '../languages';
|
|
6
6
|
|
|
7
7
|
export function extractPython(root: SgRoot, fp: string, seen: Set<string>, graph: RawGraph): void {
|
|
@@ -110,22 +110,24 @@ export function extractPython(root: SgRoot, fp: string, seen: Set<string>, graph
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/** Python-specific call extraction config for shared extractCalls(). */
|
|
114
|
+
const PYTHON_CALL_CONFIG: CallExtractionConfig = {
|
|
115
|
+
selfPrefixes: ['self.'],
|
|
116
|
+
superPrefixes: ['super().'],
|
|
117
|
+
findEnclosingClass: (node) =>
|
|
118
|
+
node.ancestors().find((a: SgNode) => a.kind() === 'class_definition') ?? null,
|
|
119
|
+
getParentClass: (classNode) => {
|
|
120
|
+
const argList =
|
|
121
|
+
classNode.field('superclasses') ||
|
|
122
|
+
classNode.children().find((c: SgNode) => c.kind() === 'argument_list');
|
|
123
|
+
return argList?.children().find((c: SgNode) => c.kind() === 'identifier')?.text();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
113
127
|
/**
|
|
114
128
|
* Extract raw call sites from a Python AST.
|
|
115
|
-
*
|
|
129
|
+
* Detects self.X() and super().X() to preserve class resolution context.
|
|
116
130
|
*/
|
|
117
131
|
export function extractCallsFromPython(root: SgRoot, fp: string, calls: RawCallSite[]): void {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
for (const m of rootNode.findAll('$CALLEE($$$ARGS)')) {
|
|
121
|
-
const callee = m.getMatch('CALLEE')?.text();
|
|
122
|
-
if (!callee) continue;
|
|
123
|
-
const callName = callee.includes('.') ? callee.split('.').pop()! : callee;
|
|
124
|
-
if (NOISE.has(callName)) continue;
|
|
125
|
-
calls.push({
|
|
126
|
-
source: fp,
|
|
127
|
-
callName,
|
|
128
|
-
line: m.range().start.line,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
132
|
+
extractCalls(root.root(), fp, PYTHON_CALL_CONFIG, calls);
|
|
131
133
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SgNode, SgRoot } from '@ast-grep/napi';
|
|
2
2
|
import type { RawCallSite, RawGraph } from '../../graph/types';
|
|
3
|
+
import { type CallExtractionConfig, extractCalls } from '../../shared/extract-calls';
|
|
3
4
|
import { computeContentHash } from '../../shared/file-hash';
|
|
4
|
-
import { NOISE } from '../../shared/filters';
|
|
5
5
|
import { log } from '../../shared/logger';
|
|
6
6
|
import { LANG_KINDS } from '../languages';
|
|
7
7
|
|
|
@@ -126,22 +126,22 @@ export function extractRuby(root: SgRoot, fp: string, seen: Set<string>, graph:
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/** Ruby-specific call extraction config for shared extractCalls(). */
|
|
130
|
+
function createRubyCallConfig(): CallExtractionConfig {
|
|
131
|
+
const kinds = LANG_KINDS.ruby;
|
|
132
|
+
return {
|
|
133
|
+
selfPrefixes: ['self.'],
|
|
134
|
+
superPrefixes: ['super'],
|
|
135
|
+
findEnclosingClass: (node) =>
|
|
136
|
+
node.ancestors().find((a: SgNode) => a.kind() === kinds.class || a.kind() === kinds.module) ?? null,
|
|
137
|
+
getParentClass: (classNode) => classNode.field('superclass')?.text(),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
129
141
|
/**
|
|
130
142
|
* Extract raw call sites from a Ruby AST.
|
|
131
|
-
*
|
|
143
|
+
* Detects self.X() and super() to preserve class resolution context.
|
|
132
144
|
*/
|
|
133
145
|
export function extractCallsFromRuby(root: SgRoot, fp: string, calls: RawCallSite[]): void {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
for (const m of rootNode.findAll('$CALLEE($$$ARGS)')) {
|
|
137
|
-
const callee = m.getMatch('CALLEE')?.text();
|
|
138
|
-
if (!callee) continue;
|
|
139
|
-
const callName = callee.includes('.') ? callee.split('.').pop()! : callee;
|
|
140
|
-
if (NOISE.has(callName)) continue;
|
|
141
|
-
calls.push({
|
|
142
|
-
source: fp,
|
|
143
|
-
callName,
|
|
144
|
-
line: m.range().start.line,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
146
|
+
extractCalls(root.root(), fp, createRubyCallConfig(), calls);
|
|
147
147
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SgNode, SgRoot } from '@ast-grep/napi';
|
|
2
2
|
import { Lang } from '@ast-grep/napi';
|
|
3
3
|
import type { RawCallSite, RawGraph } from '../../graph/types';
|
|
4
|
+
import { type CallExtractionConfig, extractCalls } from '../../shared/extract-calls';
|
|
4
5
|
import { computeContentHash } from '../../shared/file-hash';
|
|
5
6
|
import { NOISE } from '../../shared/filters';
|
|
6
7
|
import { LANG_KINDS } from '../languages';
|
|
@@ -302,6 +303,27 @@ export function extractTypeScript(
|
|
|
302
303
|
}
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
/** TypeScript-specific call extraction config for shared extractCalls(). */
|
|
307
|
+
const TS_CALL_CONFIG: CallExtractionConfig = {
|
|
308
|
+
selfPrefixes: ['this.'],
|
|
309
|
+
superPrefixes: ['super.'],
|
|
310
|
+
findEnclosingClass: (node) => {
|
|
311
|
+
const kinds = LANG_KINDS.typescript;
|
|
312
|
+
return node.ancestors().find(
|
|
313
|
+
(a: SgNode) => a.kind() === kinds.class || a.kind() === kinds.abstractClass,
|
|
314
|
+
) ?? null;
|
|
315
|
+
},
|
|
316
|
+
getParentClass: (classNode) => {
|
|
317
|
+
const heritage = classNode.children().find((c: SgNode) => c.kind() === 'class_heritage');
|
|
318
|
+
const ext = heritage?.children().find((c: SgNode) => c.kind() === 'extends_clause');
|
|
319
|
+
return ext?.children().find(
|
|
320
|
+
(c: SgNode) => c.kind() === 'identifier' || c.kind() === 'type_identifier' || c.kind() === 'member_expression',
|
|
321
|
+
)?.text();
|
|
322
|
+
},
|
|
323
|
+
// Skip this.field.method — already handled by the DI pattern above
|
|
324
|
+
skipCallee: (callee) => callee.startsWith('this.') && callee.substring(5).includes('.'),
|
|
325
|
+
};
|
|
326
|
+
|
|
305
327
|
/**
|
|
306
328
|
* Extract raw call sites from a TypeScript/JavaScript AST.
|
|
307
329
|
* Finds DI calls (this.field.method) and direct calls ($CALLEE($$$ARGS)).
|
|
@@ -323,16 +345,6 @@ export function extractCallsFromTypeScript(root: SgRoot, fp: string, calls: RawC
|
|
|
323
345
|
});
|
|
324
346
|
}
|
|
325
347
|
|
|
326
|
-
// Direct calls
|
|
327
|
-
|
|
328
|
-
const callee = m.getMatch('CALLEE')?.text();
|
|
329
|
-
if (!callee || callee.startsWith('this.')) continue;
|
|
330
|
-
const callName = callee.includes('.') ? callee.split('.').pop()! : callee;
|
|
331
|
-
if (NOISE.has(callName)) continue;
|
|
332
|
-
calls.push({
|
|
333
|
-
source: fp,
|
|
334
|
-
callName,
|
|
335
|
-
line: m.range().start.line,
|
|
336
|
-
});
|
|
337
|
-
}
|
|
348
|
+
// Direct calls + self/super detection via shared function
|
|
349
|
+
extractCalls(rootNode, fp, TS_CALL_CONFIG, calls);
|
|
338
350
|
}
|
|
@@ -77,6 +77,22 @@ export function resolveAllCalls(
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Class-aware resolution for self.X() and super().X()
|
|
81
|
+
if (call.resolveInClass) {
|
|
82
|
+
const classResolved = resolveInClass(call.callName, fp, call.resolveInClass, symbolTable);
|
|
83
|
+
if (classResolved) {
|
|
84
|
+
callEdges.push({
|
|
85
|
+
source: fp,
|
|
86
|
+
target: classResolved.target,
|
|
87
|
+
callName: call.callName,
|
|
88
|
+
line: call.line,
|
|
89
|
+
confidence: classResolved.confidence,
|
|
90
|
+
});
|
|
91
|
+
stats[classResolved.strategy]++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
// Name-based cascade fallback
|
|
81
97
|
const resolved = resolveByName(call.callName, fp, symbolTable, importMap);
|
|
82
98
|
if (resolved) {
|
|
@@ -94,6 +110,26 @@ export function resolveAllCalls(
|
|
|
94
110
|
return { callEdges, stats };
|
|
95
111
|
}
|
|
96
112
|
|
|
113
|
+
// ── Class-aware resolution (self./super.) ──
|
|
114
|
+
|
|
115
|
+
function resolveInClass(
|
|
116
|
+
callName: string,
|
|
117
|
+
currentFile: string,
|
|
118
|
+
className: string,
|
|
119
|
+
symbolTable: SymbolTable,
|
|
120
|
+
): ResolveResult | null {
|
|
121
|
+
// Try same-file class method first (self.method() or super().method())
|
|
122
|
+
const inFile = symbolTable.lookupInFile(currentFile, callName, className);
|
|
123
|
+
if (inFile) return { target: inFile, confidence: 0.9, strategy: 'same' };
|
|
124
|
+
|
|
125
|
+
// Class might be in another file (imported parent class for super())
|
|
126
|
+
const candidates = symbolTable.lookupGlobal(callName);
|
|
127
|
+
const match = candidates.find((q) => q.includes(`::${className}.${callName}`));
|
|
128
|
+
if (match) return { target: match, confidence: 0.85, strategy: 'import' };
|
|
129
|
+
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
97
133
|
// ── DI resolution ──
|
|
98
134
|
|
|
99
135
|
function resolveDICall(
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
export interface SymbolTable {
|
|
10
10
|
add(file: string, name: string, qualified: string): void;
|
|
11
11
|
lookupExact(file: string, name: string): string | null;
|
|
12
|
+
lookupInFile(file: string, name: string, className: string): string | null;
|
|
12
13
|
isUnique(name: string): boolean;
|
|
13
14
|
lookupGlobal(name: string): string[];
|
|
14
15
|
readonly size: number;
|
|
@@ -16,20 +17,31 @@ export interface SymbolTable {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export function createSymbolTable(): SymbolTable {
|
|
19
|
-
const byFile = new Map<string, Map<string, string>>();
|
|
20
|
+
const byFile = new Map<string, Map<string, string[]>>();
|
|
20
21
|
const byName = new Map<string, string[]>();
|
|
21
22
|
|
|
22
23
|
return {
|
|
23
24
|
add(file, name, qualified) {
|
|
24
25
|
if (!byFile.has(file)) byFile.set(file, new Map());
|
|
25
|
-
byFile.get(file)
|
|
26
|
+
const fileMap = byFile.get(file)!;
|
|
27
|
+
if (!fileMap.has(name)) fileMap.set(name, []);
|
|
28
|
+
fileMap.get(name)!.push(qualified);
|
|
26
29
|
|
|
27
30
|
if (!byName.has(name)) byName.set(name, []);
|
|
28
31
|
byName.get(name)!.push(qualified);
|
|
29
32
|
},
|
|
30
33
|
|
|
31
34
|
lookupExact(file, name) {
|
|
32
|
-
|
|
35
|
+
const candidates = byFile.get(file)?.get(name);
|
|
36
|
+
if (!candidates || candidates.length === 0) return null;
|
|
37
|
+
// Only return if unambiguous within this file
|
|
38
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
lookupInFile(file, name, className) {
|
|
42
|
+
const candidates = byFile.get(file)?.get(name);
|
|
43
|
+
if (!candidates || candidates.length === 0) return null;
|
|
44
|
+
return candidates.find((q) => q.includes(`::${className}.${name}`)) ?? null;
|
|
33
45
|
},
|
|
34
46
|
|
|
35
47
|
isUnique(name) {
|
|
@@ -42,7 +54,9 @@ export function createSymbolTable(): SymbolTable {
|
|
|
42
54
|
|
|
43
55
|
get size() {
|
|
44
56
|
let count = 0;
|
|
45
|
-
for (const m of byFile.values())
|
|
57
|
+
for (const m of byFile.values()) {
|
|
58
|
+
for (const arr of m.values()) count += arr.length;
|
|
59
|
+
}
|
|
46
60
|
return count;
|
|
47
61
|
},
|
|
48
62
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SgNode } from '@ast-grep/napi';
|
|
2
|
+
import type { RawCallSite } from '../graph/types';
|
|
3
|
+
import { NOISE } from './filters';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Language-specific configuration for call extraction.
|
|
7
|
+
* Each language provides its self/super patterns and how to find class context in the AST.
|
|
8
|
+
*/
|
|
9
|
+
export interface CallExtractionConfig {
|
|
10
|
+
/** Prefixes indicating a self-reference (e.g., 'self.', 'this.') */
|
|
11
|
+
selfPrefixes: string[];
|
|
12
|
+
/** Prefixes indicating a super-reference (e.g., 'super().', 'super.', 'base.') */
|
|
13
|
+
superPrefixes: string[];
|
|
14
|
+
/** Find the enclosing class/module/impl node from a call site */
|
|
15
|
+
findEnclosingClass: (node: SgNode) => SgNode | null;
|
|
16
|
+
/** Extract the parent class name from a class node (for super resolution) */
|
|
17
|
+
getParentClass?: (classNode: SgNode) => string | undefined;
|
|
18
|
+
/** Skip this callee entirely (e.g., TS skips this.field.method — handled by DI) */
|
|
19
|
+
skipCallee?: (callee: string) => boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shared call extraction for all languages.
|
|
24
|
+
*
|
|
25
|
+
* Parses `$CALLEE($$$ARGS)` pattern, detects self/super references
|
|
26
|
+
* based on language config, and populates resolveInClass for
|
|
27
|
+
* class-aware resolution downstream.
|
|
28
|
+
*/
|
|
29
|
+
export function extractCalls(
|
|
30
|
+
rootNode: SgNode,
|
|
31
|
+
fp: string,
|
|
32
|
+
config: CallExtractionConfig,
|
|
33
|
+
calls: RawCallSite[],
|
|
34
|
+
): void {
|
|
35
|
+
for (const m of rootNode.findAll('$CALLEE($$$ARGS)')) {
|
|
36
|
+
const callee = m.getMatch('CALLEE')?.text();
|
|
37
|
+
if (!callee) continue;
|
|
38
|
+
if (config.skipCallee?.(callee)) continue;
|
|
39
|
+
|
|
40
|
+
const callName = callee.includes('.') ? callee.split('.').pop()! : callee;
|
|
41
|
+
if (NOISE.has(callName)) continue;
|
|
42
|
+
|
|
43
|
+
let resolveInClass: string | undefined;
|
|
44
|
+
|
|
45
|
+
// Check self-reference: callee must be exactly `prefix + methodName` (no further chaining)
|
|
46
|
+
for (const prefix of config.selfPrefixes) {
|
|
47
|
+
if (!callee.startsWith(prefix)) continue;
|
|
48
|
+
const rest = callee.substring(prefix.length);
|
|
49
|
+
if (rest.includes('.')) break; // chained access (e.g., this.field.method) — not a self call
|
|
50
|
+
const classNode = config.findEnclosingClass(m);
|
|
51
|
+
resolveInClass = classNode?.field('name')?.text();
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check super-reference if no self match
|
|
56
|
+
if (!resolveInClass) {
|
|
57
|
+
for (const prefix of config.superPrefixes) {
|
|
58
|
+
const matches = callee === prefix || (callee.startsWith(prefix) && !callee.substring(prefix.length).includes('.'));
|
|
59
|
+
if (!matches) continue;
|
|
60
|
+
const classNode = config.findEnclosingClass(m);
|
|
61
|
+
if (classNode && config.getParentClass) {
|
|
62
|
+
resolveInClass = config.getParentClass(classNode);
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
calls.push({
|
|
69
|
+
source: fp,
|
|
70
|
+
callName,
|
|
71
|
+
line: m.range().start.line,
|
|
72
|
+
...(resolveInClass ? { resolveInClass } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|