mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 19:41:47 +08:00
A module kept in several copies, like two Android build flavors that each
hold one package, gives every name in it one candidate per copy.
Directory proximity is capped at six shared folders, so in a deep tree
the copies scored the same and the first indexed won: a call in one
flavor could land on the other flavor's copy. And since the name is not
in doubt among copies, only which copy, scoring the right pick by
proximity (0.7, or 0.4 in a shallow tree) understated it.
findBestMatch now breaks a tie among copies of one symbol (same
qualified name, kind and file name) toward the copy whose file shares
the longest directory prefix with the reference, when that copy is
uniquely closest. matchByExactName scores that pick 0.8: above a
proximity guess, below a unique name or the file's own definition (0.9,
which keeps precedence).
On top of the PR: the copies are judged among the candidates
findBestMatch weighed, so a namesake in another language no longer
blocks the 0.8; a match of the caller itself keeps the proximity score,
as it does for a same-file match; and Kotlin and Java flavor tests, since
the PR's Python fixture calls a function it never imports.
Validation, main (cde4cd74) vs this commit, 21 repos in 11 languages:
no edge added or removed; 4 targets moved, all right: spring-modulith's
example-full tests now reach example-full's own OrderManagement and
OrderCompleted, where Spring's naming rule (0.7) took the epr-jdbc
example's copy, and playframework's shutdown-happy-path test its own
src/main HomeController, not the copy its script swaps in later. 109
edges keep their target and go to 0.8, each onto the copy in the
caller's own module or app; 4 of them are vben's `defineConfig` calls
from `import { defineConfig } from 'tsdown'`, which main already links
to a namesake because that dependency is declared only in the root
package.json. In 18 sites the 0.8 now beats a framework rule's 0.7.
Index CPU, median of 3 interleaved: okhttp 34.8 vs 34.8 s, etcd 36.5 vs
36.5 s. Tests: 3 of 6 fail on main, 2 on the PR as written.
Original PR #1936 by @danusha2345.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
6.6 KiB
TypeScript
151 lines
6.6 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|