Просмотр исходного кода

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:b:` 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)
Colby Mchenry 4 недель назад
Родитель
Сommit
4d1a2b3c4d

+ 19 - 0
.claude/skills/agent-eval/corpus.json

@@ -74,5 +74,24 @@
     { "name": "Masonry", "repo": "https://github.com/SnapKit/Masonry", "size": "Small", "files": "~50", "question": "How does Masonry build and activate Auto Layout constraints from its block DSL?" },
     { "name": "FMDB", "repo": "https://github.com/ccgus/fmdb", "size": "Medium", "files": "~80", "question": "How does FMDB execute a prepared SQL statement and bind parameters?" },
     { "name": "SDWebImage", "repo": "https://github.com/SDWebImage/SDWebImage", "size": "Large", "files": "~400", "question": "How does SDWebImage download, cache, and decode an image for a UIImageView?" }
+  ],
+  "Mixed iOS (Swift+ObjC)": [
+    { "name": "Charts", "repo": "https://github.com/danielgindi/Charts", "size": "Small", "files": "~270", "question": "How does the ChartsDemo ObjC demo controller drive the Swift Charts library to animate and notify a data update?" },
+    { "name": "realm-swift", "repo": "https://github.com/realm/realm-swift", "size": "Medium", "files": "~370", "question": "How does a Swift `Realm.write { realm.add(obj) }` reach the Objective-C persistence layer?" },
+    { "name": "wikipedia-ios", "repo": "https://github.com/wikimedia/wikipedia-ios", "size": "Large", "files": "~1700", "question": "How does tapping a search result reach the article-fetch network call across the Swift / ObjC boundary?" }
+  ],
+  "React Native (legacy bridge + TurboModule)": [
+    { "name": "@react-native-async-storage", "repo": "https://github.com/react-native-async-storage/async-storage", "size": "Small", "files": "~60", "question": "How does `setItem` in JS reach the native `legacy_multiSet` implementation?" },
+    { "name": "react-native-svg", "repo": "https://github.com/software-mansion/react-native-svg", "size": "Medium", "files": "~700", "question": "How does a JS `Svg.getTotalLength(...)` reach the iOS / Android native implementation via TurboModule?" },
+    { "name": "react-native-firebase", "repo": "https://github.com/invertase/react-native-firebase", "size": "Large", "files": "~1100", "question": "How does a native iOS push notification reach the JS `messaging().onMessage(...)` listener?" }
+  ],
+  "Expo Modules": [
+    { "name": "expo-haptics", "repo": "https://github.com/expo/expo/tree/main/packages/expo-haptics", "size": "Small", "files": "~15", "question": "How does `Haptics.notificationAsync(...)` in JS reach `UINotificationFeedbackGenerator` in the Swift Module?" },
+    { "name": "expo-camera", "repo": "https://github.com/expo/expo/tree/main/packages/expo-camera", "size": "Medium", "files": "~70", "question": "How does a JS `CameraView.takePictureAsync(options)` reach the native AVCaptureSession / CameraDevice call?" }
+  ],
+  "React Native Fabric (view components)": [
+    { "name": "react-native-segmented-control", "repo": "https://github.com/react-native-segmented-control/segmented-control", "size": "Small", "files": "~25", "question": "How does JSX `<SegmentedControl onChange={cb}/>` reach the native onChange handler on iOS/Android?" },
+    { "name": "react-native-screens", "repo": "https://github.com/software-mansion/react-native-screens", "size": "Medium", "files": "~1200", "question": "How does JSX `<ScreenStack>` reach the native RNSScreenStackView component?" },
+    { "name": "react-native-skia", "repo": "https://github.com/Shopify/react-native-skia", "size": "Large", "files": "~1000", "question": "How does a `<SkiaPictureView/>` JSX usage reach the iOS / Android native renderer?" }
   ]
 }

+ 85 - 0
CHANGELOG.md

@@ -84,6 +84,91 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   bridge) is **not** in scope for this entry — that's a separate effort
   tracked under the dynamic-dispatch coverage playbook.
 
+- **Mixed iOS, React Native, and Expo cross-language bridging.** Real iOS
+  and React Native codebases live across multiple languages — a Swift caller
+  invokes an Objective-C selector that's been auto-bridged, JS calls into a
+  native module via the React Native bridge, JSX delegates to a native view
+  manager. Static tree-sitter extraction stops at each boundary. CodeGraph
+  now bridges them so `trace` / `callers` / `callees` / `impact` connect
+  end-to-end across the gap. Closes the iOS/RN parts of the request thread
+  for #401. Bridges added:
+
+  - **Swift ↔ Objective-C.** Swift `@objc` auto-bridging rules
+    (`func play(song:)` ↔ ObjC `-playWithSong:`, `init(name:, age:)` ↔
+    `-initWithName:age:`, `var x: T` ↔ `-x`/`-setX:`, `@objc(custom:)`
+    overrides) plus the Cocoa preposition-prefix forms that reverse-import
+    natively (`objectForKey:`, `stringWithFormat:`, etc.). Validated on
+    Charts (28 / 1 bridge edges objc→swift / swift→objc), realm-swift
+    (36 / 1185), wikipedia-ios (52 / 983). The high-confidence direction
+    is ObjC→Swift, since Swift callsites carry the bare method name only
+    and many overlap with Cocoa built-ins.
+
+  - **React Native legacy bridge + TurboModules.** Parses
+    `RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` (ObjC
+    & ObjC++) and `@ReactMethod` (Java/Kotlin) declarations; treats
+    `Native<X>.ts` TurboModule spec files as ground truth. A JS callsite
+    of `NativeModules.X.fn(...)` or `import X from './NativeX'; X.fn(...)`
+    resolves to the matching native method. Validated on AsyncStorage
+    (8/8 precise), react-native-svg (9 TurboModule bridges to Java),
+    react-native-firebase (18 precise after `RCTEventEmitter` built-in
+    blocklist).
+
+  - **Native → JS event channel.** Synthesizes cross-language edges
+    keyed by literal event name: ObjC `sendEventWithName:@"X"` /
+    Swift `sendEvent(withName: "X", ...)` / Java/Kotlin `.emit("X", ...)`
+    → JS `new NativeEventEmitter(...).addListener("X", handler)`.
+    Falls back to attributing the JS endpoint to an enclosing
+    `constant`/`variable` for the very common
+    `const Foo = { watchX(listener) { ... addListener('X', listener) } }`
+    wrapper-API pattern. Validated on RNFirebase (3 push-notification
+    flow edges) and RNGeolocation (2 location-event edges).
+
+  - **Expo Modules.** Parses Swift/Kotlin Expo DSL —
+    `Module { Name("X"); Function("y") { ... }; AsyncFunction("z") { ... };
+    Property("w") { ... } }` — and synthesizes `method` nodes named after
+    each declaration. JS callsites of `requireNativeModule('X').y(...)`
+    then resolve via existing name-match. Validated on expo-haptics
+    (6 method nodes across Swift + Kotlin), expo-camera (41 covering the
+    full SDK surface), and a 7-package Expo sweep (134 method nodes).
+
+  - **Fabric / Codegen + legacy Paper view components.** Parses TS
+    `codegenNativeComponent<NativeProps>('Name', ...)` Codegen specs AND
+    legacy `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp` view-manager
+    macros. Emits a `component` node per declaration and a `property`
+    node per declared prop, then a synthesizer links the component to
+    its native impl class by convention-based name+suffix
+    (`View`/`ComponentView`/`Manager`/`ViewManager`). The existing JSX
+    synthesizer then connects consumer JSX `<MyView/>` → component →
+    native class. Validated on react-native-segmented-control
+    (legacy Paper — 1 component, 11 props, 4 bridges),
+    react-native-screens (Codegen Fabric — 27 components, 272 props,
+    68 bridges), and react-native-skia (hybrid, monorepo — 5 components,
+    14 props, 15 bridges across Codegen TS specs + Android Java
+    ViewManagers + iOS ObjC).
+
+  Each bridge emits `provenance:'heuristic'` edges with a stable
+  `metadata.synthesizedBy:` channel name (`swift-objc-bridge`,
+  `react-native-bridge`, `rn-event-channel`, `fabric-native-impl`,
+  `expo-modules`) so an agent can tell at a glance how a cross-language
+  hop got into the graph. Per-bridge precision blocklists prevent
+  noisy over-linking on generic Cocoa names (`init`, `description`,
+  `count`, …) and RN event-emitter built-ins (`addListener`, `remove`,
+  …) that every NSObject / RCTEventEmitter subclass exposes.
+
+  Architectural fix surfaced during validation: the resolver's
+  `initialize()` runs at CodeGraph construction (before any files are
+  indexed), so framework resolvers whose `detect()` consults the
+  indexed file list silently dropped themselves. `indexAll()` now
+  re-initializes the resolver after extraction so all frameworks see
+  the populated index — a pre-existing latent bug that also affected
+  the UIKit and SwiftUI resolvers.
+
+  Out of scope for this round: bare JSI (non-TurboModule), dynamic
+  bridge keys (`NativeModules[someVar]`), Android-Java extraction
+  improvements beyond name-match (we use whatever the existing Java
+  extractor produces). Anti-goals documented in
+  `docs/design/mixed-ios-and-react-native-bridging.md`.
+
 ### Fixed
 - **Git worktrees no longer silently borrow another tree's index (#155).**
   When a worktree is nested inside the main checkout — exactly what agent

+ 30 - 0
README.md

@@ -137,6 +137,7 @@ The gains scale with codebase size: on large repos the agent answers from the in
 | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
 | **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
 | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 frameworks |
+| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
 | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
 
 ---
@@ -164,6 +165,35 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
 
 ---
 
+## Mixed iOS / React Native / Expo bridging
+
+Real iOS and React Native codebases live across multiple languages — a Swift caller invokes an Objective-C selector that's been auto-bridged, a JS file calls into a native module via the React Native bridge, a JSX component delegates to a native view manager. Static tree-sitter extraction stops at each language boundary. CodeGraph bridges them so `trace`, `callers`, `callees`, and `impact` connect end-to-end across the gap.
+
+| Boundary | JS / Swift side | Native side | How |
+|---|---|---|---|
+| **Swift → ObjC** | Swift `obj.foo(bar:)` | ObjC selector `-fooWithBar:` | `@objc` auto-bridging rules (including init/property/protocol forms) + Cocoa preposition prefixes (`With`/`For`/`By`/`In`/`On`/`At`/…) |
+| **ObjC → Swift** | ObjC `[obj fooWithBar:]` | Swift `@objc func foo(bar:)` | Reverse-bridge name candidates; verifies `@objc` exposure from source |
+| **React Native legacy bridge** | JS `NativeModules.X.fn(...)` | ObjC `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` · Java/Kotlin `@ReactMethod` | Parses macro/annotation declarations to build a JS-name → native-method map |
+| **React Native TurboModules** | JS `import M from './NativeM'; M.fn(...)` | Native impl matching the Codegen spec | Treats the `Native<X>.ts` spec interface as ground truth |
+| **RN native → JS events** | JS `new NativeEventEmitter(...).addListener('e', cb)` | ObjC `[self sendEventWithName:@"e" body:...]` · Swift `sendEvent(withName: "e", ...)` · Java/Kotlin `.emit("e", ...)` | Synthesized cross-language event channel keyed by literal event name |
+| **Expo Modules** | JS `requireNativeModule('X').fn(...)` | Swift / Kotlin `Module { Name("X"); AsyncFunction("fn") { ... } }` | Parses the Expo DSL literals; synthetic method nodes resolve via existing name-match |
+| **Fabric view components** | JSX `<MyView prop={v}/>` | TS Codegen spec + native impl class | Spec → `component` node; convention-based name+suffix lookup (`View`/`ComponentView`/`Manager`/`ViewManager`) bridges to native |
+| **Legacy Paper view managers** | JSX `<MyView prop={v}/>` | ObjC `RCT_EXPORT_VIEW_PROPERTY` · Java/Kotlin `@ReactProp` | Same as Fabric — Paper-era declarations also produce `component` + `property` nodes |
+
+**Validated on real codebases** (small + medium + large for each bridge):
+
+| Bridge | Small | Medium | Large |
+|---|---|---|---|
+| Swift ↔ ObjC | [Charts](https://github.com/danielgindi/Charts) | [realm-swift](https://github.com/realm/realm-swift) | [Wikipedia-iOS](https://github.com/wikimedia/wikipedia-ios) |
+| RN legacy bridge | [AsyncStorage](https://github.com/react-native-async-storage/async-storage) | [react-native-svg](https://github.com/software-mansion/react-native-svg) | [react-native-firebase](https://github.com/invertase/react-native-firebase) |
+| RN native → JS events | [RNGeolocation](https://github.com/Agontuk/react-native-geolocation-service) | — | react-native-firebase |
+| Expo Modules | expo-haptics | expo-camera | expo SDK sweep (7 packages) |
+| Fabric / Paper views | [react-native-segmented-control](https://github.com/react-native-segmented-control/segmented-control) | [react-native-screens](https://github.com/software-mansion/react-native-screens) | [react-native-skia](https://github.com/Shopify/react-native-skia) |
+
+Each bridge emits edges tagged `provenance:'heuristic'` with `metadata.synthesizedBy:` set to a stable channel name (e.g. `swift-objc-bridge`, `rn-event-channel`, `fabric-native-impl`, `expo-module-extract`), so the agent can tell at a glance how a hop got into the graph.
+
+---
+
 ## Quick Start
 
 ### 1. Run the Installer

+ 154 - 0
__tests__/expo-modules.test.ts

@@ -0,0 +1,154 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+import { expoModulesResolver } from '../src/resolution/frameworks/expo-modules';
+
+describe('Expo Modules framework extractor', () => {
+  it('extracts AsyncFunction / Function / Property literals as method nodes', () => {
+    const source = `
+import ExpoModulesCore
+
+public class HapticsModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("ExpoHaptics")
+
+    AsyncFunction("notificationAsync") { (notificationType: NotificationType) in
+      // body
+    }
+
+    AsyncFunction("impactAsync") { (style: ImpactStyle) in
+      // body
+    }
+
+    Function("synchronousThing") {
+      return 1
+    }
+
+    Property("isAvailable") {
+      return true
+    }
+  }
+}
+`;
+    const result = expoModulesResolver.extract?.('ios/HapticsModule.swift', source);
+    expect(result).toBeDefined();
+    const names = result!.nodes.map((n) => n.name);
+    expect(names).toEqual(
+      expect.arrayContaining(['notificationAsync', 'impactAsync', 'synchronousThing', 'isAvailable'])
+    );
+    expect(result!.nodes.every((n) => n.kind === 'method')).toBe(true);
+    expect(result!.nodes.every((n) => n.qualifiedName.includes('ExpoHaptics.'))).toBe(true);
+  });
+
+  it('falls back to the class name when the Module has no Name("X") literal', () => {
+    const source = `
+public class BareModule: Module {
+  public func definition() -> ModuleDefinition {
+    Function("doX") { return 1 }
+  }
+}
+`;
+    const result = expoModulesResolver.extract?.('ios/BareModule.swift', source);
+    // BareModule is used as the qualifier since there's no Name() literal.
+    expect(result!.nodes[0]?.qualifiedName).toContain('BareModule.doX');
+  });
+
+  it('returns no nodes for a Swift file that is not an Expo Module', () => {
+    const source = `
+class Helper {
+  func doX() { }
+}
+`;
+    const result = expoModulesResolver.extract?.('Helper.swift', source);
+    expect(result?.nodes).toHaveLength(0);
+  });
+
+  it('also extracts from Kotlin module files', () => {
+    const source = `
+class FooModule : Module() {
+    override fun definition() = ModuleDefinition {
+        Name("ExpoFoo")
+        AsyncFunction("doAsync") { name: String -> name.uppercase() }
+        Function("doSync") { 42 }
+    }
+}
+`;
+    const result = expoModulesResolver.extract?.('FooModule.kt', source);
+    expect(result?.nodes.length).toBe(2);
+    expect(result?.nodes.map((n) => n.name).sort()).toEqual(['doAsync', 'doSync']);
+    expect(result?.nodes.every((n) => n.language === 'kotlin')).toBe(true);
+  });
+});
+
+describe('Expo Modules end-to-end — JS caller → native AsyncFunction', () => {
+  let dir: string;
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-modules-fixture-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('JS callsite of a literal AsyncFunction("name") resolves to the native impl node', 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', 'HapticsModule.swift'),
+      `
+import ExpoModulesCore
+public class HapticsModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("ExpoHaptics")
+    AsyncFunction("uniqueExpoHapticCall") { in /* … */ }
+  }
+}
+`
+    );
+    fs.mkdirSync(path.join(dir, 'src'));
+    fs.writeFileSync(
+      path.join(dir, 'src', 'index.ts'),
+      `
+import { requireNativeModule } from 'expo-modules-core';
+const Haptics = requireNativeModule('ExpoHaptics');
+export async function impactAsync() {
+  return await Haptics.uniqueExpoHapticCall();
+}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    // The native method node should exist.
+    const native = db
+      .prepare(
+        "SELECT * FROM nodes WHERE kind='method' AND name='uniqueExpoHapticCall' AND id LIKE 'expo-module:%'"
+      )
+      .all();
+    expect(native).toHaveLength(1);
+
+    // And the JS callsite should produce a call edge targeting it.
+    const callEdge = db
+      .prepare(
+        `SELECT t.name target, t.id target_id
+         FROM edges e
+         JOIN nodes s ON s.id = e.source
+         JOIN nodes t ON t.id = e.target
+         WHERE e.kind = 'calls'
+           AND s.file_path LIKE '%index.ts'
+           AND t.name = 'uniqueExpoHapticCall'`
+      )
+      .all();
+    cg.close?.();
+    expect(callEdge.length).toBeGreaterThanOrEqual(1);
+    expect(callEdge[0].target_id.startsWith('expo-module:')).toBe(true);
+  });
+});

+ 29 - 0
__tests__/extraction.test.ts

@@ -3987,6 +3987,35 @@ void helperFunction(int count) {
     expect(calls).toEqual(expect.arrayContaining(['NSLog', 'doWork', 'MyClass.shared', 'obj.greet']));
   });
 
+  it('should reconstruct multi-keyword selectors at the call site so they resolve to the method definition', () => {
+    // Regression for the gap discovered post-#165: message_expression's
+    // multi-keyword form `[obj a:1 b:2]` was only emitting the first keyword,
+    // so calls never resolved to multi-part method definitions like
+    // `GET:parameters:headers:progress:success:failure:`. The call-site name
+    // must match the method-definition name with full keywords + trailing colons.
+    const code = `
+@implementation Caller
+- (void)demo {
+    NSMutableDictionary *d = [NSMutableDictionary new];
+    [d setObject:@"v" forKey:@"k"];
+    [d setObject:@"v2" forKey:@"k2" withRetry:@YES];
+    [self touchesBegan:nil withEvent:nil];
+}
+@end
+`;
+    const result = extractFromSource('Caller.m', code);
+    const calls = result.unresolvedReferences
+      .filter((r) => r.referenceKind === 'calls')
+      .map((r) => r.referenceName);
+    expect(calls).toEqual(
+      expect.arrayContaining([
+        'd.setObject:forKey:',
+        'd.setObject:forKey:withRetry:',
+        'touchesBegan:withEvent:',
+      ])
+    );
+  });
+
   it('should not classify pure C headers with @end in comments as objc', () => {
     const cHeader = '/* @end of file */\n#ifndef STDIO_H\nvoid printf(const char *);\n#endif\n';
     expect(detectLanguage('stdio.h', cHeader)).toBe('c');

+ 144 - 0
__tests__/fabric-view.test.ts

@@ -0,0 +1,144 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+import { fabricViewResolver } from '../src/resolution/frameworks/fabric';
+
+describe('Fabric view component extractor (codegenNativeComponent specs)', () => {
+  it('extracts a component node + prop nodes from a Native*.ts spec', () => {
+    const source = `
+'use client';
+import { codegenNativeComponent } from 'react-native';
+import type { ViewProps, CodegenTypes as CT, ColorValue } from 'react-native';
+
+type TapEvent = Readonly<{ x: number; y: number }>;
+
+export interface NativeProps extends ViewProps {
+  color?: ColorValue;
+  onTap?: CT.DirectEventHandler<TapEvent>;
+  caption?: string;
+}
+
+export default codegenNativeComponent<NativeProps>('MyView', {});
+`;
+    const result = fabricViewResolver.extract?.('src/MyViewNativeComponent.ts', source);
+    expect(result).toBeDefined();
+    const componentNodes = result!.nodes.filter((n) => n.kind === 'component');
+    const propNodes = result!.nodes.filter((n) => n.kind === 'property');
+    expect(componentNodes).toHaveLength(1);
+    expect(componentNodes[0]?.name).toBe('MyView');
+    expect(propNodes.map((n) => n.name).sort()).toEqual(['caption', 'color', 'onTap']);
+  });
+
+  it('returns nothing for a file without codegenNativeComponent', () => {
+    const source = `export const x = 1;`;
+    const result = fabricViewResolver.extract?.('plain.ts', source);
+    expect(result?.nodes).toHaveLength(0);
+  });
+
+  it('handles a spec with no NativeProps interface (rare but valid)', () => {
+    const source = `
+import { codegenNativeComponent } from 'react-native';
+export default codegenNativeComponent('BareComponent');
+`;
+    const result = fabricViewResolver.extract?.('Bare.ts', source);
+    // Component node exists; no prop nodes.
+    const components = result!.nodes.filter((n) => n.kind === 'component');
+    const props = result!.nodes.filter((n) => n.kind === 'property');
+    expect(components).toHaveLength(1);
+    expect(components[0]?.name).toBe('BareComponent');
+    expect(props).toHaveLength(0);
+  });
+});
+
+describe('Fabric end-to-end: JSX consumer → Fabric component → native class', () => {
+  let dir: string;
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-fixture-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('connects <MyView/> JSX to the native ObjC class via Fabric synthesizer', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'package.json'),
+      '{"dependencies":{"react-native":"^0.73"}}'
+    );
+    // Fabric spec.
+    fs.mkdirSync(path.join(dir, 'spec'));
+    fs.writeFileSync(
+      path.join(dir, 'spec', 'MyViewNativeComponent.ts'),
+      `import { codegenNativeComponent } from 'react-native';
+import type { ViewProps } from 'react-native';
+export interface NativeProps extends ViewProps { color?: string; }
+export default codegenNativeComponent<NativeProps>('MyView');`
+    );
+    // Native iOS implementation — class named with the `View` suffix
+    // convention.
+    fs.mkdirSync(path.join(dir, 'ios'));
+    fs.writeFileSync(
+      path.join(dir, 'ios', 'MyView.mm'),
+      `@interface MyViewView : UIView
+@end
+@implementation MyViewView
+- (void)setColor:(NSString *)c { /* … */ }
+@end`
+    );
+    // JSX consumer.
+    fs.mkdirSync(path.join(dir, 'src'));
+    fs.writeFileSync(
+      path.join(dir, 'src', 'App.tsx'),
+      `import React from 'react';
+import MyView from '../spec/MyViewNativeComponent';
+export function App() {
+  return <MyView color="red"/>;
+}`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    // 1. The Fabric component node exists.
+    const componentRows = db
+      .prepare("SELECT id, name, kind FROM nodes WHERE id LIKE 'fabric-component:%' AND name='MyView'")
+      .all();
+    expect(componentRows).toHaveLength(1);
+
+    // 2. The native class node exists.
+    const nativeRows = db
+      .prepare("SELECT id, name FROM nodes WHERE kind='class' AND language='objc' AND name='MyViewView'")
+      .all();
+    expect(nativeRows).toHaveLength(1);
+
+    // 3. Fabric synthesizer bridges component → native class.
+    const bridgeRows = db
+      .prepare(
+        `SELECT s.name comp, t.name native FROM edges e
+         JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy')='fabric-native-impl'
+           AND s.name='MyView' AND t.name='MyViewView'`
+      )
+      .all();
+    expect(bridgeRows).toHaveLength(1);
+
+    // 4. JSX synthesizer links the App function → the Fabric component
+    //    (jsx-render edge keyed on the tag name 'MyView').
+    const jsxRows = db
+      .prepare(
+        `SELECT s.name caller, t.name comp FROM edges e
+         JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy')='jsx-render'
+           AND t.id LIKE 'fabric-component:%' AND t.name='MyView'`
+      )
+      .all();
+    cg.close?.();
+    expect(jsxRows.length).toBeGreaterThanOrEqual(1);
+    expect(jsxRows[0].caller).toBe('App');
+    // The full flow: App (TSX) → MyView (fabric-component) → MyViewView (ObjC native class)
+  });
+});

+ 294 - 0
__tests__/react-native-bridge.test.ts

@@ -0,0 +1,294 @@
+import { describe, it, expect } from 'vitest';
+import type { Node, Language } from '../src/types';
+import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
+import { reactNativeBridgeResolver } from '../src/resolution/frameworks/react-native';
+
+/**
+ * Mock ResolutionContext for the React Native bridge resolver.
+ */
+function makeContext(nodes: Node[], fileContents: Record<string, string> = {}): ResolutionContext {
+  const byName = new Map<string, Node[]>();
+  for (const n of nodes) {
+    const arr = byName.get(n.name);
+    if (arr) arr.push(n);
+    else byName.set(n.name, [n]);
+  }
+  // Files = union of node files + any extra fileContents keys (for files that
+  // have content like .mm bridge declarations but no extracted nodes yet).
+  const allFiles = new Set<string>(
+    [...nodes.map((n) => n.filePath), ...Object.keys(fileContents)]
+  );
+  return {
+    getNodesInFile: (fp) => nodes.filter((n) => n.filePath === fp),
+    getNodesByName: (name) => byName.get(name) ?? [],
+    getNodesByQualifiedName: () => { throw new Error('not used'); },
+    getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind),
+    getNodesByLowerName: () => { throw new Error('not used'); },
+    fileExists: (fp) => allFiles.has(fp),
+    readFile: (fp) => fileContents[fp] ?? null,
+    getProjectRoot: () => '/test',
+    getAllFiles: () => Array.from(allFiles),
+    getImportMappings: () => [],
+  };
+}
+
+function method(
+  name: string,
+  language: Language,
+  filePath: string,
+  startLine = 10
+): Node {
+  return {
+    id: `${language}:${filePath}:${name}:${startLine}`,
+    kind: 'method',
+    name,
+    qualifiedName: `${filePath}::${name}`,
+    filePath,
+    language,
+    startLine,
+    endLine: startLine + 5,
+    startColumn: 0,
+    endColumn: 0,
+    updatedAt: Date.now(),
+  } as Node;
+}
+
+function ref(name: string, language: Language, filePath: string): UnresolvedRef {
+  return {
+    fromNodeId: `caller:${filePath}`,
+    referenceName: name,
+    referenceKind: 'calls',
+    line: 1,
+    column: 0,
+    filePath,
+    language,
+  };
+}
+
+describe('React Native bridge resolver', () => {
+  describe('detect()', () => {
+    it('returns true when package.json declares react-native', () => {
+      const ctx = makeContext([], {
+        'package.json':
+          '{"name":"x","dependencies":{"react-native":"^0.73.0"}}',
+      });
+      expect(reactNativeBridgeResolver.detect(ctx)).toBe(true);
+    });
+
+    it('returns true when an ObjC file uses RCT_EXPORT_MODULE', () => {
+      const ctx = makeContext([], {
+        'NativeFoo.mm': '@implementation Foo\nRCT_EXPORT_MODULE()\n@end',
+      });
+      expect(reactNativeBridgeResolver.detect(ctx)).toBe(true);
+    });
+
+    it('returns true when a TS file uses TurboModuleRegistry', () => {
+      const ctx = makeContext([], {
+        'NativeFoo.ts':
+          "import { TurboModuleRegistry } from 'react-native';\n" +
+          "export default TurboModuleRegistry.getEnforcing<Spec>('Foo');",
+      });
+      expect(reactNativeBridgeResolver.detect(ctx)).toBe(true);
+    });
+
+    it('returns false when none of the RN signals are present', () => {
+      const ctx = makeContext([method('hi', 'objc', 'X.m')]);
+      expect(reactNativeBridgeResolver.detect(ctx)).toBe(false);
+    });
+  });
+
+  describe('legacy bridge — ObjC side', () => {
+    it('resolves JS callsite via RCT_EXPORT_METHOD with default module name', () => {
+      // RCTGeolocation → module name 'Geolocation' (RCT prefix stripped).
+      const native = method('getCurrentPosition:', 'objc', 'RCTGeolocation.m');
+      const ctx = makeContext([native], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'RCTGeolocation.m':
+          '@implementation RCTGeolocation\n' +
+          'RCT_EXPORT_MODULE()\n' +
+          'RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb) {}\n' +
+          '@end',
+      });
+      const result = reactNativeBridgeResolver.resolve(
+        ref('getCurrentPosition', 'javascript', 'App.js'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(native.id);
+      expect(result?.resolvedBy).toBe('framework');
+    });
+
+    it('resolves via explicit module name in RCT_EXPORT_MODULE(name)', () => {
+      const native = method('startScan:', 'objc', 'Bluetooth.m');
+      const ctx = makeContext([native], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'Bluetooth.m':
+          '@implementation BluetoothImpl\n' +
+          'RCT_EXPORT_MODULE(BluetoothManager)\n' +
+          'RCT_EXPORT_METHOD(startScan:(RCTResponseSenderBlock)cb) {}\n' +
+          '@end',
+      });
+      const result = reactNativeBridgeResolver.resolve(
+        ref('startScan', 'javascript', 'App.js'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(native.id);
+    });
+
+    it('resolves RCT_REMAP_METHOD with JS-name override', () => {
+      const native = method('doInternalCompute:', 'objc', 'Computer.m');
+      const ctx = makeContext([native], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'Computer.m':
+          '@implementation Computer\n' +
+          'RCT_EXPORT_MODULE()\n' +
+          'RCT_REMAP_METHOD(compute, doInternalCompute:(NSDictionary *)opts) {}\n' +
+          '@end',
+      });
+      const result = reactNativeBridgeResolver.resolve(
+        ref('compute', 'javascript', 'App.js'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(native.id);
+    });
+  });
+
+  describe('legacy bridge — Java side', () => {
+    it('resolves @ReactMethod with getName() literal', () => {
+      const native = method('getCurrentPosition', 'java', 'GeolocationModule.java');
+      const ctx = makeContext([native], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'GeolocationModule.java':
+          'class GeolocationModule extends ReactContextBaseJavaModule {\n' +
+          '  @Override public String getName() { return "Geolocation"; }\n' +
+          '  @ReactMethod public void getCurrentPosition(Callback cb) {}\n' +
+          '}',
+      });
+      const result = reactNativeBridgeResolver.resolve(
+        ref('getCurrentPosition', 'javascript', 'App.js'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(native.id);
+    });
+
+    it('resolves Kotlin @ReactMethod fun', () => {
+      const native = method('startScan', 'kotlin', 'BluetoothModule.kt');
+      const ctx = makeContext([native], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'BluetoothModule.kt':
+          'class BluetoothModule(ctx: ReactApplicationContext) : ReactContextBaseJavaModule(ctx) {\n' +
+          '  override fun getName(): String = "BluetoothManager"\n' +
+          '  @ReactMethod fun startScan(cb: Callback) {}\n' +
+          '}',
+      });
+      const result = reactNativeBridgeResolver.resolve(
+        ref('startScan', 'javascript', 'App.js'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(native.id);
+    });
+  });
+
+  describe('TurboModule spec resolution', () => {
+    it('matches spec method to native ObjC implementation by name', () => {
+      // The Spec interface lists `getTotalLength`; ObjC has a method by the
+      // same first keyword. Bridge matches by name.
+      const native = method('getTotalLength:', 'objc', 'RNSVGRenderableManager.mm');
+      const ctx = makeContext([native], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'NativeSvgRenderableModule.ts':
+          "import { TurboModuleRegistry } from 'react-native';\n" +
+          'export interface Spec extends TurboModule {\n' +
+          '  getTotalLength(tag: number): number;\n' +
+          '  isPointInFill(tag: number, options?: object): boolean;\n' +
+          '}\n' +
+          "export default TurboModuleRegistry.getEnforcing<Spec>('RNSVGRenderableModule');",
+      });
+      const result = reactNativeBridgeResolver.resolve(
+        ref('getTotalLength', 'tsx', 'SvgComponent.tsx'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(native.id);
+    });
+
+    it('returns null when spec method has no matching native impl', () => {
+      const ctx = makeContext([], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'NativeFoo.ts':
+          "import { TurboModuleRegistry } from 'react-native';\n" +
+          'export interface Spec extends TurboModule {\n' +
+          '  thingThatDoesntExist(): void;\n' +
+          '}\n' +
+          "export default TurboModuleRegistry.getEnforcing<Spec>('Foo');",
+      });
+      const result = reactNativeBridgeResolver.resolve(
+        ref('thingThatDoesntExist', 'tsx', 'Caller.tsx'),
+        ctx
+      );
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('qualified vs bare callsite names', () => {
+    it('handles bare method name (post receiver-strip)', () => {
+      const native = method('compute:', 'objc', 'Mod.m');
+      const ctx = makeContext([native], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'Mod.m':
+          '@implementation Mod\nRCT_EXPORT_MODULE()\nRCT_EXPORT_METHOD(compute:(NSDictionary *)x) {}\n@end',
+      });
+      expect(
+        reactNativeBridgeResolver.resolve(ref('compute', 'javascript', 'App.js'), ctx)
+      ).not.toBeNull();
+    });
+
+    it('strips dot prefix on receiver-qualified callsite (NativeModules.Mod.compute → compute)', () => {
+      const native = method('compute:', 'objc', 'Mod.m');
+      const ctx = makeContext([native], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'Mod.m':
+          '@implementation Mod\nRCT_EXPORT_MODULE()\nRCT_EXPORT_METHOD(compute:(NSDictionary *)x) {}\n@end',
+      });
+      expect(
+        reactNativeBridgeResolver.resolve(
+          ref('NativeModules.Mod.compute', 'javascript', 'App.js'),
+          ctx
+        )
+      ).not.toBeNull();
+    });
+  });
+
+  it('does not resolve native-language callers (resolver is JS-side only)', () => {
+    const native = method('compute:', 'objc', 'Mod.m');
+    const ctx = makeContext([native]);
+    expect(
+      reactNativeBridgeResolver.resolve(ref('compute', 'objc', 'OtherMod.m'), ctx)
+    ).toBeNull();
+  });
+
+  describe('RCTEventEmitter built-ins blocklist', () => {
+    it('skips addListener / remove (every emitter exposes these — bridging them creates noise)', () => {
+      // A repo with RCTEventEmitter subclass: defines `addListener:` and
+      // `remove:` because that's what `[RCTEventEmitter addListener:]`
+      // requires. JS callers of `.addListener(...)` should NOT resolve
+      // here — they're hitting the JS-side `NativeEventEmitter`
+      // abstraction, not the native emitter directly.
+      const native1 = method('addListener:', 'objc', 'EventEmitter.m');
+      const native2 = method('remove:', 'objc', 'EventEmitter.m');
+      const ctx = makeContext([native1, native2], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'EventEmitter.m':
+          '@implementation EventEmitter\n' +
+          'RCT_EXPORT_MODULE()\n' +
+          'RCT_EXPORT_METHOD(addListener:(NSString *)eventName) {}\n' +
+          'RCT_EXPORT_METHOD(remove:(double)id) {}\n' +
+          '@end',
+      });
+      expect(
+        reactNativeBridgeResolver.resolve(ref('addListener', 'javascript', 'App.js'), ctx)
+      ).toBeNull();
+      expect(
+        reactNativeBridgeResolver.resolve(ref('remove', 'typescript', 'App.ts'), ctx)
+      ).toBeNull();
+    });
+  });
+});

+ 126 - 0
__tests__/rn-event-channel.test.ts

@@ -0,0 +1,126 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+/**
+ * End-to-end synthesizer test: write a fixture project with a native ObjC
+ * `sendEventWithName:` site and a JS `addListener('x', fn)` subscriber,
+ * index it, and verify the synthesized cross-language event edge.
+ */
+describe('RN event channel synthesizer', () => {
+  let dir: string;
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-event-fixture-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('synthesizes an edge from ObjC sendEventWithName: to JS addListener handler', async () => {
+    // package.json so the RN detector / general resolver sees the project as RN.
+    fs.writeFileSync(
+      path.join(dir, 'package.json'),
+      '{"name":"x","dependencies":{"react-native":"^0.73"}}'
+    );
+    fs.writeFileSync(
+      path.join(dir, 'Emitter.m'),
+      `
+@implementation Emitter
+- (void)reportLocation {
+    [self sendEventWithName:@"locationUpdate" body:@{}];
+}
+@end
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'App.js'),
+      `
+function onLocation(payload) {
+    console.log(payload);
+}
+emitter.addListener('locationUpdate', onLocation);
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+
+    const db = (cg as any).db.db;
+    const rows = db
+      .prepare(
+        `SELECT s.name source_name, s.language sl, t.name target_name, t.language tl,
+                json_extract(e.metadata,'$.event') event
+         FROM edges e
+         JOIN nodes s ON s.id = e.source
+         JOIN nodes t ON t.id = e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy') = 'rn-event-channel'`
+      )
+      .all();
+    cg.close?.();
+    expect(rows.length).toBeGreaterThanOrEqual(1);
+    // The edge should point from the ObjC method that emits to the JS handler.
+    const edge = rows.find((r: any) => r.event === 'locationUpdate');
+    expect(edge).toBeDefined();
+    expect(edge.sl).toBe('objc');
+    expect(edge.tl).toBe('javascript');
+    expect(edge.target_name).toBe('onLocation');
+  });
+
+  it('falls back to enclosing JS function when addListener handler is a parameter (wrapper-API pattern)', async () => {
+    // Matches the real RNFirebase shape: `messaging().onMessage(listener)`
+    // is a subscribe-wrapper whose body does
+    // `addListener('messaging_message_received', listener)` where `listener`
+    // is the parameter — not a globally-named symbol. Synthesizer should
+    // still produce an edge, attributed to the enclosing wrapper function.
+    fs.writeFileSync(
+      path.join(dir, 'package.json'),
+      '{"dependencies":{"react-native":"^0.73"}}'
+    );
+    fs.writeFileSync(
+      path.join(dir, 'Native.m'),
+      `
+@implementation MyEmitter
+- (void)pushMessage {
+    [[Shared shared] sendEventWithName:@"messaging_message_received" body:@{}];
+}
+@end
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'messaging.ts'),
+      `
+import { NativeEventEmitter } from 'react-native';
+const emitter = new NativeEventEmitter();
+export function onMessage(listener: (m: any) => void) {
+    return emitter.addListener('messaging_message_received', listener);
+}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+
+    const db = (cg as any).db.db;
+    const rows = db
+      .prepare(
+        `SELECT s.name source_name, t.name target_name, t.kind target_kind, t.language tl,
+                json_extract(e.metadata,'$.event') event
+         FROM edges e
+         JOIN nodes s ON s.id = e.source
+         JOIN nodes t ON t.id = e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy') = 'rn-event-channel'`
+      )
+      .all();
+    cg.close?.();
+    const edge = rows.find((r: any) => r.event === 'messaging_message_received');
+    expect(edge).toBeDefined();
+    // Target should be the wrapper function `onMessage` — the enclosing
+    // function of the addListener call, not a bareword named handler.
+    expect(edge.target_name).toBe('onMessage');
+    expect(['function', 'method']).toContain(edge.target_kind);
+  });
+});

+ 205 - 0
__tests__/swift-objc-bridge-resolver.test.ts

@@ -0,0 +1,205 @@
+import { describe, it, expect } from 'vitest';
+import type { Node } from '../src/types';
+import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
+import { swiftObjcBridgeResolver } from '../src/resolution/frameworks/swift-objc';
+
+/**
+ * Lightweight ResolutionContext mock — implements only the methods the
+ * bridge resolver actually calls. Anything else throws so a leaked call
+ * surfaces loudly in tests.
+ */
+function makeContext(nodes: Node[], fileContents: Record<string, string> = {}): ResolutionContext {
+  const byName = new Map<string, Node[]>();
+  for (const n of nodes) {
+    const arr = byName.get(n.name);
+    if (arr) arr.push(n);
+    else byName.set(n.name, [n]);
+  }
+  const allFiles = new Set(nodes.map((n) => n.filePath));
+  return {
+    getNodesInFile: (fp) => nodes.filter((n) => n.filePath === fp),
+    getNodesByName: (name) => byName.get(name) ?? [],
+    getNodesByQualifiedName: () => { throw new Error('not used'); },
+    getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind),
+    getNodesByLowerName: () => { throw new Error('not used'); },
+    fileExists: (fp) => allFiles.has(fp),
+    readFile: (fp) => fileContents[fp] ?? null,
+    getProjectRoot: () => '/test',
+    getAllFiles: () => Array.from(allFiles),
+    getImportMappings: () => [],
+  };
+}
+
+function method(name: string, language: 'swift' | 'objc', filePath: string, startLine = 10): Node {
+  return {
+    id: `${language}:${filePath}:${name}:${startLine}`,
+    kind: 'method',
+    name,
+    qualifiedName: `${filePath}::${name}`,
+    filePath,
+    language,
+    startLine,
+    endLine: startLine + 5,
+    startColumn: 0,
+    endColumn: 0,
+    updatedAt: Date.now(),
+  } as Node;
+}
+
+function ref(name: string, language: 'swift' | 'objc', filePath: string): UnresolvedRef {
+  return {
+    fromNodeId: `caller:${filePath}`,
+    referenceName: name,
+    referenceKind: 'calls',
+    line: 1,
+    column: 0,
+    filePath,
+    language,
+  };
+}
+
+describe('swiftObjcBridgeResolver integration', () => {
+  describe('detect()', () => {
+    it('returns true when both .swift and .m files exist', () => {
+      const ctx = makeContext([
+        method('foo', 'swift', 'A.swift'),
+        method('bar', 'objc', 'B.m'),
+      ]);
+      expect(swiftObjcBridgeResolver.detect(ctx)).toBe(true);
+    });
+
+    it('returns false when only .swift files exist', () => {
+      const ctx = makeContext([method('foo', 'swift', 'A.swift')]);
+      expect(swiftObjcBridgeResolver.detect(ctx)).toBe(false);
+    });
+
+    it('returns true when .swift and .mm exist (ObjC++)', () => {
+      const ctx = makeContext([
+        method('foo', 'swift', 'A.swift'),
+        method('bar', 'objc', 'B.mm'),
+      ]);
+      expect(swiftObjcBridgeResolver.detect(ctx)).toBe(true);
+    });
+  });
+
+  describe('claimsReference()', () => {
+    it('claims selector-shape names (contain :)', () => {
+      expect(swiftObjcBridgeResolver.claimsReference?.('fooWithBar:')).toBe(true);
+      expect(swiftObjcBridgeResolver.claimsReference?.('tableView:didSelectRowAtIndexPath:')).toBe(true);
+      expect(swiftObjcBridgeResolver.claimsReference?.('setName:')).toBe(true);
+    });
+
+    it('does not claim bare names (handled by normal name-matcher)', () => {
+      expect(swiftObjcBridgeResolver.claimsReference?.('foo')).toBe(false);
+      expect(swiftObjcBridgeResolver.claimsReference?.('init')).toBe(false);
+    });
+  });
+
+  describe('resolve() — Swift → ObjC direction', () => {
+    it('resolves Swift call to Cocoa-style ObjC method (fetchEntry → fetchEntryForKey:)', () => {
+      // Swift writes `cache.fetchEntry(forKey: "x")` → ref name `fetchEntry`.
+      // ObjC method is `fetchEntryForKey:` (preposition-prefix shape).
+      // `fetchEntry` is project-specific (not in the generic-names blocklist
+      // that filters init/count/description/etc. to avoid Cocoa noise).
+      const objcTarget = method('fetchEntryForKey:', 'objc', 'Cache.m');
+      const ctx = makeContext([objcTarget]);
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('fetchEntry', 'swift', 'Caller.swift'),
+        ctx
+      );
+      expect(result).not.toBeNull();
+      expect(result?.targetNodeId).toBe(objcTarget.id);
+      expect(result?.resolvedBy).toBe('framework');
+      expect(result?.confidence).toBe(0.6);
+    });
+
+    it('does NOT bridge generic Cocoa names like "init" or "description"', () => {
+      // Bridging Swift `init()` calls to arbitrary ObjC `init*:` methods is
+      // noise — every NSObject subclass has them. The regular name-matcher
+      // handles `init` on its own.
+      const objcInit = method('initWithFrame:', 'objc', 'View.m');
+      const ctx = makeContext([objcInit]);
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('init', 'swift', 'Caller.swift'),
+        ctx
+      );
+      expect(result).toBeNull();
+    });
+
+    it('resolves bridged "With" form: Swift `play(song:)` → ObjC `playWithSong:`', () => {
+      const objcTarget = method('playWithSong:', 'objc', 'Player.m');
+      const ctx = makeContext([objcTarget]);
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('play', 'swift', 'Caller.swift'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(objcTarget.id);
+    });
+
+    it('returns null when no matching ObjC method exists', () => {
+      const ctx = makeContext([method('unrelated:thing:', 'objc', 'X.m')]);
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('completelyDifferent', 'swift', 'Caller.swift'),
+        ctx
+      );
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('resolve() — ObjC → Swift direction', () => {
+    it('resolves ObjC selector to @objc-exposed Swift method (exporter form)', () => {
+      // Swift @objc export of `func animate(xAxisDuration:, yAxisDuration:)`
+      // produces ObjC selector `animateWithXAxisDuration:yAxisDuration:`
+      // (always "With" insertion on first explicit label).
+      const swiftTarget = method('animate', 'swift', 'Chart.swift', 10);
+      const ctx = makeContext([swiftTarget], {
+        'Chart.swift':
+          '\n'.repeat(8) +
+          '@objc open func animate(xAxisDuration: Double, yAxisDuration: Double) {}\n',
+      });
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('animateWithXAxisDuration:yAxisDuration:', 'objc', 'Caller.m'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(swiftTarget.id);
+      expect(result?.resolvedBy).toBe('framework');
+    });
+
+    it('does NOT resolve if the Swift method is not @objc-exposed', () => {
+      const swiftTarget = method('animate', 'swift', 'Chart.swift', 10);
+      const ctx = makeContext([swiftTarget], {
+        // Plain `func` without @objc — bridge correctly skips it
+        'Chart.swift':
+          '\n'.repeat(8) +
+          'func animate(xAxisDuration: Double, yAxisDuration: Double) {}\n',
+      });
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('animateWithXAxisDuration:yAxisDuration:', 'objc', 'Caller.m'),
+        ctx
+      );
+      expect(result).toBeNull();
+    });
+
+    it('resolves init selectors to Swift init', () => {
+      const swiftTarget = method('init', 'swift', 'MyClass.swift', 10);
+      const ctx = makeContext([swiftTarget], {
+        'MyClass.swift':
+          '\n'.repeat(8) + '@objc init(name: String, age: Int) {}\n',
+      });
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('initWithName:age:', 'objc', 'Caller.m'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(swiftTarget.id);
+    });
+
+    it('returns null for selectors with no derivable Swift candidates that exist', () => {
+      const ctx = makeContext([]);
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('someUnknownThing:', 'objc', 'Caller.m'),
+        ctx
+      );
+      expect(result).toBeNull();
+    });
+  });
+});

+ 189 - 0
__tests__/swift-objc-bridge.test.ts

@@ -0,0 +1,189 @@
+import { describe, it, expect } from 'vitest';
+import {
+  objcSelectorForSwiftMethod,
+  objcSelectorForSwiftInit,
+  objcAccessorsForSwiftProperty,
+  swiftBaseNamesForObjcSelector,
+  detectExplicitObjcName,
+  isObjcExposed,
+} from '../src/resolution/swift-objc-bridge';
+
+describe('Swift → ObjC selector bridging (auto-name rules)', () => {
+  describe('objcSelectorForSwiftMethod', () => {
+    it('no parameters → bare base name', () => {
+      expect(objcSelectorForSwiftMethod('play', [])).toBe('play');
+    });
+
+    it('single _ param → base + ":"', () => {
+      expect(objcSelectorForSwiftMethod('play', ['_'])).toBe('play:');
+      expect(objcSelectorForSwiftMethod('play', [null])).toBe('play:');
+    });
+
+    it('single labeled param → "baseWithLabel:"', () => {
+      expect(objcSelectorForSwiftMethod('play', ['song'])).toBe('playWithSong:');
+    });
+
+    it('multi-param with leading _ → "base:label2:..."', () => {
+      expect(objcSelectorForSwiftMethod('play', ['_', 'by'])).toBe('play:by:');
+      expect(
+        objcSelectorForSwiftMethod('tableView', ['_', 'didSelectRowAtIndexPath'])
+      ).toBe('tableView:didSelectRowAtIndexPath:');
+    });
+
+    it('multi-param with leading explicit label → "baseWithFirst:rest:"', () => {
+      expect(objcSelectorForSwiftMethod('play', ['song', 'by'])).toBe(
+        'playWithSong:by:'
+      );
+    });
+
+    it('@objc(custom:) overrides the rule literally', () => {
+      expect(
+        objcSelectorForSwiftMethod('whateverName', ['ignored'], 'custom:')
+      ).toBe('custom:');
+    });
+
+    it('returns null on empty base name', () => {
+      expect(objcSelectorForSwiftMethod('', [])).toBeNull();
+    });
+  });
+
+  describe('objcSelectorForSwiftInit', () => {
+    it('init() → "init"', () => {
+      expect(objcSelectorForSwiftInit([], [])).toBe('init');
+    });
+
+    it('init(name:) → "initWithName:"', () => {
+      expect(objcSelectorForSwiftInit(['name'], ['name'])).toBe('initWithName:');
+    });
+
+    it('init(name:, age:) → "initWithName:age:"', () => {
+      expect(objcSelectorForSwiftInit(['name', 'age'], ['name', 'age'])).toBe(
+        'initWithName:age:'
+      );
+    });
+
+    it('init(_ name:) uses internal name → "initWithName:"', () => {
+      expect(objcSelectorForSwiftInit(['_'], ['name'])).toBe('initWithName:');
+    });
+
+    it('@objc(custom) override on init', () => {
+      expect(objcSelectorForSwiftInit(['name'], ['name'], 'custom:')).toBe(
+        'custom:'
+      );
+    });
+  });
+
+  describe('objcAccessorsForSwiftProperty', () => {
+    it('getter = name, setter = setName:', () => {
+      expect(objcAccessorsForSwiftProperty('name')).toEqual({
+        getter: 'name',
+        setter: 'setName:',
+      });
+    });
+
+    it('camelCase → set capitalizes first', () => {
+      expect(objcAccessorsForSwiftProperty('isReady')).toEqual({
+        getter: 'isReady',
+        setter: 'setIsReady:',
+      });
+    });
+
+    it('explicit @objc(custom) overrides getter name', () => {
+      expect(objcAccessorsForSwiftProperty('name', 'displayName')).toEqual({
+        getter: 'displayName',
+        setter: 'setDisplayName:',
+      });
+    });
+  });
+});
+
+describe('ObjC selector → Swift base name candidates (reverse map)', () => {
+  it('bare no-colon selector → itself', () => {
+    expect(swiftBaseNamesForObjcSelector('play')).toEqual(['play']);
+  });
+
+  it('"play:" → ["play"]', () => {
+    expect(swiftBaseNamesForObjcSelector('play:')).toEqual(['play']);
+  });
+
+  it('"playWithSong:" → ["playWithSong", "play"]', () => {
+    expect(swiftBaseNamesForObjcSelector('playWithSong:').sort()).toEqual(
+      ['play', 'playWithSong'].sort()
+    );
+  });
+
+  it('Cocoa-style "objectForKey:" → includes "object"', () => {
+    expect(swiftBaseNamesForObjcSelector('objectForKey:')).toContain('object');
+  });
+
+  it('Cocoa-style "stringWithFormat:" → includes "string"', () => {
+    expect(swiftBaseNamesForObjcSelector('stringWithFormat:')).toContain('string');
+  });
+
+  it('Cocoa-style "imageNamed:inBundle:" → first keyword has no preposition, falls through', () => {
+    // First keyword is `imageNamed` — no With/For/By in it, so candidates is
+    // just the raw keyword. (`Named` is not in our preposition list — keep
+    // it that way, otherwise we over-match on perfectly normal verbs.)
+    expect(swiftBaseNamesForObjcSelector('imageNamed:inBundle:')).toEqual(['imageNamed']);
+  });
+
+  it('"play:by:" → ["play"]', () => {
+    expect(swiftBaseNamesForObjcSelector('play:by:')).toEqual(['play']);
+  });
+
+  it('"playWithSong:by:" → ["playWithSong", "play"]', () => {
+    expect(swiftBaseNamesForObjcSelector('playWithSong:by:').sort()).toEqual(
+      ['play', 'playWithSong'].sort()
+    );
+  });
+
+  it('"initWithName:" → includes "init"', () => {
+    expect(swiftBaseNamesForObjcSelector('initWithName:')).toContain('init');
+  });
+
+  it('"initWithName:age:" → includes "init"', () => {
+    expect(swiftBaseNamesForObjcSelector('initWithName:age:')).toContain('init');
+  });
+
+  it('"setName:" → includes the property name "name"', () => {
+    expect(swiftBaseNamesForObjcSelector('setName:')).toContain('name');
+  });
+
+  it('"tableView:didSelectRowAtIndexPath:" → ["tableView"]', () => {
+    expect(
+      swiftBaseNamesForObjcSelector('tableView:didSelectRowAtIndexPath:')
+    ).toEqual(['tableView']);
+  });
+});
+
+describe('Source-window attribute detection', () => {
+  it('detects literal @objc(custom)', () => {
+    expect(detectExplicitObjcName('  @objc(custom:)\n  func foo() {}')).toBe(
+      'custom:'
+    );
+  });
+
+  it('returns null for plain @objc', () => {
+    expect(detectExplicitObjcName('@objc func foo() {}')).toBeNull();
+  });
+
+  it('returns null when no @objc at all', () => {
+    expect(detectExplicitObjcName('public func foo() {}')).toBeNull();
+  });
+
+  it('isObjcExposed true for @objc', () => {
+    expect(isObjcExposed('@objc func foo() {}')).toBe(true);
+  });
+
+  it('isObjcExposed true for @objc(custom)', () => {
+    expect(isObjcExposed('@objc(custom:) func foo() {}')).toBe(true);
+  });
+
+  it('isObjcExposed false for no annotation', () => {
+    expect(isObjcExposed('public func foo() {}')).toBe(false);
+  });
+
+  it('@nonobjc opts out even if @objc also present (e.g. inside @objcMembers class)', () => {
+    expect(isObjcExposed('@nonobjc @objc func foo() {}')).toBe(false);
+  });
+});

+ 6 - 0
docs/design/dynamic-dispatch-coverage-playbook.md

@@ -195,6 +195,12 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
 | Dart | Flutter | setState → build; build → child widgets | S + X | ✅ **setState→build synthesizer** (Dart analog of react-render: a State method whose body calls `setState(` → `build`) gated to `.dart` + **foundational Dart method-range fix** — Dart models a method body as a *sibling* of the signature, so method nodes were signature-only (`end==start`); now `endLine` spans the body (required for ALL body analysis: callees, context slices, the synthesizer's body scan). counter `initState→build`, books `build→BookDetail/BookForm`; widget composition already static (compass_app `build→ErrorIndicator/HomeButton`). Controls unchanged (excalidraw 9,290 / django 302 — the range fix only extends sibling-body grammars). 🔬 MVVM Command/ChangeNotifier dispatch (compass_app — no setState) + `Navigator.push(MaterialPageRoute(builder:))` nav routes |
 | Lua / Luau | Neovim / Roblox | module dispatch (require→mod, mod.fn); event/callback | — | ✅ **already covered for the dominant flow (measure-first, no code change)** — Neovim is module-heavy (`require('x')` + `x.fn()`), and the general import + name resolution already handles it: telescope.nvim **220 imports + 335 cross-file `mod.fn` calls**, traces end-to-end (`map_entries ← init.lua → get_current_picker (state.lua)`). Luau instance-path `require(game:GetService(...))` handled by the extractor. 🔬 event-callback registration (`vim.keymap.set(…, fn)`, autocmd `callback=`, Roblox `signal:Connect(fn)`) is predominantly INLINE anonymous closures (corpus ~12 inline vs ~2 named) — the anonymous-handler frontier; named handlers too rare to justify a synthesizer |
 | Scala | Play / Akka | request → conf/routes → controller action | R + X | ✅ **Play `conf/routes` → controller** — the extensionless `conf/routes` wasn't indexed; added narrow file-walk opt-in (`isPlayRoutesFile`) + a Play resolver parsing `METHOD /path Controller.action(args)` → the action method (computer-database **0→8, 7/8**; starter 0→4, 3/4 — the unresolved are Play's framework `Assets` controller, external). Scala general controller→DAO dispatch already resolves. No-regression: the file-walk change only ADDS Play routes files (excalidraw 9,290 / suite 800 unchanged). 🔬 SIRD programmatic router (`-> /v1 Router` include + `case GET(p"/x")` in code) + Akka actor `receive`/`Behaviors.receiveMessage` message→handler |
+| Swift × Objective-C | mixed iOS apps | Swift `obj.foo(bar:)` → ObjC `-fooWithBar:`; ObjC `[obj fooWithBar:]` → Swift `@objc func foo(bar:)` | R | ✅ **Swift↔ObjC cross-language bridge** — `frameworks/swift-objc.ts` implements Apple's `@objc` auto-bridging name math (incl. init forms `initWith<First>:`, property getter+setter pairs, `@objc(custom:)` override) and the reverse direction strips Cocoa preposition prefixes (`With`/`For`/`By`/`In`/`On`/`At`/`From`/`To`/`Of`/`As`) to derive Swift base-name candidates. Validated on Charts S **28/1 obj→swift / swift→objc**, realm-swift M **36/1185**, wikipedia-ios L **52/983**. Genericname blocklist (`init`, `description`, `count`, …) keeps precision. Confidence 0.6 (name-match's 1.0 wins ties) — bridge only fires when name-match has no result. 🔬 Swift generics over ObjC protocols, Swift extensions on ObjC classes (silently miss; matches Java/Kotlin generics frontier) |
+| JS × native | React Native legacy bridge | JS `NativeModules.X.fn(...)` → ObjC `RCT_EXPORT_METHOD` / Java/Kotlin `@ReactMethod` | R | ✅ **RN legacy bridge** — `frameworks/react-native.ts` parses `RCT_EXPORT_MODULE` (default-name from `RCT`-prefix-stripped class name) + `RCT_EXPORT_METHOD(selector:(...))` + `RCT_REMAP_METHOD(jsName, selector)` on the ObjC side and `@ReactMethod` + `getName()` literal on Java/Kotlin. AsyncStorage S **8/8 precise** (`setItem`→`legacy_multiSet`, etc.), react-native-firebase L **18 precise after `RCTEventEmitter` built-in blocklist** (initial 78 included 60 `addListener:`/`remove:` false positives — every emitter subclass declares those via `RCT_EXPORT_METHOD`, JS callers route through the `NativeEventEmitter` abstraction not the native method directly). 🔬 dynamic bridge keys (`NativeModules[someVar]`) — literal-key only |
+| JS × native | React Native TurboModules | JS spec interface ↔ native impl | R (spec as ground truth) | ✅ partial — parses `TurboModuleRegistry.get*<Spec>('Name')` + the `Spec` interface methods. Each spec method matches to a native impl by selector first-keyword (ObjC) / identifier (JVM). react-native-svg S **9 precise** (`getTotalLength`, `getPointAtLength`, `getCTM`, `isPointInFill`, …) bridging to Java impls (the iOS side is Codegen-auto-generated without `RCT_EXPORT_METHOD` declarations). 🔬 TurboModule native impl classes that don't use legacy macros (RNSvg iOS — would need inheritance-aware bridging via the Codegen-generated `NativeFooSpec` superclass) |
+| ObjC/Java/Kotlin → JS | React Native event emitters | native `sendEventWithName:`/`emit(...)` → JS `addListener('e', handler)` | S (cross-lang channel) | ✅ **rn-event-channel synthesizer** — matches ObjC `sendEventWithName:@"X"`, Swift `sendEvent(withName: "X", ...)`, and JVM `.emit("X", ...)` to JS `addListener('X', handler)` keyed by literal event name. Same fan-out cap (`EVENT_FANOUT_CAP=6`) as in-language channel. **Subscribe-wrapper fallback** for RN-library APIs (`const Foo = { watchX(listener) { addListener('e', listener) } }`) — when the handler arg is a parameter, falls back to the enclosing function and then the enclosing `constant`/`variable` (reachability-correct attribution to the JS API surface). RNFirebase L **3 push-notification flow edges** (UIApplicationDelegate → JS `onMessage`/`onNotificationOpenedApp`), RNGeolocation S **2 location-event edges** (Swift `onLocationChange`/`onLocationError` → JS `Geolocation`). 🔬 inline arrow handlers `addListener('e', d => …)` (anonymous frontier) |
+| JS × Swift/Kotlin | Expo Modules | JS `requireNativeModule('X').fn(...)` → Swift/Kotlin `Function("fn") { ... }` | R (extract → synthetic method nodes) | ✅ **expo-modules framework extractor** — parses Swift/Kotlin `Module { Name("X"); Function("y") { ... }; AsyncFunction("z") { ... }; Property("w") { ... } }` literals and synthesizes `method` nodes named after each declaration. JS callsites resolve via existing name-matcher (no separate `resolve()` needed). expo-haptics S **6 method nodes** (`notificationAsync`, `impactAsync`, `selectionAsync` × Swift + Kotlin), expo-camera M **41** (full SDK surface incl. `takePictureAsync`, `record`, `scanFromURLAsync`, view props `width`/`height`), expo SDK sweep L **134** (7 packages, 72 Swift + 62 Kotlin). Same-name JS wrappers in the package itself shadow the native names (`CameraView.tsx`'s `pausePreview` wraps native `pausePreview`); external consumer apps bridge through to native directly. 🔬 closure body extraction (the Function trailing closure isn't a body-range node yet) |
+| JS × native | React Native Fabric / Codegen + legacy Paper view components | JSX `<MyView prop={v}/>` → Codegen spec → native class (or Paper `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp`) | R (extract) + S (native-impl) + JSX | ✅ **fabric-view extractor + fabric-native-impl synthesizer** — extractor parses **both** modern Codegen TS specs (`codegenNativeComponent<NativeProps>('Name', ...)`) **and** legacy Paper view-manager macros (`RCT_EXPORT_VIEW_PROPERTY` on ObjC, `@ReactProp` on Java/Kotlin). Emits a `component` node per declaration + a `property` node per declared prop. Synthesizer links the component to its native impl class by RN's convention-based name+suffix (`exact`/`View`/`ComponentView`/`Manager`/`ViewManager`). Combined with `reactJsxChildEdges`, full consumer flow: JSX `<MyView/>` → fabric `component` → native class. Validated on RNSegmentedControl S **(legacy Paper) 1 component + 11 props + 4 bridges**, RNScreens M **(pure Codegen) 27 components + 272 props + 68 bridges** (was 0 before Phase 6), RNSkia L **(hybrid + monorepo) 5 + 14 + 15 across Codegen TS + Android Java + iOS ObjC**. **Monorepo detect** added: probes `packages/<sub>/package.json` etc. via `listDirectories` when the root manifest is a workspace declaration (was the gating bug on RNSkia). 🔬 Fabric event-handler props (`onTap={cb}`) — JSX attribute extraction needed |
 
 (Verify the exact supported set against `src/extraction/languages/` and
 `src/resolution/frameworks/` before starting — this table is a starting point.)

+ 555 - 0
docs/design/mixed-ios-and-react-native-bridging.md

@@ -0,0 +1,555 @@
+# Mixed iOS + React Native Bridging — Coverage Design
+
+**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](./dynamic-dispatch-coverage-playbook.md) §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.
+
+---
+
+## 1. Why this matters (the gap today)
+
+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:
+
+- **Mixed iOS app:** `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.
+- **React Native app:** `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.
+- **Expo module:** `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.
+
+---
+
+## 2. The bridging mechanisms to model
+
+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.
+
+### 2a. Why these are resolvers, not synthesizers
+
+In every row, **the bridging rule is deterministic from a name**:
+- Swift's `@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.
+- Expo Modules `Function("name") { ... }` and `Module { Name("ExpoX"); ... }`
+  are literal strings inside `Module` definitions.
+- TurboModules spec interfaces are literal `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.
+
+---
+
+## 3. Concrete bridging rules (the reference table)
+
+### 3a. Swift → ObjC selector mapping (auto)
+
+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](https://developer.apple.com/documentation/swift/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.
+
+### 3b. React Native legacy bridge — name resolution
+
+```objc
+// Native side (ObjC)
+@implementation RCTGeolocation
+RCT_EXPORT_MODULE();                                    // module name: "Geolocation" (RCT prefix stripped)
+RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb) { ... }
+@end
+```
+```js
+// JS side
+import { NativeModules } from 'react-native';
+NativeModules.Geolocation.getCurrentPosition(cb);       // resolves to the ObjC method above
+```
+
+Rule:
+1. On the native side, extract a synthetic `module` node per class containing
+   `RCT_EXPORT_MODULE()`. Name = explicit string argument if present, else
+   class name with `RCT` prefix stripped.
+2. Each `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`).
+3. On the JS side, the resolver matches the literal property chain
+   `NativeModules.<Mod>.<fn>` against `(module, jsName)` pairs from the
+   native side.
+4. Resolver emits `references` (`provenance:'heuristic'`, `synthesizedBy:'rn-bridge'`)
+   from the JS callsite to the native method.
+
+### 3c. React Native TurboModule — name resolution
+
+```ts
+// Spec (TS) — codegen ground truth
+export interface Spec extends TurboModule {
+  getCurrentPosition(cb: (loc: Location) => void): void;
+}
+export default TurboModuleRegistry.getEnforcing<Spec>('Geolocation');
+```
+```objc
+// ObjC++ impl
+@implementation RCTGeolocation
+- (void)getCurrentPosition:(RCTResponseSenderBlock)cb { ... }
+@end
+```
+```js
+import Geolocation from './NativeGeolocation';
+Geolocation.getCurrentPosition(cb);  // resolves to the ObjC method via the spec
+```
+
+Rule:
+1. The spec file is the source of truth: parse `TurboModuleRegistry.get*<Spec>('<Name>')`
+   to find the module name, then read the `Spec` interface methods.
+2. Match each spec method to the native impl's same-named method (by selector
+   first-keyword, in the class identified by name convention or by reading
+   any `JSI_EXPORT_MODULE` macro if present).
+3. JS imports of the spec file get name resolution through the spec.
+4. Emits the same `references` edges as #3b, with `synthesizedBy:'rn-turbomodule'`.
+
+### 3d. Expo Modules — name resolution
+
+```swift
+// 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 /* ... */ }
+    }
+  }
+}
+```
+```js
+import { requireNativeModule } from 'expo-modules-core';
+const ExpoCamera = requireNativeModule('ExpoCamera');
+await ExpoCamera.takePictureAsync({ quality: 1 });
+```
+
+Rule:
+1. On the native side: a class extending `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`.
+2. On the JS side: `requireNativeModule('X')` produces a binding; resolve
+   property accesses on it to the named methods.
+3. `Prop("name")` for view modules behaves like RN's `RCT_EXPORT_VIEW_PROPERTY` —
+   defer with the rest of the view-component frontier.
+
+---
+
+## 4. What edges need to exist
+
+For each channel, the closed flow is:
+
+- **JS callsite → bridged-method-node** (`references`, heuristic, `synthesizedBy:'<channel>'`)
+- **Bridged-method-node → native-impl-method** (already extracted; for #6/#7
+  the bridged-method IS the native impl; for #10 the closure body IS the
+  impl)
+- **Native-impl-method → its own callees** (already extracted in-language)
+
+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.
+
+---
+
+## 5. Validation corpus (the small/medium/large bar)
+
+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.
+
+### 5a. Mixed iOS (Swift+ObjC) — pick 3
+
+| Tier | Repo | Why | Canonical flow |
+|---|---|---|---|
+| **Small** | [Charts](https://github.com/danielgindi/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](https://github.com/airbnb/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](https://github.com/realm/realm-swift) (~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](https://github.com/wikimedia/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](https://github.com/wordpress-mobile/WordPress-iOS) | Heavier ObjC legacy + Swift additions | "How does a new-post draft save reach Core Data persistence?" |
+
+Bar per repo:
+1. Pure-language probes still pass (Swift-in-Swift trace; ObjC-in-ObjC trace) — no regression vs #165's pure-ObjC baseline.
+2. **Cross-language probe passes:** the canonical flow above traces end-to-end with `trace`, no break at the language boundary.
+3. **Agent A/B (with vs without codegraph, ≥2 runs/arm):** Read = 0 within the explore-call budget; faster than without-codegraph; no regression on a pure-Swift or pure-ObjC control repo (e.g. Texture).
+4. **No node-count explosion** vs pre-bridging baseline (`select count(*) from nodes` before/after).
+
+### 5b. React Native — pick 3
+
+| Tier | Repo | Why | Canonical flow |
+|---|---|---|---|
+| **Small** | [react-native-svg](https://github.com/software-mansion/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](https://github.com/software-mansion/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](https://github.com/invertase/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](https://github.com/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:
+1. Pure-JS probes unchanged (`useState` → re-render flow still resolves — existing react synthesizer not regressed).
+2. **JS → ObjC bridge probe passes** for ≥1 known RCT_EXPORT_METHOD on each repo.
+3. **JS → TurboModule probe passes** on a repo that uses TurboModules (react-native main has both; pick one of each).
+4. **Native → JS event probe passes** for ≥1 emitter (NativeEventEmitter pattern).
+5. **Agent A/B** as above. Critical: a question that *crosses the bridge* (e.g. "how does pressing Button X reach the network call") must drop Read to 0 in ≥1 run with codegraph.
+6. **No regression** on a pure-JS control repo (existing react-realworld / excalidraw measurements unchanged).
+
+### 5c. Expo — pick 2 (smaller scope, narrower API surface)
+
+| Tier | Repo | Why |
+|---|---|---|
+| **Small/Medium** | [expo/expo](https://github.com/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`)?"
+
+---
+
+## 6. Phasing — what comes first
+
+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**.
+
+### Phase 1 — Swift ↔ ObjC bridging (rows 1–5 above)
+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.**
+
+### Phase 2 — React Native legacy bridge (rows 6–7, ObjC + Java/Kotlin)
+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.
+
+### Phase 3 — Native → JS events (row 9)
+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).
+
+### Phase 4 — Expo Modules (row 10)
+Layered on Phase 1's Swift extraction. Smaller corpus (§5c).
+
+### Phase 5 — RN TurboModules / Codegen (row 8)
+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).
+
+### Phase 6 — Fabric view components (row 11)
+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.
+
+---
+
+## 7. Anti-goals (what we will not try to do)
+
+- **Android Kotlin/Java extraction quality** — out of scope. We use what
+  Kotlin/Java extractors already produce. If they miss a `@ReactMethod`
+  annotation's literal name we may add a tiny extractor refinement, but we
+  do not redesign JVM extraction.
+- **Dynamic / computed bridge keys** — `NativeModules[someVar]`,
+  `requireNativeModule(name)` where `name` is a parameter, etc. We only
+  resolve literal-key access (matches the
+  [agent-eval Lua frontier](./dynamic-dispatch-coverage-playbook.md) — anonymous-only patterns deferred).
+- **Bridging-header file content parsing** — we *do* index `.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.
+- **Runtime dispatch on `performSelector:`** — out of scope; matches the
+  same "named-only" anti-goal.
+- **JSI (raw, non-TurboModule)** — out of scope. Apps using bare JSI
+  call into native through a custom `Host*` interface that has no documented
+  declarative spec. Wait for those apps to migrate to TurboModules.
+- **Swift-only generics over ObjC protocols / Swift extensions on ObjC
+  classes** — extension methods are still callable in ObjC if `@objc`, so
+  they go through the same Phase 1 path. Generics are not — we silently
+  miss them. Acceptable; matches Java/Kotlin generics frontier.
+
+---
+
+## 8. Coverage-matrix entries — measured
+
+| 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/>` → Codegen spec component + NativeProps | R (extract) + S (native-impl) + JSX | ✅ Phase 6 (§8g) |
+
+### 8a. Phase 1 measurements — Swift ↔ ObjC
+
+| 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).
+
+### 8b. Phase 2 + 5 (partial) measurements — React Native bridge
+
+| 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. |
+
+### 8c. Architectural fix discovered during validation
+
+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.
+
+### 8d. Bridge-precision blocklists (lessons learned)
+
+| 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. |
+
+### 8e. Phase 3 measurements — RN native → JS event channel
+
+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).
+
+### 8f. Phase 4 measurements — Expo Modules
+
+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.
+
+### 8g. Phase 6 measurements — Fabric / Codegen view components
+
+Two-part design:
+
+1. **Framework extractor** (`src/resolution/frameworks/fabric.ts`) — parses
+   TS / TSX spec files for `codegenNativeComponent<Props>('Name', ...)`
+   declarations. Emits:
+   - One `component` node per declaration (named after the JS-visible
+     component name; matches the JSX synthesizer's name+kind filter).
+   - One `property` node per declared field of the `NativeProps`
+     interface — surfacing JSX-callable props like `onTap`,
+     `nativeContainerBackgroundColor` as discoverable graph nodes.
+
+2. **Synthesizer** (`fabricNativeImplEdges` in `callback-synthesizer.ts`) —
+   walks every `fabric-component:*` node and looks for a native class
+   matching its name with one of RN's convention suffixes (empty / `View`
+   / `ViewManager` / `ComponentView` / `Manager`). Emits a `calls` edge
+   with `metadata.synthesizedBy:'fabric-native-impl'` from the component
+   to each match. The convention is precise enough that there's no name
+   collision in well-formed RN libraries.
+
+Combined with the existing `reactJsxChildEdges` JSX synthesizer, this
+closes the full JSX → native flow: consumer-app JSX `<MyView prop=v/>`
+→ Fabric `component` node `MyView` → native class `MyViewView`
+(or `MyViewManager` / `MyViewComponentView` / …).
+
+Re-validated on **react-native-screens** (the corpus repo that was
+entirely Fabric and showed 0 bridges in Phase 2):
+
+| Metric | Count |
+|---|---|
+| `codegenNativeComponent` spec declarations | 54 |
+| Fabric component nodes extracted | 27 (one per non-web spec; the `*.web.ts` variants are filtered out by spec validity) |
+| Fabric prop nodes extracted | 272 (the full NativeProps interface surface across all components) |
+| `fabric-native-impl` bridge edges | 68 |
+
+Sample bridge edges:
+
+| JS component | Native class | Suffix |
+|---|---|---|
+| `RNSFullWindowOverlay` | `RNSFullWindowOverlay` (ObjC) | (exact) |
+| `RNSFullWindowOverlay` | `RNSFullWindowOverlayManager` (ObjC) | `Manager` |
+| `RNSModalScreen` | `RNSModalScreenManager` (ObjC) | `Manager` |
+| `RNSScreenContainer` | `RNSScreenContainerView` (ObjC) | `View` |
+
+Four tests cover the extractor + a full end-to-end fixture
+(`App (TSX) → MyView (fabric-component) → MyViewView (ObjC class)`)
+that asserts the JSX→component edge AND the
+component→native-class edge both exist after indexing.
+
+---
+
+## 9. Open questions to settle in Phase 1
+
+These are not blocking the start of Phase 1 — they're the first things to
+decide *while* writing the Swift↔ObjC resolver:
+
+1. **Alias on declaration vs new bridge edge?** Storing the auto-bridged
+   ObjC selector as an alternate name on the Swift method node is cheaper
+   and aligns with how name resolution already works. The alternative
+   (synthesize a cross-language `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.
+2. **How does `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.
+3. **Where do the resolver bridging rules live?** Suggest a
+   `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.
+4. **What about `@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.
+
+---
+
+## 10. Done-bar (so we know when to stop)
+
+Phase 1 (Swift↔ObjC) is done when:
+- All three §5a corpora pass: pure-language probes unchanged; cross-language
+  canonical flow probe finds the path end-to-end; agent A/B shows Read = 0
+  in ≥1 run with codegraph, faster than without.
+- Coverage matrix row in §6 of the playbook is filled in with numbers.
+- A CHANGELOG `[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.

+ 18 - 0
src/extraction/index.ts

@@ -555,6 +555,24 @@ export class ExtractionOrchestrator {
           return null;
         }
       },
+      // Monorepo support — needed by framework detect()s that probe
+      // subpackage manifests (e.g. fabric-view looking at
+      // packages/<sub>/package.json when the root manifest is just a
+      // workspace declaration). Matches the resolver-context shape.
+      listDirectories: (relativePath: string) => {
+        const target =
+          relativePath === '.' || relativePath === ''
+            ? rootDir
+            : path.join(rootDir, relativePath);
+        try {
+          return fs
+            .readdirSync(target, { withFileTypes: true })
+            .filter((entry) => entry.isDirectory())
+            .map((entry) => entry.name);
+        } catch {
+          return [];
+        }
+      },
     };
   }
 

+ 20 - 3
src/extraction/tree-sitter.ts

@@ -1467,9 +1467,26 @@ export class TreeSitterExtractor {
         }
       }
     } else if (node.type === 'message_expression') {
-      const methodField = getChildByField(node, 'method');
-      if (methodField) {
-        const methodName = getNodeText(methodField, this.source);
+      // ObjC message expressions emit one `method` field child per selector
+      // keyword: `[obj a:1 b:2 c:3]` has three `method=identifier` siblings.
+      // Joining them with `:` reconstructs the full selector and matches the
+      // multi-part selector names produced by the ObjC method_definition
+      // extractor (`extractObjcMethodName` in languages/objc.ts). Without this
+      // join, multi-keyword call sites only emitted the first keyword and never
+      // resolved to their target methods (e.g. `GET:parameters:headers:...` had
+      // zero callers despite obviously being called).
+      const methodKeywords: string[] = [];
+      for (let i = 0; i < node.namedChildCount; i++) {
+        if (node.fieldNameForNamedChild(i) === 'method') {
+          const kw = node.namedChild(i);
+          if (kw) methodKeywords.push(getNodeText(kw, this.source));
+        }
+      }
+      if (methodKeywords.length > 0) {
+        const methodName: string =
+          methodKeywords.length === 1
+            ? (methodKeywords[0] as string)
+            : methodKeywords.map((k) => `${k}:`).join('');
         const receiverField = getChildByField(node, 'receiver');
         const SKIP_RECEIVERS = new Set(['self', 'super']);
         if (receiverField && receiverField.type !== 'message_expression') {

+ 11 - 0
src/index.ts

@@ -327,6 +327,17 @@ export class CodeGraph {
       try {
         const result = await this.orchestrator.indexAll(options.onProgress, options.signal, options.verbose);
 
+        // Re-detect frameworks now that the index is populated. The resolver
+        // is constructed with createResolver() before any files exist, so
+        // framework resolvers whose detect() consults the indexed file list
+        // (e.g. UIKit/SwiftUI scanning for imports, swift-objc-bridge looking
+        // for both Swift and ObjC files) all return false on that initial pass
+        // and silently drop themselves. Re-initializing here gives them a
+        // chance to see the actual project before resolution runs.
+        if (result.success && result.filesIndexed > 0) {
+          this.resolver.initialize();
+        }
+
         // Resolve references to create call/import/extends edges
         if (result.success && result.filesIndexed > 0) {
           // Get count without loading all refs into memory

+ 272 - 3
src/resolution/callback-synthesizer.ts

@@ -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);

+ 193 - 0
src/resolution/frameworks/expo-modules.ts

@@ -0,0 +1,193 @@
+/**
+ * Expo Modules framework — close the JS → native flow for Expo SDK packages.
+ *
+ * Expo Modules use a Swift / Kotlin DSL distinct from the React Native legacy
+ * bridge. Each native module is a class extending `Module` whose
+ * `definition()` body declares the JS surface via literal `Name(...)`,
+ * `Function(...)`, `AsyncFunction(...)`, `Property(...)`, and `View {...}`
+ * calls. Tree-sitter parses these as ordinary call_expressions with trailing
+ * closures, so the JS-visible methods don't exist as named symbol nodes by
+ * default — `Camera.takePictureAsync(...)` on the JS side has nothing to
+ * resolve to.
+ *
+ * This framework extractor walks the file source for those declarative
+ * literals and emits method nodes named `takePictureAsync` /
+ * `notificationAsync` / `width` / etc., attributed to the Swift / Kotlin
+ * file. The standard name-matcher then resolves JS `Foo.takePictureAsync(...)`
+ * to them via the existing `obj.method` → method-name path — no separate
+ * resolve() branch needed.
+ *
+ * Real-world shape (expo-haptics):
+ *
+ *   public class HapticsModule: Module {
+ *     public func definition() -> ModuleDefinition {
+ *       Name("ExpoHaptics")
+ *       AsyncFunction("notificationAsync") { ... }
+ *       AsyncFunction("impactAsync") { ... }
+ *       AsyncFunction("selectionAsync") { ... }
+ *     }
+ *   }
+ *
+ * Kotlin Module declarations are the same DSL (the API mirrors Swift).
+ *
+ * Anti-goals (deferred):
+ * - The trailing-closure BODY is not extracted as the method's body — it
+ *   remains attributed to `definition()` in the existing extraction. Future
+ *   work could synthesize a body-range for richer `trace` output, but the
+ *   reachability (which is the bridge's main value) is already complete.
+ * - `View { ... }` blocks expose JSX prop bindings; that overlaps with
+ *   Fabric (Phase 6) and is left to that phase.
+ */
+import type { Node } from '../../types';
+import {
+  FrameworkExtractionResult,
+  FrameworkResolver,
+} from '../types';
+
+/**
+ * Match `Function("name")`, `AsyncFunction("name")`, or `Property("name")`
+ * at the start of an expression (line-anchored after optional whitespace).
+ * The trailing closure that follows isn't captured — we just need the name
+ * literal that becomes the JS-visible method.
+ *
+ * NOTE: the regex deliberately requires the open paren to live on the same
+ * 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.
+ */
+const EXPO_DECL_RE =
+  /\b(Function|AsyncFunction|Property|Constants)\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g;
+
+/**
+ * Match the module name literal `Name("ExpoX")`. Used to enrich each emitted
+ * method's qualifiedName so the same JS callsite to `Foo.fn` doesn't ambiguate
+ * across multiple Expo modules in a monorepo.
+ */
+const EXPO_MODULE_NAME_RE = /\bName\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/;
+
+/**
+ * Heuristic class-name match — used as a fallback if `Name(...)` literal
+ * isn't found. Detects `class XxxModule: Module` (Swift) or
+ * `class XxxModule : Module` (Kotlin / with whitespace tolerance).
+ */
+const EXPO_CLASS_RE =
+  /\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*Module\b/;
+
+/**
+ * Detect whether a file is plausibly an Expo Module — looking for both
+ * the `: Module` inheritance and at least one declarative `Function(...)`
+ * / `AsyncFunction(...)` / `Property(...)` / `Name(...)` literal. Any one
+ * of those alone produces too many false positives (random Swift code can
+ * have `class X: Module` for unrelated reasons).
+ */
+function isExpoModuleSource(source: string): boolean {
+  if (!EXPO_CLASS_RE.test(source)) return false;
+  // Reset lastIndex defensively; EXPO_DECL_RE has the `g` flag.
+  EXPO_DECL_RE.lastIndex = 0;
+  return EXPO_DECL_RE.test(source);
+}
+
+/**
+ * Extract Expo Module method declarations from a Swift / Kotlin source
+ * file. Each `Function("X") { … }` / `AsyncFunction("X") { … }` /
+ * `Property("X") { … }` literal becomes a method node named `X`,
+ * attributed to the file at the line of the literal.
+ */
+function extractExpoMethods(filePath: string, source: string, language: 'swift' | 'kotlin'): Node[] {
+  if (!isExpoModuleSource(source)) return [];
+  const nodes: Node[] = [];
+
+  const nameMatch = source.match(EXPO_MODULE_NAME_RE);
+  const classMatch = source.match(EXPO_CLASS_RE);
+  // Prefer the explicit `Name("X")` literal — that's the JS-visible
+  // module name. Class name is the fallback.
+  const moduleName = nameMatch?.[1] ?? classMatch?.[1] ?? 'ExpoModule';
+
+  const now = Date.now();
+  const seenAtLine = new Set<string>();
+  EXPO_DECL_RE.lastIndex = 0;
+  let m: RegExpExecArray | null;
+  while ((m = EXPO_DECL_RE.exec(source)) !== null) {
+    const kind = m[1]!;
+    const methodName = m[2]!;
+    // Compute line number from match index.
+    const before = source.slice(0, m.index);
+    const startLine = before.split('\n').length;
+    // Avoid duplicates if the same method literal appears twice in one
+    // file (e.g., declared and re-declared inside a `View {...}` block).
+    const dedupKey = `${methodName}:${startLine}`;
+    if (seenAtLine.has(dedupKey)) continue;
+    seenAtLine.add(dedupKey);
+
+    const startColumn = before.length - before.lastIndexOf('\n') - 1;
+    nodes.push({
+      id: `expo-module:${filePath}:${moduleName}:${methodName}:${startLine}`,
+      kind: 'method',
+      name: methodName,
+      qualifiedName: `${filePath}::${moduleName}.${methodName}`,
+      filePath,
+      language,
+      startLine,
+      // We don't extract the closure body's end-line — use the literal's
+      // line as a single-line range. trace/explore still surfaces the
+      // declaration site, which is the main user-visible signal.
+      endLine: startLine,
+      startColumn,
+      endColumn: startColumn + kind.length + 2 + methodName.length + 2,
+      docstring: `Expo Modules ${kind}("${methodName}") in ${moduleName}`,
+      signature: `${kind}("${methodName}")`,
+      isExported: true,
+      updatedAt: now,
+    });
+  }
+
+  return nodes;
+}
+
+export const expoModulesResolver: FrameworkResolver = {
+  name: 'expo-modules',
+  languages: ['swift', 'kotlin'],
+
+  /**
+   * Detect Expo Modules by looking at the project's package.json or
+   * a small scan of source files for the `: Module` + declarative-DSL
+   * markers. Either signal suffices.
+   */
+  detect(context) {
+    const pkg = context.readFile('package.json');
+    if (pkg && /["']expo-modules-core["']\s*:/.test(pkg)) return true;
+    const files = context.getAllFiles();
+    for (let i = 0; i < Math.min(files.length, 200); i++) {
+      const f = files[i];
+      if (!f) continue;
+      if (f.endsWith('.swift') || f.endsWith('.kt')) {
+        const src = context.readFile(f);
+        if (src && isExpoModuleSource(src)) return true;
+      }
+    }
+    return false;
+  },
+
+  /**
+   * Per-file extraction — the orchestrator invokes this for every
+   * `.swift` / `.kt` file in the project. We only emit nodes when the
+   * file looks like an Expo Module; otherwise return empty.
+   */
+  extract(filePath, source): FrameworkExtractionResult {
+    const language = filePath.endsWith('.kt') ? 'kotlin' : 'swift';
+    return {
+      nodes: extractExpoMethods(filePath, source, language),
+      references: [],
+    };
+  },
+
+  /**
+   * No bespoke resolution needed — the synthetic method nodes emitted by
+   * `extract()` get picked up by the standard name-matcher when a JS
+   * callsite like `Foo.takePictureAsync(args)` resolves. Returning null
+   * here is correct.
+   */
+  resolve() {
+    return null;
+  },
+};

+ 411 - 0
src/resolution/frameworks/fabric.ts

@@ -0,0 +1,411 @@
+/**
+ * React Native Fabric / Codegen view components — Phase 6 of the
+ * mixed-iOS/RN bridging effort.
+ *
+ * In the new RN architecture, JS-visible view components are declared via
+ * Codegen TS spec files of the shape:
+ *
+ *   // src/fabric/MyComponentNativeComponent.ts
+ *   import { codegenNativeComponent } from 'react-native';
+ *   import type { ViewProps, CodegenTypes as CT } from 'react-native';
+ *
+ *   export interface NativeProps extends ViewProps {
+ *     color?: ColorValue;
+ *     onTap?: CT.DirectEventHandler<TapEvent>;
+ *   }
+ *
+ *   export default codegenNativeComponent<NativeProps>('MyComponent');
+ *
+ * Codegen then generates a native ComponentDescriptor that wires the JS
+ * component name to a native implementation class — by RN convention,
+ * one of `MyComponent`, `MyComponentView`, `MyComponentComponentView`,
+ * `MyComponentManager`, `MyComponentViewManager`. The actual implementation
+ * lives in ObjC++ (.mm) on iOS or Kotlin/Java on Android.
+ *
+ * Without bridging, JSX `<MyComponent color="red"/>` in a consumer app has
+ * nothing in the graph to land on — the JS-visible name `MyComponent` isn't
+ * a node anywhere (only `MyComponentView` is, in the .mm), and the JSX
+ * synthesizer matches strictly by name.
+ *
+ * What this extractor does:
+ *   1. Parse the spec file's `codegenNativeComponent<Props>('Name', ...)`
+ *      literal — emit a `component` node named `Name`, attributed to the
+ *      spec file.
+ *   2. Parse the `NativeProps` interface and emit one `property` node per
+ *      prop, attributed to the spec file. Props like `onTap` /
+ *      `onFinishTransitioning` are JS-callable event-handler bindings;
+ *      surfacing them as nodes lets the agent discover the JS surface of
+ *      the component.
+ *
+ * A companion synthesizer (`fabricNativeImplEdges` in
+ * callback-synthesizer.ts) links the emitted component node to its
+ * native implementation class via the convention-based name+suffix
+ * lookup — that produces the cross-language hop the JSX synthesizer's
+ * `<MyComponent>` edges naturally chain through.
+ */
+import type { Node } from '../../types';
+import {
+  FrameworkExtractionResult,
+  FrameworkResolver,
+} from '../types';
+
+const CODEGEN_DECL_RE =
+  /codegenNativeComponent\s*(?:<[^>]+>)?\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/g;
+
+/**
+ * Legacy Paper view manager macros — older RN libs (still very common,
+ * especially small libs that haven't migrated to Codegen) declare a
+ * ViewManager class and expose props via these macros. Both shapes:
+ *
+ *   RCT_EXPORT_VIEW_PROPERTY(values, NSArray)
+ *   RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock)
+ *   RCT_CUSTOM_VIEW_PROPERTY(text, NSString, RNCMyView) { … }
+ *   RCT_REMAP_VIEW_PROPERTY(jsName, nativeKeyPath, NSString)
+ *
+ * Capture the FIRST argument — that's the JS-visible prop name.
+ */
+const RCT_VIEW_PROP_RE =
+  /\bRCT_(?:EXPORT|CUSTOM|REMAP)_VIEW_PROPERTY\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/g;
+
+/**
+ * ObjC `@implementation Foo` extraction. Used to identify the ViewManager
+ * class so we can derive a JS-visible component name (strip the `Manager`
+ * suffix and a leading `RCT` prefix, both standard conventions).
+ */
+const OBJC_IMPL_RE = /@implementation\s+([A-Za-z_][A-Za-z0-9_]*)/;
+
+/**
+ * Derive the JS-visible component name from a native ViewManager class.
+ * Strip a trailing `Manager` (and optionally `ViewManager`) — RN's view
+ * registry maps `XXXManager` ↔ JS `<XXX/>` by this convention. The
+ * leading `RCT` prefix is also stripped (matches what
+ * `defaultObjcModuleName` does for RN's legacy bridge modules).
+ */
+function deriveComponentNameFromManager(className: string): string {
+  let name = className.startsWith('RCT') ? className.slice(3) : className;
+  // Trim ViewManager > Manager > View, in order.
+  if (name.endsWith('ViewManager')) name = name.slice(0, -'ViewManager'.length);
+  else if (name.endsWith('Manager')) name = name.slice(0, -'Manager'.length);
+  return name;
+}
+
+/**
+ * Cheap source-level detector — must contain `codegenNativeComponent` to
+ * be worth parsing. The presence of that import is the canonical Fabric
+ * spec signal.
+ */
+function isFabricSpec(source: string): boolean {
+  return source.includes('codegenNativeComponent');
+}
+
+/**
+ * Pull the `NativeProps` interface body out of a Fabric spec source.
+ * Returns `null` when the interface isn't declared in the expected shape.
+ */
+function findNativePropsBody(source: string): string | null {
+  // Permissive: `export interface NativeProps [extends X, Y] { … }`.
+  const m = source.match(/export\s+interface\s+NativeProps\b[^{]*\{([\s\S]*?)\n\}/);
+  return m?.[1] ?? null;
+}
+
+/**
+ * Parse the NativeProps interface body and return prop names.
+ * Each prop is `name?: Type;` or `name: Type;` on its own line.
+ * We don't care about types — just the JS-visible name.
+ */
+function extractPropNames(body: string): string[] {
+  const props: string[] = [];
+  // Anchor to start-of-line (after optional whitespace), then capture an
+  // identifier, then optional `?`, then `:`. Skip lines that look like
+  // method declarations (`name(`) — those are TurboModule spec methods,
+  // not view props.
+  const regex = /^\s*([A-Za-z_][A-Za-z0-9_]*)\??\s*:/gm;
+  let m: RegExpExecArray | null;
+  while ((m = regex.exec(body)) !== null) {
+    const name = m[1]!;
+    // Exclude any line that immediately turns into a function-shape (e.g.
+    // `onTap?: () => void` is fine — it's a prop, not a method body —
+    // but a literal `name(arg: T): R` is a method declaration).
+    const after = body.slice(m.index + m[0].length, m.index + m[0].length + 80);
+    if (/^\s*\(/.test(after)) continue; // method-shape, skip
+    props.push(name);
+  }
+  return props;
+}
+
+/**
+ * Extract legacy Paper view-manager declarations from a .m/.mm file.
+ * Emits a `component` node named after the JS-visible name (derived from
+ * the @implementation class) plus a `property` node per
+ * `RCT_EXPORT_VIEW_PROPERTY(name, ...)` macro.
+ *
+ * Returns `[]` if the file doesn't look like a ViewManager (no
+ * RCT_EXPORT_VIEW_PROPERTY macros).
+ */
+function extractLegacyViewManagerNodes(filePath: string, source: string): Node[] {
+  // Cheap gate: no view-property macros at all → not a view manager.
+  if (!source.includes('RCT_EXPORT_VIEW_PROPERTY') &&
+      !source.includes('RCT_CUSTOM_VIEW_PROPERTY') &&
+      !source.includes('RCT_REMAP_VIEW_PROPERTY')) {
+    return [];
+  }
+  const implMatch = source.match(OBJC_IMPL_RE);
+  if (!implMatch || !implMatch[1]) return [];
+  const className = implMatch[1];
+  // Only process actual ViewManagers — classes ending in Manager or
+  // (legacy) ViewManager. Classes with view-property macros that don't
+  // follow the naming convention are unusual; skip to keep precision.
+  if (!className.endsWith('Manager') && !className.endsWith('ViewManager')) return [];
+  const componentName = deriveComponentNameFromManager(className);
+  if (!componentName) return [];
+
+  const now = Date.now();
+  const nodes: Node[] = [];
+
+  // Component node — same shape as Codegen Fabric's, so the
+  // fabricNativeImplEdges synthesizer linking component → native class
+  // works for legacy too. The native class IS the manager itself in this
+  // case; the convention-based suffix lookup in the synthesizer
+  // (`Manager`, `ViewManager`) will find it.
+  const before = source.slice(0, implMatch.index ?? 0);
+  const startLine = before.split('\n').length;
+  nodes.push({
+    id: `fabric-component:${filePath}:${componentName}:${startLine}`,
+    kind: 'component',
+    name: componentName,
+    qualifiedName: `${filePath}::${componentName}`,
+    filePath,
+    language: 'objc',
+    startLine,
+    endLine: startLine,
+    startColumn: 0,
+    endColumn: componentName.length,
+    docstring: `Legacy Paper ViewManager component '${componentName}' (from @implementation ${className})`,
+    signature: `RCT_EXPORT_MODULE() // ViewManager: ${className}`,
+    isExported: true,
+    updatedAt: now,
+  });
+
+  // Property nodes per RCT_EXPORT_VIEW_PROPERTY macro.
+  const seen = new Set<string>();
+  RCT_VIEW_PROP_RE.lastIndex = 0;
+  let m: RegExpExecArray | null;
+  while ((m = RCT_VIEW_PROP_RE.exec(source)) !== null) {
+    const propName = m[1]!;
+    if (seen.has(propName)) continue;
+    seen.add(propName);
+    const propBefore = source.slice(0, m.index);
+    const propLine = propBefore.split('\n').length;
+    nodes.push({
+      id: `fabric-prop:${filePath}:${propName}:${propLine}`,
+      kind: 'property',
+      name: propName,
+      qualifiedName: `${filePath}::${componentName}.${propName}`,
+      filePath,
+      language: 'objc',
+      startLine: propLine,
+      endLine: propLine,
+      startColumn: 0,
+      endColumn: propName.length,
+      docstring: `Legacy Paper view prop '${propName}' on ${componentName}`,
+      isExported: true,
+      updatedAt: now,
+    });
+  }
+  return nodes;
+}
+
+/**
+ * Java/Kotlin `@ReactProp("name")` extraction. The annotation precedes a
+ * setter method on a class that extends `ViewManager` /
+ * `SimpleViewManager` (or in Kotlin, `:` syntax).
+ *
+ * Returns `[]` if no @ReactProp annotations are found.
+ */
+function extractJvmViewManagerNodes(filePath: string, source: string): Node[] {
+  if (!source.includes('@ReactProp')) return [];
+
+  // Class name — looking for `class FooManager [extends ViewManager...]`
+  // (Java) or `class FooManager : ViewManager...` (Kotlin). Either gates
+  // us into a ViewManager file; non-Manager classes with @ReactProp are
+  // unusual.
+  const classMatch = source.match(/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\b/);
+  if (!classMatch || !classMatch[1]) return [];
+  const className = classMatch[1];
+  if (!className.endsWith('Manager') && !className.endsWith('ViewManager')) return [];
+  const componentName = deriveComponentNameFromManager(className);
+  if (!componentName) return [];
+
+  const language: 'java' | 'kotlin' = filePath.endsWith('.kt') ? 'kotlin' : 'java';
+  const now = Date.now();
+  const nodes: Node[] = [];
+
+  const classBefore = source.slice(0, classMatch.index ?? 0);
+  const startLine = classBefore.split('\n').length;
+  nodes.push({
+    id: `fabric-component:${filePath}:${componentName}:${startLine}`,
+    kind: 'component',
+    name: componentName,
+    qualifiedName: `${filePath}::${componentName}`,
+    filePath,
+    language,
+    startLine,
+    endLine: startLine,
+    startColumn: 0,
+    endColumn: componentName.length,
+    docstring: `Android view-manager component '${componentName}' (from class ${className})`,
+    signature: `class ${className} : ViewManager`,
+    isExported: true,
+    updatedAt: now,
+  });
+
+  // @ReactProp("name") followed (after optional modifiers / args) by a
+  // setter declaration. The annotation argument is the JS-visible prop
+  // name. Permissive about the rest — we only need the literal.
+  const REACT_PROP_RE = /@ReactProp\s*\(\s*(?:name\s*=\s*)?"([^"]+)"/g;
+  const seen = new Set<string>();
+  let m: RegExpExecArray | null;
+  while ((m = REACT_PROP_RE.exec(source)) !== null) {
+    const propName = m[1]!;
+    if (seen.has(propName)) continue;
+    seen.add(propName);
+    const propBefore = source.slice(0, m.index);
+    const propLine = propBefore.split('\n').length;
+    nodes.push({
+      id: `fabric-prop:${filePath}:${propName}:${propLine}`,
+      kind: 'property',
+      name: propName,
+      qualifiedName: `${filePath}::${componentName}.${propName}`,
+      filePath,
+      language,
+      startLine: propLine,
+      endLine: propLine,
+      startColumn: 0,
+      endColumn: propName.length,
+      docstring: `Android @ReactProp prop '${propName}' on ${componentName}`,
+      isExported: true,
+      updatedAt: now,
+    });
+  }
+  return nodes;
+}
+
+function extractFabricNodes(filePath: string, source: string): Node[] {
+  if (!isFabricSpec(source)) return [];
+
+  const now = Date.now();
+  const nodes: Node[] = [];
+
+  CODEGEN_DECL_RE.lastIndex = 0;
+  let m: RegExpExecArray | null;
+  while ((m = CODEGEN_DECL_RE.exec(source)) !== null) {
+    const componentName = m[1]!;
+    const before = source.slice(0, m.index);
+    const startLine = before.split('\n').length;
+    const startColumn = before.length - before.lastIndexOf('\n') - 1;
+
+    // The component itself — kind: 'component' so the existing
+    // reactJsxChildEdges synthesizer matches `<MyComponent>` JSX tags to
+    // it (its name+kind filter is the gate).
+    const componentId = `fabric-component:${filePath}:${componentName}:${startLine}`;
+    nodes.push({
+      id: componentId,
+      kind: 'component',
+      name: componentName,
+      qualifiedName: `${filePath}::${componentName}`,
+      filePath,
+      // The spec file is .ts or .tsx; use the file's apparent language
+      // by extension. Trim to a known Language value.
+      language: filePath.endsWith('.tsx') ? 'tsx' : 'typescript',
+      startLine,
+      endLine: startLine,
+      startColumn,
+      endColumn: startColumn + 'codegenNativeComponent'.length,
+      docstring: `Fabric/Codegen native component '${componentName}'`,
+      signature: `codegenNativeComponent<NativeProps>('${componentName}')`,
+      isExported: true,
+      updatedAt: now,
+    });
+  }
+
+  // Props from the NativeProps interface. These are not "method" semantic
+  // — they're JS-visible bindings the consumer sets via JSX attributes —
+  // so use `property` kind. (The JSX synthesizer doesn't currently
+  // produce per-attribute edges, but surfacing the prop names as nodes
+  // lets `codegraph_search('onFinishTransitioning')` discover them.)
+  const body = findNativePropsBody(source);
+  if (body) {
+    const props = extractPropNames(body);
+    for (const propName of props) {
+      const propBefore = source.indexOf(propName, source.indexOf(body));
+      const propLine =
+        propBefore >= 0 ? source.slice(0, propBefore).split('\n').length : 1;
+      nodes.push({
+        id: `fabric-prop:${filePath}:${propName}:${propLine}`,
+        kind: 'property',
+        name: propName,
+        qualifiedName: `${filePath}::NativeProps.${propName}`,
+        filePath,
+        language: filePath.endsWith('.tsx') ? 'tsx' : 'typescript',
+        startLine: propLine,
+        endLine: propLine,
+        startColumn: 0,
+        endColumn: propName.length,
+        docstring: `Fabric NativeProps prop '${propName}'`,
+        isExported: true,
+        updatedAt: now,
+      });
+    }
+  }
+
+  return nodes;
+}
+
+export const fabricViewResolver: FrameworkResolver = {
+  name: 'fabric-view',
+  languages: ['typescript', 'tsx', 'objc', 'java', 'kotlin'],
+
+  detect(context) {
+    // Root package.json is the common case. The indexer only tracks
+    // SOURCE files in getAllFiles(), so package.jsons in subpackages
+    // aren't enumerable that way — we have to probe them explicitly via
+    // listDirectories() for monorepos.
+    const checkPkg = (relativePath: string) => {
+      const pkg = context.readFile(relativePath);
+      return pkg ? /["']react-native["']\s*:/.test(pkg) : false;
+    };
+    if (checkPkg('package.json')) return true;
+    // Monorepo escape hatch — react-native-skia and similar workspace
+    // repos have the RN dep only in `packages/<sub>/package.json`. Walk
+    // the common workspace roots one level deep.
+    const list = context.listDirectories;
+    if (!list) return false;
+    for (const root of ['packages', 'apps', 'modules', 'libraries']) {
+      for (const sub of list(root) ?? []) {
+        if (checkPkg(`${root}/${sub}/package.json`)) return true;
+      }
+    }
+    return false;
+  },
+
+  extract(filePath, source): FrameworkExtractionResult {
+    // Pick the right extractor by file language. The framework registry
+    // already filters by `languages` so we only see relevant files.
+    let nodes: Node[] = [];
+    if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
+      nodes = extractFabricNodes(filePath, source);
+    } else if (filePath.endsWith('.m') || filePath.endsWith('.mm')) {
+      nodes = extractLegacyViewManagerNodes(filePath, source);
+    } else if (filePath.endsWith('.java') || filePath.endsWith('.kt')) {
+      nodes = extractJvmViewManagerNodes(filePath, source);
+    }
+    return { nodes, references: [] };
+  },
+
+  resolve() {
+    // The companion synthesizer (`fabricNativeImplEdges`) handles
+    // cross-language edges; standard name resolution handles
+    // <MyComponent> → component-node via the JSX synthesizer.
+    return null;
+  },
+};

+ 16 - 0
src/resolution/frameworks/index.ts

@@ -21,6 +21,10 @@ import { goResolver } from './go';
 import { rustResolver } from './rust';
 import { aspnetResolver } from './csharp';
 import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
+import { swiftObjcBridgeResolver } from './swift-objc';
+import { reactNativeBridgeResolver } from './react-native';
+import { expoModulesResolver } from './expo-modules';
+import { fabricViewResolver } from './fabric';
 
 /**
  * All registered framework resolvers
@@ -54,6 +58,14 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   swiftUIResolver,
   uikitResolver,
   vaporResolver,
+  // Swift ↔ Objective-C cross-language bridging (mixed iOS apps)
+  swiftObjcBridgeResolver,
+  // React Native JS ↔ native bridge (legacy + TurboModules)
+  reactNativeBridgeResolver,
+  // Expo Modules — Function/AsyncFunction/Property DSL on Swift/Kotlin
+  expoModulesResolver,
+  // React Native Fabric / Codegen view components — TS spec → component nodes
+  fabricViewResolver,
 ];
 
 /**
@@ -124,3 +136,7 @@ export { goResolver } from './go';
 export { rustResolver } from './rust';
 export { aspnetResolver } from './csharp';
 export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
+export { swiftObjcBridgeResolver } from './swift-objc';
+export { reactNativeBridgeResolver } from './react-native';
+export { expoModulesResolver } from './expo-modules';
+export { fabricViewResolver } from './fabric';

+ 434 - 0
src/resolution/frameworks/react-native.ts

@@ -0,0 +1,434 @@
+/**
+ * React Native cross-language bridge resolver.
+ *
+ * Closes the JS ↔ native flow gap in React Native projects. Covers:
+ *
+ * **Legacy bridge** (older / still-prevalent in mid-tier RN libs):
+ *   - ObjC: `RCT_EXPORT_MODULE([opt_name])` declares a module; the module
+ *     name defaults to the class name minus an `RCT` prefix when no
+ *     argument is given. `RCT_EXPORT_METHOD(selector:(args))` declares a
+ *     JS-callable method whose JS name is the selector's first keyword.
+ *     `RCT_REMAP_METHOD(jsName, nativeSelector:(args))` overrides the JS
+ *     name explicitly.
+ *   - Java/Kotlin: `@ReactMethod` annotated methods on a
+ *     `ReactContextBaseJavaModule` subclass; the module name comes from
+ *     `getName()` returning a literal string.
+ *
+ * **TurboModules** (modern, used by react-native-svg, screens, FBSDK
+ * Next-gen libraries):
+ *   - TS spec interface declared in a `Native<X>.ts` file exporting
+ *     `TurboModuleRegistry.getEnforcing<Spec>('<ModuleName>')` (or
+ *     `.get<Spec>('<ModuleName>')`). The Spec interface methods are the
+ *     JS-callable surface; the matching native implementation is a class
+ *     whose method names match (selector first-keyword on ObjC,
+ *     identifier on Kotlin/Java).
+ *
+ * The two mechanisms share an end shape: a map from `(moduleName,
+ * jsMethodName)` to a native method node, plus a smaller map from
+ * `jsMethodName` alone for cases where the JS callsite doesn't carry
+ * the module qualifier (the most common JS pattern is
+ * `import Geo from './NativeGeolocation'; Geo.getPosition()` — the
+ * receiver is the default export, not literally `NativeModules.<Mod>`,
+ * so name-by-method-only is what actually resolves in practice).
+ *
+ * **Not covered** (deferred to a follow-up phase, per design doc §6):
+ *   - Fabric view components (`RCT_EXPORT_VIEW_PROPERTY` / Codegen view
+ *     specs) — these connect JSX props to native renderers, a different
+ *     flow shape that composes with the existing JSX synthesizer.
+ *   - Native → JS events (`RCTEventEmitter` / `NativeEventEmitter`) —
+ *     belongs in the callback synthesizer's cross-language channel.
+ */
+import type { Node } from '../../types';
+import {
+  FrameworkResolver,
+  ResolutionContext,
+} from '../types';
+
+/**
+ * One native RN method known to the resolver. Indexed by JS-visible name.
+ */
+interface NativeMethod {
+  /** Module name as seen from JS (`Geolocation`, `RNSVGRenderableModule`, …). */
+  moduleName: string;
+  /** JS-visible method name. */
+  jsName: string;
+  /** Native implementation node (ObjC method / Java method / Kotlin function). */
+  node: Node;
+}
+
+/** Per-context lazy map cache. */
+const nativeMethodMaps: WeakMap<
+  ResolutionContext,
+  { byJsName: Map<string, NativeMethod[]> }
+> = new WeakMap();
+
+// ─── Native-side extraction ─────────────────────────────────────────────────
+
+/**
+ * Default ObjC module name when `RCT_EXPORT_MODULE()` has no argument:
+ * strip a leading `RCT` prefix from the class name (Apple's convention)
+ * and treat the rest as the JS-visible module name. `RCTGeolocation` →
+ * `Geolocation`. Class names without an `RCT` prefix are returned
+ * unchanged.
+ */
+function defaultObjcModuleName(className: string): string {
+  return className.startsWith('RCT') && className.length > 3
+    ? className.slice(3)
+    : className;
+}
+
+/**
+ * Parse an ObjC `.m`/`.mm` file's source for `RCT_EXPORT_MODULE` and
+ * `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` declarations, returning the
+ * inferred (moduleName, jsMethodName) pairs.
+ *
+ * The macro forms (a single `RCT_EXPORT_MODULE` per file conventionally
+ * matched to a single `@implementation`):
+ *   - `RCT_EXPORT_MODULE()` — module name = class name with `RCT` prefix
+ *     stripped
+ *   - `RCT_EXPORT_MODULE(jsName)` — explicit name
+ *   - `RCT_EXPORT_METHOD(selector:(arg1)label1:(arg2)label2)` — JS name =
+ *     `selector` (the first keyword)
+ *   - `RCT_REMAP_METHOD(jsName, selector:(arg1)label1:(arg2)label2)` —
+ *     JS name = literal `jsName`
+ *
+ * Regex-based scan is sufficient — these macros are highly stylized and
+ * appear at top level. Pulling them out of the full AST would require a
+ * macro-aware ObjC parse the tree-sitter grammar doesn't provide.
+ */
+function parseObjcRNExports(
+  source: string,
+  className: string | null
+): Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string }> {
+  const results: Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string }> = [];
+
+  // RCT_EXPORT_MODULE — one per file by convention. Capture the optional arg.
+  const moduleMatch = source.match(/RCT_EXPORT_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)?\s*\)/);
+  // Need a module name to attribute methods. Prefer the explicit macro arg,
+  // then the class name, then bail (no module = nothing useful to register).
+  const moduleName =
+    moduleMatch?.[1] ??
+    (className ? defaultObjcModuleName(className) : null);
+  if (!moduleName) return results;
+
+  // RCT_EXPORT_METHOD(selectorFirstKw:(args)…)
+  // The first keyword (everything up to the first `:` or open paren) is the
+  // JS-visible name. We don't try to parse full multi-keyword selectors —
+  // RN's JS view of the method uses only the first keyword.
+  const exportRegex = /RCT_EXPORT_METHOD\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/g;
+  let m: RegExpExecArray | null;
+  while ((m = exportRegex.exec(source)) !== null) {
+    const kw = m[1];
+    if (kw) results.push({ moduleName, jsName: kw, nativeSelectorFirstKw: kw });
+  }
+
+  // RCT_REMAP_METHOD(jsName, nativeSelectorFirstKw:(args)…)
+  const remapRegex =
+    /RCT_REMAP_METHOD\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/g;
+  while ((m = remapRegex.exec(source)) !== null) {
+    const jsName = m[1];
+    const nativeKw = m[2];
+    if (jsName && nativeKw) {
+      results.push({ moduleName, jsName, nativeSelectorFirstKw: nativeKw });
+    }
+  }
+
+  return results;
+}
+
+/**
+ * Find the `@implementation` class name in an ObjC file — used as the
+ * fallback module name when `RCT_EXPORT_MODULE()` has no argument.
+ * (Categories of the form `@implementation Foo (Bar)` are correctly
+ * captured here as `Foo`, but a category file probably isn't where a
+ * fresh `RCT_EXPORT_MODULE` lives anyway.)
+ */
+function findObjcClassName(source: string): string | null {
+  const m = source.match(/@implementation\s+([A-Za-z_][A-Za-z0-9_]*)/);
+  return m?.[1] ?? null;
+}
+
+/**
+ * Parse a Java/Kotlin source file for `@ReactMethod` annotated methods
+ * and the surrounding class's `getName()` return value (the JS-visible
+ * module name).
+ *
+ * Java: `@ReactMethod public void getCurrentPosition(Callback cb) { … }`
+ * Kotlin: `@ReactMethod fun getCurrentPosition(cb: Callback) { … }`
+ *
+ * Class name comes from `class XxxModule extends ReactContextBaseJavaModule`
+ * (Java) or `class XxxModule : ReactContextBaseJavaModule(...)` (Kotlin).
+ * The JS-visible module name comes from `getName()` returning a literal
+ * string — fall back to the class name with a `Module` suffix stripped
+ * when the literal isn't present.
+ */
+function parseJvmRNExports(
+  source: string
+): Array<{ moduleName: string; jsName: string }> {
+  const results: Array<{ moduleName: string; jsName: string }> = [];
+
+  // getName() literal — Java + Kotlin both look something like:
+  //   public String getName() { return "Geolocation"; }
+  //   fun getName(): String = "Geolocation"
+  //   fun getName() = "Geolocation"
+  const getName = source.match(
+    /\bgetName\s*\([^)]*\)\s*(?::\s*String)?\s*(?:=\s*|\{[^}]*return\s*)"([^"]+)"/
+  );
+  // Class name fallback.
+  const classMatch =
+    source.match(/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\b[^{]*ReactContextBaseJavaModule/) ??
+    source.match(/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\b[^{]*ReactPackage/);
+  const moduleName =
+    getName?.[1] ?? (classMatch?.[1] ? classMatch[1].replace(/Module$/, '') : null);
+  if (!moduleName) return results;
+
+  // @ReactMethod annotations — followed (after optional modifiers / args /
+  // newlines) by either `void <name>(` (Java) or `fun <name>(` (Kotlin).
+  const methodRegex =
+    /@ReactMethod\b[^{]*?(?:\bfun\s+|\bvoid\s+|\bpublic\s+\w[\w<>\[\]]*\s+)([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
+  let m: RegExpExecArray | null;
+  while ((m = methodRegex.exec(source)) !== null) {
+    const jsName = m[1];
+    if (jsName) results.push({ moduleName, jsName });
+  }
+
+  return results;
+}
+
+/**
+ * Parse a TS file for a TurboModule spec declaration. The spec file is
+ * the JS↔native source-of-truth in the new architecture — its interface
+ * lists every JS-visible method, and a `TurboModuleRegistry.get*<Spec>(...)`
+ * default export pins the module name.
+ *
+ * Returns `null` when the file isn't a TurboModule spec.
+ */
+function parseTurboModuleSpec(
+  source: string
+): { moduleName: string; methods: string[] } | null {
+  // `TurboModuleRegistry.getEnforcing<Spec>('ModuleName')` or
+  // `TurboModuleRegistry.get<Spec>('ModuleName')`. The literal must be a
+  // single-or-double-quoted string.
+  const regMatch = source.match(
+    /TurboModuleRegistry\.(?:getEnforcing|get)\s*<[^>]*>\s*\(\s*['"]([^'"]+)['"]\s*\)/
+  );
+  if (!regMatch || !regMatch[1]) return null;
+  const moduleName = regMatch[1];
+
+  // Find `export interface Spec extends TurboModule { … }` and pull each
+  // method declaration's name. We don't need types — just names.
+  const ifaceMatch = source.match(
+    /export\s+interface\s+Spec\b[^{]*\{([\s\S]*?)\n\}/
+  );
+  if (!ifaceMatch || !ifaceMatch[1]) return null;
+  const body = ifaceMatch[1];
+
+  const methods: string[] = [];
+  // Method shape: `name(args): ReturnType;` or `name(): void;`. Skip
+  // properties (no parens before colon).
+  const methodRegex = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/gm;
+  let m: RegExpExecArray | null;
+  while ((m = methodRegex.exec(body)) !== null) {
+    const name = m[1];
+    if (name) methods.push(name);
+  }
+  return { moduleName, methods };
+}
+
+// ─── Map building ───────────────────────────────────────────────────────────
+
+/**
+ * RCTEventEmitter built-ins that every emitter subclass inherits. JS code
+ * doesn't directly call these — they're internal plumbing for the
+ * `NativeEventEmitter` abstraction. If we leave them in the bridge map,
+ * every JS `addListener` / `remove` call (Firestore subscribers, RxJS
+ * pipelines, plain Array.remove, etc.) gets mis-bridged to whichever
+ * emitter happens to define them. Skip during map building.
+ */
+const RN_EMITTER_BUILTINS = new Set([
+  'addListener',
+  'removeListeners',
+  'remove',
+  'invalidate',
+  'startObserving',
+  'stopObserving',
+]);
+
+function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, NativeMethod[]> } {
+  const cached = nativeMethodMaps.get(context);
+  if (cached) return cached;
+
+  const byJsName = new Map<string, NativeMethod[]>();
+  const allFiles = context.getAllFiles();
+  // Pre-index native methods by name for fast lookup when matching to
+  // their bridge exports.
+  const objcMethodsByFirstKw = new Map<string, Node[]>();
+  const jvmMethodsByName = new Map<string, Node[]>();
+  for (const node of context.getNodesByKind('method')) {
+    if (node.language === 'objc') {
+      const firstKw = node.name.includes(':') ? node.name.split(':')[0] : node.name;
+      if (firstKw) {
+        const arr = objcMethodsByFirstKw.get(firstKw);
+        if (arr) arr.push(node);
+        else objcMethodsByFirstKw.set(firstKw, [node]);
+      }
+    } else if (node.language === 'java' || node.language === 'kotlin') {
+      const arr = jvmMethodsByName.get(node.name);
+      if (arr) arr.push(node);
+      else jvmMethodsByName.set(node.name, [node]);
+    }
+  }
+
+  for (const file of allFiles) {
+    // Legacy bridge — ObjC side.
+    if (file.endsWith('.m') || file.endsWith('.mm')) {
+      const source = context.readFile(file);
+      if (!source) continue;
+      const className = findObjcClassName(source);
+      const exports = parseObjcRNExports(source, className);
+      for (const exp of exports) {
+        if (RN_EMITTER_BUILTINS.has(exp.jsName)) continue;
+        // Resolve to the native node by selector first-keyword. Multiple
+        // ObjC methods may share a first keyword across modules; filter by
+        // file path to attribute the export to this module's
+        // implementation file.
+        const candidates = objcMethodsByFirstKw.get(exp.nativeSelectorFirstKw) ?? [];
+        const node = candidates.find((c) => c.filePath === file) ?? candidates[0];
+        if (!node) continue;
+        const entry: NativeMethod = { moduleName: exp.moduleName, jsName: exp.jsName, node };
+        const arr = byJsName.get(exp.jsName);
+        if (arr) arr.push(entry);
+        else byJsName.set(exp.jsName, [entry]);
+      }
+    }
+
+    // Legacy bridge — Java/Kotlin side.
+    if (file.endsWith('.java') || file.endsWith('.kt')) {
+      const source = context.readFile(file);
+      if (!source) continue;
+      const exports = parseJvmRNExports(source);
+      for (const exp of exports) {
+        if (RN_EMITTER_BUILTINS.has(exp.jsName)) continue;
+        const candidates = jvmMethodsByName.get(exp.jsName) ?? [];
+        const node = candidates.find((c) => c.filePath === file) ?? candidates[0];
+        if (!node) continue;
+        const entry: NativeMethod = { moduleName: exp.moduleName, jsName: exp.jsName, node };
+        const arr = byJsName.get(exp.jsName);
+        if (arr) arr.push(entry);
+        else byJsName.set(exp.jsName, [entry]);
+      }
+    }
+
+    // TurboModule spec — TS side.
+    if (file.endsWith('.ts') || file.endsWith('.tsx')) {
+      const source = context.readFile(file);
+      if (!source) continue;
+      const spec = parseTurboModuleSpec(source);
+      if (!spec) continue;
+      // For each spec method, find a matching native implementation by
+      // name. The spec's module name doesn't determine the native file
+      // path (Codegen wires it via name convention), so we match across
+      // all native methods of the right name.
+      for (const methodName of spec.methods) {
+        if (RN_EMITTER_BUILTINS.has(methodName)) continue;
+        // ObjC first-keyword match, then JVM bare-name match. Don't
+        // require module-name match for ObjC because the native side may
+        // have stripped a prefix.
+        const objcCands = objcMethodsByFirstKw.get(methodName) ?? [];
+        const jvmCands = jvmMethodsByName.get(methodName) ?? [];
+        for (const node of [...objcCands, ...jvmCands]) {
+          const entry: NativeMethod = { moduleName: spec.moduleName, jsName: methodName, node };
+          const arr = byJsName.get(methodName);
+          if (arr) arr.push(entry);
+          else byJsName.set(methodName, [entry]);
+        }
+      }
+    }
+  }
+
+  const result = { byJsName };
+  nativeMethodMaps.set(context, result);
+  return result;
+}
+
+// ─── Resolver ───────────────────────────────────────────────────────────────
+
+export const reactNativeBridgeResolver: FrameworkResolver = {
+  name: 'react-native-bridge',
+  languages: ['javascript', 'typescript', 'tsx', 'jsx'],
+
+  /**
+   * Detect: package.json depends on `react-native`, OR any source file
+   * uses the `RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` /
+   * `TurboModuleRegistry` markers. Either signal is enough — different
+   * libraries split the JS package from the native code (`react-native-svg`'s
+   * apple/ + android/ directories vs its src/), so we don't require both.
+   */
+  detect(context) {
+    const pkg = context.readFile('package.json');
+    if (pkg && /["']react-native["']\s*:/.test(pkg)) return true;
+    // Fallback: scan a small number of files for the macro markers — only
+    // looking at the first ones returned by getAllFiles to keep detect()
+    // fast on huge repos.
+    const files = context.getAllFiles();
+    for (let i = 0; i < Math.min(files.length, 200); i++) {
+      const f = files[i];
+      if (!f) continue;
+      if (f.endsWith('.mm') || f.endsWith('.m')) {
+        const src = context.readFile(f);
+        if (src && /RCT_EXPORT_MODULE\b/.test(src)) return true;
+      }
+      if (f.endsWith('.ts') || f.endsWith('.tsx')) {
+        const src = context.readFile(f);
+        if (src && /TurboModuleRegistry\.(?:get|getEnforcing)\s*</.test(src)) return true;
+      }
+    }
+    return false;
+  },
+
+  claimsReference(_name) {
+    // JS-visible method names are ordinary identifiers and are typically
+    // already in `knownNames` (every TurboModule spec method, every
+    // RCT_EXPORT_METHOD, has a node somewhere). So we don't need to
+    // claim through the pre-filter — the ref reaches us via the normal
+    // hasAnyPossibleMatch path.
+    return false;
+  },
+
+  resolve(ref, context) {
+    // We only redirect JS callers — native callers don't need this resolver.
+    if (
+      ref.language !== 'javascript' &&
+      ref.language !== 'typescript' &&
+      ref.language !== 'tsx' &&
+      ref.language !== 'jsx'
+    ) {
+      return null;
+    }
+
+    // JS callsites of `obj.method()` reach the resolver as either
+    // `obj.method` (qualified) or `method` (bare). Strip a single dot
+    // prefix to get the JS-visible method name.
+    const name = ref.referenceName.includes('.')
+      ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
+      : ref.referenceName;
+
+    const maps = buildRNMaps(context);
+    const entries = maps.byJsName.get(name);
+    if (!entries || entries.length === 0) return null;
+
+    // Prefer the iOS (ObjC) target over Android when both exist — iOS is
+    // the conventional first-class platform for RN library docs and most
+    // graph queries. We still record only one edge; a JVM-only resolution
+    // is fine when no ObjC target exists.
+    const objc = entries.find((e) => e.node.language === 'objc');
+    const target = objc ?? entries[0];
+    if (!target) return null;
+    return {
+      original: ref,
+      targetNodeId: target.node.id,
+      confidence: 0.6,
+      resolvedBy: 'framework',
+    };
+  },
+};

+ 299 - 0
src/resolution/frameworks/swift-objc.ts

@@ -0,0 +1,299 @@
+/**
+ * Swift ↔ Objective-C bridge resolver.
+ *
+ * Closes the cross-language flow gap in mixed iOS codebases. The pure
+ * bridging name math lives in `../swift-objc-bridge.ts`; this file wires
+ * it into the resolution pipeline.
+ *
+ * **Two directions to close:**
+ *
+ * 1. **Swift call → ObjC method** — A Swift caller writes
+ *    `imageDownloader.download(url:completion:)`. Tree-sitter-swift parses
+ *    this as a call_expression whose callee identifier is `download`
+ *    (parameter labels live in the argument list, not the callee). The
+ *    name-matcher tries to find any node named `download` and fails (no
+ *    Swift method by that name in this project; the ObjC implementation is
+ *    `-downloadURL:completion:`). We catch it here: from the bare Swift
+ *    name `download`, look up ObjC methods whose bridged Swift base name
+ *    would be `download` (using `swiftBaseNamesForObjcSelector`'s reverse
+ *    map, precomputed once per session).
+ *
+ * 2. **ObjC call → Swift method** — An ObjC caller writes
+ *    `[swiftThing fooWithBar:42]`. Tree-sitter-objc parses this as a
+ *    message_expression with selector `fooWithBar:` (after the multi-
+ *    keyword fix in this branch). The name-matcher tries to find a node
+ *    named `fooWithBar:` — no Swift node has colons in its name, so it
+ *    fails. We catch it: from the ObjC selector, derive candidate Swift
+ *    base names (`['fooWithBar', 'foo']`), and look up Swift methods
+ *    named those.
+ *
+ * **Provenance:** every edge produced here is recorded as a framework-
+ * resolved reference (`resolvedBy: 'framework'`) with `confidence: 0.7`
+ * (matches the django ORM dynamic-dispatch precedent — not exact, but
+ * deterministic from the bridging rule).
+ */
+import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
+import type { Node } from '../../types';
+import {
+  swiftBaseNamesForObjcSelector,
+  isObjcExposed,
+} from '../swift-objc-bridge';
+
+/**
+ * Memoized "Swift base name → ObjC method nodes" map.
+ *
+ * Built lazily on first `resolve()` per resolver instance — the resolver is
+ * recreated when the index is rebuilt, so this naturally invalidates with
+ * the graph. Keyed by ResolutionContext identity so multiple projects sharing
+ * a process (the daemon) don't bleed maps between them.
+ */
+const objcByCandidateSwiftBase: WeakMap<
+  ResolutionContext,
+  Map<string, Node[]>
+> = new WeakMap();
+
+/**
+ * Build the reverse-bridge map: for every ObjC method node in the graph,
+ * compute the Swift base names that would auto-bridge to its selector and
+ * record the node under each.
+ *
+ * Runs once per resolver lifetime; the cost scales linearly with the count
+ * of ObjC method nodes. On Wikipedia-iOS (~2500 files, ~25k ObjC methods)
+ * this is a few hundred ms — much cheaper than re-parsing source on each
+ * unresolved ref.
+ */
+/**
+ * Names that are too generic to bridge with any precision. These are common
+ * Cocoa / NSObject conventions that almost every ObjC class implements; if a
+ * Swift caller writes `init()` or `description`, mapping it to an arbitrary
+ * project-local ObjC method of the same name produces noise, not signal.
+ *
+ * Critically, refs of these names virtually always resolve via the regular
+ * name-matcher (every project has many `init` nodes) — skipping them here
+ * just keeps the bridge from competing with name-match on already-handled
+ * refs.
+ */
+const GENERIC_NAMES = new Set([
+  'init',
+  'description',
+  'debugDescription',
+  'hash',
+  'isEqual',
+  'isEqualTo',
+  'copy',
+  'mutableCopy',
+  'class',
+  'self',
+  'count',
+  'length',
+  'value',
+  'name',
+  'data',
+  'string',
+  'object',
+  'add',
+  'remove',
+  'update',
+  'load',
+  'save',
+  'reload',
+  'cancel',
+  'start',
+  'stop',
+  'pause',
+  'resume',
+  'close',
+  'open',
+  'show',
+  'hide',
+  'toString',
+  'dealloc',
+  'release',
+  'retain',
+  'autorelease',
+]);
+
+function buildObjcMap(context: ResolutionContext): Map<string, Node[]> {
+  const cached = objcByCandidateSwiftBase.get(context);
+  if (cached) return cached;
+
+  const map = new Map<string, Node[]>();
+  const objcMethods = context
+    .getNodesByKind('method')
+    .filter((n) => n.language === 'objc');
+  for (const node of objcMethods) {
+    const candidates = swiftBaseNamesForObjcSelector(node.name);
+    for (const c of candidates) {
+      // Skip the trivial case where the Swift base name equals the ObjC
+      // method name verbatim (no colons) — the regular name-matcher
+      // already handles that and our map would just duplicate the work.
+      if (c === node.name && !node.name.includes(':')) continue;
+      // Skip generic Cocoa names (init, description, etc.) — they would
+      // false-positive against any project-local ObjC method of the same
+      // name. The regular name-matcher handles them.
+      if (GENERIC_NAMES.has(c)) continue;
+      const arr = map.get(c);
+      if (arr) arr.push(node);
+      else map.set(c, [node]);
+    }
+  }
+  objcByCandidateSwiftBase.set(context, map);
+  return map;
+}
+
+/**
+ * Window of source text around a Swift declaration used by `isObjcExposed`
+ * to spot `@objc` / `@nonobjc` annotations. Read line above + the
+ * declaration line — Swift attributes typically sit on the preceding line
+ * (`@objc` on a line of its own) or inline.
+ */
+const SOURCE_PROBE_LINES = 3;
+
+/**
+ * Read a small window of source ending at `node.startLine`, used to
+ * inspect Swift attribute annotations attached to a declaration. Returns
+ * an empty string if the source can't be read.
+ */
+function declarationSourceWindow(node: Node, context: ResolutionContext): string {
+  const content = context.readFile(node.filePath);
+  if (!content) return '';
+  const lines = content.split(/\r?\n/);
+  const startIdx = Math.max(0, node.startLine - 1 - SOURCE_PROBE_LINES);
+  const endIdx = Math.min(lines.length, node.startLine);
+  return lines.slice(startIdx, endIdx).join('\n');
+}
+
+/**
+ * Try to resolve a Swift caller's bare reference to an ObjC implementation.
+ *
+ * Strategy: look up the ObjC reverse-bridge map for nodes whose Swift base
+ * name would match. Return the first match (matches the existing
+ * single-target resolution contract).
+ */
+function resolveSwiftCallToObjc(
+  ref: UnresolvedRef,
+  context: ResolutionContext
+): ResolvedRef | null {
+  // Swift call sites of `obj.foo(bar:)` reach the resolver as either bare
+  // name `foo` (tree-sitter-swift) or qualified `obj.foo` — strip prefix.
+  const rawName = ref.referenceName.includes('.')
+    ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
+    : ref.referenceName;
+
+  const map = buildObjcMap(context);
+  const candidates = map.get(rawName);
+  if (!candidates || candidates.length === 0) return null;
+
+  // Prefer ObjC methods whose corresponding Swift declaration isn't itself
+  // present (so we don't wrongly redirect a Swift call to ObjC when a Swift
+  // method of the same name is the real target — that's the in-language case
+  // and should already be resolved by the name-matcher). Since this resolver
+  // runs AFTER exact-match, any matching Swift node would already have won;
+  // so a candidate reaching us is a legitimate cross-language hit.
+  const target = candidates[0];
+  if (!target) return null;
+  return {
+    original: ref,
+    targetNodeId: target.id,
+    confidence: 0.6,
+    resolvedBy: 'framework',
+  };
+}
+
+/**
+ * Try to resolve an ObjC caller's selector reference to a Swift `@objc`
+ * implementation.
+ *
+ * Strategy: derive candidate Swift base names from the selector via
+ * `swiftBaseNamesForObjcSelector`. For each, look up Swift methods named
+ * that and verify with a source-window check that the declaration is
+ * `@objc`-exposed (filters out false matches where a Swift function
+ * happens to share the name but isn't bridged).
+ */
+function resolveObjcCallToSwift(
+  ref: UnresolvedRef,
+  context: ResolutionContext
+): ResolvedRef | null {
+  // ObjC call sites get receiver-prefixed when the receiver isn't self/super
+  // (see tree-sitter.ts message_expression handling): `[obj foo:bar:]`
+  // becomes `obj.foo:bar:`. Strip the receiver prefix to recover the raw
+  // selector for the bridge math.
+  const rawSelector = ref.referenceName.includes('.')
+    ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
+    : ref.referenceName;
+
+  // Bridge math only applies to selector-shape names (contain `:`).
+  if (!rawSelector.includes(':')) return null;
+
+  const candidates = swiftBaseNamesForObjcSelector(rawSelector);
+  for (const candidate of candidates) {
+    const matches = context
+      .getNodesByName(candidate)
+      .filter((n) => n.language === 'swift' && (n.kind === 'method' || n.kind === 'function'));
+    for (const match of matches) {
+      const window = declarationSourceWindow(match, context);
+      if (isObjcExposed(window)) {
+        return {
+          original: ref,
+          targetNodeId: match.id,
+          confidence: 0.6,
+          resolvedBy: 'framework',
+        };
+      }
+    }
+  }
+  return null;
+}
+
+export const swiftObjcBridgeResolver: FrameworkResolver = {
+  name: 'swift-objc-bridge',
+  // Applies to both languages — bridging crosses the boundary.
+  languages: ['swift', 'objc'],
+
+  /**
+   * Detect: this resolver is relevant when the project has both Swift and
+   * Objective-C source. Either-side-only projects don't need bridging
+   * (and the empty reverse-map would be a no-op anyway).
+   */
+  detect(context) {
+    const files = context.getAllFiles();
+    let hasSwift = false;
+    let hasObjc = false;
+    for (const f of files) {
+      if (f.endsWith('.swift')) hasSwift = true;
+      else if (f.endsWith('.m') || f.endsWith('.mm')) hasObjc = true;
+      if (hasSwift && hasObjc) return true;
+    }
+    return false;
+  },
+
+  /**
+   * Let selector-shape references (anything containing a `:`) through the
+   * resolver's name-exists pre-filter — no Swift node has a colon in its
+   * name, so without this opt-in those refs would be dropped before
+   * `resolve()` sees them. Also opt-in `setX:`-style names that aren't
+   * otherwise declared symbols, in case the Swift side is a property.
+   */
+  claimsReference(name) {
+    if (name.includes(':')) return true;
+    // Bare names without colons are handled by the regular name-exists
+    // pre-filter — no need to opt them in here.
+    return false;
+  },
+
+  /**
+   * Route based on which language the caller is in. The two directions are
+   * symmetric in shape but very different in implementation (forward
+   * direction uses the precomputed reverse-bridge map; reverse direction
+   * uses the deterministic name-derivation).
+   */
+  resolve(ref, context) {
+    if (ref.language === 'swift') {
+      return resolveSwiftCallToObjc(ref, context);
+    }
+    if (ref.language === 'objc') {
+      return resolveObjcCallToSwift(ref, context);
+    }
+    return null;
+  },
+};

+ 276 - 0
src/resolution/swift-objc-bridge.ts

@@ -0,0 +1,276 @@
+/**
+ * Swift ↔ Objective-C bridging rules.
+ *
+ * Apple's auto-bridging mechanism exposes Swift declarations to the ObjC
+ * runtime under a deterministic selector name. The full rule set:
+ * https://developer.apple.com/documentation/swift/importing-swift-into-objective-c
+ *
+ * This module is **pure name math** — given a Swift declaration's base name
+ * + parameter external labels (or the raw signature text), produce the
+ * bridged ObjC selector(s); given an ObjC selector, produce the
+ * candidate Swift base names. No graph/DB access here.
+ *
+ * Used by `frameworks/swift-objc.ts` (the framework resolver that wires
+ * the rules into the resolution pipeline) and by its tests.
+ *
+ * ─── Bridging cheat sheet ───────────────────────────────────────────────
+ *
+ *   Swift declaration                             ObjC selector
+ *   ─────────────────────────────────────────     ─────────────────────────
+ *   func play()                                    play
+ *   func play(_ song: String)                      play:
+ *   func play(song: String)                        playWithSong:
+ *   func play(_ song: String, by artist: String)   play:by:
+ *   func play(song: String, by artist: String)     playWithSong:by:
+ *   init(name: String)                             initWithName:
+ *   init(name: String, age: Int)                   initWithName:age:
+ *   var name: String  (getter / setter)            name  /  setName:
+ *   @objc(custom:) func f(_ x: Int)                custom:        (literal override)
+ *
+ * The reverse direction (ObjC → Swift) collapses the bridge: a Swift call
+ * site for `play(song:)` reaches us as the bare base name `play` (Swift's
+ * tree-sitter call_expression strips parameter labels from the callee
+ * name). So `swiftBaseNamesForObjcSelector('playWithSong:')` returns
+ * `['play']` — the resolver looks up Swift methods named `play`.
+ */
+
+/**
+ * Capitalize the first character of a string. Used for the "With"-prefix
+ * form on the first selector keyword when the Swift declaration has an
+ * explicit first-parameter label (e.g. `func play(song:)` → `playWithSong:`).
+ */
+function capFirst(s: string): string {
+  return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
+}
+
+/**
+ * Lowercase the first character. Used in reverse: `setName:` setter ↔
+ * Swift property `name`.
+ */
+function lowerFirst(s: string): string {
+  return s.length > 0 ? s.charAt(0).toLowerCase() + s.slice(1) : s;
+}
+
+/**
+ * Compute the auto-bridged ObjC selector for a Swift method declaration.
+ *
+ * @param baseName  The Swift method's base name (e.g. `play`).
+ * @param externalLabels  Parameter EXTERNAL labels in declaration order;
+ *                        `null` for a `_` (unlabeled) parameter.
+ *                        `[]` for a no-parameter method.
+ * @param explicitObjcName  If `@objc(customSel:)` was specified, the
+ *                          literal selector — short-circuits the rule
+ *                          and is returned as-is.
+ * @returns The ObjC selector (e.g. `playWithSong:by:`), or `null` if it
+ *          can't be determined.
+ *
+ * **Method rules:**
+ * - No params → base name (no colons)
+ * - Single param, `_` label → `baseName:`
+ * - Single param, explicit label `L` → `baseNameWithL:`
+ * - Multi-param, `_` first label → `baseName:label2:label3:`
+ * - Multi-param, explicit first label `L1` → `baseNameWithL1:label2:label3:`
+ *
+ * Initializer rules are handled by `objcSelectorForSwiftInit`.
+ */
+export function objcSelectorForSwiftMethod(
+  baseName: string,
+  externalLabels: (string | null)[],
+  explicitObjcName?: string | null
+): string | null {
+  if (!baseName) return null;
+  if (explicitObjcName) return explicitObjcName;
+
+  if (externalLabels.length === 0) {
+    return baseName;
+  }
+
+  const [first, ...rest] = externalLabels;
+  // Single param: "_" → "base:" ; "label" → "baseWithLabel:"
+  // Multi-param mirrors the same first-keyword formation, then appends each
+  // subsequent label as its own keyword. A `null` later label is invalid
+  // ObjC (no way to express unlabeled middle params) — keep as `:` to be safe.
+  const firstKeyword =
+    first === null || first === undefined || first === '_' || first === ''
+      ? `${baseName}:`
+      : `${baseName}With${capFirst(first)}:`;
+
+  const restKeywords = rest.map((l) => `${l ?? ''}:`).join('');
+  return firstKeyword + restKeywords;
+}
+
+/**
+ * Compute the bridged ObjC selector for a Swift `init(...)` declaration.
+ *
+ * **Init rules** (different from regular methods — Apple always uses
+ * `initWith` regardless of whether the first label is `_`):
+ * - `init()`                       → `init`
+ * - `init(_ name: String)`         → `initWithName:`  (uses the INTERNAL
+ *                                    name when external is `_`, per Apple's
+ *                                    bridging conventions)
+ * - `init(name: String)`           → `initWithName:`
+ * - `init(name: String, age: Int)` → `initWithName:age:`
+ *
+ * For the `_` case we need the internal (second identifier) name —
+ * passed via `internalNames`.
+ */
+export function objcSelectorForSwiftInit(
+  externalLabels: (string | null)[],
+  internalNames: string[],
+  explicitObjcName?: string | null
+): string | null {
+  if (explicitObjcName) return explicitObjcName;
+
+  if (externalLabels.length === 0) {
+    return 'init';
+  }
+
+  const [firstExt, ...restExt] = externalLabels;
+  const [firstInt] = internalNames;
+  // Use the internal name when external is "_"; ObjC needs *some* keyword,
+  // and Swift's auto-bridger uses the parameter's local name in this case.
+  const firstLabel =
+    firstExt === null || firstExt === '_' || firstExt === ''
+      ? firstInt
+      : firstExt;
+  if (!firstLabel) return null;
+
+  const firstKeyword = `initWith${capFirst(firstLabel)}:`;
+  const restKeywords = restExt
+    .map((label, idx) => {
+      const internal = internalNames[idx + 1];
+      const name = label && label !== '_' ? label : internal ?? '';
+      return `${name}:`;
+    })
+    .join('');
+  return firstKeyword + restKeywords;
+}
+
+/**
+ * Compute the bridged ObjC getter + setter for a Swift `@objc` property.
+ *
+ * - `var name: String`        → getter `name`, setter `setName:`
+ * - `var isReady: Bool`       → getter `isReady`, setter `setIsReady:`
+ *   (no special `is` handling — Swift's `isReady` stays as `isReady` in ObjC;
+ *   `@objc(name:)` overrides if a Cocoa-style getter `isReady` / setter
+ *   `setReady:` pairing is needed — that's the responsibility of the
+ *   declaration's `@objc(customGetter)` annotation, which we surface via
+ *   `explicitObjcName`.)
+ */
+export function objcAccessorsForSwiftProperty(
+  swiftName: string,
+  explicitObjcName?: string | null
+): { getter: string; setter: string } | null {
+  if (!swiftName) return null;
+  // The override syntax `@objc(customGetterName)` re-points the GETTER only;
+  // the setter still follows the `setX:` rule but is keyed off the override.
+  // (`@objc(getX:setY:)` is not currently supported — that's a rarer
+  // shape; can extend later if a real codebase needs it.)
+  const getter = explicitObjcName ?? swiftName;
+  return {
+    getter,
+    setter: `set${capFirst(getter)}:`,
+  };
+}
+
+/**
+ * Reverse: from an ObjC selector, return the candidate Swift base names
+ * the resolver should try when looking for the bridged Swift declaration.
+ *
+ * Examples:
+ *   `play`                 → ['play']
+ *   `play:`                → ['play']
+ *   `playWithSong:`        → ['play', 'playWithSong']
+ *   `play:by:`             → ['play']
+ *   `playWithSong:by:`     → ['play', 'playWithSong']
+ *   `initWithName:`        → ['init']                      (init is its own base name)
+ *   `initWithName:age:`    → ['init']
+ *   `setName:`             → ['name', 'setName']           (could be a setter OR a regular func)
+ *   `tableView:didSel…:`   → ['tableView']
+ *
+ * Returns multiple candidates because the bare base name is ambiguous —
+ * `playWithSong:` could correspond to either `func play(song:)` or
+ * `func playWithSong(_ x:)` (a Swift method literally named that with a
+ * `_` first label). The resolver tries each.
+ */
+export function swiftBaseNamesForObjcSelector(selector: string): string[] {
+  if (!selector) return [];
+
+  // Strip trailing colons and split into keywords.
+  const keywords = selector.replace(/:+$/g, '').split(':');
+  const firstKeyword = keywords[0];
+  if (!firstKeyword) return [];
+
+  const candidates: Set<string> = new Set();
+
+  // Always a candidate: the raw first keyword. Covers
+  //   `play:`           → `play`
+  //   `play:by:`        → `play`
+  //   `playWithSong:`   → `playWithSong` (a literal Swift name)
+  //   `tableView:...:`  → `tableView`
+  candidates.add(firstKeyword);
+
+  // `initWith<X>:` and `initWith<X>:<more>:` always reduce to `init`.
+  if (firstKeyword.startsWith('initWith')) {
+    candidates.add('init');
+  }
+
+  // Preposition-prefix patterns: `<base>(With|For|By|In|On|At|From|To|Of|As)<Cap>:`
+  // covers both Swift's @objc EXPORT rule (always "With") and Cocoa's
+  // IMPORTED selectors which use other prepositions natively (e.g.
+  // `objectForKey:`, `stringWithFormat:`, `compareTo:`,
+  // `imageNamed:inBundle:`). Strip to recover the Swift base name a caller
+  // would use (e.g. `object`, `string`, `compare`, `image`).
+  const prepositionMatch = firstKeyword.match(
+    /^([a-z][a-zA-Z0-9]*?)(?:With|For|By|In|On|At|From|To|Of|As)[A-Z]/
+  );
+  if (prepositionMatch && prepositionMatch[1]) {
+    candidates.add(prepositionMatch[1]);
+  }
+
+  // `setX:` could be a property setter — the Swift property is `x` (lowercase).
+  // Only fires for the obvious shape: `set` + capital letter + ':' (one param).
+  if (
+    keywords.length === 1 &&
+    /^set[A-Z]/.test(firstKeyword) &&
+    selector.endsWith(':')
+  ) {
+    const propName = lowerFirst(firstKeyword.slice(3));
+    if (propName) candidates.add(propName);
+  }
+
+  return Array.from(candidates);
+}
+
+/**
+ * Detect whether a Swift method `@objc` declaration uses the `@objc(custom:)`
+ * override form, returning the literal selector when present.
+ *
+ * Regex-based scan over the small chunk of source preceding the declaration —
+ * tree-sitter would be more precise but this is only consulted as a fallback
+ * when the structured AST isn't available (e.g. resolver-time lookups
+ * via `context.readFile`).
+ *
+ * Returns `null` when the declaration is plain `@objc` (no override) or has
+ * no `@objc` attribute at all.
+ */
+export function detectExplicitObjcName(sourceSlice: string): string | null {
+  // `@objc(customName:)` or `@objc(custom:name:)` — the parens contents are
+  // the literal ObjC selector. Whitespace permitted.
+  const m = sourceSlice.match(/@objc\s*\(\s*([^)\s]+)\s*\)/);
+  return m && m[1] ? m[1] : null;
+}
+
+/**
+ * Detect whether a Swift declaration is `@objc`-exposed by scanning the
+ * source slice that precedes it. Returns true for explicit `@objc`,
+ * `@objc(custom:)`, or membership in a `@objcMembers` class (caller's
+ * responsibility to pass class-level context if relevant).
+ *
+ * `@nonobjc` returns false even if `@objc` also appears (per Swift's rule
+ * that `@nonobjc` opts out of class-level `@objcMembers`).
+ */
+export function isObjcExposed(sourceSlice: string): boolean {
+  if (/@nonobjc\b/.test(sourceSlice)) return false;
+  return /@objc\b/.test(sourceSlice);
+}