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; }
}
}
}

// 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
} else {
symbols.calls.push(Call {
name,
Expand Down
37 changes: 36 additions & 1 deletion src/domain/graph/builder/stages/build-edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1227,17 +1227,22 @@ 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(
caller: { id: number },
targets: ReadonlyArray<{ id: number; file: string }>,
importedFrom: string | null | undefined,
isDynamic: number,
hasDynamicKind: boolean,
relPath: string,
seenCallEdges: Set<string>,
ptsEdgeRows: Map<string, number>,
allEdgeRows: EdgeRowTuple[],
dynZeroEdgeRows?: Map<string, number>,
): void {
// Sort targets by confidence descending before emitting edges.
// For multi-target calls with duplicate (source_id, target_id) pairs the
Expand All @@ -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
Expand All @@ -1273,7 +1294,13 @@ function emitDirectCallEdgesForCall(
seenCallEdges.add(edgeKey);
Comment on lines 1283 to 1294

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 PTS-promoted dyn=0 edge not tracked in dynZeroEdgeRows

When a PTS edge is upgraded in-place to a direct-call edge with dyn=0 (lines 1283–1294), the key is moved from ptsEdgeRows to seenCallEdges but is never written into dynZeroEdgeRows. If a subsequent dyn=1+dynamicKind call arrives for the same source/target pair, dynZeroEdgeRows.get(edgeKey) returns undefined and the upgrade to dyn=1 is silently skipped, leaving the edge as dyn=0. The decorator case described in the PR (both calls resolved through the direct-call path) won't hit this, but a target that first appears via a PTS alias and later via a bare decorator would remain misclassified.

Fix in Claude Code

} 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);
}
}
}
}
Expand Down Expand Up @@ -1535,6 +1562,12 @@ function buildFileCallEdges(
// no longer tracked here.
const ptsEdgeRows = new Map<string, number>();

// 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<string, number>();

// 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
Expand Down Expand Up @@ -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.
Expand Down
Loading