Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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; }
}
}
}
Comment on lines +1022 to +1068

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Undocumented RES-3 block affects Java/Scala/Groovy — no fixture coverage in this PR

The diff adds a full RES-3 JVM-reflection resolution block (typeMap lookup + caller-class prefix) but the PR description and test plan cover only dynamic-kotlin. The block is guarded by !is_module_scoped_language so it fires for any non-JS/TS file. All 423 cargo test cases pass, so there's no regression, but there's also no explicit fixture exercising the new getMethod/invokeMethod path in this changeset. A dedicated Java or Groovy reflection fixture (or at minimum a unit test) would give future reviewers confidence that the two qualified forms behave as intended.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code


// 4. Scoped fallback (this/self/super or no receiver)
if call.receiver.is_none()
|| call.receiver.as_deref() == Some("this")
Expand Down
16 changes: 7 additions & 9 deletions crates/codegraph-core/src/extractors/kotlin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<dynamic:unresolved>".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
Comment on lines 385 to +386

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 invoke suppression is file-wide, not scoped to unresolvable function references

The guard if name == "invoke" drops every .invoke() navigation call regardless of receiver type. A Kotlin class with operator fun invoke(...) called via obj.invoke(...) would also be silently skipped, even if obj's type is statically known. Since this is an intentional WASM-parity decision it may be acceptable, but a short comment noting that operator-invoke edges are also dropped (not just function-reference calls) would help future maintainers distinguish deliberate suppression from an oversight.

Fix in Claude Code

} else {
symbols.calls.push(Call {
name,
Expand Down
Loading