From 293dd7a198eb57665eaaa6de4521d8bb8e415b99 Mon Sep 17 00:00:00 2001 From: ferres Date: Mon, 31 Aug 2026 11:30:46 +0000 Subject: [PATCH] fix(resolution): don't believe a bare-name call across language families MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems at the same comparison, one masking the other. The single-candidate confidence penalty compared raw language tags, so a `.tsx` file calling a `.ts` helper counted as cross-language and every ordinary call in a React codebase was scored 0.5 instead of 0.9. Families already exist for exactly this (`sameLanguageFamily`); using them makes 0.5 mean what it says. With that fixed, 0.5 identifies the real problem: a call resolved to a same-named symbol in an unrelated language on no evidence but the name. Cross-family calls are deliberately allowed — FFI bindings, native bridges are real, which is why the language gate covers `references` and `imports` but not `calls`. But a language's own builtins and macros have no definition of their own in the graph to bind to, so the lone same-named foreign symbol won by default: an ExUnit `test` binding to a test runner's fixture in TypeScript, `dataclasses.field` binding to an unrelated Elixir helper. The matcher already recognised these and wrote the verdict into the edge's metadata as a low confidence — and every consumer then believed the edge anyway, so `callers` and `impact` reported it as fact. On a mixed-language repository that was thousands of impossible edges, concentrated on a small set of generically-named symbols whose reported callers were then mostly or entirely fictional. Matches at or below the floor are now declined rather than recorded. The precise strategies are untouched, which is how a genuine binding keeps its edge: it resolves through an import or a qualified name, well above the floor, never through a bare one. Tunable via `CODEGRAPH_CROSS_FAMILY_CALL_FLOOR`, and `=0` restores the old behaviour. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7 --- CHANGELOG.md | 5 ++ __tests__/resolution.test.ts | 116 ++++++++++++++++++++++++++++++++- src/resolution/name-matcher.ts | 72 ++++++++++++++++---- 3 files changed, 181 insertions(+), 12 deletions(-) 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;