diff --git a/CHANGELOG.md b/CHANGELOG.md index e27e5157a..c87eaa745 100644 --- a/CHANGELOG.md +++ b/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("getBatteryLevelAsync")` — is now indexed (the `` 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("getBatteryLevelAsync")` — is now indexed (the `` 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. diff --git a/__tests__/react-native-bridge.test.ts b/__tests__/react-native-bridge.test.ts index e443ab337..0760d7bd9 100644 --- a/__tests__/react-native-bridge.test.ts +++ b/__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 + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 8ee383b51..918d261c7 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/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(); + 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(); + 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(); @@ -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, ]) {