Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading.
- In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged.
- A name that only one definition in its own file can mean, like a call to a function the file defines, is no longer marked as an uncertain match when the file sits near the top of the project, and it now wins over a framework's weaker same-named guess in another file: in an ASP.NET app, `ToastService.OnShow += …` now reads the component's own `ToastService` property instead of linking to the `ToastService` class. Thanks @danusha2345.
- When a project keeps copies of the same code in several folders, like Android build flavors that each hold one package, a call now links to the copy in the caller's own folder, even in deeply nested trees where it could land on another copy. Thanks @danusha2345.
- In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading.
- In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading.
- In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading.
Expand Down
150 changes: 150 additions & 0 deletions __tests__/same-module-copy-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* A module duplicated under another source root (`rc2/…` vs `rcpro2/…`) gives
* every bare name two same-named candidates, one per copy. Directory proximity
* is capped, so on a deep tree both copies score the same and the first-indexed
* one used to win — a call in module B could land on module A's copy. The
* caller's own copy, when it is uniquely closest, must win; when no copy is
* closer (the caller sits above both), behavior is unchanged.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';

// Six shared segments already reach the proximity cap.
const ROOT = 'a/b/c/d/e/f';

function write(dir: string, rel: string, lines: string[]): void {
const full = path.join(dir, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, lines.join('\n') + '\n');
}

describe('exact-name resolution prefers the caller\'s own copy of a duplicated module', () => {
let tempDir: string;
let cg: CodeGraph | null = null;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-same-module-'));
for (const mod of ['rc2', 'rcpro2']) {
write(tempDir, `${ROOT}/${mod}/util.py`, ['def compute():', ' return 1']);
write(tempDir, `${ROOT}/${mod}/main.py`, ['def run():', ' return compute()']);
}
write(tempDir, `${ROOT}/rc2/only.py`, ['def solo():', ' return 2']);
write(tempDir, `${ROOT}/rc2/solo_caller.py`, ['def use_solo():', ' return solo()']);
// Equidistant from both copies.
write(tempDir, `${ROOT}/top.py`, ['def top():', ' return compute()']);
});

afterEach(() => {
cg?.destroy();
cg = null;
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway.
}
});

async function callsFrom(fnName: string, filePath: string) {
const caller = cg!.getNodesByKind('function').find((n) => n.name === fnName && n.filePath === filePath);
expect(caller).toBeDefined();
return cg!.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls');
}

function fn(name: string, filePath: string) {
const node = cg!.getNodesByKind('function').find((n) => n.name === name && n.filePath === filePath);
expect(node).toBeDefined();
return node!;
}

it('resolves each module\'s call to its own copy', async () => {
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const calls = await Promise.all(
['rc2', 'rcpro2'].map((mod) => callsFrom('run', `${ROOT}/${mod}/main.py`)),
);
expect(calls.map((c) => c.map((e) => e.target))).toEqual(
['rc2', 'rcpro2'].map((mod) => [fn('compute', `${ROOT}/${mod}/util.py`).id]),
);
expect(calls.map((c) => c[0]!.metadata?.confidence)).toEqual([0.8, 0.8]);
});

it('keeps the prior choice and confidence when the caller is equidistant', async () => {
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const calls = await callsFrom('top', `${ROOT}/top.py`);
expect(calls).toHaveLength(1);
const copies = ['rc2', 'rcpro2'].map((m) => fn('compute', `${ROOT}/${m}/util.py`).id);
expect(copies).toContain(calls[0]!.target);
expect(calls[0]!.metadata?.confidence).toBe(0.7);
});

// Android build flavors keep one package in two source sets that are never
// compiled together, so a name the package declares needs no import and
// means the caller's own flavor's copy.
const FLAVORS = 'platform/apps/mobile/android/app/src';

it("resolves a Kotlin flavor's call to its own copy of the package", async () => {
for (const flavor of ['rc2', 'rcpro2']) {
const dir = `${FLAVORS}/${flavor}/kotlin/com/app/util`;
write(tempDir, `${dir}/Util.kt`, ['package com.app.util', '', 'fun compute(): Int = 1']);
write(tempDir, `${dir}/Main.kt`, ['package com.app.util', '', 'fun run(): Int = compute()']);
}
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const flavors = ['rc2', 'rcpro2'];
const calls = await Promise.all(flavors.map((f) => callsFrom('run', `${FLAVORS}/${f}/kotlin/com/app/util/Main.kt`)));
expect(calls.map((c) => c.map((e) => e.target))).toEqual(
flavors.map((f) => [fn('compute', `${FLAVORS}/${f}/kotlin/com/app/util/Util.kt`).id]),
);
expect(calls.map((c) => c[0]!.metadata?.confidence)).toEqual([0.8, 0.8]);
});

it("resolves a Java flavor's `new Util()` to its own copy of the class", async () => {
for (const flavor of ['rc2', 'rcpro2']) {
const dir = `${FLAVORS}/${flavor}/java/com/app/util`;
write(tempDir, `${dir}/Util.java`, ['package com.app.util;', '', 'public class Util {', ' public int compute() { return 1; }', '}']);
write(tempDir, `${dir}/Main.java`, ['package com.app.util;', '', 'public class Main {', ' int run() { return new Util().compute(); }', '}']);
}
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

for (const flavor of ['rc2', 'rcpro2']) {
const dir = `${FLAVORS}/${flavor}/java/com/app/util`;
const run = cg.getNodesByKind('method').find((n) => n.name === 'run' && n.filePath === `${dir}/Main.java`);
const util = cg.getNodesByKind('class').find((n) => n.name === 'Util' && n.filePath === `${dir}/Util.java`);
const made = cg.getOutgoingEdges(run!.id).filter((e) => e.kind === 'instantiates').map((e) => e.target);
expect(made).toEqual([util!.id]);
}
});

it('leaves a match of the caller itself at the proximity score', async () => {
// Recursion, or a receiver the call lost (`json.dumps` inside `dumps`):
// the caller's own copy being the caller says nothing of which copy.
for (const mod of ['rc2', 'rcpro2']) {
write(tempDir, `${ROOT}/${mod}/walk.py`, ['def walk(n):', ' return walk(n - 1)']);
}
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const calls = await callsFrom('walk', `${ROOT}/rcpro2/walk.py`);
expect(calls.map((e) => e.target)).toEqual([fn('walk', `${ROOT}/rcpro2/walk.py`).id]);
expect(calls[0]!.metadata?.confidence).toBe(0.7);
});

it('leaves a single-candidate name unaffected', async () => {
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const calls = await callsFrom('use_solo', `${ROOT}/rc2/solo_caller.py`);
expect(calls).toHaveLength(1);
expect(calls[0]!.target).toBe(fn('solo', `${ROOT}/rc2/only.py`).id);
expect(calls[0]!.metadata?.confidence).toBe(0.9);
});
});
67 changes: 66 additions & 1 deletion src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12049,8 +12049,19 @@ export function matchByExactName(
if (bestMatch && isCrossFileReachable(bestMatch, ref, context)) {
// Lower confidence when the match is from a distant/unrelated module
const proximity = computePathProximity(ref.filePath, bestMatch.filePath);
// Every candidate is a copy of one symbol and this is the caller's own
// copy: the name itself is not ambiguous, only which copy — so rank it
// above a proximity guess, but below a unique name or the file's own
// definition (0.9). The candidates are those findBestMatch weighed: a
// namesake in another language never wins while one in the reference's
// own language exists. The caller's own copy being the caller itself is
// no evidence, as for a same-file match (isDecisiveSameFileMatch).
const weighed = candidates.some((c) => c.language === ref.language)
? candidates.filter((c) => c.language === ref.language)
: candidates;
const confidence = isDecisiveSameFileMatch(bestMatch, ref, candidates, context)
? 0.9
: bestMatch.id !== ref.fromNodeId && closestCopy(ref, weighed) === bestMatch ? 0.8
: proximity >= 30 ? 0.7 : 0.4;
return {
original: ref,
Expand Down Expand Up @@ -26798,6 +26809,7 @@ function findBestMatch(

let bestScore = -1;
let bestNode: Node | null = null;
let tied: Node[] = [];

// Split the ref's path once (it's the same across every candidate) instead of
// re-splitting it inside computePathProximity per candidate (#915 hot spot).
Expand Down Expand Up @@ -26888,10 +26900,63 @@ function findBestMatch(
if (score > bestScore) {
bestScore = score;
bestNode = candidate;
tied = [candidate];
} else if (score === bestScore) {
tied.push(candidate);
}
}

return bestNode;
// Proximity is capped, so on deep trees two copies of the same symbol in
// sibling modules can tie; the caller's own copy, if uniquely closest, wins.
return (tied.length > 1 && closestCopy(ref, tied)) || bestNode;
}

/**
* Number of leading directory segments two file paths share (uncapped).
*/
function sharedDirDepth(filePath1: string, filePath2: string): number {
const a = filePath1.split('/');
const b = filePath2.split('/');
a.pop();
b.pop();
let shared = 0;
while (shared < a.length && shared < b.length && a[shared] === b[shared]) shared++;
return shared;
}

/**
* When every candidate is a copy of the same symbol — same qualified name, kind
* and file basename, as when a module is duplicated under another source root
* (`rc2/flymod/src/…` vs `rcpro2/flymod/src/…`) — return the copy whose file
* shares the longest directory prefix with the reference, i.e. the one in the
* caller's own module. Returns null when the candidates are not all copies or
* no copy is uniquely closest, so callers keep their existing choice.
*/
function closestCopy(ref: UnresolvedRef, candidates: Node[]): Node | null {
if (candidates.length < 2) return null;
const first = candidates[0]!;
const base = path.posix.basename(first.filePath);
let best: Node | null = null;
let bestDepth = -1;
let unique = false;
for (const c of candidates) {
if (
c.qualifiedName !== first.qualifiedName ||
c.kind !== first.kind ||
path.posix.basename(c.filePath) !== base
) {
return null;
}
const depth = sharedDirDepth(ref.filePath, c.filePath);
if (depth > bestDepth) {
bestDepth = depth;
best = c;
unique = true;
} else if (depth === bestDepth) {
unique = false;
}
}
return unique ? best : null;
}

/**
Expand Down