From 5bed41bc3034f11a22ad269b94269ccb371cbdd9 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 16:15:19 -0600 Subject: [PATCH 1/2] fix(native): add RES-3 reflection resolution to Rust call resolver for Groovy/Java/Scala JVM getMethod/invokeMethod calls with literal method names were emitted by the Rust extractors with dynamicKind='reflection' and keyExpr set, but resolve_call_targets in build_edges.rs had no path to resolve them. Methods in Groovy/Java/Scala are stored as class-qualified names (e.g. DynamicDispatch.greet), so the plain name lookup of 'greet' finds nothing. The TypeScript resolver has a RES-3 fallback in resolveFallbackTargets that handles this via two qualified lookups: 1. typeMap[receiver] -> resolvedType.keyExpr (type-annotated locals) 2. callerName class prefix -> CallerClass.keyExpr (same-class siblings) Add the equivalent block to resolve_call_targets in Rust, scoped to non-JS/TS files to avoid interfering with the existing JS reflection path. Fixes the wasm=1 native=0 divergence for: dynamic-groovy: DynamicDispatch.runInvokeMethod -> DynamicDispatch.greet dynamic-java: Reflection.runGetMethod -> Reflection.greet dynamic-scala: Reflection.runGetMethod -> Reflection.greet Closes #1681 --- .../graph/builder/stages/build_edges.rs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) 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..c0f35a17 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 @@ -996,6 +996,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") From 1b82c361949beed356f9c2877b34d6f034605c64 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 16:29:18 -0600 Subject: [PATCH 2/2] fix(native): kotlin callable-ref prefers class method; suppress spurious invoke sink RES-4 pre-pass in resolve_call_targets: when a call has dynamicKind='reflection' and a receiver but no keyExpr (i.e. Greeter::greet member callable reference), look up {Receiver}.{name} in the same file before the plain {name} same-file lookup. Without this, greet matched the top-level free function instead of Greeter.greet (the class method), diverging from the WASM engine which has an equivalent RES-4 pre-pass in resolveFallbackTargets. For fn.invoke() patterns: the WASM grammar models navigation_expression with a navigation_suffix child, so WASM's lastChild.type check never sees 'simple_identifier' and emits nothing. Native was emitting an call which produced a spurious sink edge. Suppress the invoke call in native to match WASM behaviour. Closes #1682 --- .../graph/builder/stages/build_edges.rs | 23 +++++++++++++++++++ .../codegraph-core/src/extractors/kotlin.rs | 16 ++++++------- 2 files changed, 30 insertions(+), 9 deletions(-) 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 c0f35a17..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)) 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,