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

feat(resolution): React Native cross-language bridge (Phase 2 + part of Phase 5)

Closes the JS ↔ native flow gap in React Native projects. Covers two
adjacent dispatch channels in one resolver, since they share the same
end shape (a JS-visible method name backed by a native impl):

**Legacy bridge** (Phase 2):
- ObjC: parse `RCT_EXPORT_MODULE([opt_name])` for the module name
  (defaulting to class name minus `RCT` prefix), `RCT_EXPORT_METHOD`
  for the JS-visible method (the selector's first keyword), and
  `RCT_REMAP_METHOD(jsName, selector)` for the explicit-rename form.
- Java/Kotlin: parse `@ReactMethod` annotated methods plus the
  surrounding class's `getName()` literal (with a class-name fallback
  stripping a trailing `Module`).

**TurboModules** (Phase 5):
- Scan `Native<X>.ts` spec files for
  `TurboModuleRegistry.get[Enforcing]<Spec>('ModuleName')` and the
  `export interface Spec extends TurboModule` body. Each spec method
  is matched to a native impl by selector first-keyword (ObjC) or
  identifier (Java/Kotlin).

Resolver applies only to JS-family callers; the bridge map is built
lazily on first resolve() and cached per-context. Prefers ObjC over
Java when both implementations exist (iOS is conventionally the
first-class platform for RN library queries).

Validated on react-native-svg (small RN; 700 source files across iOS
ObjC++, Android Java/Kotlin, and JS/TS):
- 9 framework-resolved JS→Java edges covering the full TurboModule
  spec surface: `isPointInStroke`, `isPointInFill`,
  `getTotalLength`, `getPointAtLength`, `getCTM`,
  `getScreenCTM`, `getBBox`, `toDataURL`. Examples:
    [tsx] SvgNativeMethods → [java] getTotalLength
      apps/common/example/examples/Svg.tsx →
        android/src/main/java/com/horcrux/svg/RNSVGRenderableManager.java
- In-language baselines unchanged (Java=1961, TSX=199, ObjC=570,
  TS=98 in-lang calls).
- 14 unit tests covering: default module-name detection,
  RCT_EXPORT_MODULE(explicit), RCT_REMAP_METHOD, Java @ReactMethod
  with getName(), Kotlin @ReactMethod, TurboModule spec resolution,
  bare-vs-qualified callsite handling, and non-JS caller rejection.

Not yet covered (deferred per design doc §6):
- Fabric view components (RCT_EXPORT_VIEW_PROPERTY / Codegen view
  specs) — JSX prop → native renderer flow.
- Native → JS events (RCTEventEmitter / NativeEventEmitter).
- TurboModule native implementation classes that don't use legacy
  macros (RNSvg's iOS side falls in this gap — methods exist as
  plain ObjC methods on classes inheriting from a Codegen-generated
  spec; matching requires inheritance-aware bridging).

916/918 existing tests still pass (2 skipped); +14 new RN bridge tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 4 недель назад
Родитель
Сommit
6120c4df34

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

@@ -0,0 +1,267 @@
+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();
+  });
+});

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

@@ -22,6 +22,7 @@ import { rustResolver } from './rust';
 import { aspnetResolver } from './csharp';
 import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
 import { swiftObjcBridgeResolver } from './swift-objc';
+import { reactNativeBridgeResolver } from './react-native';
 
 /**
  * All registered framework resolvers
@@ -57,6 +58,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   vaporResolver,
   // Swift ↔ Objective-C cross-language bridging (mixed iOS apps)
   swiftObjcBridgeResolver,
+  // React Native JS ↔ native bridge (legacy + TurboModules)
+  reactNativeBridgeResolver,
 ];
 
 /**
@@ -128,3 +131,4 @@ export { rustResolver } from './rust';
 export { aspnetResolver } from './csharp';
 export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
 export { swiftObjcBridgeResolver } from './swift-objc';
+export { reactNativeBridgeResolver } from './react-native';

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

@@ -0,0 +1,414 @@
+/**
+ * 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 ───────────────────────────────────────────────────────────
+
+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) {
+        // 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) {
+        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) {
+        // 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',
+    };
+  },
+};