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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
]) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user