Skip to content

Commit f93d0cb

Browse files
committed
fix: align enclosing-caller attribution for variable bindings (haskell, zig)
Both engines used different rules for attributing calls inside variable bindings: WASM: attributed to the narrowest enclosing span regardless of kind, so local variable declarations inside fn main() shadowed the enclosing function (Zig: calls attributed to repo/svc variables instead of main), and nested let-bindings inside a Haskell do-block shadowed the top-level main binding. Native: loaded allNodes from a query that excluded 'variable' kind, so top-level Haskell bind nodes (main = do …, kind='variable') never matched in defs_with_ids, causing all calls to fall back to the file node. Unified rule implemented in findCaller (TS) and find_enclosing_caller (Rust): - Function/method definitions are preferred over any variable/constant binding as the enclosing caller scope — local var declarations inside a function body never shadow the enclosing function (fixes Zig repo/svc attribution). - When no function/method encloses the call, fall back to the WIDEST (outermost) variable/constant binding — this handles Haskell where main is a top-level bind node with kind 'variable'. Widest span is used so that nested let-bindings do not shadow the outer main binding. - File node remains the absolute last resort. Also adds 'variable' to NODE_KIND_FILTER_SQL (JS) and EDGE_NODE_KIND_FILTER (Rust pipeline.rs) so top-level variable bindings are included in the allNodes set available for caller matching. parity-compare.mjs --langs haskell,zig --hybrid: PARITY OK — 2/2 fixtures.
1 parent 7313330 commit f93d0cb

4 files changed

Lines changed: 130 additions & 25 deletions

File tree

crates/codegraph-core/src/domain/graph/builder/pipeline.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1115,7 +1115,7 @@ fn builtin_call_receivers() -> Vec<String> {
11151115
.collect()
11161116
}
11171117

1118-
const EDGE_NODE_KIND_FILTER: &str = "kind IN ('function','method','class','interface','struct','type','module','enum','trait','record','constant')";
1118+
const EDGE_NODE_KIND_FILTER: &str = "kind IN ('function','method','class','interface','struct','type','module','enum','trait','record','constant','variable')";
11191119

11201120
/// For the scoped (incremental, small-batch) path of the edge builder,
11211121
/// compute the set of files that must be loaded: changed/reverse-dep files

crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ pub struct ComputedEdge {
127127
/// Internal struct for caller resolution (def line range → node ID).
128128
struct DefWithId<'a> {
129129
name: &'a str,
130+
kind: &'a str,
130131
line: u32,
131132
end_line: u32,
132133
node_id: Option<u32>,
@@ -473,7 +474,7 @@ fn process_file<'a>(
473474
let node_id = file_nodes.iter()
474475
.find(|n| n.name == d.name && n.kind == d.kind && n.line == d.line)
475476
.map(|n| n.id);
476-
DefWithId { name: &d.name, line: d.line, end_line: d.end_line.unwrap_or(u32::MAX), node_id }
477+
DefWithId { name: &d.name, kind: &d.kind, line: d.line, end_line: d.end_line.unwrap_or(u32::MAX), node_id }
477478
}).collect();
478479

479480
// Phase 8.3: build pts map for alias resolution — mirrors buildPointsToMapForFile.
@@ -654,25 +655,76 @@ fn process_file<'a>(
654655
emit_hierarchy_edges(ctx, file_input, rel_path, edges);
655656
}
656657

658+
/// Callable definition kinds — only function/method bodies act as enclosing
659+
/// caller scopes. Variable/constant bindings are a lower-priority fallback
660+
/// tier for top-level bindings like Haskell `main = do …` (kind `variable`).
661+
/// Mirrors `CALLABLE_KINDS` / `TOP_LEVEL_BINDING_KINDS` in call-resolver.ts.
662+
fn is_callable_kind(kind: &str) -> bool {
663+
kind == "function" || kind == "method"
664+
}
665+
666+
fn is_top_level_binding_kind(kind: &str) -> bool {
667+
kind == "variable" || kind == "constant"
668+
}
669+
657670
/// Find the narrowest enclosing definition for a call at the given line.
658-
/// Returns `(caller_id, caller_name)` — `caller_name` is `""` when the call is at file scope.
671+
///
672+
/// Two-pass strategy (mirrors the updated `findCaller` in call-resolver.ts):
673+
/// Pass 1 — narrowest enclosing function/method. Local variable declarations
674+
/// inside a function body must not shadow the enclosing function.
675+
/// Pass 2 — narrowest enclosing variable/constant binding. Used as fallback
676+
/// when no function/method encloses the call (e.g. Haskell top-level
677+
/// `main = do …` is a `bind` node with kind `variable`).
678+
///
679+
/// Returns `(caller_id, caller_name)` — `caller_name` is `""` when the call
680+
/// falls back to file scope.
659681
fn find_enclosing_caller<'a>(defs: &[DefWithId<'a>], call_line: u32, file_node_id: u32) -> (u32, &'a str) {
660-
let mut caller_id = file_node_id;
661-
let mut caller_name = "";
662-
let mut caller_span = u32::MAX;
682+
let mut fn_caller_id: Option<u32> = None;
683+
let mut fn_caller_name = "";
684+
let mut fn_caller_span = u32::MAX;
685+
686+
// For variable/constant bindings we pick the WIDEST span (outermost binding),
687+
// not the narrowest, so that nested `let` bindings inside `main`'s do-block
688+
// do not shadow `main` itself. The outermost enclosing variable is the
689+
// "function-like" top-level binding (e.g. Haskell `main = do …`).
690+
// var_caller_span starts at 0 — any real spanning binding has span >= 0
691+
// and we overwrite only when span is strictly greater.
692+
let mut var_caller_id: Option<u32> = None;
693+
let mut var_caller_name = "";
694+
// Using i64 so the initial sentinel (-1) is always beaten by a real span (>= 0).
695+
let mut var_caller_span: i64 = -1;
696+
663697
for def in defs {
664698
if def.line <= call_line && call_line <= def.end_line {
665-
let span = def.end_line - def.line;
666-
if span < caller_span {
667-
if let Some(id) = def.node_id {
668-
caller_id = id;
669-
caller_name = def.name;
670-
caller_span = span;
699+
let span = def.end_line.saturating_sub(def.line);
700+
if is_callable_kind(def.kind) {
701+
if span < fn_caller_span {
702+
if let Some(id) = def.node_id {
703+
fn_caller_id = Some(id);
704+
fn_caller_name = def.name;
705+
fn_caller_span = span;
706+
}
707+
}
708+
} else if is_top_level_binding_kind(def.kind) {
709+
if (span as i64) > var_caller_span {
710+
if let Some(id) = def.node_id {
711+
var_caller_id = Some(id);
712+
var_caller_name = def.name;
713+
var_caller_span = span as i64;
714+
}
671715
}
672716
}
673717
}
674718
}
675-
(caller_id, caller_name)
719+
720+
// Prefer function/method over variable/constant binding.
721+
if let Some(id) = fn_caller_id {
722+
return (id, fn_caller_name);
723+
}
724+
if let Some(id) = var_caller_id {
725+
return (id, var_caller_name);
726+
}
727+
(file_node_id, "")
676728
}
677729

678730
/// Multi-strategy call target resolution: import-aware → same-file → type-aware → scoped.

src/domain/graph/builder/call-resolver.ts

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,22 @@ export function isModuleScopedLanguage(relPath: string): boolean {
4747

4848
// ── Shared resolution functions ──────────────────────────────────────────
4949

50+
/**
51+
* Callable definition kinds — variable/constant bindings are NOT callable
52+
* in the function-as-enclosing-scope sense (they are local declarations, not
53+
* function bodies). Top-level variable bindings (e.g. Haskell `main = do …`)
54+
* are handled separately as a fallback tier.
55+
*/
56+
const CALLABLE_KINDS = new Set(['function', 'method']);
57+
58+
/**
59+
* Variable-like binding kinds that may act as top-level callers when no
60+
* enclosing function/method exists (e.g. Haskell top-level `main` is a
61+
* `bind` node → kind `variable`). Local variable declarations inside a
62+
* function body must NOT win over the enclosing function.
63+
*/
64+
const TOP_LEVEL_BINDING_KINDS = new Set(['variable', 'constant']);
65+
5066
export function findCaller(
5167
lookup: CallNodeLookup,
5268
call: { line: number },
@@ -59,26 +75,63 @@ export function findCaller(
5975
relPath: string,
6076
fileNodeRow: { id: number },
6177
): { id: number; callerName: string | null } {
62-
let caller: { id: number } | null = null;
63-
let callerName: string | null = null;
64-
let callerSpan = Infinity;
78+
// Pass 1: find the narrowest enclosing function/method.
79+
let fnCaller: { id: number } | null = null;
80+
let fnCallerName: string | null = null;
81+
let fnCallerSpan = Infinity;
82+
83+
// Pass 2: find the widest (outermost) enclosing variable/constant binding.
84+
// Used as fallback when no function/method encloses the call site
85+
// (e.g. Haskell `main = do …` is a `bind` node with kind `variable`).
86+
// We pick the WIDEST span (outermost binding), not the narrowest, so that
87+
// nested `let` bindings inside `main`'s do-block do not shadow `main`
88+
// itself as the attributing caller. The outermost enclosing variable is
89+
// the "function-like" top-level binding.
90+
let varCaller: { id: number } | null = null;
91+
let varCallerName: string | null = null;
92+
let varCallerSpan = -1; // looking for WIDEST span, so start at -1
93+
6594
for (const def of definitions) {
6695
if (def.line <= call.line) {
6796
const end = def.endLine || Infinity;
6897
if (call.line <= end) {
69-
const span = end - def.line;
70-
if (span < callerSpan) {
71-
const row = lookup.nodeId(def.name, def.kind, relPath, def.line);
72-
if (row) {
73-
caller = row;
74-
callerName = def.name;
75-
callerSpan = span;
98+
const span = end === Infinity ? Infinity : end - def.line;
99+
if (CALLABLE_KINDS.has(def.kind)) {
100+
if (span < fnCallerSpan) {
101+
const row = lookup.nodeId(def.name, def.kind, relPath, def.line);
102+
if (row) {
103+
fnCaller = row;
104+
fnCallerName = def.name;
105+
fnCallerSpan = span;
106+
}
107+
}
108+
} else if (TOP_LEVEL_BINDING_KINDS.has(def.kind)) {
109+
if (span > varCallerSpan) {
110+
const row = lookup.nodeId(def.name, def.kind, relPath, def.line);
111+
if (row) {
112+
varCaller = row;
113+
varCallerName = def.name;
114+
varCallerSpan = span;
115+
}
76116
}
77117
}
78118
}
79119
}
80120
}
81-
return { ...(caller ?? fileNodeRow), callerName };
121+
122+
// Prefer function/method enclosing scope over variable binding.
123+
// If a function/method encloses the call, use it — local variable
124+
// declarations inside the function body must not shadow it.
125+
// Only fall back to a variable/constant binding when the call is at
126+
// top-level scope (no enclosing function/method found), which handles
127+
// languages like Haskell where `main` is a top-level `bind` node.
128+
if (fnCaller) {
129+
return { ...fnCaller, callerName: fnCallerName };
130+
}
131+
if (varCaller) {
132+
return { ...varCaller, callerName: varCallerName };
133+
}
134+
return { ...fileNodeRow, callerName: null };
82135
}
83136

84137
export function resolveByMethodOrGlobal(

src/domain/graph/builder/stages/build-edges.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1471,7 +1471,7 @@ function reconnectReverseDepEdges(ctx: PipelineContext): void {
14711471
* their import targets. Falls back to loading ALL nodes for full builds or
14721472
* larger incremental changes.
14731473
*/
1474-
const NODE_KIND_FILTER_SQL = `kind IN ('function','method','class','interface','struct','type','module','enum','trait','record','constant')`;
1474+
const NODE_KIND_FILTER_SQL = `kind IN ('function','method','class','interface','struct','type','module','enum','trait','record','constant','variable')`;
14751475

14761476
function loadNodes(ctx: PipelineContext): { rows: QueryNodeRow[]; scoped: boolean } {
14771477
const { db, fileSymbols, isFullBuild, batchResolved } = ctx;

0 commit comments

Comments
 (0)