feat(impact): React Native / Expo cross-language bridges — generic Expo Functions, cross-platform pairing, type-ref precision

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<Float>
  ("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
  `<module>.<method>` (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) <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-06-04 14:18:11 -04:00
co-authored by Claude Opus 4.8
parent 33ce43194c
commit dbc4862940
7 changed files with 180 additions and 7 deletions
+47
View File
@@ -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 `<module>.<method>` 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
* `<file>::<module>.<method>` by the framework extractor.
*/
function expoCrossPlatformEdges(queries: QueryBuilder): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const byKey = new Map<string, Node[]>();
for (const m of queries.getNodesByKind('method')) {
if (!m.id.startsWith('expo-module:')) continue;
const key = m.qualifiedName.split('::').pop(); // `<module>.<method>`
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<string>();
@@ -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,
]) {
+6 -1
View File
@@ -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<Float>("getBatteryLevelAsync")`, `AsyncFunction<Int, String>(…)`)
* — 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
+19 -4
View File
@@ -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;
}
}
/**
+30 -2
View File
@@ -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<string, string> = {
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);