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
parent 33ce43194c
commit dbc4862940
7 changed files with 180 additions and 7 deletions
+1
View File
@@ -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<Float>("getBatteryLevelAsync")` — is now indexed (the `<Float>` 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.
+53
View File
@@ -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<T> 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<Float>("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<Float> is extracted — before the
// fix the `<Float>` 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
});
});
+24
View File
@@ -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', () => {
+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);