diff --git a/__tests__/cross-language-resolution.test.ts b/__tests__/cross-language-resolution.test.ts new file mode 100644 index 000000000..bfd40dbbd --- /dev/null +++ b/__tests__/cross-language-resolution.test.ts @@ -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) { + 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 { 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 ; }', + 'Page.vue': '', + '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': '' + + '', + }); + 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(); + } + }); +}); diff --git a/__tests__/expression-receiver-calls.test.ts b/__tests__/expression-receiver-calls.test.ts new file mode 100644 index 000000000..e22de1f74 --- /dev/null +++ b/__tests__/expression-receiver-calls.test.ts @@ -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 = { + '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 { 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) -> Vec { v.iter().map(|s| s.len()).collect() }\n' + + 'pub fn opt(o: Option) -> Option { 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', + ]); + }); +}); diff --git a/__tests__/fixtures/kernel-parity/torture.js b/__tests__/fixtures/kernel-parity/torture.js index 190f56e60..6221c7a77 100644 --- a/__tests__/fixtures/kernel-parity/torture.js +++ b/__tests__/fixtures/kernel-parity/torture.js @@ -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(); +} diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index 7e16df56b..1b42e4aa6 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -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'); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 13cea5e16..0fb2a1bfd 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -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 diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 525cbc00d..6426db315 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -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, + } +} diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 62a194db7..5304a534d 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -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()`, `(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; } diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index fad6c0f01..3aff95c2b 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -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) => { - 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); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 3dadf624c..a98ebd217 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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 `` 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; } } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e9a08fed4..7f2c82c09 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -142,61 +142,88 @@ function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node { */ const LANGUAGE_FAMILY: Record = { 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` / // `` 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 = { + ...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