From dbc486294053c4efbce9c8da9e2d85bec37ed314 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 4 Jun 2026 14:18:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(impact):=20React=20Native=20/=20Expo=20cro?= =?UTF-8?q?ss-language=20bridges=20=E2=80=94=20generic=20Expo=20Functions,?= =?UTF-8?q?=20cross-platform=20pairing,=20type-ref=20precision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated the cross-language bridge graph on real RN/Expo repos (expo-battery, react-native-device-info) and fixed three gaps: - Generic-typed Expo Functions: Android Expo Modules write `AsyncFunction ("getBatteryLevelAsync")`, but the framework extractor's regex allowed no generic type parameter between the keyword and `(`, so EVERY Android Expo method was silently dropped — a JS call resolved only to the iOS Swift impl. The regex now accepts an optional `<…>`. - Cross-platform pairing: an Expo Module exposes the same JS method from BOTH a Swift (iOS) and a Kotlin (Android) impl, but a JS callsite name-resolves to only one. New `expoCrossPlatformEdges` links the two platform impls of the same `.` (both directions), so a JS call reaching one reaches the other and editing either surfaces the JS caller. - Cross-language type-reference precision: a `Type.member` static read in native code (`BatteryManager.EXTRA_LEVEL`, the Android system class) name-matched to a coincidentally same-named TS class. `references` (type-usage) edges are now gated to the same language family (JVM java/kotlin/scala, Apple swift/objc, web ts/js, C c/cpp) at the name-match + import strategies — framework resolvers are NOT gated, so deliberate config→code bridges (Drupal routing.yml→PHP, Spring @Value→YAML) and JS↔native `calls` bridges are unaffected. The classic RN NativeModules bridge (`@ReactMethod`/`RCT_EXPORT_METHOD`) already worked and is unchanged (118 JS→Java + JS→ObjC `calls` on device-info). Full suite green; node count stable. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + __tests__/expo-modules.test.ts | 53 +++++++++++++++++++++++ __tests__/extraction.test.ts | 24 ++++++++++ src/resolution/callback-synthesizer.ts | 47 ++++++++++++++++++++ src/resolution/frameworks/expo-modules.ts | 7 ++- src/resolution/index.ts | 23 ++++++++-- src/resolution/name-matcher.ts | 32 +++++++++++++- 7 files changed, 180 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d84264813..e27e5157a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- React Native / Expo cross-language bridges are more complete and more precise. An Expo Module method declared with a generic type — Android's `AsyncFunction("getBatteryLevelAsync")` — is now indexed (the `` used to defeat the matcher, so every Android Expo method was dropped and a JS call resolved only to the iOS Swift impl). The iOS and Android implementations of the same JS-visible method are now linked to each other, so a JS call that resolves to one platform still reaches the other and editing either surfaces the caller. And a `Type.member` static read in native code (e.g. Android's `BatteryManager.EXTRA_LEVEL`) no longer falsely links to a coincidentally same-named class in another language (a web `BatteryManager`) — type references stay within a language family, while genuine cross-language bridges (config→code, JS↔native calls) are unaffected. (React Native, Expo) - `codegraph affected` now reports the tests and files that actually depend on your changes. It used to follow only `import` statements — but those never cross file boundaries in CodeGraph's graph — so it returned **no affected tests for any change, in every language**. It now traces the real cross-file usage graph (calls, references, instantiations, and class `extends` / `implements`), so `git diff --name-only | codegraph affected` surfaces the test files that exercise the changed code. Circular-dependency detection, which had the same blind spot, now works too. - Blast radius, callers, and `codegraph affected` now recognize far more of the dependencies that were already in your code. A symbol now counts as a dependency whether it's called, used only in a type annotation inside a function body (`const items: Foo[] = []`), imported and placed in a registry array or passed as an argument, used as a JSX component, simply re-exported from a barrel (`export { X } from './x'`), or pulled in as a namespace (`import * as ns from '@/x'`) — including through tsconfig path aliases like `@/`. Previously only called, instantiated, or signature-typed symbols created a cross-file link, so a file that used a dependency in any other way could look like it depended on nothing — and the file that defined a widely-used symbol could look like nothing depended on it. The graph still indexes exactly the same symbols; it just connects the ones that were already there. (TypeScript/JavaScript) - The same completeness fix now applies to **Python**: a name brought in with `from module import X` is recorded as a dependency on that module even when `X` is only stored in a list/dict, passed as an argument, used as a decorator, or re-exported through an `__init__.py`. Previously Python linked only imports that were called or instantiated, so a module consumed purely by value — or only re-exported — looked like nothing depended on it. diff --git a/__tests__/expo-modules.test.ts b/__tests__/expo-modules.test.ts index 9da68594c..eab16c1c3 100644 --- a/__tests__/expo-modules.test.ts +++ b/__tests__/expo-modules.test.ts @@ -151,4 +151,57 @@ export async function impactAsync() { expect(callEdge.length).toBeGreaterThanOrEqual(1); expect(callEdge[0].target_id.startsWith('expo-module:')).toBe(true); }); + + it('extracts GENERIC-typed Kotlin AsyncFunction and pairs the iOS + Android impls', async () => { + fs.writeFileSync( + path.join(dir, 'package.json'), + '{"dependencies":{"expo-modules-core":"^1.0.0"}}' + ); + fs.mkdirSync(path.join(dir, 'ios')); + fs.writeFileSync( + path.join(dir, 'ios', 'BatteryModule.swift'), + `import ExpoModulesCore +public class BatteryModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoBattery") + AsyncFunction("getBatteryLevelAsync") { () -> Float in return 1.0 } + } +} +` + ); + fs.mkdirSync(path.join(dir, 'android')); + fs.writeFileSync( + path.join(dir, 'android', 'BatteryModule.kt'), + `import expo.modules.kotlin.modules.Module +class BatteryModule : Module() { + override fun definition() = ModuleDefinition { + Name("ExpoBattery") + AsyncFunction("getBatteryLevelAsync") { 1.0f } + } +} +` + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + // The Android (Kotlin) GENERIC AsyncFunction is extracted — before the + // fix the `` defeated the regex and it was silently dropped. + const kt = db.prepare( + "SELECT * FROM nodes WHERE name='getBatteryLevelAsync' AND language='kotlin' AND id LIKE 'expo-module:%'" + ).all(); + expect(kt).toHaveLength(1); + + // The iOS (Swift) and Android (Kotlin) impls of the same JS method are linked + // to each other, so a JS call that resolves to one platform reaches the other. + const pair = db.prepare( + `SELECT count(*) c FROM edges e + JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target + WHERE s.name='getBatteryLevelAsync' AND t.name='getBatteryLevelAsync' + AND s.language != t.language` + ).get(); + cg.close?.(); + expect(pair.c).toBeGreaterThanOrEqual(2); // swift->kotlin AND kotlin->swift + }); }); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 3eed25526..90fc6afdb 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -3741,6 +3741,30 @@ describe('Static-member / value-read references', () => { .filter((n) => n.name === 'this' || n.name === 'helper'); expect(refTargets.length).toBe(0); }); + + it('does not link a static-member read across language families (coincidental name)', async () => { + // A native (Kotlin) `Build.VERSION` reads the Android system class — it must + // NOT link to a coincidentally same-named TS class (the cross-language false + // positive that name-matching produces; `references` edges are language-local). + fs.writeFileSync( + path.join(tempDir, 'Build.ts'), + `export class Build {\n static version = 1;\n}\n` + ); + fs.writeFileSync( + path.join(tempDir, 'Device.kt'), + `package app\nclass Device {\n fun sdk(): Int = Build.VERSION\n}\n` + ); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + const tsBuild = cg.getNodesByKind('class').find((n) => n.name === 'Build' && n.filePath.endsWith('Build.ts')); + expect(tsBuild).toBeDefined(); + // The Kotlin file is `app/Device.kt`; the TS Build must have NO dependent there. + const deps = [...cg.getImpactRadius(tsBuild!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(deps.some((p) => p.endsWith('Device.kt'))).toBe(false); + }); }); describe('Objective-C messages, class receivers, and #import', () => { diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index cc2e5d70c..8ee383b51 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1077,6 +1077,51 @@ function rnEventEdges(ctx: ResolutionContext): Edge[] { */ const FABRIC_NATIVE_SUFFIXES = ['', 'View', 'ViewManager', 'ComponentView', 'Manager']; +/** + * Expo Modules cross-platform pairing. An Expo Module exposes the SAME + * JS-visible method (`AsyncFunction("getBatteryLevelAsync")`) from BOTH an iOS + * (Swift) and an Android (Kotlin) implementation. A JS callsite name-resolves to + * only ONE of them, so the other platform's impl looked like nothing called it + * (and editing it showed no blast radius). Link the iOS and Android impls of the + * same `.` to each other (both directions), so a JS call that + * reaches one platform reaches the other, and editing either surfaces the JS + * caller. The Expo method nodes are id-prefixed `expo-module:` and qualified + * `::.` by the framework extractor. + */ +function expoCrossPlatformEdges(queries: QueryBuilder): Edge[] { + const edges: Edge[] = []; + const seen = new Set(); + const byKey = new Map(); + for (const m of queries.getNodesByKind('method')) { + if (!m.id.startsWith('expo-module:')) continue; + const key = m.qualifiedName.split('::').pop(); // `.` + if (!key) continue; + const arr = byKey.get(key); + if (arr) arr.push(m); + else byKey.set(key, [m]); + } + for (const group of byKey.values()) { + if (group.length < 2) continue; + for (const a of group) { + for (const b of group) { + if (a.id === b.id || a.language === b.language) continue; // cross-platform only + const key = `${a.id}>${b.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: a.id, + target: b.id, + kind: 'calls', + line: a.startLine, + provenance: 'heuristic', + metadata: { synthesizedBy: 'expo-cross-platform', via: a.name }, + }); + } + } + } + return edges; +} + function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] { const edges: Edge[] = []; const seen = new Set(); @@ -1336,6 +1381,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo const goGrpcEdges = goGrpcStubImplEdges(queries); const rnEventEdgesList = rnEventEdges(ctx); const fabricNativeEdges = fabricNativeImplEdges(ctx); + const expoXPlatEdges = expoCrossPlatformEdges(queries); const mybatisEdges = mybatisJavaXmlEdges(queries); const ginEdges = ginMiddlewareChainEdges(queries, ctx); @@ -1355,6 +1401,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo ...goGrpcEdges, ...rnEventEdgesList, ...fabricNativeEdges, + ...expoXPlatEdges, ...mybatisEdges, ...ginEdges, ]) { diff --git a/src/resolution/frameworks/expo-modules.ts b/src/resolution/frameworks/expo-modules.ts index 9f89f1846..a9f60174a 100644 --- a/src/resolution/frameworks/expo-modules.ts +++ b/src/resolution/frameworks/expo-modules.ts @@ -54,9 +54,14 @@ import { * line as the keyword, which matches every real Expo Module declaration * style. Multi-line `AsyncFunction(\n"x"\n)` forms aren't a real shape in * the SDK; if any appear we'd extend the regex. + * + * The optional `<…>` covers Kotlin's GENERIC-typed declarations + * (`AsyncFunction("getBatteryLevelAsync")`, `AsyncFunction(…)`) + * — without it, every Android Expo Module method was silently dropped, so a JS + * callsite resolved only to the iOS Swift impl and never the Android one. */ const EXPO_DECL_RE = - /\b(Function|AsyncFunction|Property|Constants)\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g; + /\b(Function|AsyncFunction|Property|Constants)\s*(?:<[^(]*>)?\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g; /** * Match the module name literal `Name("ExpoX")`. Used to enrich each emitted diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 1e75744a6..5cd63e297 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -16,7 +16,7 @@ import { FrameworkResolver, ImportMapping, } from './types'; -import { matchReference } from './name-matcher'; +import { matchReference, sameLanguageFamily } from './name-matcher'; import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs } from './import-resolver'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; @@ -622,7 +622,9 @@ export class ReferenceResolver { const candidates: ResolvedRef[] = []; - // Strategy 1: Try framework-specific resolution + // Strategy 1: Try framework-specific resolution. NOT language-gated: + // framework resolvers deliberately bridge config↔code across languages + // (Drupal `routing.yml` → PHP controller, Spring `@Value` → YAML key). for (const framework of this.frameworks) { const result = framework.resolve(ref, this.context); if (result) { @@ -632,14 +634,14 @@ export class ReferenceResolver { } // Strategy 2: Try import-based resolution - const importResult = resolveViaImport(ref, this.context); + const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref); if (importResult) { if (importResult.confidence >= 0.9) return importResult; candidates.push(importResult); } // Strategy 3: Try name matching - const nameResult = matchReference(ref, this.context); + const nameResult = this.gateLanguage(matchReference(ref, this.context), ref); if (nameResult) { candidates.push(nameResult); } @@ -947,6 +949,19 @@ export class ReferenceResolver { const node = this.queries.getNodeById(nodeId); return node?.language || 'unknown'; } + + /** + * Drop a resolution that crosses a language family when the reference is + * `sameLanguageOnly` (a `Type.member` static read names a same-family type, + * never a coincidentally same-named symbol in another language). Covers ALL + * strategies (framework / import / name-match) at one chokepoint. + */ + private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { + if (!result || ref.referenceKind !== 'references') return result; + const tgt = this.getLanguageFromNodeId(result.targetNodeId); + if (tgt && ref.language && !sameLanguageFamily(tgt, ref.language)) return null; + return result; + } } /** diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index bf42f25b6..17a35aef3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -68,6 +68,34 @@ export function matchByFilePath( return null; } +/** + * Language families that share a type system / runtime, so a same-language-only + * reference may still resolve across them (a Kotlin `Foo.BAR` can name a Java + * `Foo`). Anything not listed forms its own singleton family. + */ +const LANGUAGE_FAMILY: Record = { + java: 'jvm', kotlin: 'jvm', scala: 'jvm', + swift: 'apple', objc: 'apple', + typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', + c: 'c', cpp: 'c', +}; +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]; +} +/** + * Drop cross-family candidates for a `references` (type-usage) edge. A type used + * in language X — a field/param/return type, a `Type.member` static read — names + * a same-family type, never a coincidentally same-named symbol in another + * language (the Android `BatteryManager` system class vs a JS `BatteryManager`). + * Cross-language communication is modeled by `calls` bridges, not `references`. + */ +function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] { + if (ref.referenceKind !== 'references') return candidates; + return candidates.filter((c) => sameLanguageFamily(c.language, ref.language)); +} + /** * Try to resolve a reference by exact name match */ @@ -75,7 +103,7 @@ export function matchByExactName( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { - const candidates = context.getNodesByName(ref.referenceName); + const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref); if (candidates.length === 0) { return null; @@ -668,7 +696,7 @@ export function matchFuzzy( // Filter to callable kinds only (function, method, class) const callableKinds = new Set(['function', 'method', 'class']); - const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind)); + const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref); // Prefer same-language matches const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);