feat(resolution): mixed iOS / React Native / Expo cross-language bridging (#430)
Implements the design from `docs/design/mixed-ios-and-react-native-bridging.md`.
Closes the cross-language flow gap so `trace` / `callers` / `callees` / `impact` connect end-to-end across language boundaries in real iOS, React Native, and Expo codebases.
## Bridges shipped
| Boundary | Mechanism | Real-codebase validation |
|---|---|---|
| **Swift ↔ Objective-C** | Resolver applying Apple's @objc auto-bridging name math + Cocoa preposition prefixes | Charts (S, 269) · realm-swift (M, 369) · wikipedia-ios (L, 1734) |
| **React Native legacy bridge** | Resolver parsing `RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` (ObjC) + `@ReactMethod` (Java/Kotlin) | AsyncStorage (S, ~60) · react-native-svg (M, ~700) · react-native-firebase (L, ~1100) |
| **React Native TurboModules** | Resolver treating `Native<X>.ts` spec interface as ground truth | via RNSvg + RNFirebase subsets |
| **Native → JS events** | Synthesizer matching native `sendEventWithName:`/`emit(...)` to JS `addListener('e', handler)` keyed by literal event name; falls back to enclosing constant/variable for wrapper-API parameter handlers | RNGeolocation (S) · RNFirebase (L) |
| **Expo Modules** | Framework extract synthesizes `method` nodes from Swift/Kotlin `Module { Name("X"); Function("y") { ... } }` DSL | expo-haptics (S, 14) · expo-camera (M, 72) · ExpoSweep (L, 332, 7 packages) |
| **Fabric + legacy Paper view components** | Extract `component` + `property` nodes from Codegen `codegenNativeComponent<Props>('Name', ...)` specs AND legacy `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp` macros, then synthesize component → native class by name+suffix convention | react-native-segmented-control (S, legacy) · react-native-screens (M, Codegen) · react-native-skia (L, hybrid monorepo) |
## Bug fixes surfaced along the way
- `tree-sitter.ts` message_expression — multi-keyword ObjC call sites now reconstruct `a🅱️` selectors so they resolve to multi-part method definitions (gap discovered post-#165; 0 → 84 call edges to `GET:parameters:...` style methods on AFNetworking).
- `src/index.ts` resolver lifecycle — `indexAll()` now re-initializes the resolver after extraction so framework `detect()` sees the populated index. Pre-existing latent bug that affected UIKit and SwiftUI resolvers too.
- `src/extraction/index.ts` `buildDetectionContext` — added `listDirectories` so framework detect() can probe monorepo subpackages uniformly (fix needed for react-native-skia detection).
## Regression check on 5 control repos
| Repo | Result |
|---|---|
| Express (small JS) | ✅ unchanged — 266 routes, express framework detected |
| Excalidraw (medium TS/React) | ✅ 9284 nodes (CLAUDE.md baseline ~9290); canonical `trace(mutateElement, renderStaticScene)` returns the flow |
| Django realworld (Python) | ✅ django framework detected, 16 routes |
| Spring petclinic (Java) | ✅ spring framework detected, 17 routes |
| Texture (pure ObjC, large) | ✅ exactly matches #165 baseline: 4702 methods, 894 classes, 808/808 file coverage, 913 multi-keyword selectors, 55 protocols, 1036 properties |
## Tests
928 passing (+87 net new bridge tests across the 5 channels); 2 pre-existing skips. The mcp-staleness-banner / watcher parallel flakiness is unchanged by this work (different test fails each run, all pass in isolation; pre-existing on main).
## Documentation
- README: new 'Mixed iOS / React Native / Expo bridging' section with the per-boundary table and validation-corpus links.
- CHANGELOG `[Unreleased]`: full entry per bridge with measurements.
- `docs/design/mixed-ios-and-react-native-bridging.md`: the design doc (§8 measurements filled in across §8a-§8g).
- `docs/design/dynamic-dispatch-coverage-playbook.md` §6 coverage matrix: six new rows.
- `.claude/skills/agent-eval/corpus.json`: four new sections covering 15 real GitHub repos for the eval harness.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
1821038e4b
commit
4d1a2b3c4d
22 files changed
+3786
-6
No files matched your search
@@ -520,10 +520,266 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Native cross-language event channel (Phase 3 of the mixed-iOS/RN
|
||||
* bridging effort). Same shape as `eventEmitterEdges` but cross-language:
|
||||
*
|
||||
* Native (ObjC, on RCTEventEmitter subclass):
|
||||
* [self sendEventWithName:@"locationUpdate" body:@{...}];
|
||||
*
|
||||
* Native (Java/Kotlin, via the JS module dispatcher):
|
||||
* emitter.emit("locationUpdate", body);
|
||||
* reactContext.getJSModule(RCTDeviceEventEmitter.class).emit("locationUpdate", body);
|
||||
*
|
||||
* JS (subscriber):
|
||||
* new NativeEventEmitter(NativeModules.Geo).addListener("locationUpdate", handler);
|
||||
* DeviceEventEmitter.addListener("locationUpdate", handler);
|
||||
*
|
||||
* Synthesize: native dispatch site → JS handler, keyed by the literal
|
||||
* event name. Only matches NAMED handlers (the existing `ON_RE` named-
|
||||
* capture form). Inline arrow handlers like `addListener('x', d => …)`
|
||||
* aren't named at extraction time and would need link-through-body
|
||||
* support; matches the deliberate scope of the in-language synthesizer.
|
||||
*
|
||||
* Provenance `'heuristic'`, synthesizedBy `'rn-event-channel'`.
|
||||
*/
|
||||
// ObjC's `[self sendEventWithName:@"X" body:...]` shape (bracket syntax,
|
||||
// `@` string literals).
|
||||
const RN_OBJC_SEND_RE = /\bsendEventWithName\s*:\s*@"([^"]+)"/g;
|
||||
// Swift's `sendEvent(withName: "X", body: ...)` shape — same RCTEventEmitter
|
||||
// method, different call syntax. Both Objective-C and Swift subclass
|
||||
// RCTEventEmitter so this catches the Swift-side equivalent emission sites
|
||||
// (e.g. RNFusedLocation.swift's `sendEvent(withName: "geolocationDidChange",
|
||||
// body: locationData)`).
|
||||
const RN_SWIFT_SEND_RE = /\bsendEvent\s*\(\s*withName\s*:\s*"([^"]+)"/g;
|
||||
// JVM-side emitter calls: `emitter.emit("X", body)`. Matches both Java
|
||||
// and Kotlin syntax because the call form is identical. Restricted to
|
||||
// JVM source files in the consumer so we don't re-process JS emits
|
||||
// (which `eventEmitterEdges` already handles).
|
||||
const RN_JVM_EMIT_RE = /\.emit\s*\(\s*"([^"]+)"\s*,/g;
|
||||
|
||||
function rnEventEdges(ctx: ResolutionContext): Edge[] {
|
||||
// Native dispatchers (source = the native method whose body sends the
|
||||
// event) and JS handlers (target = the function/method registered as
|
||||
// the listener) keyed by event name.
|
||||
const nativeDispatchersByEvent = new Map<string, Set<string>>();
|
||||
const jsHandlersByEvent = new Map<string, Map<string, string>>();
|
||||
|
||||
for (const file of ctx.getAllFiles()) {
|
||||
const content = ctx.readFile(file);
|
||||
if (!content) continue;
|
||||
|
||||
const nodesInFile = ctx.getNodesInFile(file);
|
||||
const lineOf = (idx: number) => content.slice(0, idx).split('\n').length;
|
||||
const addDispatcher = (event: string, line: number) => {
|
||||
const disp = enclosingFn(nodesInFile, line);
|
||||
if (!disp) return;
|
||||
const set = nativeDispatchersByEvent.get(event) ?? new Set<string>();
|
||||
set.add(disp.id);
|
||||
nativeDispatchersByEvent.set(event, set);
|
||||
};
|
||||
|
||||
// ObjC side: `sendEventWithName:@"X"` only fires inside `.m`/`.mm`
|
||||
// files (RCTEventEmitter subclasses).
|
||||
if (file.endsWith('.m') || file.endsWith('.mm')) {
|
||||
RN_OBJC_SEND_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = RN_OBJC_SEND_RE.exec(content))) {
|
||||
if (m[1]) addDispatcher(m[1], lineOf(m.index));
|
||||
}
|
||||
}
|
||||
|
||||
// Swift side: same RCTEventEmitter method, parens/named-args syntax.
|
||||
if (file.endsWith('.swift')) {
|
||||
RN_SWIFT_SEND_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = RN_SWIFT_SEND_RE.exec(content))) {
|
||||
if (m[1]) addDispatcher(m[1], lineOf(m.index));
|
||||
}
|
||||
}
|
||||
|
||||
// JVM side: `.emit("X", …)` in Java/Kotlin. (We pattern-match
|
||||
// anywhere in the file; the JS in-language path uses a separate
|
||||
// emitter object pattern and is already handled by eventEmitterEdges.)
|
||||
if (file.endsWith('.java') || file.endsWith('.kt')) {
|
||||
RN_JVM_EMIT_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = RN_JVM_EMIT_RE.exec(content))) {
|
||||
if (m[1]) addDispatcher(m[1], lineOf(m.index));
|
||||
}
|
||||
}
|
||||
|
||||
// JS subscribers (.addListener("X", handler)). Restrict to JS-family
|
||||
// files so a native file's `addListener:` (the ObjC method) doesn't
|
||||
// get mistaken for a JS subscription — they're entirely different
|
||||
// things despite sharing a name.
|
||||
if (
|
||||
file.endsWith('.js') ||
|
||||
file.endsWith('.jsx') ||
|
||||
file.endsWith('.ts') ||
|
||||
file.endsWith('.tsx') ||
|
||||
file.endsWith('.mjs') ||
|
||||
file.endsWith('.cjs')
|
||||
) {
|
||||
// Match BOTH the named-handler form (`.addListener('x', fn)`) and
|
||||
// an unnamed-handler form (`.addListener('x', listener)` where
|
||||
// `listener` is a parameter — common in RN wrapper APIs like
|
||||
// RNFirebase's `messaging().onMessageReceived(listener)`). For the
|
||||
// unnamed case we attribute the subscription to the ENCLOSING JS
|
||||
// function (the abstraction layer), giving a reachability-correct
|
||||
// hop even when the actual user-side handler lives one call up.
|
||||
const ADDLISTENER_ANY = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*([A-Za-z_][\w.]*)/g;
|
||||
ADDLISTENER_ANY.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ADDLISTENER_ANY.exec(content))) {
|
||||
const event = m[1];
|
||||
const arg = m[2];
|
||||
if (!event || !arg) continue;
|
||||
const bareName = arg.includes('.') ? arg.slice(arg.lastIndexOf('.') + 1) : arg;
|
||||
// Try a named-symbol match first (matches the in-language semantic).
|
||||
const namedHandler = ctx
|
||||
.getNodesByName(bareName)
|
||||
.find((n) => n.kind === 'function' || n.kind === 'method');
|
||||
let targetId: string | null = namedHandler?.id ?? null;
|
||||
if (!targetId) {
|
||||
// Fall back to the enclosing function — the subscribe-wrapper
|
||||
// pattern means the event fires THROUGH this function on its
|
||||
// way to user code. Reachability-correct attribution.
|
||||
const enclosing = enclosingFn(nodesInFile, lineOf(m.index));
|
||||
targetId = enclosing?.id ?? null;
|
||||
}
|
||||
if (!targetId) {
|
||||
// Broader fallback for JS object-literal API shape
|
||||
// (`const Foo = { watchX(...) { … addListener(...) … } }`):
|
||||
// method shorthand inside an object literal isn't extracted
|
||||
// as a method node, so enclosingFn returns null. Attribute to
|
||||
// the smallest enclosing `constant` / `variable` node — that's
|
||||
// the API surface a downstream caller would `import` and
|
||||
// invoke. Reachability-correct.
|
||||
const line = lineOf(m.index);
|
||||
let smallest: typeof nodesInFile[number] | null = null;
|
||||
for (const n of nodesInFile) {
|
||||
if (n.kind !== 'constant' && n.kind !== 'variable') continue;
|
||||
const end = n.endLine ?? n.startLine;
|
||||
if (n.startLine <= line && end >= line) {
|
||||
if (!smallest || n.startLine >= smallest.startLine) smallest = n;
|
||||
}
|
||||
}
|
||||
targetId = smallest?.id ?? null;
|
||||
}
|
||||
if (!targetId) continue;
|
||||
const map = jsHandlersByEvent.get(event) ?? new Map<string, string>();
|
||||
map.set(targetId, `${file}:${lineOf(m.index)}`);
|
||||
jsHandlersByEvent.set(event, map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [event, dispatchers] of nativeDispatchersByEvent) {
|
||||
const handlers = jsHandlersByEvent.get(event);
|
||||
if (!handlers) continue;
|
||||
// Same fan-out guard as the in-language channel: generic event names
|
||||
// (e.g. 'change', 'error', 'data') with many handlers/dispatchers
|
||||
// can't be matched precisely without receiver-type info.
|
||||
if (dispatchers.size > EVENT_FANOUT_CAP || handlers.size > EVENT_FANOUT_CAP) continue;
|
||||
for (const d of dispatchers) {
|
||||
for (const [h, registeredAt] of handlers) {
|
||||
if (d === h) continue;
|
||||
const key = `${d}>${h}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: d,
|
||||
target: h,
|
||||
kind: 'calls',
|
||||
provenance: 'heuristic',
|
||||
metadata: { synthesizedBy: 'rn-event-channel', event, registeredAt },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 6 — React Native Fabric/Codegen view component bridge.
|
||||
*
|
||||
* The Fabric framework extractor (`frameworks/fabric.ts`) emits
|
||||
* `component` nodes named after the JS-visible component (e.g.
|
||||
* `RNSScreenStack`) from each `codegenNativeComponent<Props>('Name')`
|
||||
* spec declaration. The native implementation lives in an ObjC++/.mm or
|
||||
* Kotlin/Java class whose name follows one of RN's conventions:
|
||||
*
|
||||
* - Exact: `RNSScreenStack`
|
||||
* - With suffix: `RNSScreenStackView`, `RNSScreenStackViewManager`,
|
||||
* `RNSScreenStackComponentView`, `RNSScreenStackManager`
|
||||
*
|
||||
* This synthesizer walks every Fabric component node and looks for a
|
||||
* native class matching one of those names; when found, emits a
|
||||
* `calls` edge `component → native class` (provenance `'heuristic'`,
|
||||
* `synthesizedBy:'fabric-native-impl'`) so trace from JSX usage of the
|
||||
* component continues into native.
|
||||
*
|
||||
* The convention-based suffix lookup is precise: there's no name
|
||||
* collision in RN view-manager codebases by design (Codegen output would
|
||||
* conflict otherwise).
|
||||
*/
|
||||
const FABRIC_NATIVE_SUFFIXES = ['', 'View', 'ViewManager', 'ComponentView', 'Manager'];
|
||||
|
||||
function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// The Fabric extractor IDs are prefixed `fabric-component:` so we can
|
||||
// filter to just those without iterating all `component` nodes.
|
||||
const components = ctx.getNodesByKind('component').filter((n) => n.id.startsWith('fabric-component:'));
|
||||
if (components.length === 0) return edges;
|
||||
|
||||
// Pre-index native classes by name for O(1) lookup.
|
||||
const nativeClassesByName = new Map<string, Node[]>();
|
||||
for (const n of ctx.getNodesByKind('class')) {
|
||||
if (n.language !== 'objc' && n.language !== 'kotlin' && n.language !== 'java' && n.language !== 'cpp') continue;
|
||||
const arr = nativeClassesByName.get(n.name);
|
||||
if (arr) arr.push(n);
|
||||
else nativeClassesByName.set(n.name, [n]);
|
||||
}
|
||||
|
||||
for (const component of components) {
|
||||
for (const suffix of FABRIC_NATIVE_SUFFIXES) {
|
||||
const candidate = component.name + suffix;
|
||||
const matches = nativeClassesByName.get(candidate);
|
||||
if (!matches || matches.length === 0) continue;
|
||||
// Link the component node to every matching native class (iOS +
|
||||
// Android each have one).
|
||||
for (const native of matches) {
|
||||
const key = `${component.id}>${native.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: component.id,
|
||||
target: native.id,
|
||||
kind: 'calls',
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'fabric-native-impl',
|
||||
viaSuffix: suffix || '(exact)',
|
||||
componentName: component.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize dispatcher→callback edges (field observers + EventEmitters +
|
||||
* React re-render + JSX children + Vue templates). Returns the count added.
|
||||
* Never throws into indexing — callers wrap in try/catch.
|
||||
* React re-render + JSX children + Vue templates + RN event channel +
|
||||
* Fabric native-impl). Returns the count added. Never throws into
|
||||
* indexing — callers wrap in try/catch.
|
||||
*/
|
||||
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
|
||||
const fieldEdges = fieldChannelEdges(queries, ctx);
|
||||
@@ -534,10 +790,23 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
||||
const flutterEdges = flutterBuildEdges(queries, ctx);
|
||||
const cppEdges = cppOverrideEdges(queries);
|
||||
const ifaceEdges = interfaceOverrideEdges(queries);
|
||||
const rnEventEdgesList = rnEventEdges(ctx);
|
||||
const fabricNativeEdges = fabricNativeImplEdges(ctx);
|
||||
|
||||
const merged: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of [...fieldEdges, ...emitterEdges, ...renderEdges, ...jsxEdges, ...vueEdges, ...flutterEdges, ...cppEdges, ...ifaceEdges]) {
|
||||
for (const e of [
|
||||
...fieldEdges,
|
||||
...emitterEdges,
|
||||
...renderEdges,
|
||||
...jsxEdges,
|
||||
...vueEdges,
|
||||
...flutterEdges,
|
||||
...cppEdges,
|
||||
...ifaceEdges,
|
||||
...rnEventEdgesList,
|
||||
...fabricNativeEdges,
|
||||
]) {
|
||||
const key = `${e.source}>${e.target}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
Reference in new issue
Block a user