Skip to content

fix(native): kotlin callable-ref prefers class method; suppress spurious invoke sink#1686

Merged
carlos-alm merged 2 commits into
mainfrom
fix/parity-1682-kotlin-callable-ref
Jun 21, 2026
Merged

fix(native): kotlin callable-ref prefers class method; suppress spurious invoke sink#1686
carlos-alm merged 2 commits into
mainfrom
fix/parity-1682-kotlin-callable-ref

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • RES-4 pre-pass in resolve_call_targets: when a reflection-kind call has a receiver but no keyExpr (e.g. 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 in build-edges.ts.
  • Suppress spurious invoke sink: the WASM grammar models navigation_expression with a navigation_suffix child so its lastChild.type check never sees 'simple_identifier' and emits nothing for fn.invoke() patterns. Native was emitting an <dynamic:unresolved> call which produced a spurious sink edge not present in WASM. Suppress the invoke emission in native to match WASM behaviour.

Fixes both parity gaps reported in #1682dynamic-kotlin fixture now shows 0 edge diffs between WASM and native (verified with node scripts/parity-compare.mjs --langs dynamic-kotlin).

Test plan

  • node scripts/parity-compare.mjs --langs dynamic-kotlin → PARITY OK (0 diffs, was 3)
  • npx vitest run tests/benchmarks/resolution/resolution-benchmark.test.tsdynamic-kotlin: precision=100%, recall=100% (3/3 edges)
  • cargo test in crates/codegraph-core → 423/423 tests pass

Closes #1682

…r 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
…ous 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 <dynamic:unresolved> call which produced a
spurious sink edge. Suppress the invoke call in native to match WASM behaviour.

Closes #1682
@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR closes two native/WASM parity gaps for Kotlin call-graph resolution: it adds a RES-4 pre-pass so member callable references (Greeter::greet) prefer the class method over a same-name top-level function, and it suppresses the spurious <dynamic:unresolved> sink emitted for .invoke() calls to match the WASM engine's behaviour.

  • RES-4 pre-pass (build_edges.rs): When dynamic_kind == \"reflection\", receiver is set, and key_expr is absent, the qualified {Receiver}.{name} form is tried in the same file before the plain unqualified lookup, mirroring an equivalent pre-pass in build-edges.ts.
  • invoke suppression (kotlin.rs): The branch that previously emitted <dynamic:unresolved> for fn.invoke(args) is now a no-op, matching WASM which never surfaces this call because navigation_expression / navigation_suffix prevents simple_identifier from surfacing at the lastChild check.
  • RES-3 block (build_edges.rs): A second new block handling JVM reflection (getMethod / invokeMethod) is also added but is not mentioned in the PR description or test plan — it applies to Java/Scala/Groovy files and has no explicit fixture coverage in this PR.

Confidence Score: 4/5

Safe to merge; both changes are narrowly scoped and the existing 423-test suite passes cleanly.

The RES-4 pre-pass and invoke suppression are clean, well-bounded fixes that restore parity with the WASM engine and are covered by the dynamic-kotlin benchmark. The RES-3 block is new JVM-reflection logic that touches a broader surface (Java/Scala/Groovy) without a dedicated fixture in this PR — the existing test suite is not guaranteed to exercise it, leaving a small amount of untested surface area.

The new RES-3 block in build_edges.rs (lines 1022–1068) would benefit from a Java or Groovy reflection fixture to confirm the two qualified-lookup strategies behave as intended.

Important Files Changed

Filename Overview
crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs Adds RES-4 same-file pre-pass for Kotlin member callable references (well-scoped, mirrors WASM) and a RES-3 JVM reflection block (undocumented in PR description, no dedicated fixture coverage in the test plan).
crates/codegraph-core/src/extractors/kotlin.rs Suppresses emission of dynamic:unresolved for .invoke() navigation calls; clean, intentional, and matches the WASM engine behaviour documented in the PR.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[resolve_call_targets called] --> B{Step 1: import-aware?\nimported_from set?}
    B -- Yes --> C[Lookup name in imported file\nReturn if found]
    B -- No --> D{RES-4 pre-pass:\ndynamic_kind='reflection'\nreceiver set, no key_expr\nnon-module-scoped lang?}
    D -- Yes --> E[Lookup Receiver.name\nin same file]
    E -- Found --> F[Return qualified method\ne.g. Greeter.greet]
    E -- Not found --> G[Step 2: same-file unqualified\nlookup of name]
    D -- No --> G
    G -- Found --> H[Return same-file match]
    G -- Not found --> I[Step 3: type-aware\nreceiver to typeMap lookup]
    I --> J{RES-3:\ndynamic_kind='reflection'\nkey_expr set, receiver set\nnon-module-scoped lang?}
    J -- Yes --> K[RES-3.1: typeMap receiver\nto resolvedType.keyExpr]
    K -- Found --> L[Return typed match]
    K -- Not found --> M[RES-3.2: callerClass.keyExpr\nfrom caller_name prefix]
    M -- Found --> N[Return class-scoped match]
    M -- Not found --> O[Step 4: scoped fallback]
    J -- No --> O

    subgraph kotlin_rs[kotlin.rs]
    P[navigation_expression call node] --> Q{name == 'invoke'?}
    Q -- Yes --> R[Emit nothing - WASM parity]
    Q -- No --> S[Emit Call with name + receiver]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[resolve_call_targets called] --> B{Step 1: import-aware?\nimported_from set?}
    B -- Yes --> C[Lookup name in imported file\nReturn if found]
    B -- No --> D{RES-4 pre-pass:\ndynamic_kind='reflection'\nreceiver set, no key_expr\nnon-module-scoped lang?}
    D -- Yes --> E[Lookup Receiver.name\nin same file]
    E -- Found --> F[Return qualified method\ne.g. Greeter.greet]
    E -- Not found --> G[Step 2: same-file unqualified\nlookup of name]
    D -- No --> G
    G -- Found --> H[Return same-file match]
    G -- Not found --> I[Step 3: type-aware\nreceiver to typeMap lookup]
    I --> J{RES-3:\ndynamic_kind='reflection'\nkey_expr set, receiver set\nnon-module-scoped lang?}
    J -- Yes --> K[RES-3.1: typeMap receiver\nto resolvedType.keyExpr]
    K -- Found --> L[Return typed match]
    K -- Not found --> M[RES-3.2: callerClass.keyExpr\nfrom caller_name prefix]
    M -- Found --> N[Return class-scoped match]
    M -- Not found --> O[Step 4: scoped fallback]
    J -- No --> O

    subgraph kotlin_rs[kotlin.rs]
    P[navigation_expression call node] --> Q{name == 'invoke'?}
    Q -- Yes --> R[Emit nothing - WASM parity]
    Q -- No --> S[Emit Call with name + receiver]
    end
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(native): kotlin callable-ref prefers..." | Re-trigger Greptile

Comment on lines +1022 to +1068
// 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; }
}
}
}

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

Comment on lines 385 to +386
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

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

@carlos-alm carlos-alm merged commit f3e5bf3 into main Jun 21, 2026
37 checks passed
@carlos-alm carlos-alm deleted the fix/parity-1682-kotlin-callable-ref branch June 21, 2026 23:08
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Engine parity: dynamic-kotlin callable-ref resolution mismatch (member ref targets wrong function; missing invoke sink edge in WASM)

1 participant