@kodus/kodus-graph 0.2.6 → 0.2.8
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 +40 -2
- package/src/cli.ts +3 -0
- package/src/commands/context.ts +45 -2
- 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/src/shared/logger.ts +3 -0
package/package.json
CHANGED
package/src/analysis/diff.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IndexedGraph } from '../graph/loader';
|
|
2
2
|
import type { GraphEdge, GraphNode } from '../graph/types';
|
|
3
|
+
import { log } from '../shared/logger';
|
|
3
4
|
|
|
4
5
|
export interface NodeChange {
|
|
5
6
|
qualified_name: string;
|
|
@@ -45,6 +46,12 @@ export function computeStructuralDiff(
|
|
|
45
46
|
if (changedSet.has(n.file_path)) newNodesMap.set(n.qualified_name, n);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
log.debug('diff: input', {
|
|
50
|
+
oldNodesInChanged: oldNodesInChanged.size,
|
|
51
|
+
newNodesInChanged: newNodesMap.size,
|
|
52
|
+
changedFiles,
|
|
53
|
+
});
|
|
54
|
+
|
|
48
55
|
// Classify nodes
|
|
49
56
|
const added: NodeChange[] = [];
|
|
50
57
|
const removed: NodeChange[] = [];
|
|
@@ -79,12 +86,34 @@ export function computeStructuralDiff(
|
|
|
79
86
|
if (n.content_hash && newN.content_hash) {
|
|
80
87
|
// Definitive: hash comparison catches ALL content changes,
|
|
81
88
|
// even same-line-count edits (e.g. `return 1` → `return 2`).
|
|
82
|
-
if (n.content_hash !== newN.content_hash)
|
|
89
|
+
if (n.content_hash !== newN.content_hash) {
|
|
90
|
+
changes.push('body');
|
|
91
|
+
log.debug('diff: body change detected', {
|
|
92
|
+
node: qn,
|
|
93
|
+
oldHash: n.content_hash.substring(0, 8),
|
|
94
|
+
newHash: newN.content_hash.substring(0, 8),
|
|
95
|
+
});
|
|
96
|
+
} else {
|
|
97
|
+
log.debug('diff: hash match (displacement only)', {
|
|
98
|
+
node: qn,
|
|
99
|
+
oldLines: `${n.line_start}-${n.line_end}`,
|
|
100
|
+
newLines: `${newN.line_start}-${newN.line_end}`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
83
103
|
} else if (n.line_start !== newN.line_start || n.line_end !== newN.line_end) {
|
|
84
104
|
// Fallback (legacy data without content_hash): size heuristic.
|
|
85
105
|
const oldSize = n.line_end - n.line_start;
|
|
86
106
|
const newSize = newN.line_end - newN.line_start;
|
|
87
|
-
if (oldSize !== newSize)
|
|
107
|
+
if (oldSize !== newSize) {
|
|
108
|
+
changes.push('line_range');
|
|
109
|
+
log.debug('diff: line_range fallback (no content_hash)', {
|
|
110
|
+
node: qn,
|
|
111
|
+
hasOldHash: !!n.content_hash,
|
|
112
|
+
hasNewHash: !!newN.content_hash,
|
|
113
|
+
oldSize,
|
|
114
|
+
newSize,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
88
117
|
}
|
|
89
118
|
if ((n.params || '') !== (newN.params || '')) changes.push('params');
|
|
90
119
|
if ((n.return_type || '') !== (newN.return_type || '')) changes.push('return_type');
|
|
@@ -121,6 +150,15 @@ export function computeStructuralDiff(
|
|
|
121
150
|
riskByFile[file] = { dependents: count, risk };
|
|
122
151
|
}
|
|
123
152
|
|
|
153
|
+
log.info('diff: result', {
|
|
154
|
+
added: added.length,
|
|
155
|
+
removed: removed.length,
|
|
156
|
+
modified: modified.length,
|
|
157
|
+
edgesAdded: addedEdges.length,
|
|
158
|
+
edgesRemoved: removedEdges.length,
|
|
159
|
+
modifiedDetails: modified.map((m) => `${m.qualified_name} [${m.changes.join(',')}]`),
|
|
160
|
+
});
|
|
161
|
+
|
|
124
162
|
return {
|
|
125
163
|
changed_files: changedFiles,
|
|
126
164
|
summary: { added: added.length, removed: removed.length, modified: modified.length },
|
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,9 @@ import { executeUpdate } from './commands/update';
|
|
|
14
14
|
const program = new Command();
|
|
15
15
|
|
|
16
16
|
import pkg from '../package.json';
|
|
17
|
+
import { log } from './shared/logger';
|
|
18
|
+
|
|
19
|
+
log.info(`kodus-graph v${pkg.version}`, { node: process.version, platform: process.platform });
|
|
17
20
|
program.name('kodus-graph').description('Code graph builder for Kodus code review').version(pkg.version);
|
|
18
21
|
|
|
19
22
|
program
|
package/src/commands/context.ts
CHANGED
|
@@ -22,6 +22,15 @@ interface ContextOptions {
|
|
|
22
22
|
export async function executeContext(opts: ContextOptions): Promise<void> {
|
|
23
23
|
const repoDir = resolve(opts.repoDir);
|
|
24
24
|
|
|
25
|
+
log.info('context: starting', {
|
|
26
|
+
files: opts.files,
|
|
27
|
+
repoDir,
|
|
28
|
+
graph: opts.graph ?? null,
|
|
29
|
+
format: opts.format,
|
|
30
|
+
minConfidence: opts.minConfidence,
|
|
31
|
+
maxDepth: opts.maxDepth,
|
|
32
|
+
});
|
|
33
|
+
|
|
25
34
|
// Parse changed files using secure temp
|
|
26
35
|
const tmp = createSecureTempFile('ctx');
|
|
27
36
|
try {
|
|
@@ -33,6 +42,11 @@ export async function executeContext(opts: ContextOptions): Promise<void> {
|
|
|
33
42
|
});
|
|
34
43
|
const parseResult = JSON.parse(readFileSync(tmp.filePath, 'utf-8'));
|
|
35
44
|
|
|
45
|
+
log.info('context: parse done', {
|
|
46
|
+
nodes: parseResult.nodes?.length ?? 0,
|
|
47
|
+
edges: parseResult.edges?.length ?? 0,
|
|
48
|
+
});
|
|
49
|
+
|
|
36
50
|
// Load and merge with main graph if provided
|
|
37
51
|
let mergedGraph: GraphData;
|
|
38
52
|
let oldGraph: GraphData | null = null;
|
|
@@ -53,6 +67,12 @@ export async function executeContext(opts: ContextOptions): Promise<void> {
|
|
|
53
67
|
const changedSet = new Set(opts.files);
|
|
54
68
|
const sameBranch = detectSameBranch(validated.data.nodes, parseResult.nodes, changedSet);
|
|
55
69
|
|
|
70
|
+
log.info('context: baseline graph loaded', {
|
|
71
|
+
graphNodes: validated.data.nodes.length,
|
|
72
|
+
graphEdges: validated.data.edges.length,
|
|
73
|
+
sameBranch,
|
|
74
|
+
});
|
|
75
|
+
|
|
56
76
|
if (sameBranch) {
|
|
57
77
|
// --graph was built from the same commit (e.g. kodus-ai's parse --all on PR branch).
|
|
58
78
|
// Exclude changed files from oldGraph so diff detects their functions as "added"
|
|
@@ -88,6 +108,16 @@ export async function executeContext(opts: ContextOptions): Promise<void> {
|
|
|
88
108
|
maxDepth: opts.maxDepth,
|
|
89
109
|
});
|
|
90
110
|
|
|
111
|
+
log.info('context: analysis done', {
|
|
112
|
+
changedFunctions: output.analysis.changed_functions.length,
|
|
113
|
+
diff: output.analysis.structural_diff.summary,
|
|
114
|
+
blastRadius: output.analysis.blast_radius.total_functions,
|
|
115
|
+
risk: `${output.analysis.risk.level} (${output.analysis.risk.score})`,
|
|
116
|
+
testGaps: output.analysis.test_gaps.length,
|
|
117
|
+
affectedFlows: output.analysis.affected_flows.length,
|
|
118
|
+
duration_ms: output.analysis.metadata.duration_ms,
|
|
119
|
+
});
|
|
120
|
+
|
|
91
121
|
if (opts.format === 'prompt') {
|
|
92
122
|
writeFileSync(opts.out, formatPrompt(output));
|
|
93
123
|
} else {
|
|
@@ -120,7 +150,10 @@ function detectSameBranch(
|
|
|
120
150
|
}
|
|
121
151
|
|
|
122
152
|
// No overlap means graph has no nodes for changed files — not same-branch scenario
|
|
123
|
-
if (graphHashes.size === 0)
|
|
153
|
+
if (graphHashes.size === 0) {
|
|
154
|
+
log.debug('detectSameBranch: no graph hashes for changed files');
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
124
157
|
|
|
125
158
|
const parseHashes = new Map<string, string>();
|
|
126
159
|
for (const n of parseNodes) {
|
|
@@ -132,8 +165,18 @@ function detectSameBranch(
|
|
|
132
165
|
// If any overlapping file has different hash → different branch
|
|
133
166
|
for (const [file, hash] of graphHashes) {
|
|
134
167
|
const parseHash = parseHashes.get(file);
|
|
135
|
-
if (parseHash && parseHash !== hash)
|
|
168
|
+
if (parseHash && parseHash !== hash) {
|
|
169
|
+
log.debug('detectSameBranch: hash mismatch → different branch', {
|
|
170
|
+
file,
|
|
171
|
+
graphHash: hash.substring(0, 8),
|
|
172
|
+
parseHash: parseHash.substring(0, 8),
|
|
173
|
+
});
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
136
176
|
}
|
|
137
177
|
|
|
178
|
+
log.debug('detectSameBranch: all hashes match → same branch', {
|
|
179
|
+
filesCompared: graphHashes.size,
|
|
180
|
+
});
|
|
138
181
|
return true;
|
|
139
182
|
}
|
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
|
+
}
|
package/src/shared/logger.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// src/shared/logger.ts
|
|
2
2
|
export const log = {
|
|
3
|
+
info(msg: string, ctx?: Record<string, unknown>): void {
|
|
4
|
+
process.stderr.write(`[INFO] ${msg}${ctx ? ` ${JSON.stringify(ctx)}` : ''}\n`);
|
|
5
|
+
},
|
|
3
6
|
debug(msg: string, ctx?: Record<string, unknown>): void {
|
|
4
7
|
if (process.env.KODUS_GRAPH_DEBUG) {
|
|
5
8
|
process.stderr.write(`[DEBUG] ${msg}${ctx ? ` ${JSON.stringify(ctx)}` : ''}\n`);
|