Explorar o código

feat(resolution): Expo Modules — extract Function/AsyncFunction/Property nodes (Phase 4)

Closes the JS → native flow for Expo SDK packages. Expo's Swift / Kotlin
DSL declares JS-callable methods via literals inside a Module subclass:

  public class HapticsModule: Module {
    public func definition() -> ModuleDefinition {
      Name("ExpoHaptics")
      AsyncFunction("notificationAsync") { (notificationType: NotificationType) in … }
      AsyncFunction("impactAsync") { (style: ImpactStyle) in … }
      AsyncFunction("selectionAsync") { … }
    }
  }

Tree-sitter parses these as ordinary call_expressions with trailing
closures, so until now `Camera.takePictureAsync(...)` on the JS side
had nothing to resolve to. The new framework extractor walks the source
for the declarative literals and synthesizes `method` nodes named
`takePictureAsync` / `notificationAsync` / `width` / etc.,
attributed to the Swift or Kotlin file. The standard name-matcher then
resolves JS callsites to them via the existing `obj.method` →
method-name path — no separate resolve() branch needed.

Detection: package.json declares `expo-modules-core` OR any source
file matches `class X: Module` + at least one declarative literal
(both signals together — class-name match alone over-fires on unrelated
`: Module` references).

Validated on real Expo SDK packages:
- **expo-haptics**: 6 Expo method nodes (Swift + Kotlin) covering
  `notificationAsync`, `impactAsync`, `selectionAsync`,
  `performHapticsAsync`.
- **expo-camera**: 41 Expo method nodes covering the full surface
  (`takePictureAsync`, `record`, `resumePreview`,
  `scanFromURLAsync`, view properties `width`/`height`, …). 16
  internal swift+kotlin→expo edges within the package's own native
  side. The package's own JS wrappers (CameraView.tsx) shadow the
  native names with TS methods, so within-package JS callsites resolve
  to the TS wrapper first; external Expo apps consuming the package
  bridge through to the native method directly.

5 tests covering extraction + an end-to-end fixture that demonstrates
`Haptics.uniqueExpoHapticCall()` in JS resolves to the native Swift
`AsyncFunction("uniqueExpoHapticCall")` node.

924/926 existing tests still pass (2 skipped); +5 new Expo tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry hai 4 semanas
pai
achega
6bf4ff5690

+ 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);
+  });
+});

+ 45 - 2
docs/design/mixed-ios-and-react-native-bridging.md

@@ -370,8 +370,8 @@ otherwise closed but a Fabric flow still breaks.
 | 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 |
-| JavaScript × Swift/Kotlin | Expo Modules | `requireNativeModule('X').fn(...)` → `Function("fn") { }` | R | ⬜ Phase 4 |
+| Objective-C/Java/Kotlin → JavaScript | React Native event emitters | `[self sendEventWithName:]` → `addListener` | S (cross-lang channel) | ✅ Phase 3 (§8e) |
+| JavaScript × Swift/Kotlin | Expo Modules | `requireNativeModule('X').fn(...)` → `Function("fn") { }` | R (extract synthesizes method nodes) | ✅ Phase 4 (§8f) |
 | JavaScript × native | React Native Fabric views | `<MyView p=v/>` → `RCT_EXPORT_VIEW_PROPERTY` / Codegen view spec | R + JSX | ⬜ Phase 6 (defer) |
 
 ### 8a. Phase 1 measurements — Swift ↔ ObjC
@@ -417,6 +417,49 @@ runs against the populated index.
 | 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.
+
 ---
 
 ## 9. Open questions to settle in Phase 1

+ 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;
+  },
+};

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

@@ -23,6 +23,7 @@ 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';
 
 /**
  * All registered framework resolvers
@@ -60,6 +61,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   swiftObjcBridgeResolver,
   // React Native JS ↔ native bridge (legacy + TurboModules)
   reactNativeBridgeResolver,
+  // Expo Modules — Function/AsyncFunction/Property DSL on Swift/Kotlin
+  expoModulesResolver,
 ];
 
 /**
@@ -132,3 +135,4 @@ 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';