Skip to content

Commit af17b6d

Browse files
committed
fix: scope global call-resolution fallback to same-language candidates (docs check acknowledged)
The global-by-name call-resolution fallback (resolveByGlobal/resolveByReceiver in strategy.ts, resolveReceiverEdge in call-resolver.ts, and their Rust mirrors) filtered candidates purely by proximity-based confidence (computeConfidence), with no language-consistency check at all. A bare-name call with no import/receiver match could therefore resolve against a same-named symbol in a completely unrelated language, as long as the two files were proximate enough (e.g. same directory). Concretely: ruby-tracer.rb's builtin `Kernel#load` call was falsely attributed as a consumer of loader-hooks.mjs's unrelated `load` export, because both files live in the same directory and computeConfidence scores same-directory pairs at 0.7 — well above the resolver's 0.5 threshold — with no check that a Ruby caller and a JS target aren't even in the same language. Adds isSameLanguageFamily()/is_same_language_family() to resolve.ts/resolve.rs, derived from LANGUAGE_REGISTRY (TS) / LanguageKind::from_extension (Rust), with JavaScript/TypeScript/TSX collapsed into one family since those routinely call into each other within the same project. computeConfidence now rejects cross-family candidates before scoring proximity, which transitively hardens every fallback tier gated on it (resolveByGlobal's exact-name lookup, resolveByReceiver's typed/prototype/direct-qualified/ composite-pts lookups, the same-class-sibling and accessor-this-dispatch fallbacks, and the CHA/property-propagation post-passes). resolveReceiverEdge's global fallback had no confidence gate at all, so it gets an explicit isSameLanguageFamily filter instead. Verified via the resolution-benchmark suite: precision/recall are unchanged across all 40 single-language fixtures (expected, since none mix languages within one directory); the fix is validated by the exact repro plus new targeted unit tests covering both cross-language rejection and same-language regression safety in both engines. Fixes #1783 Impact: 4 functions changed, 30 affected
1 parent 75978e2 commit af17b6d

6 files changed

Lines changed: 431 additions & 3 deletions

File tree

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

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1342,9 +1342,13 @@ fn emit_receiver_edge(
13421342
samefile_candidates
13431343
} else {
13441344
// Fall back to any cross-file class/struct/interface candidate.
1345+
// Cross-language candidates are never legitimate receiver targets
1346+
// (#1783) — a `new Foo()` in one language can't statically resolve to
1347+
// an unrelated same-named class in another. Mirrors JS resolveReceiverEdge.
13451348
ctx.nodes_by_name.get(effective_receiver).cloned().unwrap_or_default()
13461349
.into_iter()
1347-
.filter(|n| ctx.receiver_kinds.contains(n.kind.as_str()))
1350+
.filter(|n| ctx.receiver_kinds.contains(n.kind.as_str())
1351+
&& resolve::is_same_language_family(rel_path, &n.file))
13481352
.collect()
13491353
};
13501354

@@ -2319,6 +2323,65 @@ mod call_edge_tests {
23192323
);
23202324
}
23212325

2326+
/// Issue #1783: the global (cross-file) receiver fallback had no
2327+
/// language-consistency check at all, so `Widget.render()` in a Python
2328+
/// caller with no same-file `Widget` definition could resolve to an
2329+
/// unrelated same-named class declared in a JS file purely by name.
2330+
#[test]
2331+
fn receiver_edge_rejects_cross_language_match() {
2332+
let all_nodes = vec![
2333+
node(1, "main", "function", "widget.py", 3),
2334+
node(2, "Widget", "class", "widget.js", 1),
2335+
];
2336+
2337+
let files = vec![make_file(
2338+
"widget.py",
2339+
10,
2340+
vec![def("main", "function", 3, 8)],
2341+
vec![call("render", 7, Some("Widget"))],
2342+
vec![],
2343+
vec![],
2344+
)];
2345+
2346+
let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS);
2347+
2348+
let receiver_edge = edges.iter().find(|e| e.kind == "receiver");
2349+
assert!(
2350+
receiver_edge.is_none(),
2351+
"a Python caller must not resolve a receiver edge to an unrelated same-named JS class; got: {:?}",
2352+
edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::<Vec<_>>()
2353+
);
2354+
}
2355+
2356+
/// Same-language global receiver fallback must still work after the
2357+
/// #1783 language-scoping fix — only cross-language candidates are rejected.
2358+
#[test]
2359+
fn receiver_edge_still_resolves_same_language_cross_file_match() {
2360+
let all_nodes = vec![
2361+
node(1, "main", "function", "widget.py", 3),
2362+
node(2, "Widget", "class", "widget_impl.py", 1),
2363+
];
2364+
2365+
let files = vec![make_file(
2366+
"widget.py",
2367+
10,
2368+
vec![def("main", "function", 3, 8)],
2369+
vec![call("render", 7, Some("Widget"))],
2370+
vec![],
2371+
vec![],
2372+
)];
2373+
2374+
let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS);
2375+
2376+
let receiver_edge = edges.iter().find(|e| e.kind == "receiver");
2377+
assert!(
2378+
receiver_edge.is_some(),
2379+
"same-language cross-file receiver fallback must still resolve; got: {:?}",
2380+
edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::<Vec<_>>()
2381+
);
2382+
assert_eq!(receiver_edge.unwrap().target_id, 2);
2383+
}
2384+
23222385
/// Issue #1453: `this.logger.error()` inside `UserService.create` where the
23232386
/// constructor seeded the class-scoped key `UserService.logger → Logger`.
23242387
/// The resolver must fall back to the `ClassName.prop` typeMap key (#1323).

crates/codegraph-core/src/domain/graph/resolve.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
33

44
use rayon::prelude::*;
55

6+
use crate::domain::parser::LanguageKind;
67
use crate::types::{ImportResolutionInput, PathAliases, ResolvedImport};
78

89
/// Check file existence using known_files set when available, falling back to FS.
@@ -281,6 +282,40 @@ fn directory_distance(a: &str, b: &str) -> usize {
281282
usize::MAX
282283
}
283284

285+
/// Coarse "language family" for a file, derived from its extension via
286+
/// `LanguageKind::from_extension`. Collapses TypeScript/Tsx into the same
287+
/// family as JavaScript: despite being distinct `LanguageKind` variants (one
288+
/// per tree-sitter grammar), `.ts`/`.tsx` files routinely import from and
289+
/// call into `.js` files and vice versa within the same project (this
290+
/// codebase's own `src/` tree does this throughout) — treating them as
291+
/// separate families would reject huge amounts of legitimate same-project
292+
/// resolution. Every other `LanguageKind` variant keeps its own family,
293+
/// preserving `from_extension`'s existing per-language extension groupings
294+
/// (e.g. C's `.c`+`.h`, C++'s `.cpp`/`.cc`/`.cxx`/`.hpp`).
295+
fn language_family(file: &str) -> Option<LanguageKind> {
296+
match LanguageKind::from_extension(file) {
297+
Some(LanguageKind::TypeScript) | Some(LanguageKind::Tsx) => Some(LanguageKind::JavaScript),
298+
other => other,
299+
}
300+
}
301+
302+
/// True when `file_a` and `file_b` belong to the same language family, or
303+
/// when either extension is unrecognised (ambiguous cases are not rejected —
304+
/// they fall through to normal scoring). False only when both extensions are
305+
/// recognised AND resolve to different families.
306+
///
307+
/// Guards the global-by-name call-resolution fallback against matching a
308+
/// same-named symbol across unrelated languages — e.g. a Ruby file's bare
309+
/// `load` call has no static relationship to a same-named `load` export in a
310+
/// JS file, even when both happen to live in the same directory (issue
311+
/// #1783). Mirrors `isSameLanguageFamily()` in resolve.ts.
312+
pub fn is_same_language_family(file_a: &str, file_b: &str) -> bool {
313+
match (language_family(file_a), language_family(file_b)) {
314+
(Some(a), Some(b)) => a == b,
315+
_ => true,
316+
}
317+
}
318+
284319
/// Compute proximity-based confidence for call resolution.
285320
/// Mirrors `computeConfidence()` in resolve.ts.
286321
pub fn compute_confidence(
@@ -299,6 +334,12 @@ pub fn compute_confidence(
299334
return 1.0;
300335
}
301336
}
337+
// Cross-language candidates are never legitimate call targets (#1783) —
338+
// reject before scoring proximity so a same-directory, same-named symbol
339+
// in an unrelated language can never pass the resolver's 0.5 threshold.
340+
if !is_same_language_family(caller_file, target_file) {
341+
return 0.0;
342+
}
302343

303344
let caller_dir = Path::new(caller_file)
304345
.parent()
@@ -532,4 +573,86 @@ mod tests {
532573
// not a direct sibling pair despite sharing the `src` ancestor.
533574
assert_eq!(directory_distance("src/graph/algorithms", "src/features"), 3);
534575
}
576+
577+
// Regression tests for #1783: the global-by-name call-resolution fallback
578+
// had no language-consistency check at all, so a bare-name call with no
579+
// import/receiver match could resolve against a same-named symbol in a
580+
// completely unrelated language — e.g. a Ruby file's builtin `Kernel#load`
581+
// call matched a JS ESM loader hook's unrelated `load` export purely
582+
// because both files sat in the same directory (confidence 0.7 from
583+
// proximity alone, well above the resolver's 0.5 threshold).
584+
585+
#[test]
586+
fn is_same_language_family_rejects_ruby_and_js() {
587+
assert!(!is_same_language_family("tracer/ruby-tracer.rb", "tracer/loader-hooks.mjs"));
588+
}
589+
590+
#[test]
591+
fn is_same_language_family_rejects_python_and_go() {
592+
assert!(!is_same_language_family("src/main.py", "src/main.go"));
593+
}
594+
595+
#[test]
596+
fn is_same_language_family_accepts_same_extension() {
597+
assert!(is_same_language_family("src/a.rb", "lib/b.rb"));
598+
}
599+
600+
#[test]
601+
fn is_same_language_family_merges_javascript_and_typescript() {
602+
assert!(is_same_language_family("src/a.ts", "src/b.js"));
603+
assert!(is_same_language_family("src/a.tsx", "src/b.mjs"));
604+
assert!(is_same_language_family("src/a.cjs", "src/b.jsx"));
605+
}
606+
607+
#[test]
608+
fn is_same_language_family_merges_c_source_and_header() {
609+
assert!(is_same_language_family("src/a.c", "src/a.h"));
610+
}
611+
612+
#[test]
613+
fn is_same_language_family_merges_cpp_source_and_header_variants() {
614+
assert!(is_same_language_family("src/a.cpp", "src/a.hpp"));
615+
assert!(is_same_language_family("src/a.cc", "src/a.cxx"));
616+
}
617+
618+
#[test]
619+
fn is_same_language_family_does_not_merge_c_and_cpp() {
620+
assert!(!is_same_language_family("src/a.c", "src/a.cpp"));
621+
}
622+
623+
#[test]
624+
fn is_same_language_family_does_not_reject_unrecognised_extensions() {
625+
// Ambiguous (unrecognised) extensions fall through rather than being rejected.
626+
assert!(is_same_language_family("README", "src/b.rb"));
627+
assert!(is_same_language_family("src/a.rb", "Makefile"));
628+
}
629+
630+
#[test]
631+
fn compute_confidence_rejects_cross_language_same_directory_match() {
632+
// The exact #1783 repro shape: same directory, different languages.
633+
let conf = compute_confidence(
634+
"tests/benchmarks/resolution/tracer/ruby-tracer.rb",
635+
"tests/benchmarks/resolution/tracer/loader-hooks.mjs",
636+
None,
637+
);
638+
assert_eq!(conf, 0.0);
639+
}
640+
641+
#[test]
642+
fn compute_confidence_still_scores_same_language_same_directory_pair() {
643+
let conf = compute_confidence(
644+
"tests/benchmarks/resolution/tracer/ruby-tracer.rb",
645+
"tests/benchmarks/resolution/tracer/other-tracer.rb",
646+
None,
647+
);
648+
assert_eq!(conf, 0.7);
649+
}
650+
651+
#[test]
652+
fn compute_confidence_does_not_regress_same_project_js_ts_resolution() {
653+
// A .ts caller resolving a same-directory .js target must be unaffected —
654+
// TS/JS are one family despite being different LanguageKind variants.
655+
let conf = compute_confidence("src/graph/a.ts", "src/graph/b.js", None);
656+
assert_eq!(conf, 0.7);
657+
}
535658
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* `resolveByMethodOrGlobal` delegates its two branches to strategy helpers
1010
* in `../resolver/strategy.ts` to keep per-strategy complexity manageable.
1111
*/
12-
import { computeConfidence } from '../resolve.js';
12+
import { computeConfidence, isSameLanguageFamily } from '../resolve.js';
1313
import {
1414
isModuleScopedLanguage,
1515
resolveByGlobal,
@@ -347,9 +347,15 @@ export function resolveReceiverEdge(
347347
const sameFileAll = lookup.byNameAndFile(effectiveReceiver, relPath);
348348
const isLocalDefinition = sameFileAll.length > 0 && !importedNames?.has(effectiveReceiver);
349349
const sameFileCandidates = sameFileAll.filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
350+
// Cross-language candidates are never legitimate receiver targets (#1783) —
351+
// a `new Foo()` in one language can't statically resolve to an unrelated
352+
// same-named class in another. Only the global (cross-file) branch needs
353+
// the check: sameFileCandidates are already scoped to relPath itself.
350354
const candidates = isLocalDefinition
351355
? sameFileCandidates
352-
: lookup.byName(effectiveReceiver).filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
356+
: lookup
357+
.byName(effectiveReceiver)
358+
.filter((n) => RECEIVER_KINDS.has(n.kind ?? '') && isSameLanguageFamily(relPath, n.file));
353359
if (candidates.length === 0) return null;
354360
const recvTarget = candidates[0]!;
355361
const recvKey = `recv|${caller.id}|${recvTarget.id}`;

src/domain/graph/resolve.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { loadNative } from '../../infrastructure/native.js';
55
import { normalizePath } from '../../shared/constants.js';
66
import { toErrorMessage } from '../../shared/errors.js';
77
import type { BareSpecifier, BatchResolvedMap, ImportBatchItem, PathAliases } from '../../types.js';
8+
import { LANGUAGE_REGISTRY } from '../parser.js';
89

910
// ── package.json exports resolution ─────────────────────────────────
1011

@@ -496,6 +497,68 @@ function directoryDistance(a: string, b: string): number {
496497
return Infinity;
497498
}
498499

500+
// ── Language-family scoping for global-by-name fallback resolution ─────────
501+
502+
/**
503+
* LANGUAGE_REGISTRY ids that must be treated as one family for cross-file
504+
* call resolution. TypeScript/TSX compile to and interoperate directly with
505+
* JavaScript — a `.ts` file routinely imports from and calls into a `.js`
506+
* file and vice versa (this codebase's own `src/` tree does this
507+
* throughout) — despite being three separate grammar entries in
508+
* LANGUAGE_REGISTRY. Every other registry id keeps its own family, which
509+
* preserves LANGUAGE_REGISTRY's existing per-language extension groupings
510+
* (e.g. C's `.c`+`.h`, C++'s `.cpp`/`.cc`/`.cxx`/`.hpp`).
511+
*/
512+
const JS_FAMILY_REGISTRY_IDS = new Set(['javascript', 'typescript', 'tsx']);
513+
514+
/**
515+
* extension → language-family lookup, derived from LANGUAGE_REGISTRY (the
516+
* single source of truth for language definitions) so newly-added languages
517+
* are automatically covered without a second hand-maintained extension list.
518+
*/
519+
const _extToLanguageFamily: Map<string, string> = new Map();
520+
for (const entry of LANGUAGE_REGISTRY) {
521+
const family = JS_FAMILY_REGISTRY_IDS.has(entry.id) ? 'javascript' : entry.id;
522+
for (const ext of entry.extensions) {
523+
_extToLanguageFamily.set(ext, family);
524+
}
525+
}
526+
527+
/**
528+
* Resolve a file's coarse language family from its extension. Returns null
529+
* for extensionless or unrecognised files so ambiguous cases fall through to
530+
* ordinary distance-based scoring rather than being rejected outright.
531+
*/
532+
function languageFamily(file: string): string | null {
533+
const dot = file.lastIndexOf('.');
534+
if (dot === -1) return null;
535+
return _extToLanguageFamily.get(file.slice(dot).toLowerCase()) ?? null;
536+
}
537+
538+
/**
539+
* True when `fileA` and `fileB` belong to the same language family, or when
540+
* either extension is unrecognised (ambiguous cases are not rejected — they
541+
* fall through to normal scoring). False only when both extensions are
542+
* recognised AND resolve to different families.
543+
*
544+
* Guards the global-by-name call-resolution fallback against matching a
545+
* same-named symbol across unrelated languages — e.g. a Ruby file's bare
546+
* `load` call has no static relationship to a same-named `load` export in a
547+
* JS file, even when both happen to live in the same directory (issue
548+
* #1783). This codebase has no cross-language static-call mechanism its
549+
* resolvers legitimately model (the `dead-ffi` role classifier only
550+
* suppresses false dead-code flags for compiled-language files consumed via
551+
* FFI — it never creates call edges), so rejecting cross-family candidates
552+
* is a strict precision improvement with no legitimate resolution to
553+
* regress.
554+
*/
555+
export function isSameLanguageFamily(fileA: string, fileB: string): boolean {
556+
const famA = languageFamily(fileA);
557+
const famB = languageFamily(fileB);
558+
if (!famA || !famB) return true;
559+
return famA === famB;
560+
}
561+
499562
function computeConfidenceJS(
500563
callerFile: string,
501564
targetFile: string,
@@ -506,6 +569,10 @@ function computeConfidenceJS(
506569
if (importedFrom === targetFile) return 1.0;
507570
// Workspace-resolved imports get high confidence even across package boundaries
508571
if (importedFrom && _workspaceResolvedPaths.has(importedFrom)) return 0.95;
572+
// Cross-language candidates are never legitimate call targets (#1783) — reject
573+
// before scoring proximity so a same-directory, same-named symbol in an
574+
// unrelated language can never pass the resolver's 0.5 confidence threshold.
575+
if (!isSameLanguageFamily(callerFile, targetFile)) return 0;
509576
const dist = directoryDistance(path.dirname(callerFile), path.dirname(targetFile));
510577
if (dist === 0) return 0.7; // same directory
511578
if (dist === 1) return 0.6; // direct parent/child directory

0 commit comments

Comments
 (0)