Skip to content

Commit 7290c10

Browse files
committed
fix(receiver): local function constructors block cross-file class receiver edges
A same-file `function C(){}` (kind="function") is a local definition that owns the name `C` in its file. `resolveReceiverEdge` was applying RECEIVER_KINDS as a pre-filter before deciding whether to fall back to global candidates. This caused the same-file `C(function)` to be filtered out, leaving the same-file set empty, which triggered the global fallback and picked an unrelated cross-file `C(class)`. Fix: check same-file presence (unfiltered) first. If the same-file node is a locally-defined symbol (not listed in importedNames), use the kind-filtered same-file set — no global fallback. If the same-file node is an import artifact (importedNames has the name), fall through to the global candidates so the real class in the defining file wins. Both the TypeScript (WASM path) and Rust (native path) engines are updated with identical logic. A new Rust unit test covers the function-constructor case; the existing import-artifact test is updated to seed importedNames correctly. Closes #1539
1 parent 4a1329f commit 7290c10

4 files changed

Lines changed: 83 additions & 38 deletions

File tree

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

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,7 @@ fn process_file<'a>(
649649
}
650650
}
651651

652-
emit_receiver_edge(ctx, call, caller_id, rel_path, &type_map, &mut seen_edges, edges);
652+
emit_receiver_edge(ctx, call, caller_id, rel_path, &type_map, &imported_names, &mut seen_edges, edges);
653653
}
654654

655655
emit_hierarchy_edges(ctx, file_input, rel_path, edges);
@@ -1025,6 +1025,7 @@ fn emit_call_edges(
10251025
fn emit_receiver_edge(
10261026
ctx: &EdgeContext, call: &CallInfo, caller_id: u32, rel_path: &str,
10271027
type_map: &HashMap<&str, (&str, f64)>,
1028+
imported_names: &HashMap<&str, &str>,
10281029
seen_edges: &mut HashSet<u64>, edges: &mut Vec<ComputedEdge>,
10291030
) {
10301031
let Some(ref receiver) = call.receiver else { return };
@@ -1035,18 +1036,21 @@ fn emit_receiver_edge(
10351036
let type_entry = type_map.get(receiver.as_str());
10361037
let effective_receiver = type_entry.map(|&(t, _)| t).unwrap_or(receiver.as_str());
10371038

1038-
// Filter-before: apply receiver_kinds to same-file candidates first, then
1039-
// fall back to global candidates (also filtered) only when same-file yields
1040-
// nothing. This prevents an imported name emitted as kind='function' in the
1041-
// importing file from blocking the fallback to the actual class/struct/etc.
1042-
// node in the defining file.
1043-
let samefile_candidates: Vec<&NodeInfo> = ctx.nodes_by_name_and_file
1039+
// Block global fallback only when the same-file node is a local definition,
1040+
// not when it's an import artifact (e.g. `const { C } = require(…)` seeds a
1041+
// kind="function" node in the importer but the real class lives elsewhere).
1042+
// A locally-defined `function C(){}` owns the name — no cross-file class
1043+
// should shadow it (issue #1539). Mirror of JS resolveReceiverEdge logic.
1044+
let samefile_all: Vec<&NodeInfo> = ctx.nodes_by_name_and_file
10441045
.get(&(effective_receiver, rel_path))
1045-
.cloned().unwrap_or_default()
1046-
.into_iter()
1046+
.cloned().unwrap_or_default();
1047+
let is_local_definition = !samefile_all.is_empty()
1048+
&& !imported_names.contains_key(effective_receiver);
1049+
let samefile_candidates: Vec<&NodeInfo> = samefile_all.iter()
1050+
.copied()
10471051
.filter(|n| ctx.receiver_kinds.contains(n.kind.as_str()))
10481052
.collect();
1049-
let receiver_nodes: Vec<&NodeInfo> = if !samefile_candidates.is_empty() {
1053+
let receiver_nodes: Vec<&NodeInfo> = if is_local_definition {
10501054
samefile_candidates
10511055
} else {
10521056
ctx.nodes_by_name.get(effective_receiver).cloned().unwrap_or_default()
@@ -1816,11 +1820,13 @@ mod call_edge_tests {
18161820
}
18171821

18181822
/// Regression: when the same file has a `kind="function"` node for the
1819-
/// effective receiver (e.g. `Calculator` imported via destructuring), the
1820-
/// same-file "function" node must NOT block the fallback to the global
1821-
/// class node in another file. Filter-before semantics required.
1823+
/// effective receiver created by a destructured import (e.g.
1824+
/// `const { Calculator } = require('./utils')`), that import artifact must
1825+
/// NOT block the fallback to the global class node in another file.
1826+
/// The import must be listed in `imported_names` so the resolver knows it
1827+
/// is an import artifact, not a local function-constructor definition.
18221828
#[test]
1823-
fn receiver_edge_filter_before_skips_same_file_function_node() {
1829+
fn receiver_edge_imported_function_node_falls_through_to_global_class() {
18241830
let all_nodes = vec![
18251831
node(1, "main", "function", "index.js", 3),
18261832
// Destructured import `const { Calculator } = require('./utils')` → kind "function" in index.js
@@ -1829,25 +1835,60 @@ mod call_edge_tests {
18291835
node(3, "compute", "method", "utils.js", 3),
18301836
];
18311837

1832-
let files = vec![make_file(
1838+
let mut file = make_file(
18331839
"index.js",
18341840
10,
18351841
vec![def("main", "function", 3, 8)],
18361842
vec![call("compute", 7, Some("calc"))],
18371843
vec![type_map_entry("calc", "Calculator", 1.0)],
18381844
vec![],
1839-
)];
1845+
);
1846+
// Mark `Calculator` as an imported name so the resolver treats the
1847+
// same-file kind="function" node as an import artifact and falls through.
1848+
file.imported_names = vec![ImportedName { name: "Calculator".to_string(), file: "utils.js".to_string() }];
18401849

1841-
let edges = build_call_edges(files, all_nodes, vec![]);
1850+
let edges = build_call_edges(vec![file], all_nodes, vec![]);
18421851

18431852
let receiver_edge = edges.iter().find(|e| e.kind == "receiver");
18441853
assert!(
18451854
receiver_edge.is_some(),
1846-
"same-file 'function' node must not block fallback to global class; got: {:?}",
1855+
"imported 'function' node must not block fallback to global class; got: {:?}",
18471856
edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::<Vec<_>>()
18481857
);
18491858
let re = receiver_edge.unwrap();
1850-
assert_eq!(re.target_id, 2, "receiver edge must point to Calculator class (id=2), not function (id=4)");
1859+
assert_eq!(re.target_id, 2, "receiver edge must point to Calculator class (id=2), not import artifact (id=4)");
1860+
}
1861+
1862+
/// Issue #1539: `function C(){}` (function constructor) in the same file as
1863+
/// `var v = new C(); v.foo()` must block the global fallback to any cross-file
1864+
/// class `C`. A locally-defined function constructor owns the name in its
1865+
/// file — no cross-file class should win over it.
1866+
#[test]
1867+
fn receiver_edge_local_function_ctor_blocks_global_class() {
1868+
let all_nodes = vec![
1869+
node(1, "C", "function", "prototypes.js", 1), // local function constructor
1870+
node(2, "C.foo", "method", "prototypes.js", 3),
1871+
node(3, "C", "class", "classes.js", 1), // cross-file class with same name
1872+
];
1873+
1874+
// No imported_names — `C` is locally defined.
1875+
let files = vec![make_file(
1876+
"prototypes.js",
1877+
10,
1878+
vec![def("C", "function", 1, 2)],
1879+
vec![call("foo", 8, Some("v"))],
1880+
vec![type_map_entry("v", "C", 1.0)],
1881+
vec![],
1882+
)];
1883+
1884+
let edges = build_call_edges(files, all_nodes, vec![]);
1885+
1886+
let receiver_edge = edges.iter().find(|e| e.kind == "receiver");
1887+
assert!(
1888+
receiver_edge.is_none(),
1889+
"local function constructor must block global class fallback — no receiver edge expected; got: {:?}",
1890+
edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::<Vec<_>>()
1891+
);
18511892
}
18521893

18531894
/// Issue #1453: `this.logger.error()` inside `UserService.create` where the

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

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -355,13 +355,17 @@ export function resolveCallTargets(
355355
* Returns the edge tuple to insert, or null if nothing matched or the edge
356356
* was already seen. Callers are responsible for the actual DB/array insert.
357357
*
358-
* Receiver resolution collects all same-file candidates first (no kind
359-
* filter), falls back to global candidates only when the same-file set is
360-
* entirely empty, then filters the chosen set by RECEIVER_KINDS. This
361-
* matches the native Rust build path: if a file imports a name that happens
362-
* to be emitted as `kind='function'` in the importer, the same-file set is
363-
* non-empty and blocks the global fallback, so no receiver edge is emitted.
364-
* Keeping this behaviour identical to the Rust path maintains engine parity.
358+
* Receiver resolution:
359+
* 1. Look up same-file nodes for `effectiveReceiver` (unfiltered by kind).
360+
* 2. If any same-file node exists AND `effectiveReceiver` is not in `importedNames`
361+
* (i.e. it is a locally-defined symbol, not an import artifact), apply
362+
* RECEIVER_KINDS and return the filtered set — no global fallback.
363+
* A local `function C(){}` means this file owns `C`; no cross-file class
364+
* should win over it (issue #1539).
365+
* 3. If the same-file node IS an import artifact (e.g. destructured require),
366+
* or no same-file node exists at all, fall back to global candidates filtered
367+
* by RECEIVER_KINDS. This preserves the pre-#1539 behaviour for cases where
368+
* an imported name appears as kind='function' in the importer file.
365369
*/
366370
export function resolveReceiverEdge(
367371
lookup: CallNodeLookup,
@@ -370,6 +374,7 @@ export function resolveReceiverEdge(
370374
relPath: string,
371375
typeMap: Map<string, unknown>,
372376
seenCallEdges: Set<string>,
377+
importedNames?: ReadonlyMap<string, string>,
373378
): { callerId: number; receiverId: number; confidence: number } | null {
374379
const typeEntry = typeMap.get(call.receiver);
375380
const typeName = typeEntry
@@ -382,18 +387,15 @@ export function resolveReceiverEdge(
382387
? ((typeEntry as { confidence?: number }).confidence ?? null)
383388
: null;
384389
const effectiveReceiver = typeName || call.receiver;
385-
// Filter-before: apply RECEIVER_KINDS to same-file candidates first, then
386-
// fall back to global candidates (also filtered) only when same-file yields
387-
// nothing. This prevents an imported name emitted as kind='function' in the
388-
// importing file from blocking the fallback to the actual class/struct/etc.
389-
// node in the defining file.
390-
const sameFileCandidates = lookup
391-
.byNameAndFile(effectiveReceiver, relPath)
392-
.filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
393-
const candidates =
394-
sameFileCandidates.length > 0
395-
? sameFileCandidates
396-
: lookup.byName(effectiveReceiver).filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
390+
// Block global fallback only when the same-file node is a local definition,
391+
// not when it's an import artifact (e.g. `const { C } = require(…)` seeds a
392+
// kind='function' node in the importer but the real class lives elsewhere).
393+
const sameFileAll = lookup.byNameAndFile(effectiveReceiver, relPath);
394+
const isLocalDefinition = sameFileAll.length > 0 && !importedNames?.has(effectiveReceiver);
395+
const sameFileCandidates = sameFileAll.filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
396+
const candidates = isLocalDefinition
397+
? sameFileCandidates
398+
: lookup.byName(effectiveReceiver).filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
397399
if (candidates.length === 0) return null;
398400
const recvTarget = candidates[0]!;
399401
const recvKey = `recv|${caller.id}|${recvTarget.id}`;

src/domain/graph/builder/incremental.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,7 @@ function buildCallEdges(
620620
relPath,
621621
typeMap,
622622
seenCallEdges,
623+
importedNames,
623624
);
624625
if (recv) {
625626
stmts.insertEdge.run(recv.callerId, recv.receiverId, 'receiver', recv.confidence, 0);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,7 @@ function buildFileCallEdges(
12931293
relPath,
12941294
typeMap as Map<string, unknown>,
12951295
seenCallEdges,
1296+
importedNames,
12961297
);
12971298
if (recv) {
12981299
allEdgeRows.push([recv.callerId, recv.receiverId, 'receiver', recv.confidence, 0, null]);

0 commit comments

Comments
 (0)