mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-31 08:03:58 +08:00
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:
co-authored by
Claude Opus 4.8
parent
33ce43194c
commit
dbc4862940
@@ -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