Переглянути джерело

feat(impact): classic RN NativeModules cross-platform pairing (Android @ReactMethod ↔ iOS impl)

The classic React Native bridge links a JS callsite to a native module method,
but only ONE platform's impl — the other (the iOS method when JS resolved to
Android, or vice versa) looked like nothing called it and editing it showed no
blast radius. Same gap fixed for Expo, now for the classic bridge.

`rnCrossPlatformEdges`: a native method (java/kotlin/objc/cpp) that HAS a JS-side
`calls` edge is a confirmed bridge method; link it to the same-named native
method in another language (the other platform's impl), both directions. Names
are normalized to the first selector keyword (`getFreeDiskStorage:` →
`getFreeDiskStorage`) so an iOS selector lines up with the bare Android name.
RN runtime-infrastructure methods (addListener / getConstants / getName / … —
present on every module) are excluded so they don't cross-link unrelated modules
in a multi-module repo.

Measured on react-native-device-info: 152 cross-platform pairs (Java↔ObjC↔C++);
the classic JS→Java/ObjC bridge itself is unchanged. Heuristic edges (provenance
`heuristic`, `synthesizedBy:'rn-cross-platform'`). Full suite green; node count
stable. (Note: iOS methods written with the `RCT_EXPORT_METHOD` macro parse as a
macro, not a method node, so only regular ObjC method impls pair today — macro
extraction is a separate follow-up.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 тижнів тому
батько
коміт
4a64ca5

+ 1 - 1
CHANGELOG.md

@@ -15,7 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
-- React Native / Expo cross-language bridges are more complete and more precise. An Expo Module method declared with a generic type — Android's `AsyncFunction<Float>("getBatteryLevelAsync")` — is now indexed (the `<Float>` used to defeat the matcher, so every Android Expo method was dropped and a JS call resolved only to the iOS Swift impl). The iOS and Android implementations of the same JS-visible method are now linked to each other, so a JS call that resolves to one platform still reaches the other and editing either surfaces the caller. And a `Type.member` static read in native code (e.g. Android's `BatteryManager.EXTRA_LEVEL`) no longer falsely links to a coincidentally same-named class in another language (a web `BatteryManager`) — type references stay within a language family, while genuine cross-language bridges (config→code, JS↔native calls) are unaffected. (React Native, Expo)
+- React Native / Expo cross-language bridges are more complete and more precise. An Expo Module method declared with a generic type — Android's `AsyncFunction<Float>("getBatteryLevelAsync")` — is now indexed (the `<Float>` used to defeat the matcher, so every Android Expo method was dropped and a JS call resolved only to the iOS Swift impl). The iOS and Android implementations of the same JS-visible method — both Expo Modules and classic NativeModules (`@ReactMethod` on Android, the matching method on iOS) — are now linked to each other, so a JS call that resolves to one platform still reaches the other and editing either platform's native code surfaces the JS caller. And a `Type.member` static read in native code (e.g. Android's `BatteryManager.EXTRA_LEVEL`) no longer falsely links to a coincidentally same-named class in another language (a web `BatteryManager`) — type references stay within a language family, while genuine cross-language bridges (config→code, JS↔native calls) are unaffected. (React Native, Expo)
 - `codegraph affected` now reports the tests and files that actually depend on your changes. It used to follow only `import` statements — but those never cross file boundaries in CodeGraph's graph — so it returned **no affected tests for any change, in every language**. It now traces the real cross-file usage graph (calls, references, instantiations, and class `extends` / `implements`), so `git diff --name-only | codegraph affected` surfaces the test files that exercise the changed code. Circular-dependency detection, which had the same blind spot, now works too.
 - Blast radius, callers, and `codegraph affected` now recognize far more of the dependencies that were already in your code. A symbol now counts as a dependency whether it's called, used only in a type annotation inside a function body (`const items: Foo[] = []`), imported and placed in a registry array or passed as an argument, used as a JSX component, simply re-exported from a barrel (`export { X } from './x'`), or pulled in as a namespace (`import * as ns from '@/x'`) — including through tsconfig path aliases like `@/`. Previously only called, instantiated, or signature-typed symbols created a cross-file link, so a file that used a dependency in any other way could look like it depended on nothing — and the file that defined a widely-used symbol could look like nothing depended on it. The graph still indexes exactly the same symbols; it just connects the ones that were already there. (TypeScript/JavaScript)
 - The same completeness fix now applies to **Python**: a name brought in with `from module import X` is recorded as a dependency on that module even when `X` is only stored in a list/dict, passed as an argument, used as a decorator, or re-exported through an `__init__.py`. Previously Python linked only imports that were called or instantiated, so a module consumed purely by value — or only re-exported — looked like nothing depended on it.

+ 42 - 1
__tests__/react-native-bridge.test.ts

@@ -1,7 +1,11 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
 import type { Node, Language } from '../src/types';
 import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
 import { reactNativeBridgeResolver } from '../src/resolution/frameworks/react-native';
+import { CodeGraph } from '../src';
 
 /**
  * Mock ResolutionContext for the React Native bridge resolver.
@@ -292,3 +296,40 @@ describe('React Native bridge resolver', () => {
     });
   });
 });
+
+describe('React Native cross-platform pairing — end to end', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-xplat-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  it('links the Android (@ReactMethod) and iOS (RCT_EXPORT_METHOD) impls of a JS-called method', async () => {
+    fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.74.0"}}');
+    fs.writeFileSync(path.join(dir, 'index.ts'),
+      "import { NativeModules } from 'react-native';\n" +
+      "export function ping() { return NativeModules.RNThing.uniquePingMethod(); }\n");
+    fs.writeFileSync(path.join(dir, 'RNThing.java'),
+      "public class RNThing extends ReactContextBaseJavaModule {\n" +
+      "  @Override public String getName() { return \"RNThing\"; }\n" +
+      "  @ReactMethod public void uniquePingMethod(Callback cb) {}\n}\n");
+    fs.writeFileSync(path.join(dir, 'RNThing.m'),
+      "@implementation RNThing\n" +
+      "RCT_EXPORT_MODULE()\n" +
+      "- (void)uniquePingMethod:(RCTResponseSenderBlock)cb {}\n@end\n");
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    // The Java and ObjC impls of `uniquePingMethod` are linked to each other, so
+    // a JS call that resolves to one platform reaches the other.
+    const pair = db.prepare(
+      `SELECT count(*) c 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-cross-platform'
+         AND s.name LIKE 'uniquePingMethod%' AND t.name LIKE 'uniquePingMethod%'
+         AND s.language != t.language`
+    ).get();
+    cg.close?.();
+    expect(pair.c).toBeGreaterThanOrEqual(2); // java<->objc both directions
+  });
+});

+ 81 - 0
src/resolution/callback-synthesizer.ts

@@ -1122,6 +1122,85 @@ function expoCrossPlatformEdges(queries: QueryBuilder): Edge[] {
   return edges;
 }
 
+/**
+ * Classic React Native NativeModules cross-platform pairing. A native module
+ * method (`@ReactMethod` on Android, `RCT_EXPORT_METHOD` on iOS) is implemented
+ * on BOTH platforms, but a JS callsite name-resolves to only ONE — so the other
+ * platform's impl looked like nothing called it. A native method that HAS a JS
+ * caller is a confirmed bridge method; link it to the same-named native method
+ * in another language (the other platform's impl) so a JS call reaching one
+ * platform reaches the other, and editing either surfaces the JS caller.
+ *
+ * Names are normalized to the first selector keyword (`getFreeDiskStorage:` →
+ * `getFreeDiskStorage`) — that's the JS-visible name, and how the iOS selector
+ * lines up with the bare Android method name.
+ */
+function rnCrossPlatformEdges(queries: QueryBuilder): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const NATIVE = new Set(['java', 'kotlin', 'objc', 'cpp']);
+  const JS = new Set(['typescript', 'tsx', 'javascript', 'jsx']);
+  // RN module INFRASTRUCTURE methods exist on every native module (called by the
+  // RN runtime, not user JS), so pairing them by name would cross-link unrelated
+  // modules in a multi-module repo. Skip them — they aren't user-facing methods.
+  const RN_INFRA = new Set([
+    'addListener', 'removeListeners', 'getConstants', 'constantsToExport', 'getName',
+    'invalidate', 'initialize', 'getDefaultEventTypes', 'supportedEvents',
+    'requiresMainQueueSetup', 'methodQueue',
+  ]);
+  const norm = (name: string): string => {
+    const i = name.indexOf(':');
+    return i >= 0 ? name.slice(0, i) : name;
+  };
+
+  // Index native methods by their JS-visible (normalized) name. Only names with
+  // impls in ≥2 native languages can pair, so the per-method JS-caller check
+  // below only runs for genuine cross-platform candidates.
+  const byName = new Map<string, Node[]>();
+  for (const m of queries.iterateNodesByKind('method')) {
+    if (!NATIVE.has(m.language)) continue;
+    const key = norm(m.name);
+    const arr = byName.get(key);
+    if (arr) arr.push(m);
+    else byName.set(key, [m]);
+  }
+
+  for (const [groupName, group] of byName) {
+    if (RN_INFRA.has(groupName)) continue;
+    const langs = new Set(group.map((m) => m.language));
+    if (langs.size < 2) continue; // single-platform — nothing to pair
+    for (const m of group) {
+      // Is m a bridge method? (a JS-language `calls` edge points at it)
+      const incoming = queries.getIncomingEdges(m.id, ['calls']);
+      if (incoming.length === 0) continue;
+      const sources = queries.getNodesByIds(incoming.map((e) => e.source));
+      const isBridge = incoming.some((e) => {
+        const s = sources.get(e.source);
+        return !!s && JS.has(s.language);
+      });
+      if (!isBridge) continue;
+      // Link to the other-platform impls (both directions).
+      for (const sib of group) {
+        if (sib.id === m.id || sib.language === m.language) continue;
+        for (const [a, b] of [[m, sib], [sib, m]] as const) {
+          const key = `${a.id}>${b.id}`;
+          if (seen.has(key)) continue;
+          seen.add(key);
+          edges.push({
+            source: a.id,
+            target: b.id,
+            kind: 'calls',
+            line: a.startLine,
+            provenance: 'heuristic',
+            metadata: { synthesizedBy: 'rn-cross-platform', via: norm(m.name) },
+          });
+        }
+      }
+    }
+  }
+  return edges;
+}
+
 function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
   const edges: Edge[] = [];
   const seen = new Set<string>();
@@ -1382,6 +1461,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const rnEventEdgesList = rnEventEdges(ctx);
   const fabricNativeEdges = fabricNativeImplEdges(ctx);
   const expoXPlatEdges = expoCrossPlatformEdges(queries);
+  const rnXPlatEdges = rnCrossPlatformEdges(queries);
   const mybatisEdges = mybatisJavaXmlEdges(queries);
   const ginEdges = ginMiddlewareChainEdges(queries, ctx);
 
@@ -1402,6 +1482,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...rnEventEdgesList,
     ...fabricNativeEdges,
     ...expoXPlatEdges,
+    ...rnXPlatEdges,
     ...mybatisEdges,
     ...ginEdges,
   ]) {