mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
66d5dc3641
The last-resort fuzzy match looks names up in a lowercase index — right for PHP, Pascal/Delphi, CFML, COBOL and VB.NET, whose identifiers ignore case, and wrong for every other language. jsoup's 1,844 `@Test` annotations decorated a `CharPredicate.test` method; commons-lang's `new BitSet()` instantiated a `bitSet` field accessor 64 times; mall's `new Info()` an `info()` endpoint; gson's `Method` type a test's `method()`; fmt's `#include <optional>` gtest's `Optional`. This generalizes the per-language exact-case rules (Rust, Python, JS) into one: outside CASE_INSENSITIVE_LANGUAGES a fuzzy candidate must match the reference's exact name. The cross-language fuzzy-gate test now builds its winner from an exact-case name. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
8.8 KiB
TypeScript
159 lines
8.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { CodeGraph } from '../src';
|
|
import { matchReference, matchByQualifiedName, matchFuzzy, gateLanguageMatch } from '../src/resolution/name-matcher';
|
|
import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
|
|
|
|
describe('cross-language name resolution (#1986)', () => {
|
|
let dir: string;
|
|
let cg: CodeGraph | undefined;
|
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-language-')); });
|
|
afterEach(() => {
|
|
cg?.close();
|
|
cg = undefined;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function index(files: Record<string, string>) {
|
|
for (const [file, source] of Object.entries(files)) {
|
|
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, file), source);
|
|
}
|
|
cg = await CodeGraph.init(dir, { silent: true });
|
|
await cg.indexAll();
|
|
return cg;
|
|
}
|
|
|
|
function targets(name: string) {
|
|
const source = cg!.getNodesByName(name).find((n) => n.kind !== 'file');
|
|
expect(source, name).toBeDefined();
|
|
return cg!.getOutgoingEdges(source!.id)
|
|
.filter((e) => ['calls', 'instantiates', 'extends', 'implements'].includes(e.kind))
|
|
.map((e) => cg!.getNode(e.target)!.name);
|
|
}
|
|
|
|
it('rejects foreign calls, explicit constructors, extends and implements', async () => {
|
|
await index({
|
|
'lib.rs': 'pub fn parse() -> Result<u32, String> { Ok(1) }',
|
|
'Ok.scala': 'object Ok { def apply(): Int = 1 }',
|
|
'total.py': 'def total():\n return round(1.2)\n',
|
|
'round.ts': 'export function round() { return 1; }',
|
|
'consumer.swift': 'func consumer() { mystery() }',
|
|
'foreign.py': 'def mystery():\n return 1\nclass ForeignThing:\n pass\nclass ForeignBase:\n pass\n',
|
|
'contract.java': 'public interface ForeignContract {}',
|
|
'App.tsx': 'export function App() { return <ForeignThing/>; }',
|
|
'Page.vue': '<template><ForeignThing @click="mystery"/></template><script>export default {};</script>',
|
|
'construct.ts': 'export function construct() { return new ForeignThing(); }\n' +
|
|
'export class Child extends ForeignBase {}\nexport class Impl implements ForeignContract {}',
|
|
'plain.go': 'package main\nfunc UnexportedABI() {}\n',
|
|
'native.c': 'void nativeConsumer(void) { UnexportedABI(); }',
|
|
});
|
|
for (const name of ['parse', 'total', 'consumer', 'construct', 'Child', 'Impl', 'nativeConsumer']) {
|
|
expect(targets(name), name).toEqual([]);
|
|
}
|
|
const app = cg!.getNodesByName('App').find((n) => n.kind === 'component' || n.kind === 'function')!;
|
|
expect(app).toBeDefined();
|
|
expect(cg!.getOutgoingEdges(app.id).filter((e) => cg!.getNode(e.target)?.language === 'python')).toEqual([]);
|
|
const page = cg!.getNodesInFile('Page.vue').find((n) => n.kind === 'component')!;
|
|
expect(page).toBeDefined();
|
|
expect(cg!.getOutgoingEdges(page.id).filter((e) => cg!.getNode(e.target)?.language === 'python')).toEqual([]);
|
|
});
|
|
|
|
it('preserves web, JVM, native and .NET interoperability', async () => {
|
|
await index({
|
|
'helper.js': 'export function helper() { return 1; }\nexport class WebBase {}',
|
|
'web.ts': "import { helper, WebBase } from './helper.js';\n" +
|
|
'export function webCaller() { return helper(); }\nexport class WebChild extends WebBase {}',
|
|
'JvmBase.java': 'public class JvmBase {}',
|
|
'Child.kt': 'class JvmChild : JvmBase()',
|
|
'native.c': 'int nativeHelper(void) { return 1; }',
|
|
'Native.swift': 'func nativeCaller() { nativeHelper() }',
|
|
'NetBase.cs': 'public class NetBase {}',
|
|
'NetChild.vb': 'Public Class NetChild\n Inherits NetBase\nEnd Class',
|
|
'widget.js': 'export function WebWidget() { return null; }',
|
|
'View.vue': '<template><WebWidget @click="localHandler"/></template>' +
|
|
'<script>import { WebWidget } from "./widget.js"; function localHandler() {} export default {};</script>',
|
|
});
|
|
expect(targets('webCaller')).toContain('helper');
|
|
expect(targets('WebChild')).toContain('WebBase');
|
|
expect(targets('JvmChild')).toContain('JvmBase');
|
|
expect(targets('nativeCaller')).toContain('nativeHelper');
|
|
expect(targets('NetChild')).toContain('NetBase');
|
|
expect(targets('View')).toEqual(expect.arrayContaining(['WebWidget', 'localHandler']));
|
|
});
|
|
|
|
it('requires ABI evidence for free functions outside the native family', async () => {
|
|
await index({
|
|
'export.go': 'package main\nimport "C"\n//export StopGo\nfunc StopGo() {}\n',
|
|
'export.rs': '#[no_mangle]\npub extern "C" fn stop_rust() {}\n',
|
|
'Native.swift': 'func stopNative() {\n StopGo()\n stop_rust()\n}',
|
|
'ordinary.py': 'def stop_python():\n return 1\n',
|
|
'Wrong.swift': 'func wrongNative() { stop_python() }',
|
|
'cfunc.c': 'int cfunc(void) { return 1; }',
|
|
'call.go': 'package main\n/* int cfunc(void); */\nimport "C"\nfunc useC() { C.cfunc() }',
|
|
'wrong.py': 'def wrongC():\n return cfunc()\n',
|
|
});
|
|
expect(targets('stopNative')).toEqual(expect.arrayContaining(['StopGo', 'stop_rust']));
|
|
expect(targets('wrongNative')).toEqual([]);
|
|
expect(targets('wrongC')).toEqual([]);
|
|
});
|
|
|
|
it('gates qualified, method and fuzzy winners without choosing a replacement', async () => {
|
|
const graph = await index({
|
|
'caller.swift': 'func caller() {}',
|
|
'cfunc.c': 'int cfunc(void) { return 1; }',
|
|
'call.go': 'package main\nimport "C"\nfunc useC() { C.cfunc() }',
|
|
'call.rs': 'extern "C" { fn cfunc() -> i32; }\npub fn use_c() { unsafe { cfunc(); } }',
|
|
'foreign.py': 'class Foreign:\n def act(self):\n pass\ndef fuzzyName():\n pass\n',
|
|
'near/pick.py': 'class Container:\n class Winner:\n pass\n',
|
|
'far/Winner.swift': 'class Winner {}',
|
|
});
|
|
const files = ['caller.swift', 'foreign.py', 'near/pick.py', 'far/Winner.swift', 'cfunc.c', 'call.go', 'call.rs'];
|
|
const context: ResolutionContext = {
|
|
getNodesInFile: (file) => graph.getNodesInFile(file),
|
|
getNodesByName: (name) => graph.getNodesByName(name),
|
|
getNodesByQualifiedName: (name) => graph.getNodesByQualifiedName(name),
|
|
getNodesByKind: (kind) => graph.getNodesByKind(kind),
|
|
getNodeById: (id) => graph.getNode(id),
|
|
getNodesByLowerName: (name) => files.flatMap((file) => graph.getNodesInFile(file)).filter((n) => n.name.toLowerCase() === name),
|
|
fileExists: (file) => fs.existsSync(path.join(dir, file)),
|
|
readFile: (file) => fs.existsSync(path.join(dir, file)) ? fs.readFileSync(path.join(dir, file), 'utf8') : null,
|
|
getProjectRoot: () => dir,
|
|
getAllFiles: () => files,
|
|
};
|
|
const caller = graph.getNodesByName('caller')[0]!;
|
|
const ref = (referenceName: string): UnresolvedRef => ({
|
|
fromNodeId: caller.id, referenceName, referenceKind: 'calls',
|
|
filePath: caller.filePath, language: 'swift', line: 1, column: 0,
|
|
});
|
|
const method = graph.getNodesByName('act')[0]!;
|
|
expect(matchByQualifiedName(ref(method.qualifiedName), context)?.targetNodeId).toBe(method.id);
|
|
expect(matchReference(ref(method.qualifiedName), context)).toBeNull();
|
|
expect(matchReference(ref('Foreign.act'), context)).toBeNull();
|
|
// Fuzzy is case-sensitive outside PHP/Pascal/CFML/COBOL/VB.NET, so the
|
|
// fuzzy winner here shares the exact name; the language gate still rejects it.
|
|
expect(matchFuzzy(ref('fuzzyName'), context)).not.toBeNull();
|
|
expect(matchReference(ref('fuzzyName'), context)).toBeNull();
|
|
expect(matchFuzzy(ref('FUZZYNAME'), context)).toBeNull();
|
|
// Exact qualified match selects Python even though a Swift type has the
|
|
// same bare name. Rejection must not fall through to that Swift type.
|
|
const foreign = graph.getNodesByName('Winner').find((n) => n.language === 'python')!;
|
|
expect(foreign.qualifiedName).not.toBe('Winner');
|
|
const constructor = { ...ref(foreign.qualifiedName), referenceKind: 'instantiates' as const };
|
|
expect(matchByQualifiedName(constructor, context)?.targetNodeId).toBe(foreign.id);
|
|
expect(matchReference(constructor, context)).toBeNull();
|
|
|
|
// ABI declarations allow a C-function result if a strategy supplies it.
|
|
// This does not invent an edge where the receiver matcher cannot find one.
|
|
const cfunc = graph.getNodesByName('cfunc').find((n) => n.language === 'c')!;
|
|
for (const language of ['go', 'rust'] as const) {
|
|
const abiRef = { ...ref(language === 'go' ? 'C.cfunc' : 'cfunc'), language, filePath: language === 'go' ? 'call.go' : 'call.rs' };
|
|
const result = { original: abiRef, targetNodeId: cfunc.id, confidence: 0.8, resolvedBy: 'exact-match' as const };
|
|
expect(gateLanguageMatch(result, abiRef, context)).toEqual(result);
|
|
expect(gateLanguageMatch(result, { ...abiRef, filePath: 'caller.swift' }, context)).toBeNull();
|
|
}
|
|
});
|
|
});
|