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, 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.