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 910203e6..8eb1d7f4 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 @@ -1342,9 +1342,13 @@ fn emit_receiver_edge( samefile_candidates } else { // Fall back to any cross-file class/struct/interface candidate. + // Cross-language candidates are never legitimate receiver targets + // (#1783) — a `new Foo()` in one language can't statically resolve to + // an unrelated same-named class in another. Mirrors JS resolveReceiverEdge. ctx.nodes_by_name.get(effective_receiver).cloned().unwrap_or_default() .into_iter() - .filter(|n| ctx.receiver_kinds.contains(n.kind.as_str())) + .filter(|n| ctx.receiver_kinds.contains(n.kind.as_str()) + && resolve::is_same_language_family(rel_path, &n.file)) .collect() }; @@ -2393,6 +2397,65 @@ mod call_edge_tests { ); } + /// Issue #1783: the global (cross-file) receiver fallback had no + /// language-consistency check at all, so `Widget.render()` in a Python + /// caller with no same-file `Widget` definition could resolve to an + /// unrelated same-named class declared in a JS file purely by name. + #[test] + fn receiver_edge_rejects_cross_language_match() { + let all_nodes = vec![ + node(1, "main", "function", "widget.py", 3), + node(2, "Widget", "class", "widget.js", 1), + ]; + + let files = vec![make_file( + "widget.py", + 10, + vec![def("main", "function", 3, 8)], + vec![call("render", 7, Some("Widget"))], + vec![], + vec![], + )]; + + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); + + let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); + assert!( + receiver_edge.is_none(), + "a Python caller must not resolve a receiver edge to an unrelated same-named JS class; got: {:?}", + edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::>() + ); + } + + /// Same-language global receiver fallback must still work after the + /// #1783 language-scoping fix — only cross-language candidates are rejected. + #[test] + fn receiver_edge_still_resolves_same_language_cross_file_match() { + let all_nodes = vec![ + node(1, "main", "function", "widget.py", 3), + node(2, "Widget", "class", "widget_impl.py", 1), + ]; + + let files = vec![make_file( + "widget.py", + 10, + vec![def("main", "function", 3, 8)], + vec![call("render", 7, Some("Widget"))], + vec![], + vec![], + )]; + + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); + + let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); + assert!( + receiver_edge.is_some(), + "same-language cross-file receiver fallback must still resolve; got: {:?}", + edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::>() + ); + assert_eq!(receiver_edge.unwrap().target_id, 2); + } + /// Issue #1453: `this.logger.error()` inside `UserService.create` where the /// constructor seeded the class-scoped key `UserService.logger → Logger`. /// The resolver must fall back to the `ClassName.prop` typeMap key (#1323). diff --git a/crates/codegraph-core/src/domain/graph/resolve.rs b/crates/codegraph-core/src/domain/graph/resolve.rs index eb23378d..6cece574 100644 --- a/crates/codegraph-core/src/domain/graph/resolve.rs +++ b/crates/codegraph-core/src/domain/graph/resolve.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use rayon::prelude::*; +use crate::domain::parser::LanguageKind; use crate::types::{ImportResolutionInput, PathAliases, ResolvedImport}; /// Check file existence using known_files set when available, falling back to FS. @@ -307,6 +308,53 @@ fn directory_distance(a: &str, b: &str) -> usize { dist } +/// Coarse "language family" for a file, derived from its extension via +/// `LanguageKind::from_extension`. Collapses TypeScript/Tsx into the same +/// family as JavaScript: despite being distinct `LanguageKind` variants (one +/// per tree-sitter grammar), `.ts`/`.tsx` files routinely import from and +/// call into `.js` files and vice versa within the same project (this +/// codebase's own `src/` tree does this throughout) — treating them as +/// separate families would reject huge amounts of legitimate same-project +/// resolution. Every other `LanguageKind` variant keeps its own family, +/// preserving `from_extension`'s existing per-language extension groupings +/// (e.g. C's `.c`+`.h`, C++'s `.cpp`/`.cc`/`.cxx`/`.hpp`) — EXCEPT `.h`, +/// treated as ambiguous (returns `None`) rather than inheriting +/// `from_extension`'s C-only mapping: `from_extension` needs one canonical +/// grammar per extension, but a `.h` header is real-world ambiguous between +/// C and C++, and the extremely common case of a `.cpp` file calling into +/// its own project's `.h` header would otherwise be misclassified as +/// cross-language and rejected outright — a real regression from the +/// pre-#1783 same-directory score of 0.7 (Greptile review). This keeps the +/// C/C++-header case working without merging C and C++ source-file families +/// wholesale (`.c` vs `.cpp` intentionally do NOT merge — see +/// is_same_language_family_does_not_merge_c_and_cpp). +fn language_family(file: &str) -> Option { + if file.to_ascii_lowercase().ends_with(".h") { + return None; + } + match LanguageKind::from_extension(file) { + Some(LanguageKind::TypeScript) | Some(LanguageKind::Tsx) => Some(LanguageKind::JavaScript), + other => other, + } +} + +/// True when `file_a` and `file_b` belong to the same language family, or +/// when either extension is unrecognised (ambiguous cases are not rejected — +/// they fall through to normal scoring). False only when both extensions are +/// recognised AND resolve to different families. +/// +/// Guards the global-by-name call-resolution fallback against matching a +/// same-named symbol across unrelated languages — e.g. a Ruby file's bare +/// `load` call has no static relationship to a same-named `load` export in a +/// JS file, even when both happen to live in the same directory (issue +/// #1783). Mirrors `isSameLanguageFamily()` in resolve.ts. +pub fn is_same_language_family(file_a: &str, file_b: &str) -> bool { + match (language_family(file_a), language_family(file_b)) { + (Some(a), Some(b)) => a == b, + _ => true, + } +} + /// Compute proximity-based confidence for call resolution. /// Mirrors `computeConfidence()` in resolve.ts. pub fn compute_confidence( @@ -325,6 +373,12 @@ pub fn compute_confidence( return 1.0; } } + // Cross-language candidates are never legitimate call targets (#1783) — + // reject before scoring proximity so a same-directory, same-named symbol + // in an unrelated language can never pass the resolver's 0.5 threshold. + if !is_same_language_family(caller_file, target_file) { + return 0.0; + } let caller_dir = Path::new(caller_file) .parent() @@ -558,4 +612,95 @@ mod tests { // not a direct sibling pair despite sharing the `src` ancestor. assert_eq!(directory_distance("src/graph/algorithms", "src/features"), 3); } + + // Regression tests for #1783: the global-by-name call-resolution fallback + // had no language-consistency check at all, so a bare-name call with no + // import/receiver match could resolve against a same-named symbol in a + // completely unrelated language — e.g. a Ruby file's builtin `Kernel#load` + // call matched a JS ESM loader hook's unrelated `load` export purely + // because both files sat in the same directory (confidence 0.7 from + // proximity alone, well above the resolver's 0.5 threshold). + + #[test] + fn is_same_language_family_rejects_ruby_and_js() { + assert!(!is_same_language_family("tracer/ruby-tracer.rb", "tracer/loader-hooks.mjs")); + } + + #[test] + fn is_same_language_family_rejects_python_and_go() { + assert!(!is_same_language_family("src/main.py", "src/main.go")); + } + + #[test] + fn is_same_language_family_accepts_same_extension() { + assert!(is_same_language_family("src/a.rb", "lib/b.rb")); + } + + #[test] + fn is_same_language_family_merges_javascript_and_typescript() { + assert!(is_same_language_family("src/a.ts", "src/b.js")); + assert!(is_same_language_family("src/a.tsx", "src/b.mjs")); + assert!(is_same_language_family("src/a.cjs", "src/b.jsx")); + } + + #[test] + fn is_same_language_family_merges_c_source_and_header() { + assert!(is_same_language_family("src/a.c", "src/a.h")); + } + + #[test] + fn is_same_language_family_treats_h_as_ambiguous_with_cpp() { + // Greptile follow-up to #1783: `.h` is real-world ambiguous between C + // and C++ (LANGUAGE_REGISTRY/from_extension assigns it to C alone for + // grammar-selection purposes), so a `.cpp` file calling into its own + // project's `.h` header must not be rejected as cross-language. + assert!(is_same_language_family("src/widget.cpp", "src/widget.h")); + } + + #[test] + fn is_same_language_family_merges_cpp_source_and_header_variants() { + assert!(is_same_language_family("src/a.cpp", "src/a.hpp")); + assert!(is_same_language_family("src/a.cc", "src/a.cxx")); + } + + #[test] + fn is_same_language_family_does_not_merge_c_and_cpp() { + assert!(!is_same_language_family("src/a.c", "src/a.cpp")); + } + + #[test] + fn is_same_language_family_does_not_reject_unrecognised_extensions() { + // Ambiguous (unrecognised) extensions fall through rather than being rejected. + assert!(is_same_language_family("README", "src/b.rb")); + assert!(is_same_language_family("src/a.rb", "Makefile")); + } + + #[test] + fn compute_confidence_rejects_cross_language_same_directory_match() { + // The exact #1783 repro shape: same directory, different languages. + let conf = compute_confidence( + "tests/benchmarks/resolution/tracer/ruby-tracer.rb", + "tests/benchmarks/resolution/tracer/loader-hooks.mjs", + None, + ); + assert_eq!(conf, 0.0); + } + + #[test] + fn compute_confidence_still_scores_same_language_same_directory_pair() { + let conf = compute_confidence( + "tests/benchmarks/resolution/tracer/ruby-tracer.rb", + "tests/benchmarks/resolution/tracer/other-tracer.rb", + None, + ); + assert_eq!(conf, 0.7); + } + + #[test] + fn compute_confidence_does_not_regress_same_project_js_ts_resolution() { + // A .ts caller resolving a same-directory .js target must be unaffected — + // TS/JS are one family despite being different LanguageKind variants. + let conf = compute_confidence("src/graph/a.ts", "src/graph/b.js", None); + assert_eq!(conf, 0.7); + } } diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index dde9fb23..fb97377f 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -9,7 +9,7 @@ * `resolveByMethodOrGlobal` delegates its two branches to strategy helpers * in `../resolver/strategy.ts` to keep per-strategy complexity manageable. */ -import { computeConfidence } from '../resolve.js'; +import { computeConfidence, isSameLanguageFamily } from '../resolve.js'; import { isModuleScopedLanguage, resolveByGlobal, @@ -347,9 +347,15 @@ export function resolveReceiverEdge( const sameFileAll = lookup.byNameAndFile(effectiveReceiver, relPath); const isLocalDefinition = sameFileAll.length > 0 && !importedNames?.has(effectiveReceiver); const sameFileCandidates = sameFileAll.filter((n) => RECEIVER_KINDS.has(n.kind ?? '')); + // Cross-language candidates are never legitimate receiver targets (#1783) — + // a `new Foo()` in one language can't statically resolve to an unrelated + // same-named class in another. Only the global (cross-file) branch needs + // the check: sameFileCandidates are already scoped to relPath itself. const candidates = isLocalDefinition ? sameFileCandidates - : lookup.byName(effectiveReceiver).filter((n) => RECEIVER_KINDS.has(n.kind ?? '')); + : lookup + .byName(effectiveReceiver) + .filter((n) => RECEIVER_KINDS.has(n.kind ?? '') && isSameLanguageFamily(relPath, n.file)); if (candidates.length === 0) return null; const recvTarget = candidates[0]!; const recvKey = `recv|${caller.id}|${recvTarget.id}`; diff --git a/src/domain/graph/resolve.ts b/src/domain/graph/resolve.ts index 98a07d75..d8e2a81b 100644 --- a/src/domain/graph/resolve.ts +++ b/src/domain/graph/resolve.ts @@ -5,6 +5,7 @@ import { loadNative } from '../../infrastructure/native.js'; import { normalizePath } from '../../shared/constants.js'; import { toErrorMessage } from '../../shared/errors.js'; import type { BareSpecifier, BatchResolvedMap, ImportBatchItem, PathAliases } from '../../types.js'; +import { LANGUAGE_REGISTRY } from '../parser.js'; // ── package.json exports resolution ───────────────────────────────── @@ -518,6 +519,87 @@ function directoryDistance(a: string, b: string): number { return dist; } +// ── Language-family scoping for global-by-name fallback resolution ───────── + +/** + * LANGUAGE_REGISTRY ids that must be treated as one family for cross-file + * call resolution. TypeScript/TSX compile to and interoperate directly with + * JavaScript — a `.ts` file routinely imports from and calls into a `.js` + * file and vice versa (this codebase's own `src/` tree does this + * throughout) — despite being three separate grammar entries in + * LANGUAGE_REGISTRY. Every other registry id keeps its own family, which + * preserves LANGUAGE_REGISTRY's existing per-language extension groupings + * (e.g. C's `.c`+`.h`, C++'s `.cpp`/`.cc`/`.cxx`/`.hpp`). + */ +const JS_FAMILY_REGISTRY_IDS = new Set(['javascript', 'typescript', 'tsx']); + +/** + * Extensions excluded from the family map entirely, so `languageFamily` + * returns null for them and they fall through to the ambiguous-extension + * path (ordinary distance-based scoring, never rejected outright). + * + * `.h` is real-world ambiguous between C and C++: LANGUAGE_REGISTRY assigns + * it to the `c` entry alone (grammar selection needs one canonical parser), + * but the extremely common case of a `.cpp` file calling into its own + * project's `.h` header would otherwise be misclassified as cross-language + * and rejected outright (confidence 0) — a real regression from the + * pre-#1783 same-directory score of 0.7 (Greptile review). Treating `.h` as + * ambiguous — like an unrecognised extension — keeps the C/C++-header case + * working without merging C and C++ source-file families wholesale (`.c` + * vs `.cpp` intentionally do NOT merge — see + * is_same_language_family_does_not_merge_c_and_cpp). + */ +const AMBIGUOUS_EXTENSIONS = new Set(['.h']); + +/** + * extension → language-family lookup, derived from LANGUAGE_REGISTRY (the + * single source of truth for language definitions) so newly-added languages + * are automatically covered without a second hand-maintained extension list. + */ +const _extToLanguageFamily: Map = new Map(); +for (const entry of LANGUAGE_REGISTRY) { + const family = JS_FAMILY_REGISTRY_IDS.has(entry.id) ? 'javascript' : entry.id; + for (const ext of entry.extensions) { + if (AMBIGUOUS_EXTENSIONS.has(ext)) continue; + _extToLanguageFamily.set(ext, family); + } +} + +/** + * Resolve a file's coarse language family from its extension. Returns null + * for extensionless or unrecognised files so ambiguous cases fall through to + * ordinary distance-based scoring rather than being rejected outright. + */ +function languageFamily(file: string): string | null { + const dot = file.lastIndexOf('.'); + if (dot === -1) return null; + return _extToLanguageFamily.get(file.slice(dot).toLowerCase()) ?? null; +} + +/** + * True when `fileA` and `fileB` belong to the same language family, or when + * either extension is unrecognised (ambiguous cases are not rejected — they + * fall through to normal scoring). False only when both extensions are + * recognised AND resolve to different families. + * + * Guards the global-by-name call-resolution fallback against matching a + * same-named symbol across unrelated languages — e.g. a Ruby file's bare + * `load` call has no static relationship to a same-named `load` export in a + * JS file, even when both happen to live in the same directory (issue + * #1783). This codebase has no cross-language static-call mechanism its + * resolvers legitimately model (the `dead-ffi` role classifier only + * suppresses false dead-code flags for compiled-language files consumed via + * FFI — it never creates call edges), so rejecting cross-family candidates + * is a strict precision improvement with no legitimate resolution to + * regress. + */ +export function isSameLanguageFamily(fileA: string, fileB: string): boolean { + const famA = languageFamily(fileA); + const famB = languageFamily(fileB); + if (!famA || !famB) return true; + return famA === famB; +} + function computeConfidenceJS( callerFile: string, targetFile: string, @@ -528,6 +610,10 @@ function computeConfidenceJS( if (importedFrom === targetFile) return 1.0; // Workspace-resolved imports get high confidence even across package boundaries if (importedFrom && _workspaceResolvedPaths.has(importedFrom)) return 0.95; + // Cross-language candidates are never legitimate call targets (#1783) — reject + // before scoring proximity so a same-directory, same-named symbol in an + // unrelated language can never pass the resolver's 0.5 confidence threshold. + if (!isSameLanguageFamily(callerFile, targetFile)) return 0; const dist = directoryDistance(path.dirname(callerFile), path.dirname(targetFile)); if (dist === 0) return 0.7; // same directory if (dist === 1) return 0.6; // direct parent/child directory diff --git a/tests/unit/call-resolver.test.ts b/tests/unit/call-resolver.test.ts index a50a1045..0ee65d17 100644 --- a/tests/unit/call-resolver.test.ts +++ b/tests/unit/call-resolver.test.ts @@ -213,6 +213,50 @@ describe('resolveByMethodOrGlobal — bare-call JS/TS module-scope guard (#1407) }); }); +describe('resolveByMethodOrGlobal — cross-language global fallback rejection (#1783)', () => { + // Mirrors the #1783 repro: ruby-tracer.rb's bare `Kernel#load` call has no + // static relationship to loader-hooks.mjs's unrelated `load` export, even + // though both files live in the same directory (which would otherwise + // score confidence 0.7 — well above the resolver's 0.5 threshold). + it('does not resolve a bare call to a same-directory, same-named symbol in a different language', () => { + const jsExport = { id: 1, file: 'tracer/loader-hooks.mjs', kind: 'function' }; + const lookup = makeLookup({ load: [jsExport] }); + const result = resolveByMethodOrGlobal( + lookup, + { name: 'load', receiver: null }, + 'tracer/ruby-tracer.rb', + new Map(), + ); + expect(result).toEqual([]); + }); + + it('still resolves a bare call to a same-directory, same-named symbol in the SAME language', () => { + const rbTarget = { id: 2, file: 'tracer/other-tracer.rb', kind: 'function' }; + const lookup = makeLookup({ load: [rbTarget] }); + const result = resolveByMethodOrGlobal( + lookup, + { name: 'load', receiver: null }, + 'tracer/ruby-tracer.rb', + new Map(), + ); + expect(result).toEqual([rbTarget]); + }); + + it('does not resolve a typed-method lookup to a same-named type in a different language', () => { + // receiver 'w' typed to 'Widget' via typeMap; a same-directory JS 'Widget.render' + // method must not satisfy a Python caller's 'w.render()' call. + const jsMethod = { id: 3, file: 'lib/widget.js', kind: 'method' }; + const lookup = makeLookup({ 'Widget.render': [jsMethod] }); + const result = resolveByMethodOrGlobal( + lookup, + { name: 'render', receiver: 'w' }, + 'lib/widget.py', + new Map([['w', 'Widget']]), + ); + expect(result).toEqual([]); + }); +}); + // ── resolveReceiverEdge ────────────────────────────────────────────────────── /** @@ -290,6 +334,44 @@ describe('resolveReceiverEdge — local function constructor blocks global class }); }); +describe('resolveReceiverEdge — cross-language global fallback rejection (#1783)', () => { + // The global (cross-file) receiver-resolution branch used no confidence or + // language check at all, so `new Widget()` in one language could resolve + // to an unrelated same-named class declared in a completely different + // language. Only the global branch needs the check — sameFileCandidates + // are already scoped to the caller's own file (trivially same-language). + it('does not resolve a receiver to a same-named class in a different language', () => { + const jsClass = { id: 1, file: 'lib/Widget.js', kind: 'class' }; + const lookup = makeReceiverLookup({}, { Widget: [jsClass] }); + const result = resolveReceiverEdge( + lookup, + { name: 'render', receiver: 'Widget' }, + { id: 99 }, + 'lib/widget.py', + new Map(), + new Set(), + new Map(), + ); + expect(result).toBeNull(); + }); + + it('still resolves a receiver to a same-named class in the SAME language', () => { + const pyClass = { id: 2, file: 'lib/widget_impl.py', kind: 'class' }; + const lookup = makeReceiverLookup({}, { Widget: [pyClass] }); + const result = resolveReceiverEdge( + lookup, + { name: 'render', receiver: 'Widget' }, + { id: 99 }, + 'lib/widget.py', + new Map(), + new Set(), + new Map(), + ); + expect(result).not.toBeNull(); + expect(result?.receiverId).toBe(2); + }); +}); + // ── resolveDefinePropertyAccessorTarget ─────────────────────────────────── /** diff --git a/tests/unit/resolve.test.ts b/tests/unit/resolve.test.ts index 7ad38e4d..2f9a6a56 100644 --- a/tests/unit/resolve.test.ts +++ b/tests/unit/resolve.test.ts @@ -15,6 +15,7 @@ import { computeConfidence, computeConfidenceJS, convertAliasesForNative, + isSameLanguageFamily, isWorkspaceResolved, parseBareSpecifier, resolveImportPathJS, @@ -728,3 +729,97 @@ describe('computeConfidenceJS workspace confidence', () => { expect(conf).toBeLessThan(0.95); }); }); + +// ─── Cross-language fallback rejection (#1783) ─────────────────────── + +// Regression tests for #1783: the global-by-name call-resolution fallback +// had no language-consistency check at all, so a bare-name call with no +// import/receiver match could resolve against a same-named symbol in a +// completely unrelated language — e.g. a Ruby file's builtin `Kernel#load` +// call matched a JS ESM loader hook's unrelated `load` export purely because +// both files sat in the same directory (confidence 0.7 from proximity alone). +describe('isSameLanguageFamily (#1783)', () => { + it('returns false for a Ruby file and a JS file', () => { + expect(isSameLanguageFamily('tracer/ruby-tracer.rb', 'tracer/loader-hooks.mjs')).toBe(false); + }); + + it('returns false for a Python file and a Go file', () => { + expect(isSameLanguageFamily('src/main.py', 'src/main.go')).toBe(false); + }); + + it('returns true for two files with the same extension', () => { + expect(isSameLanguageFamily('src/a.rb', 'lib/b.rb')).toBe(true); + }); + + it('treats JavaScript and TypeScript as the same family', () => { + expect(isSameLanguageFamily('src/a.ts', 'src/b.js')).toBe(true); + expect(isSameLanguageFamily('src/a.tsx', 'src/b.mjs')).toBe(true); + expect(isSameLanguageFamily('src/a.cjs', 'src/b.jsx')).toBe(true); + }); + + it('treats C and its own header extension as the same family', () => { + expect(isSameLanguageFamily('src/a.c', 'src/a.h')).toBe(true); + }); + + it('treats .h as ambiguous with C++ (Greptile follow-up)', () => { + // `.h` is real-world ambiguous between C and C++ (LANGUAGE_REGISTRY + // assigns it to C alone for grammar-selection purposes), so a `.cpp` + // file calling into its own project's `.h` header must not be rejected + // as cross-language. + expect(isSameLanguageFamily('src/widget.cpp', 'src/widget.h')).toBe(true); + }); + + it('treats C++ source and header extensions as the same family', () => { + expect(isSameLanguageFamily('src/a.cpp', 'src/a.hpp')).toBe(true); + expect(isSameLanguageFamily('src/a.cc', 'src/a.cxx')).toBe(true); + }); + + it('does not treat C and C++ as the same family', () => { + expect(isSameLanguageFamily('src/a.c', 'src/a.cpp')).toBe(false); + }); + + it('returns true (does not reject) when either extension is unrecognised', () => { + expect(isSameLanguageFamily('README', 'src/b.rb')).toBe(true); + expect(isSameLanguageFamily('src/a.rb', 'Makefile')).toBe(true); + }); +}); + +describe('computeConfidenceJS — cross-language rejection (#1783)', () => { + it('returns 0 for same-directory files in different languages (the #1783 repro shape)', () => { + // ruby-tracer.rb's bare `load` call must never match loader-hooks.mjs's + // `load` export just because both files live in the same directory. + const conf = computeConfidenceJS( + 'tests/benchmarks/resolution/tracer/ruby-tracer.rb', + 'tests/benchmarks/resolution/tracer/loader-hooks.mjs', + undefined, + ); + expect(conf).toBe(0); + }); + + it('still returns same-directory confidence for a same-language pair', () => { + const conf = computeConfidenceJS( + 'tests/benchmarks/resolution/tracer/ruby-tracer.rb', + 'tests/benchmarks/resolution/tracer/other-tracer.rb', + undefined, + ); + expect(conf).toBe(0.7); + }); + + it('does not regress same-project JS/TS cross-file resolution', () => { + // A .ts caller resolving a same-directory .js target must be unaffected — + // TS/JS are one family despite being different LANGUAGE_REGISTRY entries. + const conf = computeConfidenceJS('src/graph/a.ts', 'src/graph/b.js', undefined); + expect(conf).toBe(0.7); + }); + + it('rejects a cross-language match even when same-file/importedFrom shortcuts do not apply', () => { + const conf = computeConfidenceJS('src/main.py', 'src/main.go', undefined); + expect(conf).toBeLessThan(0.5); + expect(conf).toBe(0); + }); + + it('does not reject when the target extension is unrecognised (falls through to distance scoring)', () => { + const conf = computeConfidenceJS('src/a.rb', 'src/README', undefined); + expect(conf).toBeGreaterThan(0); + }); +});