Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
116 changes: 115 additions & 1 deletion __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>): 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
Expand Down
72 changes: 61 additions & 11 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down