diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff17..b45495d63d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- Calls no longer resolve to a same-named symbol in an unrelated language on the strength of the name alone. Nothing in Python can call a function in Elixir, but a language's own builtins and test macros — `test`, `describe`, `apply`, `max`, `field` — have no definition of their own to bind to, so a lone same-named symbol in another language was winning by default. On a mixed-language repository that produced thousands of impossible call edges, and the symbols they landed on reported callers and impact that were mostly or entirely fictional. Genuine cross-language calls — FFI bindings, native bridges — are unaffected, because those resolve through an import or a qualified name rather than a bare one. Set `CODEGRAPH_CROSS_FAMILY_CALL_FLOOR=0` to restore the old behaviour. +- A `.tsx` file calling a `.ts` helper is no longer treated as crossing a language boundary. The two are the same runtime, but they were compared by file type rather than by language family, so ordinary calls throughout a React codebase were scored as low-confidence guesses. + ## [1.6.0] - 2026-08-26 diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index decaadee57..6d07279636 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -4,7 +4,7 @@ * Tests for Phase 3: Reference Resolution */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -36,6 +36,120 @@ describe('Resolution Module', () => { } }); + + describe('Cross-family call confidence', () => { + // A `calls` edge is deliberately allowed to cross language families — FFI + // bindings, a React Native bridge method, a pybind entry point are all real + // calls, which is why the language gate covers `references` and `imports` + // but not `calls`. What is not real is a cross-family edge whose only + // evidence is that some symbol somewhere carries the same name: nothing in + // Python can call a function in Elixir, and an unresolved language builtin + // or test macro has no definition in its own language to bind to, so the + // lone same-named foreign symbol used to win by default. + const node = (over: Partial): Node => ({ + id: 'n', kind: 'function', name: 'x', qualifiedName: 'x', + filePath: 'a.ts', language: 'typescript', + startLine: 1, endLine: 2, startColumn: 0, endColumn: 0, + updatedAt: Date.now(), ...over, + }); + const contextOf = (nodes: Node[]): ResolutionContext => ({ + getNodesInFile: () => nodes, + getNodesByName: (name: string) => nodes.filter((n) => n.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + getNodesByLowerName: (lower: string) => + nodes.filter((n) => n.name.toLowerCase() === lower), + getImportMappings: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => nodes.map((n) => n.filePath), + } as any); + const refFrom = (language: string, filePath: string, name: string) => ({ + fromNodeId: `caller:${filePath}:c:1`, + referenceName: name, + referenceKind: 'calls' as const, + line: 5, column: 0, filePath, language: language as any, + }); + + it('declines a lone same-named match in an unrelated language', () => { + // `test` is an ExUnit macro: it has no Elixir definition to bind to, so + // the only candidate anywhere is a test runner's fixture in TypeScript. + const nodes = [node({ + id: 'ts:test', name: 'test', qualifiedName: 'fixtures.ts::test', + filePath: 'e2e/fixtures.ts', language: 'typescript', + })]; + expect(matchReference(refFrom('elixir', 'lib/app/user_test.exs', 'test'), contextOf(nodes))) + .toBeNull(); + }); + + it('still resolves a lone same-named match inside one family', () => { + // `.tsx` calling a `.ts` helper is the same runtime, and is most calls in + // a React codebase — it must keep both its edge and a high confidence. + const nodes = [node({ + id: 'ts:formatDate', name: 'formatDate', qualifiedName: 'fmt.ts::formatDate', + filePath: 'src/lib/fmt.ts', language: 'typescript', + })]; + const result = matchReference( + refFrom('tsx', 'src/components/Row.tsx', 'formatDate'), contextOf(nodes)); + expect(result?.targetNodeId).toBe('ts:formatDate'); + // Scored on the family, not the raw language tag — `tsx` vs `typescript` + // is not a boundary crossing. + expect(result!.confidence).toBeGreaterThan(0.5); + }); + + it('prefers a same-language definition over a foreign same-named one', () => { + const nodes = [ + node({ id: 'ex:parse', name: 'parse', qualifiedName: 'App.Parser::parse', + filePath: 'lib/app/parser.ex', language: 'elixir' }), + node({ id: 'ts:parse', name: 'parse', qualifiedName: 'p.ts::parse', + filePath: 'src/p.ts', language: 'typescript' }), + ]; + const result = matchReference( + refFrom('elixir', 'lib/app/reader.ex', 'parse'), contextOf(nodes)); + expect(result?.targetNodeId).toBe('ex:parse'); + }); + + it('keeps a cross-family match that a precise strategy resolved', () => { + // A real FFI binding resolves by qualified name, well above the floor — + // the floor only declines matches whose sole evidence is the bare name. + const nodes = [node({ + id: 'cpp:Engine.step', name: 'step', qualifiedName: 'Engine::step', + filePath: 'native/engine.cpp', language: 'cpp', kind: 'method', + })]; + const context = { + ...contextOf(nodes), + getNodesByQualifiedName: (q: string) => + nodes.filter((n) => n.qualifiedName === q), + } as ResolutionContext; + const result = matchReference(refFrom('python', 'py/sim.py', 'Engine::step'), context); + expect(result?.targetNodeId).toBe('cpp:Engine.step'); + expect(result!.confidence).toBeGreaterThan(0.5); + }); + + it('can be switched off for a project that relies on the old behaviour', async () => { + // The gate removes edges, so it needs an escape hatch — read at module + // load, which is why this reimports rather than mutating in place. + const prev = process.env.CODEGRAPH_CROSS_FAMILY_CALL_FLOOR; + process.env.CODEGRAPH_CROSS_FAMILY_CALL_FLOOR = '0'; + try { + vi.resetModules(); + const fresh = await import('../src/resolution/name-matcher'); + const nodes = [node({ + id: 'ts:test', name: 'test', qualifiedName: 'fixtures.ts::test', + filePath: 'e2e/fixtures.ts', language: 'typescript', + })]; + expect(fresh.matchReference( + refFrom('elixir', 'lib/app/user_test.exs', 'test'), contextOf(nodes)) + ).not.toBeNull(); + } finally { + if (prev === undefined) delete process.env.CODEGRAPH_CROSS_FAMILY_CALL_FLOOR; + else process.env.CODEGRAPH_CROSS_FAMILY_CALL_FLOOR = prev; + vi.resetModules(); + } + }); + }); + describe('Name Matcher', () => { it('should match exact name references', () => { // Create a mock context diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272e..371a8eb466 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -34,6 +34,49 @@ function resolveAmbiguousNameCeiling(): number { } const AMBIGUOUS_NAME_CEILING = resolveAmbiguousNameCeiling(); +/** + * Confidence a name match must EXCEED to be believed across language families. + * + * A `calls` edge is deliberately allowed to cross families — an FFI binding, a + * React Native bridge method, a pybind entry point are all real calls, which is + * why `applyLanguageGate` gates `references` and `imports` but not `calls`. + * What is not real is a cross-family edge whose only evidence is that some + * symbol somewhere carries the same name. Nothing in Python can call a function + * in Elixir, and an unresolved language builtin or test macro — `test`, + * `describe`, `apply`, `max`, `field` — has no definition in its own language to + * bind to, so the lone same-named symbol in another language wins by default. + * + * Those matches were already recognised: this is exactly the branch that scores + * a lone cross-family candidate 0.5 instead of 0.9, and fuzzy 0.3. The verdict + * was recorded in the edge's metadata and then believed anyway by every + * consumer, so `callers` and `impact` reported it as fact. Declining below the + * floor keeps the precise strategies — qualified-name, import-based, class-name + * — which is how a genuine FFI binding resolves, and how it keeps its edge. + * + * Set `CODEGRAPH_CROSS_FAMILY_CALL_FLOOR=0` to restore the old behaviour. + */ +const DEFAULT_CROSS_FAMILY_CALL_FLOOR = 0.5; +function resolveCrossFamilyCallFloor(): number { + const raw = process.env.CODEGRAPH_CROSS_FAMILY_CALL_FLOOR; + if (!raw) return DEFAULT_CROSS_FAMILY_CALL_FLOOR; + const parsed = Number.parseFloat(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CROSS_FAMILY_CALL_FLOOR; +} +const CROSS_FAMILY_CALL_FLOOR = resolveCrossFamilyCallFloor(); + +/** + * Drop a name match that crosses a language family on weak evidence. Same-family + * matches (and anything above the floor) pass through untouched. + */ +function declineWeakCrossFamily( + match: ResolvedRef, + target: Node, + ref: UnresolvedRef +): ResolvedRef | null { + if (sameLanguageFamily(target.language, ref.language)) return match; + return match.confidence > CROSS_FAMILY_CALL_FLOOR ? match : null; +} + /** * Try to resolve a path-like reference (e.g., "snippets/drawer-menu.liquid") * by matching the filename against file nodes. @@ -413,15 +456,19 @@ export function matchByExactName( return null; } - // If only one match, use it — but penalize cross-language matches + // If only one match, use it — but penalize matches from another language + // FAMILY. Comparing the raw language tag instead treats `.tsx` calling a + // `.ts` helper as cross-language and marks a perfectly ordinary same-runtime + // call 0.5, which is most calls in any React codebase; the family is what + // decides whether the two halves can reach each other at all. if (candidates.length === 1) { - const isCrossLanguage = candidates[0]!.language !== ref.language; - return { + const isCrossFamily = !sameLanguageFamily(candidates[0]!.language, ref.language); + return declineWeakCrossFamily({ original: ref, targetNodeId: candidates[0]!.id, - confidence: isCrossLanguage ? 0.5 : 0.9, + confidence: isCrossFamily ? 0.5 : 0.9, resolvedBy: 'exact-match', - }; + }, candidates[0]!, ref); } // Ubiquitous-name ceiling (#999): above it, picking one target among K @@ -439,12 +486,12 @@ export function matchByExactName( // Lower confidence when the match is from a distant/unrelated module const proximity = computePathProximity(ref.filePath, bestMatch.filePath); const confidence = proximity >= 30 ? 0.7 : 0.4; - return { + return declineWeakCrossFamily({ original: ref, targetNodeId: bestMatch.id, confidence, resolvedBy: 'exact-match', - }; + }, bestMatch, ref); } return null; @@ -2419,13 +2466,16 @@ export function matchFuzzy( const finalCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates; if (finalCandidates.length === 1) { - const isCrossLanguage = finalCandidates[0]!.language !== ref.language; - return { + // Family, not raw language tag: a `.tsx` ref matching a `.ts` definition is + // the same runtime, and scoring it as though it crossed a boundary + // understates every call in a React codebase. + const isCrossFamily = !sameLanguageFamily(finalCandidates[0]!.language, ref.language); + return declineWeakCrossFamily({ original: ref, targetNodeId: finalCandidates[0]!.id, - confidence: isCrossLanguage ? 0.3 : 0.5, + confidence: isCrossFamily ? 0.3 : 0.5, resolvedBy: 'fuzzy', - }; + }, finalCandidates[0]!, ref); } return null;