@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodus/kodus-graph",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Code graph builder for Kodus code review — parses source code into structural graphs with nodes, edges, and analysis",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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) changes.push('body');
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) changes.push('line_range');
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
@@ -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) return false;
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) return false;
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
  }
@@ -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 {
@@ -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
- * Direct calls only.
127
+ * Uses per-language config for self/super detection.
75
128
  */
76
- export function extractCallsFromGeneric(root: SgRoot, fp: string, calls: RawCallSite[]): void {
77
- const rootNode = root.root();
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
- * Direct calls only Python has no DI pattern.
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
- const rootNode = root.root();
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
- * Direct calls only Ruby has no DI pattern.
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
- const rootNode = root.root();
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: $CALLEE($$$ARGS)
327
- for (const m of rootNode.findAll('$CALLEE($$$ARGS)')) {
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)!.set(name, qualified);
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
- return byFile.get(file)?.get(name) ?? null;
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()) count += m.size;
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
+ }
@@ -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`);