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

feat(synthesizer): RN event channel — native sendEventWithName → JS addListener handler (Phase 3)

Extends src/resolution/callback-synthesizer.ts with a cross-language
event channel for React Native's RCTEventEmitter pattern:

- Native dispatch sites:
  - ObjC: `[self sendEventWithName:@"X" body:...]` (matched via
    RN_OBJC_SEND_RE in .m/.mm files)
  - Java/Kotlin: `.emit("X", ...)` (matched via RN_JVM_EMIT_RE in
    .java/.kt files only — JS-side .emit is handled by the existing
    in-language eventEmitterEdges path)
- JS subscribers:
  - `.addListener('X', handler)` / `.on('X', handler)` /
    `.once('X', handler)` — matches both named-handler and
    parameter-handler forms (the latter is common in RN wrapper APIs
    like RNFirebase's `messaging().onMessage(listener)` where the
    handler arg is a parameter that flows up to user code; we attribute
    to the ENCLOSING JS function in that case for a reachability-correct
    hop)

Synthesized edges:
- kind: 'calls', provenance: 'heuristic',
  metadata.synthesizedBy: 'rn-event-channel'
- Same fan-out cap as the in-language channel (skip events with > 6
  handlers or > 6 dispatchers — over-generic names would over-link
  without type info)
- De-duplicated against other synthesizer channels via the existing
  seen-set in synthesizeCallbackEdges

Validated on RNFirebase (large, ~1100 source files): 3 precise cross-
language edges connecting the canonical iOS push notification flow:
- application:didReceiveRemoteNotification:fetchCompletionHandler:
    → TS onMessage  (event: messaging_message_received)
- userNotificationCenter:willPresentNotification:withCompletionHandler:
    → TS onMessage  (event: messaging_message_received)
- userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:
    → TS onNotificationOpenedApp  (event: messaging_notification_opened)

Two new tests: __tests__/rn-event-channel.test.ts covers the
named-handler form and the wrapper-API parameter-handler fallback.

918/920 existing tests still pass (2 skipped); +2 new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 4 недель назад
Родитель
Сommit
3f5118fe6c
2 измененных файлов с 290 добавлено и 3 удалено
  1. 126 0
      __tests__/rn-event-channel.test.ts
  2. 164 3
      src/resolution/callback-synthesizer.ts

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

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

@@ -520,10 +520,160 @@ 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 `sendEventWithName:@"X"` shape. We don't need to track the
+// `body:` argument — just the event name.
+const RN_OBJC_SEND_RE = /\bsendEventWithName\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));
+      }
+    }
+
+    // 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) 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;
+}
+
 /**
  * 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).
+ * 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 +684,21 @@ 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 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,
+  ]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;
     seen.add(key);