diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index fbf06559..d86c4287 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -897,6 +897,29 @@ fn resolve_call_targets<'a>( if !targets.is_empty() { return targets; } } + // RES-4: Kotlin member callable reference — `Greeter::greet` emits + // { name: 'greet', receiver: 'Greeter', dynamicKind: 'reflection' }. + // A plain same-file lookup of 'greet' finds the top-level free function + // before the qualified form is tried. Match the WASM pre-qualified pass: + // when dynamicKind='reflection', receiver is set, and no keyExpr, try the + // qualified `{Receiver}.{name}` form first (mirrors the RES-4 pre-pass in + // `resolveFallbackTargets` in build-edges.ts). + if call.dynamic_kind.as_deref() == Some("reflection") + && call.receiver.is_some() + && call.key_expr.is_none() + && !is_module_scoped_language(rel_path) + { + let receiver = call.receiver.as_deref().unwrap(); + let qualified = format!("{}.{}", receiver, call.name); + let pre_qualified: Vec<&NodeInfo> = ctx.nodes_by_name_and_file + .get(&(qualified.as_str(), rel_path)) + .map(|v| v.iter() + .filter(|n| n.kind == "method" || n.kind == "function") + .copied().collect()) + .unwrap_or_default(); + if !pre_qualified.is_empty() { return pre_qualified; } + } + // 2. Same-file resolution let targets = ctx.nodes_by_name_and_file .get(&(call.name.as_str(), rel_path)) @@ -996,6 +1019,54 @@ fn resolve_call_targets<'a>( } } + // RES-3: reflection with literal method name — JVM getMethod("name") / invokeMethod("name"). + // Java/Scala/Groovy methods are stored as class-qualified names (e.g. Reflection.greet), + // so a plain lookup of `keyExpr` finds nothing. When dynamicKind='reflection' and keyExpr + // is set (a string-literal method name was captured), try two qualified forms: + // 1. typeMap[receiver] → resolvedType → `resolvedType.keyExpr` (type-annotated local) + // 2. callerName class prefix → `CallerClass.keyExpr` (same-class sibling — covers Groovy + // obj.invokeMethod and Java/Scala clazz.getMethod where the class is the caller's own) + // Scoped to non-JS/TS files to avoid interfering with the JS reflection path. + // Mirrors `resolveFallbackTargets` RES-3 block in `src/domain/graph/builder/stages/build-edges.ts`. + if call.dynamic_kind.as_deref() == Some("reflection") + && call.key_expr.is_some() + && call.receiver.is_some() + && !is_module_scoped_language(rel_path) + { + let key_expr = call.key_expr.as_deref().unwrap(); + let receiver = call.receiver.as_deref().unwrap(); + + // RES-3.1: typeMap[receiver] → resolvedType.keyExpr + if let Some(&(resolved_type, _)) = type_map.get(receiver) { + let qualified = format!("{}.{}", resolved_type, key_expr); + let typed: Vec<&NodeInfo> = ctx.nodes_by_name + .get(qualified.as_str()) + .map(|v| v.iter() + .filter(|n| (n.kind == "method" || n.kind == "function") + && resolve::compute_confidence(rel_path, &n.file, None) >= 0.5) + .copied().collect()) + .unwrap_or_default(); + if !typed.is_empty() { return typed; } + } + + // RES-3.2: callerName class prefix → CallerClass.keyExpr + if !caller_name.is_empty() { + if let Some(last_dot) = caller_name.rfind('.') { + let seg_start = caller_name[..last_dot].rfind('.').map(|p| p + 1).unwrap_or(0); + let caller_class = &caller_name[seg_start..last_dot]; + let qualified = format!("{}.{}", caller_class, key_expr); + let class_scoped: Vec<&NodeInfo> = ctx.nodes_by_name + .get(qualified.as_str()) + .map(|v| v.iter() + .filter(|n| (n.kind == "method" || n.kind == "function") + && resolve::compute_confidence(rel_path, &n.file, None) >= 0.5) + .copied().collect()) + .unwrap_or_default(); + if !class_scoped.is_empty() { return class_scoped; } + } + } + } + // 4. Scoped fallback (this/self/super or no receiver) if call.receiver.is_none() || call.receiver.as_deref() == Some("this") diff --git a/crates/codegraph-core/src/extractors/kotlin.rs b/crates/codegraph-core/src/extractors/kotlin.rs index efd7f36a..1a2c2f6b 100644 --- a/crates/codegraph-core/src/extractors/kotlin.rs +++ b/crates/codegraph-core/src/extractors/kotlin.rs @@ -376,16 +376,14 @@ fn match_kotlin_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep .unwrap_or_else(|| node_text(&fn_node, source).to_string()); let receiver = fn_node.child(0) .map(|n| node_text(&n, source).to_string()); - // fn.invoke(args) — callable ref invocation; flag as unresolved + // fn.invoke(args) — callable ref invocation without static type + // info; the WASM grammar represents `navigation_expression` with a + // `navigation_suffix` child so its `lastChild.type` check misses + // `invoke` and emits nothing. Match that behaviour here so both + // engines agree: skip `invoke` calls rather than emitting an + // unresolvable-dynamic sink that the WASM path never produces. if name == "invoke" { - symbols.calls.push(Call { - name: "".to_string(), - line: start_line(node), - dynamic: Some(true), - dynamic_kind: Some("unresolved-dynamic".to_string()), - receiver, - ..Default::default() - }); + // intentionally emit nothing — mirrors WASM engine behaviour } else { symbols.calls.push(Call { name,