From 5bed41bc3034f11a22ad269b94269ccb371cbdd9 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 16:15:19 -0600 Subject: [PATCH 1/3] 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/3] 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, From 2c63d0689841853a714e93688b13ef63828a1d33 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 16:39:39 -0600 Subject: [PATCH 3/3] fix(wasm): preserve dyn=1 when deduplicating edges with same source/target/kind/conf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two resolved call edges share the same (source, target) pair but differ in the dynamic flag — e.g. a bare `@Log` decorator (dyn=1, dynamicKind=reflection) and a call-expression `@Log()` decorator (dyn=0) both resolving to the same function — the dedup logic in buildFileCallEdges previously let the first edge emitted win unconditionally. In the query path, `@Log()` is captured as a callfn_node match before the runCollectorWalk pass emits the bare `@Log` via handleDecorator. This means the dyn=0 edge is added to seenCallEdges first, and the subsequent dyn=1 call is silently dropped. Fix: add a dynZeroEdgeRows map (parallel to ptsEdgeRows) that tracks the allEdgeRows index of any direct-call edge emitted with dyn=0. When a later call for the same (source, target) pair carries both dyn=1 AND an explicit dynamicKind (e.g. 'reflection'), the existing dyn=0 row is upgraded in-place to dyn=1. The dynamicKind guard prevents generic dynamic=true alias/callback calls (f.call, f.bind — no dynamicKind) from incorrectly overriding dyn=0 direct-call edges. Closes #1683 Impact: 2 functions changed, 4 affected --- .../graph/builder/stages/build-edges.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index f9fa88e9..e5663f33 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -1227,6 +1227,9 @@ function resolveFallbackTargets( * - Skips self-edges and already-seen edges. * - If a pts edge already exists for this pair, upgrades it in-place to * direct-call confidence and promotes to seenCallEdges. + * - If a dyn=0 edge already exists and the incoming call has an explicit + * dynamicKind (e.g. 'reflection' for bare decorators), upgrades the + * existing row to dyn=1 in-place so the semantic classification wins. * - Otherwise records a new `calls` edge with `ts-native` technique. */ function emitDirectCallEdgesForCall( @@ -1234,10 +1237,12 @@ function emitDirectCallEdgesForCall( targets: ReadonlyArray<{ id: number; file: string }>, importedFrom: string | null | undefined, isDynamic: number, + hasDynamicKind: boolean, relPath: string, seenCallEdges: Set, ptsEdgeRows: Map, allEdgeRows: EdgeRowTuple[], + dynZeroEdgeRows?: Map, ): void { // Sort targets by confidence descending before emitting edges. // For multi-target calls with duplicate (source_id, target_id) pairs the @@ -1257,7 +1262,23 @@ function emitDirectCallEdgesForCall( const edgeKey = `${caller.id}|${t.id}`; if (t.id === caller.id) continue; const confidence = computeConfidence(relPath, t.file, importedFrom ?? null); - if (seenCallEdges.has(edgeKey)) continue; + if (seenCallEdges.has(edgeKey)) { + // Edge already emitted. If the incoming call carries an explicit semantic + // dynamic classification (dynamicKind set — e.g. 'reflection' for bare + // decorators) and the existing edge was recorded with dyn=0, upgrade it + // in-place so the more specific classification wins. + // Generic dynamic=true without dynamicKind (alias/callback calls) does + // NOT override dyn=0 to avoid false positives on f.call/f.bind patterns. + if (isDynamic === 1 && hasDynamicKind && dynZeroEdgeRows) { + const dynZeroIdx = dynZeroEdgeRows.get(edgeKey); + if (dynZeroIdx !== undefined) { + const row = allEdgeRows[dynZeroIdx]; + if (row) row[4] = 1; + dynZeroEdgeRows.delete(edgeKey); + } + } + continue; + } const ptsIdx = ptsEdgeRows.get(edgeKey); if (ptsIdx !== undefined) { // A pts-resolved edge already exists for this caller→target pair with a @@ -1273,7 +1294,13 @@ function emitDirectCallEdgesForCall( seenCallEdges.add(edgeKey); } else { seenCallEdges.add(edgeKey); + const newIdx = allEdgeRows.length; allEdgeRows.push([caller.id, t.id, 'calls', confidence, isDynamic, 'ts-native', null]); + // Track dyn=0 edges so a later dyn=1+dynamicKind call for the same pair + // can upgrade them (e.g. bare decorator after call-expression decorator). + if (isDynamic === 0 && dynZeroEdgeRows) { + dynZeroEdgeRows.set(edgeKey, newIdx); + } } } } @@ -1535,6 +1562,12 @@ function buildFileCallEdges( // no longer tracked here. const ptsEdgeRows = new Map(); + // Tracks direct-call edges emitted with dyn=0 (edgeKey → allEdgeRows index). + // When a later call to the same target has dyn=1 (e.g. a bare decorator `@Log` + // processed after the call-expression `@Log()` in the query path), the existing + // dyn=0 row is upgraded in-place so the more specific dynamic classification wins. + const dynZeroEdgeRows = new Map(); + // Pre-compute the set of names that appear as lhs in fnRefBindings so that // case (c) of the pts gate below only fires for names that are genuine // bind/alias entries, not for every locally-defined function or import that @@ -1564,10 +1597,12 @@ function buildFileCallEdges( targets, importedFrom, isDynamic, + !!call.dynamicKind, relPath, seenCallEdges, ptsEdgeRows, allEdgeRows, + dynZeroEdgeRows, ); // Step 3: Phase 8.3/8.3c pts fallback for unresolved no-receiver calls.