mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
* fix(resolution): stop expression-receiver calls from binding to unrelated methods A method called on an expression receiver reached the resolver as the bare method name, and exact-match bound it to any project method of that name: `(await list()).map(...)` in TypeScript onto a UI adapter class's `map` (0.9), and `v.iter().map(...)` in Rust onto the same TypeScript method across languages (0.5). TS/JS extraction (mirrored in the kernel): look through receiver wrappers that keep the same object — parentheses, `!`, `as`/`satisfies`, `<T>x`, `await` (including the grammar's `(a && b)!` parse of `a && b!`) — so `x!.m()` is `x.m` and `(await f()).m()` is `f().m`; any other receiver with no static type (`(a ?? b).m()`, `f().list.m()`, `arr[0].m()`) emits nothing instead of the bare name. `this`/`super` (and chains rooted at them), `window.*` chains and `new C().m()` keep their existing bare form. Resolution: a bare-named call may cross a language family only through the C ABI (a C/C++ symbol, or a free function a C/Objective-C/Swift caller reaches, e.g. a cgo `//export`). A class member of another family, or a function/class/variable outside that pair, is rejected as the candidate a strategy commits to (exact-match single/best and fuzzy), never filtered out of the pool, so no lone survivor is manufactured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(resolution): reject unrelated cross-language matches (#1986) Name-based calls, constructors, and inheritance could select unrelated foreign-language symbols. Apply a shared interoperability policy to selected results without manufacturing replacement matches, including JSX/Vue synthesis. Preserve framework exports and ABI-backed calls, building on PR #1944's TS/Rust expression-receiver fixes. Validate with both repro scripts, 529 targeted tests, kernel/wasm parity, and clean TypeScript checks. Co-authored-by: danusha2345 <ewidusoc498@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: danusha2345 <ewidusoc498@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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();
|
||||
expect(matchFuzzy(ref('FUZZYNAME'), context)).not.toBeNull();
|
||||
expect(matchReference(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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* A method called on an EXPRESSION receiver never binds by bare name to an
|
||||
* unrelated project method. `(await list()).map(...)` in TypeScript and
|
||||
* `v.iter().map(...)` in Rust both reached the resolver as the bare `map`,
|
||||
* which exact-matched a TypeScript class's `map` — the Rust one across
|
||||
* languages. TS/JS now looks through wrappers that keep the receiver
|
||||
* (`(x)`, `x!`, `x as T`, `await`) and emits nothing for a receiver with no
|
||||
* static type; the resolver refuses a bare-named call into another language
|
||||
* family's class members.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { extractFromSource } from '../src/extraction';
|
||||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||
|
||||
let dir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
const files: Record<string, string> = {
|
||||
'src/adapter.ts':
|
||||
'export class GraphAdapter { async map(req?: string) { return req ?? \'\'; } }\n' +
|
||||
'export class Runner { run() { return 1; } }\n' +
|
||||
'export class Base { hello() { return 1; } }\n',
|
||||
'src/use.ts':
|
||||
"import { GraphAdapter, Runner, Base } from './adapter';\n" +
|
||||
"import { helper } from './helper.js';\n" +
|
||||
'async function list(): Promise<string[]> { return []; }\n' +
|
||||
'export async function names() {\n' +
|
||||
' const a = (await list()).map((d) => d.length);\n' +
|
||||
' const b = [1, 2].map((x) => x + 1);\n' +
|
||||
' const c = (a ?? []).map((x) => x);\n' +
|
||||
' return [a, b, c];\n' +
|
||||
'}\n' +
|
||||
'export function typed(g: GraphAdapter | undefined) {\n' +
|
||||
' return g!.map();\n' +
|
||||
'}\n' +
|
||||
'export function fresh() { return new Runner().run() + helper(); }\n' +
|
||||
'export class Child extends Base {\n' +
|
||||
' greet() { return this.hello() + super.hello(); }\n' +
|
||||
'}\n',
|
||||
'src/helper.js': 'export function helper() { return 2; }\n',
|
||||
'k/src/lib.rs':
|
||||
'pub fn lens(v: Vec<String>) -> Vec<usize> { v.iter().map(|s| s.len()).collect() }\n' +
|
||||
'pub fn opt(o: Option<u8>) -> Option<u16> { o.map(|x| x as u16) }\n',
|
||||
// A cgo `//export` function called from Swift through the bridging header.
|
||||
'export_ios.go': 'package main\n\nimport "C"\n\n//export OpenFluxStop\nfunc OpenFluxStop() {}\n',
|
||||
'ios/Tunnel.swift': 'func stopTunnel() {\n OpenFluxStop()\n}\n',
|
||||
// Kotlin's `Log.i(...)` reaches the resolver as the bare `i`.
|
||||
'web/min.js': 'function i(a) { return a; }\n',
|
||||
'android/Diag.kt': 'fun report() {\n android.util.Log.i("tag", "msg")\n}\n',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await initGrammars();
|
||||
await loadAllGrammars();
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-exprrecv-'));
|
||||
for (const [rel, text] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, rel), text);
|
||||
}
|
||||
cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg.destroy();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const node = (name: string, file: string) =>
|
||||
cg.getNodesByName(name).find((n) => n.filePath.endsWith(file) && (n.kind === 'function' || n.kind === 'method'))!;
|
||||
const calleesOf = (name: string, file: string) =>
|
||||
cg.getCallees(node(name, file).id)
|
||||
.filter(({ edge }) => edge.kind === 'calls')
|
||||
.map(({ node: n }) => n.qualifiedName)
|
||||
.sort();
|
||||
|
||||
describe('expression receivers', () => {
|
||||
it('TS: a call-result receiver does not bind `.map` to a project method', () => {
|
||||
expect(calleesOf('names', 'use.ts')).toEqual(['list']);
|
||||
expect(cg.getCallers(node('map', 'adapter.ts').id).map(({ node: n }) => n.name)).toEqual(['typed']);
|
||||
});
|
||||
|
||||
it('Rust: `v.iter().map()` does not bind to a TypeScript class method', () => {
|
||||
expect(calleesOf('lens', 'lib.rs')).toEqual([]);
|
||||
expect(calleesOf('opt', 'lib.rs')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps receiver-typed, bare, constructor, this/super and TS→JS calls', () => {
|
||||
expect(calleesOf('typed', 'use.ts')).toEqual(['GraphAdapter::map']);
|
||||
expect(calleesOf('fresh', 'use.ts')).toEqual(['Runner::run', 'helper']);
|
||||
expect(calleesOf('greet', 'use.ts')).toEqual(['Base::hello']);
|
||||
});
|
||||
|
||||
it('Kotlin: a bare-named call does not bind to a JavaScript function', () => {
|
||||
expect(calleesOf('report', 'Diag.kt')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a cross-language call to an exported free function (Swift → cgo)', () => {
|
||||
expect(calleesOf('stopTunnel', 'Tunnel.swift')).toEqual(['OpenFluxStop']);
|
||||
});
|
||||
|
||||
it('TS extraction: wrappers peel to the receiver, untyped expressions emit nothing', () => {
|
||||
const src =
|
||||
'async function f(x: X, y: any) {\n' +
|
||||
' (await list()).map(g);\n' +
|
||||
' x!.run();\n' +
|
||||
' (y as X).run();\n' +
|
||||
' (x satisfies X).stop();\n' +
|
||||
' getTarget("a")!.install();\n' +
|
||||
' (a ?? b).map(g);\n' +
|
||||
' arr[0].run();\n' +
|
||||
' f().list.map(g);\n' +
|
||||
' (() => 1).call(null);\n' +
|
||||
' this.a.b.run();\n' +
|
||||
' new Runner().go();\n' +
|
||||
' window.Api.start();\n' +
|
||||
'}\n';
|
||||
const refs = extractFromSource('t.ts', src, 'typescript').unresolvedReferences
|
||||
.filter((r) => r.referenceKind === 'calls')
|
||||
.map((r) => r.referenceName);
|
||||
expect(refs).toEqual([
|
||||
'list().map', 'list', 'x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget',
|
||||
'f', 'run', 'go', 'start',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -92,3 +92,13 @@ function bucketChains(d, k, v) {
|
||||
arr[0]().go();
|
||||
obj.make().run().again();
|
||||
}
|
||||
// --- expression receivers ----------------------------------------------------
|
||||
async function exprReceivers(x) {
|
||||
(await list()).map(g);
|
||||
(x).run();
|
||||
(a ?? b).map(g);
|
||||
f().list.map(g);
|
||||
(() => 1).call(null);
|
||||
this.a.b.run();
|
||||
new Runner().go();
|
||||
}
|
||||
|
||||
@@ -158,6 +158,36 @@ class Vault {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
|
||||
] as const)('peels transparent receivers and drops untyped expression receivers: %s', (ext, language) => {
|
||||
const typed = language === 'typescript' || language === 'tsx';
|
||||
const result = assertParity(`fixture.${ext}`, `
|
||||
function list() { return []; }
|
||||
class Runner { go() { return 1; } }
|
||||
async function exprReceivers(x, y) {
|
||||
(await list()).map(g);
|
||||
(x).run();
|
||||
${typed ? 'x!.run(); (y as X).run(); (x satisfies X).stop(); getTarget("a")!.install(); if (x && y!.c.has(1)) {} if (x && this.e!.c.has(1)) {}' : ''}
|
||||
(a ?? b).map(g);
|
||||
arr[0].run();
|
||||
f().list.map(g);
|
||||
(() => 1).call(null);
|
||||
this.a.b.run();
|
||||
super.stop();
|
||||
new Runner().go();
|
||||
window.Api.start();
|
||||
}
|
||||
`, language);
|
||||
const fn = result.nodes.find((n) => n.name === 'exprReceivers');
|
||||
expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls' && r.fromNodeId === fn!.id)
|
||||
.map((r) => r.referenceName)).toEqual([
|
||||
'list().map', 'list', 'x.run',
|
||||
...(typed ? ['x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget', 'has'] : []),
|
||||
'f', 'run', 'stop', 'go', 'start',
|
||||
]);
|
||||
});
|
||||
|
||||
it('torture fixture (tsx): components, stores, RTK, fn-refs, value-refs, decorators', () => {
|
||||
const file = path.join(FIXTURE_DIR, 'torture.tsx');
|
||||
assertParity('fixtures/torture.tsx', fs.readFileSync(file, 'utf8'), 'tsx');
|
||||
|
||||
@@ -67,6 +67,19 @@ describe('Resolution Module', () => {
|
||||
|
||||
});
|
||||
|
||||
it('does not turn cross-language name collisions into call or constructor edges (#1986)', async () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'foreign.py'), 'class ForeignThing:\n pass\ndef mystery():\n pass\n');
|
||||
fs.writeFileSync(path.join(tempDir, 'caller.ts'), 'export function build() { return new ForeignThing(); }');
|
||||
fs.writeFileSync(path.join(tempDir, 'Caller.swift'), 'func consumer() { mystery() }');
|
||||
cg = await CodeGraph.init(tempDir, { silent: true });
|
||||
await cg.indexAll();
|
||||
for (const name of ['build', 'consumer']) {
|
||||
const caller = cg.getNodesByName(name).find((n) => n.kind === 'function')!;
|
||||
expect(caller).toBeDefined();
|
||||
expect(cg.getOutgoingEdges(caller.id).filter((e) => e.kind === 'calls' || e.kind === 'instantiates')).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Name Matcher', () => {
|
||||
it('should match exact name references', () => {
|
||||
// Create a mock context
|
||||
|
||||
@@ -1179,7 +1179,10 @@ impl<'t> Walker<'t> {
|
||||
.child_by_field_name("object")
|
||||
.or_else(|| func.child_by_field_name("operand"))
|
||||
.or_else(|| func.child_by_field_name("argument"))
|
||||
.or_else(|| func.named_child(0));
|
||||
.or_else(|| func.named_child(0))
|
||||
// Look through `(x)`, `x!`, `(x as T)`, `(await f())`
|
||||
// (mirrors peelTsJsReceiver).
|
||||
.map(peel_receiver);
|
||||
// Literal receivers call builtins, never project symbols (#1230).
|
||||
if let Some(r) = receiver {
|
||||
if is_literal_receiver(r.kind()) {
|
||||
@@ -1214,6 +1217,12 @@ impl<'t> Walker<'t> {
|
||||
// TreeSitterExtractor.extractCall.
|
||||
let Some(inner) = self.plain_inner_callee(r) else { return };
|
||||
callee_name = format!("{inner}().{method_name}");
|
||||
} else if receiver.is_some_and(|r| !keeps_bare_receiver(r, self.src)) {
|
||||
// An expression receiver with no static type
|
||||
// (`(a ?? b).map()`, `f().list.map()`): emit nothing
|
||||
// rather than the bare method name. Mirrors
|
||||
// TreeSitterExtractor.extractCall.
|
||||
return;
|
||||
} else {
|
||||
callee_name = method_name.to_string();
|
||||
}
|
||||
@@ -1508,3 +1517,59 @@ fn collapse_ws(s: &str) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// TS/JS wrappers that keep a member call's receiver the same object
|
||||
/// (mirrors TS_JS_TRANSPARENT_RECEIVER_TYPES in tree-sitter.ts).
|
||||
fn is_transparent_receiver(kind: &str) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
"parenthesized_expression"
|
||||
| "non_null_expression"
|
||||
| "as_expression"
|
||||
| "satisfies_expression"
|
||||
| "type_assertion"
|
||||
| "await_expression"
|
||||
)
|
||||
}
|
||||
|
||||
/// Strip transparent wrappers off a receiver (mirrors peelTsJsReceiver),
|
||||
/// including the grammar's `(a && b)!` parse of `a && b!`.
|
||||
fn peel_receiver(node: Node<'_>) -> Node<'_> {
|
||||
let mut cur = node;
|
||||
while is_transparent_receiver(cur.kind()) {
|
||||
let mut inner = if cur.kind() == "type_assertion" {
|
||||
cur.named_child(cur.named_child_count().saturating_sub(1))
|
||||
} else {
|
||||
cur.named_child(0)
|
||||
};
|
||||
if cur.kind() == "non_null_expression" {
|
||||
while let Some(b) = inner.filter(|n| n.kind() == "binary_expression") {
|
||||
inner = b.child_by_field_name("right");
|
||||
}
|
||||
}
|
||||
match inner {
|
||||
Some(i) => cur = i,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
/// `this` / `super`, a member chain rooted at either or at `window`, or
|
||||
/// `new C()` still collapse to the bare method name (mirrors
|
||||
/// keepsBareTsJsReceiver).
|
||||
fn keeps_bare_receiver(node: Node<'_>, src: &str) -> bool {
|
||||
let mut cur = node;
|
||||
while matches!(cur.kind(), "member_expression" | "subscript_expression") {
|
||||
match cur.child_by_field_name("object") {
|
||||
Some(object) => cur = peel_receiver(object),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
match cur.kind() {
|
||||
"this" | "super" => true,
|
||||
"identifier" => &src[cur.byte_range()] == "window",
|
||||
"new_expression" => cur.id() == node.id(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +426,56 @@ function isUnresolvedTsJsChain(node: SyntaxNode, source: string): boolean {
|
||||
return !!cur && cur.type === 'identifier' && getNodeText(cur, source) !== 'window';
|
||||
}
|
||||
|
||||
/**
|
||||
* TS/JS wrappers that leave a member call's receiver the same object:
|
||||
* `(x).m()`, `x!.m()`, `(x as T).m()`, `(x satisfies T).m()`, `(<T>x).m()`
|
||||
* and `(await x).m()` all call `m` on what `x` holds.
|
||||
*/
|
||||
const TS_JS_TRANSPARENT_RECEIVER_TYPES = new Set([
|
||||
'parenthesized_expression', 'non_null_expression', 'as_expression',
|
||||
'satisfies_expression', 'type_assertion', 'await_expression',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Strip {@link TS_JS_TRANSPARENT_RECEIVER_TYPES} wrappers off a receiver.
|
||||
* tree-sitter-typescript parses `a && b!.c()` as `(a && b)!.c()`; the `!`
|
||||
* belongs to the right operand, so a non-null over a binary expression peels
|
||||
* to that operand.
|
||||
*/
|
||||
function peelTsJsReceiver(node: SyntaxNode): SyntaxNode {
|
||||
let cur = node;
|
||||
while (TS_JS_TRANSPARENT_RECEIVER_TYPES.has(cur.type)) {
|
||||
let inner = cur.type === 'type_assertion'
|
||||
? cur.namedChild(cur.namedChildCount - 1)
|
||||
: cur.namedChild(0);
|
||||
if (cur.type === 'non_null_expression') {
|
||||
while (inner?.type === 'binary_expression') inner = getChildByField(inner, 'right');
|
||||
}
|
||||
if (!inner) break;
|
||||
cur = inner;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a TS/JS receiver still collapses to the bare method name: `this` /
|
||||
* `super` (the resolver reads the owner off the enclosing class), a member
|
||||
* chain rooted at either or at `window` (the project-global escape of
|
||||
* {@link isUnresolvedTsJsChain}), and `new C()` (its class is written at the
|
||||
* call).
|
||||
*/
|
||||
function keepsBareTsJsReceiver(node: SyntaxNode, source: string): boolean {
|
||||
let cur: SyntaxNode | null = node;
|
||||
while (cur && TS_JS_CHAIN_RECEIVER_TYPES.has(cur.type)) {
|
||||
const object = getChildByField(cur, 'object');
|
||||
cur = object ? peelTsJsReceiver(object) : null;
|
||||
}
|
||||
if (!cur) return false;
|
||||
if (cur.type === 'this' || cur.type === 'super') return true;
|
||||
if (cur.type === 'identifier') return getNodeText(cur, source) === 'window';
|
||||
return cur === node && cur.type === 'new_expression';
|
||||
}
|
||||
|
||||
/**
|
||||
* React hooks that bind a NAME to a handler function (`const onPress =
|
||||
* useCallback(() => {…}, [])`). The arrow inside is extracted as a function
|
||||
@@ -4713,11 +4763,18 @@ export class TreeSitterExtractor {
|
||||
// This helps the resolver distinguish method calls from bare function calls
|
||||
// (e.g., Python's console.print() vs builtin print())
|
||||
// Skip self/this/cls as they don't aid resolution
|
||||
const receiver =
|
||||
const rawReceiver =
|
||||
getChildByField(func, 'object') ||
|
||||
getChildByField(func, 'operand') ||
|
||||
getChildByField(func, 'argument') ||
|
||||
func.namedChild(0);
|
||||
// TS/JS: look through wrappers that keep the receiver the same
|
||||
// object (`(x).m()`, `x!.m()`, `(x as T).m()`, `(await f()).m()`),
|
||||
// so the branches below see the identifier / call / chain the
|
||||
// call is really made on.
|
||||
const receiver = rawReceiver && TS_JS_CHAIN_LANGUAGES.has(this.language)
|
||||
? peelTsJsReceiver(rawReceiver)
|
||||
: rawReceiver;
|
||||
// A LITERAL receiver — `", ".join(...)`, `"x".toUpperCase()`,
|
||||
// `5.times`, `[].concat(...)` — calls a builtin of the literal's
|
||||
// type, never a project symbol. The bare-name fallback below let
|
||||
@@ -4917,6 +4974,19 @@ export class TreeSitterExtractor {
|
||||
const chain = getNodeText(func, this.source).replace(/\s+/g, '').replace(/\?\./g, '.');
|
||||
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*){2,}$/.test(chain)) return;
|
||||
calleeName = chain;
|
||||
} else if (
|
||||
TS_JS_CHAIN_LANGUAGES.has(this.language) &&
|
||||
receiver &&
|
||||
!keepsBareTsJsReceiver(receiver, this.source)
|
||||
) {
|
||||
// Any other TS/JS receiver is an expression with no static
|
||||
// type here — `(a ?? b).map()`, `f().list.map()`,
|
||||
// `arr[0].run()`, `(() => {}).call()`. The bare method name this
|
||||
// used to emit exact-matched whichever project method shared the
|
||||
// name (`(await list()).map()` onto an adapter class's `map`).
|
||||
// Emit nothing: a silent miss, never a wrong edge. Mirrored in
|
||||
// the kernel's extract_call (tsjs/extractors.rs).
|
||||
return;
|
||||
} else {
|
||||
calleeName = methodName;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createYielder, type MaybeYield } from './cooperative-yield';
|
||||
import { crossTierEdges } from './tier-synthesizer';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
import { resolveImportPath } from './import-resolver';
|
||||
import { crossesCodeBoundary } from './name-matcher';
|
||||
|
||||
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
|
||||
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
|
||||
@@ -1296,7 +1297,7 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
|
||||
for (const name of names) {
|
||||
if (added >= MAX_JSX_CHILDREN) break;
|
||||
const child = jsxChild(ctx, name, file, importsOf);
|
||||
if (!child || child.id === parent.id) continue;
|
||||
if (!child || child.id === parent.id || crossesCodeBoundary(parent.language, child.language)) continue;
|
||||
const key = `${parent.id}>${child.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
@@ -1371,7 +1372,8 @@ async function vueTemplateEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
|
||||
|
||||
let added = 0;
|
||||
const addEdge = (target: Node | undefined, meta: Record<string, unknown>) => {
|
||||
if (added >= MAX_JSX_CHILDREN || !target || target.id === comp.id) return;
|
||||
if (added >= MAX_JSX_CHILDREN || !target || target.id === comp.id ||
|
||||
crossesCodeBoundary(comp.language, target.language)) return;
|
||||
const k = `${comp.id}>${target.id}>${meta.synthesizedBy}`;
|
||||
if (seen.has(k)) return;
|
||||
seen.add(k);
|
||||
|
||||
+14
-37
@@ -19,7 +19,7 @@ import {
|
||||
isInheritanceRef,
|
||||
isImportableKind,
|
||||
} from './types';
|
||||
import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
|
||||
import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
|
||||
import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath } from './import-resolver';
|
||||
import { ResolverPool, minRefsForPool } from './resolver-pool';
|
||||
import { resolveAliasBinding } from './alias-binding';
|
||||
@@ -888,7 +888,10 @@ export class ReferenceResolver {
|
||||
* the alias names (see ./alias-binding), regardless of the strategy.
|
||||
*/
|
||||
resolveOne(ref: UnresolvedRef): ResolvedRef | null {
|
||||
const resolved = this.gateTargetKind(this.resolveOneInner(ref), ref);
|
||||
const candidate = this.gateTargetKind(this.resolveOneInner(ref), ref);
|
||||
const resolved = candidate?.resolvedBy === 'framework'
|
||||
? this.gateFrameworkLanguage(candidate, ref)
|
||||
: this.gateLanguage(candidate, ref);
|
||||
if (!resolved || ref.referenceKind !== 'calls') return resolved;
|
||||
|
||||
const target = this.queries.getNodeById(resolved.targetNodeId);
|
||||
@@ -899,11 +902,11 @@ export class ReferenceResolver {
|
||||
const forwarded = resolveAliasBinding(target, memberName, this.context);
|
||||
if (!forwarded || forwarded.id === resolved.targetNodeId) return resolved;
|
||||
|
||||
return {
|
||||
return this.gateLanguage({
|
||||
...resolved,
|
||||
targetNodeId: forwarded.id,
|
||||
confidence: Math.min(resolved.confidence, 0.85),
|
||||
};
|
||||
}, ref);
|
||||
}
|
||||
|
||||
private resolveOneInner(ref: UnresolvedRef): ResolvedRef | null {
|
||||
@@ -1015,9 +1018,7 @@ export class ReferenceResolver {
|
||||
|
||||
// Strategy 1: Try framework-specific resolution. Cross-language bridges
|
||||
// are deliberately preserved (Drupal `routing.yml` → PHP controller, RN
|
||||
// JS → native `calls`) — `gateFrameworkLanguage` only drops a type/import
|
||||
// edge between two KNOWN families (see its doc), never a `calls` bridge or
|
||||
// a config↔code edge.
|
||||
// JS → native `calls`); other code references obey the shared family gate.
|
||||
const tFw = this.profileStages ? process.hrtime.bigint() : 0n;
|
||||
let fwEarly: ResolvedRef | null = null;
|
||||
for (const framework of this.frameworks) {
|
||||
@@ -2324,18 +2325,6 @@ export class ReferenceResolver {
|
||||
return node?.language || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an import/name-strategy resolution that crosses a language family.
|
||||
* Two regimes (mirrors `applyLanguageGate`'s candidate filter):
|
||||
* - `references` (type usage): STRICT — a `Type.member` static read names a
|
||||
* same-family type, never a coincidentally same-named symbol in another
|
||||
* language. Drops any non-same-family target.
|
||||
* - `imports` (import binding / `#include`): both-known — a C++ `#include
|
||||
* "X.h"` must not resolve to a same-named ObjC header on another platform
|
||||
* (basename collision), but a singleton-family / SFC language (`vue` →
|
||||
* `.ts`) importing across is left alone.
|
||||
* Applies to the import (strategy 2) + name-match (strategy 3) results.
|
||||
*/
|
||||
/**
|
||||
* Collect the `@using` namespaces in scope for a `.razor`/`.cshtml` file: its
|
||||
* own `@using` directives plus every `_Imports.razor` from the file's folder up
|
||||
@@ -2673,33 +2662,21 @@ export class ReferenceResolver {
|
||||
|
||||
private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||||
if (!result) return result;
|
||||
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
|
||||
if (!tgt || !ref.language) return result;
|
||||
if ((ref.referenceKind === 'references' || ref.referenceKind === 'function_ref') && !sameLanguageFamily(tgt, ref.language)) return null;
|
||||
if (ref.referenceKind === 'imports' && crossesKnownFamily(tgt, ref.language)) return null;
|
||||
return result;
|
||||
return gateLanguageMatch(result, ref, this.context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a FRAMEWORK-strategy resolution that crosses two *known* language
|
||||
* families for a type-usage (`references`) or import-binding (`imports`)
|
||||
* edge. The framework strategy is intentionally ungated for cross-language
|
||||
* bridges, but those legitimate bridges are either `calls` edges (RN/Expo
|
||||
* JS → native) or config↔code edges whose config side (`yaml`/`blade`/…) is
|
||||
* not a known programming-language family. A `references`/`imports` edge
|
||||
* between two *known* families is always a coincidental name collision — the
|
||||
* React/Svelte/Vue PascalCase component resolvers name-match `getNodesByName`
|
||||
* without a language check, so a TS `<TestRunner>` ref happily matched a
|
||||
* Kotlin `class TestRunner`. Gating only the both-known-cross-family case
|
||||
* lets config bridges and `calls` bridges through untouched.
|
||||
* Framework calls carry bridge evidence (RN/Expo JS → native). Other
|
||||
* framework results obey the same code-family boundary as name matches;
|
||||
* markup/config transitions remain open.
|
||||
*/
|
||||
private gateFrameworkLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||||
if (!result) return result;
|
||||
if (ref.referenceKind !== 'references' && ref.referenceKind !== 'imports') return result;
|
||||
if (ref.referenceKind === 'calls') return result;
|
||||
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
|
||||
// Package imports cannot target prose found by a framework's name lookup.
|
||||
if (ref.referenceKind === 'imports' && (tgt as string) === 'markdown' && (ref.language as string) !== 'markdown') return null;
|
||||
if (tgt && ref.language && crossesKnownFamily(tgt, ref.language)) return null;
|
||||
if (tgt && ref.language && crossesCodeBoundary(tgt, ref.language)) return null;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,61 +142,88 @@ function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node {
|
||||
*/
|
||||
const LANGUAGE_FAMILY: Record<string, string> = {
|
||||
java: 'jvm', kotlin: 'jvm', scala: 'jvm',
|
||||
swift: 'apple', objc: 'apple',
|
||||
swift: 'native', objc: 'native',
|
||||
// ArkTS is a TS superset — every HarmonyOS project mixes `.ets` UI with
|
||||
// `.ts` logic modules, so refs must cross freely between them.
|
||||
typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', arkts: 'web',
|
||||
c: 'c', cpp: 'c',
|
||||
c: 'native', cpp: 'native',
|
||||
// Razor/Blazor markup names C# types — same family so `@model Foo` /
|
||||
// `<MyComponent/>` resolve to their `.cs` class through the cross-family gate.
|
||||
csharp: 'dotnet', razor: 'dotnet',
|
||||
csharp: 'dotnet', razor: 'dotnet', vbnet: 'dotnet',
|
||||
svelte: 'web', vue: 'web', astro: 'web',
|
||||
cfml: 'cfml', cfscript: 'cfml',
|
||||
};
|
||||
export function sameLanguageFamily(a: string, b: string): boolean {
|
||||
if (a === b) return true;
|
||||
const fa = LANGUAGE_FAMILY[a];
|
||||
return fa !== undefined && fa === LANGUAGE_FAMILY[b];
|
||||
}
|
||||
/**
|
||||
* True when `lang` belongs to a known multi-language family (jvm/apple/web/c).
|
||||
* Languages not listed (php, python, go, ruby, rust, dart, …) and config
|
||||
* formats (yaml/xml/blade) form their own singleton families and return
|
||||
* `false` — used to leave config↔code framework bridges (whose config side is
|
||||
* never a known programming-language family) out of the cross-family gate.
|
||||
*/
|
||||
export function isKnownLanguageFamily(lang: string): boolean {
|
||||
return LANGUAGE_FAMILY[lang] !== undefined;
|
||||
/** Config/markup transitions stay open; every other code language has a family. */
|
||||
const CODE_FAMILY: Record<string, string> = {
|
||||
...LANGUAGE_FAMILY,
|
||||
python: 'python', go: 'go', rust: 'rust', php: 'php', ruby: 'ruby', dart: 'dart',
|
||||
lua: 'lua', luau: 'lua', r: 'r', erlang: 'erlang', pascal: 'pascal', solidity: 'solidity',
|
||||
nix: 'nix', cobol: 'cobol',
|
||||
};
|
||||
|
||||
export function crossesCodeBoundary(a: string, b: string): boolean {
|
||||
return CODE_FAMILY[a] !== undefined && CODE_FAMILY[b] !== undefined &&
|
||||
CODE_FAMILY[a] !== CODE_FAMILY[b];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `a` and `b` are two DIFFERENT *known* language families — the
|
||||
* signature of a coincidental cross-language name collision (a TS `import
|
||||
* React` matching a Swift `import React`, a C++ `#include "X.h"` matching a
|
||||
* same-named ObjC header on another platform). The both-*known* test is
|
||||
* deliberately weaker than {@link sameLanguageFamily}'s negation: a
|
||||
* single-file-component language that carries its own tag (`vue`/`svelte`)
|
||||
* importing a `.ts` module, or any singleton-family language (php/go/ruby/…),
|
||||
* returns `false` here and is left alone.
|
||||
* Cross-family name matches need a framework export or an actual ABI boundary,
|
||||
* not merely a native caller. ABI evidence is scoped to the named free function.
|
||||
*/
|
||||
export function crossesKnownFamily(a: string, b: string): boolean {
|
||||
return isKnownLanguageFamily(a) && isKnownLanguageFamily(b) && !sameLanguageFamily(a, b);
|
||||
function hasBridgeEvidence(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
if (ref.referenceKind !== 'calls') return false;
|
||||
// Expo's extractor creates explicit JS exports, resolved by the ordinary
|
||||
// name matcher rather than a framework resolve() branch.
|
||||
if (CODE_FAMILY[ref.language] === 'web' && candidate.id.startsWith('expo-module:') &&
|
||||
candidate.isExported && (candidate.language === 'swift' || candidate.language === 'kotlin')) return true;
|
||||
if (candidate.kind !== 'function') return false;
|
||||
const name = candidate.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (CODE_FAMILY[ref.language] === 'native') {
|
||||
const source = context.readFile(candidate.filePath);
|
||||
if (!source) return false;
|
||||
if (candidate.language === 'go') {
|
||||
const declaration = source.split('\n').slice(Math.max(0, candidate.startLine - 2), candidate.endLine).join('\n');
|
||||
return /\bimport\s+(?:\(\s*)?"C"/.test(stripCommentsForRegex(source, 'go')) &&
|
||||
new RegExp('^//export ' + name + '\\r?\\nfunc ' + name + '\\s*\\(', 'm').test(declaration);
|
||||
}
|
||||
if (candidate.language === 'rust') {
|
||||
const declaration = source.split('\n').slice(candidate.startLine - 1, candidate.endLine).join('\n');
|
||||
return new RegExp('\\bpub\\s+extern\\s+"C"\\s+fn\\s+' + name + '\\b')
|
||||
.test(stripCommentsForRegex(declaration, 'rust'));
|
||||
}
|
||||
}
|
||||
if (candidate.language === 'c' || candidate.language === 'cpp') {
|
||||
const source = context.readFile(ref.filePath);
|
||||
if (!source) return false;
|
||||
if (ref.language === 'go') {
|
||||
return /\bimport\s+(?:\(\s*)?"C"/.test(stripCommentsForRegex(source, 'go')) &&
|
||||
ref.referenceName === 'C.' + candidate.name;
|
||||
}
|
||||
if (ref.language === 'rust') {
|
||||
return new RegExp('extern\\s+"C"\\s*\\{[^}]*\\bfn\\s+' + name + '\\s*\\(')
|
||||
.test(stripCommentsForRegex(source, 'rust'));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Drop cross-language candidates from a name lookup. Two regimes:
|
||||
* - `references` (type-usage): a type named in language X resolves to a
|
||||
* SAME-family type, never a coincidentally same-named symbol in another
|
||||
* language (the Android `BatteryManager` system class vs a JS one). Strict
|
||||
* same-family filter — cross-language communication is `calls`, not refs.
|
||||
* - `imports` (import binding): an `import`/`#include` never crosses two
|
||||
* KNOWN families (TS `import React` ↮ Swift `import React`). Weaker
|
||||
* both-known filter so `.vue`/`.svelte` (own tag) importing `.ts` survives.
|
||||
*/
|
||||
function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] {
|
||||
if (ref.referenceKind === 'references' || ref.referenceKind === 'function_ref') {
|
||||
return candidates.filter((c) => sameLanguageFamily(c.language, ref.language));
|
||||
}
|
||||
if (ref.referenceKind === 'imports') {
|
||||
return candidates.filter((c) => !crossesKnownFamily(c.language, ref.language));
|
||||
}
|
||||
return candidates;
|
||||
|
||||
/** Reject the chosen result without shrinking a pool or trying a replacement. */
|
||||
export function gateLanguageMatch(
|
||||
result: ResolvedRef | null,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
if (!result) return result;
|
||||
const target = context.getNodeById?.(result.targetNodeId) ??
|
||||
context.getNodesByName(ref.referenceName).find((n) => n.id === result.targetNodeId);
|
||||
if (target && crossesCodeBoundary(ref.language, target.language) &&
|
||||
!hasBridgeEvidence(target, ref, context)) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -773,7 +800,11 @@ export function matchByExactName(
|
||||
const storeAction = matchJsStoreBindingCall(ref, context);
|
||||
if (storeAction) return storeAction;
|
||||
}
|
||||
const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref)
|
||||
const candidates = context.getNodesByName(ref.referenceName)
|
||||
// Type/value references retain same-family eligibility: a native namesake
|
||||
// must not hide the actual web type. Calls still gate only the winner.
|
||||
.filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') ||
|
||||
sameLanguageFamily(n.language, ref.language))
|
||||
.filter((n) => n.kind !== 'import')
|
||||
// Nested locals are only reachable from inside their container (#1230).
|
||||
.filter((n) => isLexicallyReachable(n, ref, context))
|
||||
@@ -3578,10 +3609,9 @@ export function matchFuzzy(
|
||||
|
||||
// Filter to callable kinds only (function, method, class)
|
||||
const callableKinds = new Set(['function', 'method', 'class']);
|
||||
const callableCandidates = applyLanguageGate(
|
||||
candidates.filter((n) => callableKinds.has(n.kind)),
|
||||
ref
|
||||
);
|
||||
const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind))
|
||||
.filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') ||
|
||||
sameLanguageFamily(n.language, ref.language));
|
||||
|
||||
// Prefer same-language matches
|
||||
const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);
|
||||
@@ -3676,6 +3706,13 @@ export function dumpNameMatcherProfile(label: string): void {
|
||||
export function matchReference(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
return gateLanguageMatch(matchReferenceInner(ref, context), ref, context);
|
||||
}
|
||||
|
||||
function matchReferenceInner(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
// Function-as-value refs (#756) resolve ONLY through the dedicated matcher —
|
||||
// never the fuzzy/qualified fallthrough below (a wrong callback edge is
|
||||
|
||||
Reference in New Issue
Block a user