Audience: a Claude agent (or human) continuing this work after #165 landed
pure-Objective-C support.
Mission: make codegraph's trace / callers / callees / impact /
flow-context calls connect end-to-end across cross-language runtime
dispatch boundaries that today silently break flows: Swift ↔ Objective-C
in mixed iOS codebases, and JavaScript ↔ native in React Native / Expo
apps.
This doc is the plan, not the implementation. No code lands on this branch — only the design, the validation corpus, and the success bar. Coding starts on a follow-up branch per phase.
This work is the next item on the dynamic-dispatch coverage playbook §6 matrix: row "Swift × Objective-C bridging" and a new "React Native bridge" row. Both are resolver patterns (named refs exist on both sides — the bridging rule is deterministic) — not synthesizer patterns. See §3a of the playbook for the reference Django ORM resolver.
After #165, codegraph indexes Swift, Objective-C, and JavaScript/TypeScript each correctly in isolation. But the value is in cross-language flows — exactly where iOS apps and React Native apps live:
MyViewController.swift calls imageDownloader.download(url:completion:),
which is -[ImageDownloader downloadURL:completion:] in ImageDownloader.m.
Today: a trace("MyViewController.viewDidLoad", "downloadURL:completion:")
call returns no path. The Swift callsite parses as a call_expression whose
selector goes nowhere; the ObjC method exists as a node with no incoming
edge. The agent reads both files to reconstruct the bridge.useEffect(() => NativeModules.Geolocation.getCurrentPosition(cb))
in App.js reaches RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb)
in RNCGeolocation.m. Today: the JS callsite has no outgoing edge to
the ObjC implementation; the ObjC handler has no incoming edge from JS.
impact(getCurrentPosition) (ObjC side) shows no JS callers.await ExpoCamera.takePictureAsync(options) (JS) reaches
AsyncFunction("takePictureAsync") { ... } in ExpoCamera.swift (Expo
Modules API). Same break.In every case a name exists on both sides that an agent or a name-matcher
can correlate — Swift's auto-bridged ObjC selector, RCT_EXPORT_METHOD's
literal first argument, an Expo Function("name") literal. The fix is a
resolver that knows the bridging rules per channel and emits
references edges with provenance:'heuristic' and metadata.synthesizedBy:'<channel>'.
The playbook's load-bearing warning applies here harder than usual:
Partial coverage is WORSE than none. Bridging one boundary but not the next reveals a hop the agent then drills + reads to finish. Always close the flow end-to-end and re-measure — never ship a half-bridged flow.
For mixed iOS, this means both directions (Swift→ObjC and ObjC→Swift) and
all bridged kinds (methods, properties, init/initializers, protocols)
must close before measuring. For React Native, JS→native AND
native→JS (RCTEventEmitter, sendEvent) must both close, AND on both
the legacy bridge and TurboModules, or apps that mix them will half-bridge.
Each row is a separate dispatch channel in the playbook's vocabulary — each gets its own resolver (or synthesizer if no static ref exists), its own validation, its own row in the §6 matrix.
| # | Direction | Channel | Mapping rule | Where it lives | Difficulty |
|---|---|---|---|---|---|
| 1 | Swift → ObjC | direct call, ObjC class imported via -Bridging-Header.h |
Swift call obj.x(y:z:) ↔ ObjC selector -x:z: (literal mapping, see §3a) |
resolver in frameworks/swift-objc.ts |
medium |
| 2 | ObjC → Swift | @objc exposure |
Swift @objc func foo(bar:) ↔ ObjC -fooWithBar: (auto-name); @objc(custom:) overrides |
resolver in frameworks/swift-objc.ts |
medium |
| 3 | Swift ↔ ObjC | property/getter/setter bridging | Swift var name: String ↔ ObjC -name / -setName: |
resolver in frameworks/swift-objc.ts |
low |
| 4 | Swift ↔ ObjC | initializer bridging | Swift init(name:age:) ↔ ObjC -initWithName:age: |
resolver in frameworks/swift-objc.ts |
low |
| 5 | Swift ↔ ObjC | protocol bridging (@objc protocol) |
conformance edges across language | resolver in frameworks/swift-objc.ts |
medium |
| 6 | JS → ObjC (RN legacy bridge) | NativeModules.<Mod>.<fn> ↔ RCT_EXPORT_METHOD(<fn>:...) or RCT_REMAP_METHOD(<jsName>, <selector>:...) |
name match keyed by RCT_EXPORT_MODULE() literal on the ObjC side |
resolver in frameworks/react-native.ts |
medium |
| 7 | JS → Java/Kotlin (RN legacy bridge, Android) | NativeModules.<Mod>.<fn> ↔ @ReactMethod annotated method on a ReactContextBaseJavaModule subclass with getName() returning <Mod> |
resolver — same shape as #6, JVM side | medium | |
| 8 | JS ↔ native (RN TurboModules / Codegen) | TurboModuleRegistry.get('Mod') ↔ generated spec interface (NativeMod TS type) ↔ ObjC++/Kotlin impl matching the spec |
resolver that reads the spec file as ground truth | hard | |
| 9 | Native → JS (events) | ObjC [self sendEventWithName:@"x" body:b] (extending RCTEventEmitter) ↔ JS new NativeEventEmitter(NativeModules.Mod).addListener('x', cb) |
EventEmitter-style synthesizer (matches existing callback-synthesizer.ts for in-language EventEmitter) |
medium | |
| 10 | JS → native (Expo modules) | JS ExpoX.fn(args) ↔ Swift Function("fn") { ... } or AsyncFunction("fn") { ... } inside a Module subclass with Name("ExpoX") |
resolver in frameworks/expo-modules.ts |
medium | |
| 11 | JS → native (Fabric view components) | JS <MyView prop={v}/> ↔ ObjC/Swift RCT_EXPORT_VIEW_PROPERTY(prop, ...) or Codegen view spec |
resolver + JSX hop (compose with existing JSX synthesizer) | hard (defer) |
The Difficulty column drives phasing — see §6.
In every row, the bridging rule is deterministic from a name:
@objc exposure is a documented automatic mapping; @objc(custom:)
is an explicit override; both are statically extractable.RCT_EXPORT_METHOD takes a literal selector; RCT_EXPORT_MODULE() takes
an optional literal module name (default: class name minus RCT prefix);
NativeModules.Mod.fn is a literal-property access on a known global.Function("name") { ... } and Module { Name("ExpoX"); ... }
are literal strings inside Module definitions.Native<Name> exports with
TurboModuleRegistry.get<...>('<Name>').So the work is: extract the bridging-side names → make the resolver match
them. Same shape as djangoResolver resolving _iterable_class to
ModelIterable — no whole-graph correlation pass needed.
The one exception is #9 native→JS events, where the registration sites look very much like the in-language EventEmitter pattern the existing callback synthesizer already handles. Extending that synthesizer with a cross-language channel is the natural fit.
Swift uses standard rules to derive an ObjC selector from a Swift method:
| Swift declaration | ObjC selector |
|---|---|
func greet() |
greet |
func say(_ msg: String) |
say: |
func set(name: String) |
setWithName: |
func setName(_ name: String) |
setName: |
func move(to point: CGPoint) |
moveTo: |
func move(from a: CGPoint, to b: CGPoint) |
moveFrom:to: |
init(name: String) |
initWithName: |
init(name: String, age: Int) |
initWithName:age: |
var name: String (getter) |
name |
var name: String (setter) |
setName: |
@objc(customSel:) func f(...) |
customSel: (explicit override) |
The full rule set is at Apple — Importing Swift into Objective-C — specifically the "method name translation" and "initializer name translation" sections. The resolver implements this mapping in one direction at extract time (Swift declarations produce the bridged ObjC name, attached as an alias on the Swift method node), so name resolution on the ObjC side finds the Swift method through normal name-matching.
// Native side (ObjC)
@implementation RCTGeolocation
RCT_EXPORT_MODULE(); // module name: "Geolocation" (RCT prefix stripped)
RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb) { ... }
@end
// JS side
import { NativeModules } from 'react-native';
NativeModules.Geolocation.getCurrentPosition(cb); // resolves to the ObjC method above
Rule:
module node per class containing
RCT_EXPORT_MODULE(). Name = explicit string argument if present, else
class name with RCT prefix stripped.RCT_EXPORT_METHOD(<sel>) and RCT_REMAP_METHOD(<jsName>, <sel>)
becomes a method node attached to that module node, with the JS-visible
name (<sel>'s first keyword for RCT_EXPORT_METHOD, or <jsName> for
RCT_REMAP_METHOD).NativeModules.<Mod>.<fn> against (module, jsName) pairs from the
native side.references (provenance:'heuristic', synthesizedBy:'rn-bridge')
from the JS callsite to the native method.// Spec (TS) — codegen ground truth
export interface Spec extends TurboModule {
getCurrentPosition(cb: (loc: Location) => void): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('Geolocation');
// ObjC++ impl
@implementation RCTGeolocation
- (void)getCurrentPosition:(RCTResponseSenderBlock)cb { ... }
@end
import Geolocation from './NativeGeolocation';
Geolocation.getCurrentPosition(cb); // resolves to the ObjC method via the spec
Rule:
TurboModuleRegistry.get*<Spec>('<Name>')
to find the module name, then read the Spec interface methods.JSI_EXPORT_MODULE macro if present).references edges as #3b, with synthesizedBy:'rn-turbomodule'.// Native (Swift, expo-modules-core API)
public class ExpoCameraModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoCamera")
AsyncFunction("takePictureAsync") { (options: CameraOptions) in /* ... */ }
View(ExpoCameraView.self) {
Prop("type") { (view: ExpoCameraView, type: String) in /* ... */ }
}
}
}
import { requireNativeModule } from 'expo-modules-core';
const ExpoCamera = requireNativeModule('ExpoCamera');
await ExpoCamera.takePictureAsync({ quality: 1 });
Rule:
Module whose definition() (or
init { /* DSL */ } for newer API) contains a Name("X") call defines
the module. Each Function("y") / AsyncFunction("y") literal defines a
method. The trailing closure is the implementation body — extract as a
method node named y, attached to module X.requireNativeModule('X') produces a binding; resolve
property accesses on it to the named methods.Prop("name") for view modules behaves like RN's RCT_EXPORT_VIEW_PROPERTY —
defer with the rest of the view-component frontier.For each channel, the closed flow is:
references, heuristic, synthesizedBy:'<channel>')For Swift↔ObjC specifically, the cleanest model is alias-name on the declaration node: extend Swift method extraction to compute the ObjC auto-bridged name and store it as an alternate name the resolver considers. No new edges between Swift and ObjC method nodes are needed — normal name resolution suffices because both sides agree on the bridged selector after extraction.
The MCP read tools surface heuristic edges inline already
(see metadata.synthesizedBy plumbing from #312/#403); these new edges
ride that path with no additional plumbing.
Following CLAUDE.md's validation methodology — ≥3 flow prompts each on small / medium / large repos, with deterministic probes + agent A/B, ≥2 runs/arm. Picks below are candidates to commit to in the implementation branch; the implementation PR confirms the choices after verifying each repo still builds an index cleanly.
| Tier | Repo | Why | Canonical flow |
|---|---|---|---|
| Small | Charts (~150 files Swift+ObjC) | Swift-first lib with ObjC compatibility layer; well-known | "How does setting data on a ChartView reach the renderer?" |
| Small (alt) | Lottie-ios (~300 files, was mixed; current may be pure-Swift — verify) | Animation engine, well-known mix | "How does AnimationView.play() reach the layer compositor?" |
| Medium | Realm-Cocoa (~500 files) | Heavy Swift-on-top-of-ObjC: Swift API wraps an ObjC core that wraps C++ Realm Core | "How does Realm.write { realm.add(obj) } reach the ObjC persistence layer?" |
| Large | Wikipedia-iOS (~2500 Swift+ObjC files) | Real app, deeply mixed, active development | "How does tapping a search result reach the article-fetch network call?" |
| Large (alt) | WordPress-iOS | Heavier ObjC legacy + Swift additions | "How does a new-post draft save reach Core Data persistence?" |
Bar per repo:
trace, no break at the language boundary.select count(*) from nodes before/after).| Tier | Repo | Why | Canonical flow |
|---|---|---|---|
| Small | react-native-svg (~100 files JS+ObjC+Java) | Small, well-scoped native module set | "How does setting <Path d=.../> reach the iOS Core Graphics call?" |
| Medium | react-native-screens (~300 files, JS+native) | Real navigation primitives, both legacy bridge and Fabric | "How does navigating to a new screen reach UINavigationController?" |
| Medium (alt) | react-native-firebase (~1000 files across packages) | Many native modules, both platforms — stresses module discovery | "How does firestore().collection('x').get() reach the iOS Firebase SDK call?" |
| Large | facebook/react-native RNTester subset (~3000 files) | The framework itself + sample app; canonical bridge usage | "How does pressing a button in RNTester's GeolocationExample reach the iOS Core Location call?" |
Bar per repo:
useState → re-render flow still resolves — existing react synthesizer not regressed).| Tier | Repo | Why |
|---|---|---|
| Small/Medium | expo/expo — one SDK module like expo-camera or expo-location |
The cleanest Expo Modules API examples; live |
| Large | full expo/expo monorepo (all SDK modules + the JS API) |
Stress-test module-name resolution across many packages |
Canonical flow: "How does await Camera.takePictureAsync() (JS) reach the
native camera API call (Swift AVCaptureSession or Kotlin
CameraDevice)?"
Per the playbook's difficulty gradient and the half-bridge rule, the order is fixed by what closes a flow end-to-end on the smallest repo first.
Smallest scope, deterministic name mapping, no JS involved. Validate on the Charts/Realm/Wikipedia corpus before moving on. Don't proceed to Phase 2 until Phase 1 passes the §5a bar on all three repos.
Both iOS and Android sides must close in the same PR — half-bridging one platform reveals the half-coverage hop on the other and the agent reads. Validate on the §5b corpus.
Extends the existing callback synthesizer with a cross-language channel. Validate on the same §5b corpus (most RN libs use at least one event emitter).
Layered on Phase 1's Swift extraction. Smaller corpus (§5c).
Requires reading the spec file as cross-language ground truth. Validate on the §5b corpus's TurboModule users (react-native main, post-0.73 libs).
Deferred — composes with the existing JSX synthesizer and the view side of TurboModules. Address when ≥1 of the §5b corpus repos has its bridge otherwise closed but a Fabric flow still breaks.
@ReactMethod
annotation's literal name we may add a tiny extractor refinement, but we
do not redesign JVM extraction.NativeModules[someVar],
requireNativeModule(name) where name is a parameter, etc. We only
resolve literal-key access (matches the
agent-eval Lua frontier — anonymous-only patterns deferred)..h files
(already does via #165's content sniff) but we do not parse the
bridging header's #import list as a special "what's visible to Swift"
manifest. Treat it as a normal ObjC header.performSelector: — out of scope; matches the
same "named-only" anti-goal.Host* interface that has no documented
declarative spec. Wait for those apps to migrate to TurboModules.@objc, so
they go through the same Phase 1 path. Generics are not — we silently
miss them. Acceptable; matches Java/Kotlin generics frontier.| Language | Framework | Canonical flow | Mechanism | Status |
|---|---|---|---|---|
| Swift × Objective-C | bridging | Swift call → ObjC selector; ObjC call → @objc Swift method | R | ✅ Phase 1 (§8a) |
| JavaScript × Objective-C/Java/Kotlin | React Native legacy bridge | NativeModules.<M>.<f> → RCT_EXPORT_METHOD / @ReactMethod |
R | ✅ Phase 2 (§8b) |
| JavaScript × native | React Native TurboModules | spec interface ↔ impl | R (spec as ground truth) | ✅ partial — name-match path lands (§8b) |
| Objective-C/Java/Kotlin → JavaScript | React Native event emitters | [self sendEventWithName:] → addListener |
S (cross-lang channel) | ✅ Phase 3 (§8e) |
| JavaScript × Swift/Kotlin | Expo Modules | requireNativeModule('X').fn(...) → Function("fn") { } |
R (extract synthesizes method nodes) | ✅ Phase 4 (§8f) |
| JavaScript × native | React Native Fabric views | <MyView p=v/> → RCT_EXPORT_VIEW_PROPERTY / Codegen view spec |
R + JSX | ⬜ Phase 6 (defer) |
| Repo | Source files | Bridge edges (framework-resolved) | Sample edges |
|---|---|---|---|
| Charts (small) | 269 (205 Swift + 59 ObjC/.h) | 28 objc→swift, 1 swift→objc | handleOption:forChartView: → animate · setupPieChartView: → setExtraOffsets · setDataCount:range: → setColor |
| realm-swift (medium) | 369 (151 Swift + 218 ObjC family) | 36 objc→swift, 1185 swift→objc | valueForUndefinedKey: → get · setValue:forUndefinedKey: → set · promote:on: → initialize |
| wikipedia-ios (large) | 1734 (1234 Swift + 500 ObjC/.h) | 52 objc→swift, 983 swift→objc | real-iOS-app bridging across many feature modules |
All three: in-language baselines unchanged, no node-count explosion,
trace connects canonical flows across the boundary (verified on
Charts: trace(handleOption:forChartView:, animate) surfaces the
bridge edge directly).
| Repo | Source files | Bridge edges (framework-resolved) | Notes |
|---|---|---|---|
| react-native-svg (small/medium) | ~700 (93 .mm + 115 .java + 6 .kt + 49 js + 92 ts + 154 tsx) | 9 tsx→java via TurboModule spec | RNSvg's iOS uses TurboModule auto-gen (no RCT_EXPORT_METHOD); resolutions land on Java. All 9 precise: isPointInStroke, isPointInFill, getTotalLength, getPointAtLength, getCTM, getScreenCTM, getBBox, toDataURL. |
| AsyncStorage (small, pure legacy bridge) | ~60 (28 kt + 2 mm + 16 ts + 14 tsx + …) | 8/8 precise | The canonical legacy bridge test — Kotlin @ReactMethod + ObjC RCT_EXPORT_METHOD. JS setItem → Kotlin legacy_multiSet; getItem → legacy_multiGet; clear → legacy_clear; etc. |
| react-native-firebase (large) | ~1100 (111 .java + 63 .m + 13 .mm + 239 js + 427 ts + 9 tsx) | 18 after RCTEventEmitter blocklist (was 78 before) | Initial 78 included 60 false positives targeting addListener: / remove: (every RCTEventEmitter declares them; every JS call to .addListener(...) resolved into noise). Blocklist cut to 18, all precise: httpsCallable:region:emulatorHost:..., signInWithProvider, configureProvider, removeFunctionsStreaming:. |
| react-native-screens (medium) | 1211 | 0 — empty TurboModule spec, no RCT_EXPORT_METHOD, all Fabric/Codegen view-side |
RNScreens lives entirely in Phase 6 (Fabric, deferred). The bridge declining to over-match here is the right behavior. |
The resolver's initialize() runs at CodeGraph construction — before any
files are indexed — so framework resolvers whose detect() consults
the indexed file list (UIKit / SwiftUI scanning for imports,
swift-objc-bridge looking for both Swift and ObjC files,
react-native-bridge looking for RN markers) all returned false on that
initial pass and silently dropped themselves. This affected every
framework resolver in the codebase that read context.getAllFiles() /
context.readFile() rather than scanning the filesystem directly — a
pre-existing latent bug, not bridge-specific. Fixed: indexAll() now
calls resolver.initialize() after extraction completes, so detect()
runs against the populated index.
| Bridge | Blocked names | Reason |
|---|---|---|
| swift-objc | init, description, hash, isEqual, copy, count, value, data, string, object, add, remove, update, load, save, reload, cancel, start, stop, pause, resume, close, open, show, hide, dealloc, release, retain, autorelease, … |
Every NSObject subclass implements these; bridging them to arbitrary project-local ObjC methods produces noise. Regular name-matcher handles them on its own. |
| react-native | addListener, removeListeners, remove, invalidate, startObserving, stopObserving |
Every RCTEventEmitter subclass declares these via RCT_EXPORT_METHOD. JS callers of .addListener(...) / .remove(...) go through NativeEventEmitter (JS abstraction), not the native bridge directly. |
Synthesizer pattern; extends src/resolution/callback-synthesizer.ts with a
cross-language event channel keyed by literal event name. Validates on
RNFirebase (large):
| Synthesized event channel | Edges | Sample |
|---|---|---|
messaging_message_received |
2 | application:didReceiveRemoteNotification:fetchCompletionHandler: → TS onMessage (and the UNUserNotificationCenter willPresent variant → same onMessage) |
messaging_notification_opened |
1 | userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler: → TS onNotificationOpenedApp |
Each edge is provenance:'heuristic',
metadata.synthesizedBy:'rn-event-channel'. Same EVENT_FANOUT_CAP = 6
as the in-language channel — generic event names with too many handlers
or dispatchers skip rather than over-link.
The synthesizer also handles the subscribe-wrapper pattern common in
RN libraries (messaging().onMessage(listener) where listener is a
parameter that flows up to user code): when the JS handler arg isn't a
named symbol, it attributes the listener to the ENCLOSING JS function
(reachability-correct, attributes to the abstraction layer).
Framework extract() parses Swift / Kotlin source for literal
Function("X") { … } / AsyncFunction("X") { … } / Property("X") { … }
/ Constants declarations inside class X: Module (or : Module() in
Kotlin) and emits a method node named X per literal. The standard
name-matcher resolves JS callsites like Foo.takePictureAsync(...) to
these synthetic nodes via the existing obj.method → method-name path.
Validated on real Expo SDK packages:
| Package | Files indexed | Expo method nodes extracted | Cross-language edges |
|---|---|---|---|
| expo-haptics | 14 | 6 (3 Swift + 3 Kotlin: notificationAsync, impactAsync, selectionAsync / performHapticsAsync) |
Module nodes registered; consumer-app callers resolve via name-match |
| expo-camera | 72 | 41 (Swift + Kotlin; covers takePictureAsync, record, resumePreview, getAvailableLenses, scanFromURLAsync, requestCameraPermissionsAsync, view-side width / height properties, …) |
9 swift→expo, 7 kotlin→expo internal edges. JS-side callsites in the package shadow the native names with TS wrappers (pausePreview() defined on CameraView.tsx); name-match correctly prefers the local TS method. An external consumer app of Camera.takePictureAsync() resolves through to the native method directly. |
Five tests cover the extractor + an end-to-end fixture:
JS callsite of literal AsyncFunction("uniqueExpoHapticCall") resolves
to the native impl node — confirms the resolver-free bridge path
works when names aren't shadowed.
These are not blocking the start of Phase 1 — they're the first things to decide while writing the Swift↔ObjC resolver:
references edge between matching nodes)
is more explicit in trace output but adds N edges per @objc symbol.
Default: alias. Verify the alias surfaces in callers/callees/trace
results.trace display a cross-language hop? The MCP trace tool
inlines each hop's body. A Swift → ObjC hop should make this obvious in
the rendered output ("Swift func foo(bar:) → bridged to ObjC selector
-fooWithBar: → ObjC -[ImageDownloader fooWithBar:]"). Will likely
need a small renderer tweak in trace.ts to label the bridge.src/resolution/frameworks/swift-objc.ts for the auto-name mapping (a
pure function) imported by both the Swift extractor (to compute the
alias at extract time) and tests. Keeps the mapping in one place.@objcMembers? Class-level export — applies to all members
unless @nonobjc. Handle by checking the class's modifiers in the Swift
extractor and defaulting each member's @objc-ness from that.Phase 1 (Swift↔ObjC) is done when:
[Unreleased] entry exists, written user-side.Each subsequent Phase has the same shape — its own §5 corpus, its own matrix row, its own CHANGELOG entry — and doesn't ship until the previous one passes. Half-bridges are not optional to avoid here; they actively make codegraph worse on these codebases than not having any bridging at all.