fix(resolution): reject unrelated cross-language matches (#1986) (#2032)

* 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:
Colby Mchenry
2026-09-27 13:40:31 +00:00
committed by GitHub
parent 17d09bf506
commit 9649ce1bd6
10 changed files with 575 additions and 86 deletions
+155
View File
@@ -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();
}
});
});
+130
View File
@@ -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();
}
+30
View File
@@ -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');
+13
View File
@@ -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
+66 -1
View File
@@ -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,
}
}
+71 -1
View File
@@ -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;
}
+4 -2
View File
@@ -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
View File
@@ -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;
}
}
+82 -45
View File
@@ -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