Files
codegraph/src/resolution/frameworks/react-native.ts
T
Colby McHenry 873f133c96 feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API
Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
2026-08-28 09:46:50 -05:00

640 lines
27 KiB
TypeScript

/**
* 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).
*
* **Swift modules via `RCT_EXTERN_MODULE`** — the shape an app's OWN native
* code usually takes: a Swift class exposed through a thin `.m` shim.
* - `@interface RCT_EXTERN_MODULE(ClassName, RCTSuperclass)` names the
* Swift class, which is also the JS module (`NativeModules.ClassName`);
* `RCT_EXTERN_REMAP_MODULE(jsName, ClassName, Super)` renames it.
* - `RCT_EXTERN_METHOD(selector:(args)…)` exposes the Swift method named
* by the selector's first keyword; `RCT_EXTERN_REMAP_METHOD(jsName,
* selector…)` under another JS name. The implementation is the Swift
* `@objc func` of that name on the class or one of its extensions — a
* real node from the Swift extractor, so no synthetic node is minted.
*
* **Receiver evidence.** `captureView.finalizeCaptureSession()` where
* `const captureView = NativeModules.CaptureView` names the module as surely
* as `NativeModules.CaptureView.finalizeCaptureSession()` does: both resolve
* at 0.95 to that module's method — ahead of the import resolver, which
* would otherwise land the call on the `captureView` constant and the flow
* would stop one hop short of native. A bare `.method()` with no module
* evidence keeps the by-name match at 0.6.
*
* **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[]>; aliases: Map<string, string> }
> = 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; line: number }> {
const results: Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string; line: number }> = [];
// 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;
const lineOf = (idx: number): number => {
let line = 1;
for (let i = 0; i < idx && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
return line;
};
// 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, line: lineOf(m.index) });
}
// 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, line: lineOf(m.index) });
}
}
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;
}
/**
* `RCT_EXTERN_MODULE` / `RCT_EXTERN_METHOD` — the `.m` shim that exposes a
* Swift class. One entry per exposed method, each naming the Swift class the
* implementation lives on (the module name is the class name unless
* `RCT_EXTERN_REMAP_MODULE` says otherwise).
*/
export function parseObjcRNExterns(
source: string
): Array<{ moduleName: string; className: string; jsName: string; nativeSelectorFirstKw: string; line: number }> {
const results: Array<{ moduleName: string; className: string; jsName: string; nativeSelectorFirstKw: string; line: number }> = [];
const remap = source.match(
/RCT_EXTERN_REMAP_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/
);
const plain = source.match(/RCT_EXTERN_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/);
const moduleName = remap?.[1] ?? plain?.[1] ?? null;
const className = remap?.[2] ?? plain?.[1] ?? null;
if (!moduleName || !className) return results;
const lineOf = (idx: number): number => {
let line = 1;
for (let i = 0; i < idx && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
return line;
};
const methodRegex = /RCT_EXTERN_METHOD\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/g;
let m: RegExpExecArray | null;
while ((m = methodRegex.exec(source)) !== null) {
const kw = m[1];
if (kw) results.push({ moduleName, className, jsName: kw, nativeSelectorFirstKw: kw, line: lineOf(m.index) });
}
const remapRegex =
/RCT_EXTERN_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, className, jsName, nativeSelectorFirstKw: nativeKw, line: lineOf(m.index) });
}
}
return results;
}
/**
* Local names bound to a native module on the JS side — the receiver evidence
* `resolve()` trusts:
*
* const captureView = NativeModules.CaptureView
* export const { CaptureEvents } = NativeModules
* const { Geo: geolocation } = NativeModules
*
* Collected across the project by name: an alias is nearly always an exported
* constant imported elsewhere under the same name. A name bound to two
* different modules is dropped as ambiguous rather than guessed.
*/
export function collectNativeModuleAliases(
source: string,
aliases: Map<string, string>,
ambiguous: Set<string>
): void {
const bind = (alias: string, moduleName: string): void => {
if (ambiguous.has(alias)) return;
const prior = aliases.get(alias);
if (prior !== undefined && prior !== moduleName) {
aliases.delete(alias);
ambiguous.add(alias);
return;
}
aliases.set(alias, moduleName);
};
const direct = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*NativeModules\.([A-Z][\w$]*)/g;
let m: RegExpExecArray | null;
while ((m = direct.exec(source)) !== null) bind(m[1]!, m[2]!);
const destructured = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*NativeModules\b/g;
while ((m = destructured.exec(source)) !== null) {
for (const part of m[1]!.split(',')) {
const entry = part.trim().match(/^([A-Za-z_$][\w$]*)(?:\s*:\s*([A-Za-z_$][\w$]*))?$/);
if (entry) bind(entry[2] ?? entry[1]!, entry[1]!);
}
}
}
/**
* 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 ───────────────────────────────────────────────────────────
/**
* RCTEventEmitter built-ins that every emitter subclass inherits. JS code
* doesn't directly call these — they're internal plumbing for the
* `NativeEventEmitter` abstraction. If we leave them in the bridge map,
* every JS `addListener` / `remove` call (Firestore subscribers, RxJS
* pipelines, plain Array.remove, etc.) gets mis-bridged to whichever
* emitter happens to define them. Skip during map building.
*/
const RN_EMITTER_BUILTINS = new Set([
'addListener',
'removeListeners',
'remove',
'invalidate',
'startObserving',
'stopObserving',
]);
function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, NativeMethod[]>; aliases: Map<string, string> } {
const cached = nativeMethodMaps.get(context);
if (cached) return cached;
const byJsName = new Map<string, NativeMethod[]>();
const aliases = new Map<string, string>();
const ambiguousAliases = new Set<string>();
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[]>();
const swiftMethodsByName = 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]);
} else if (node.language === 'swift') {
const arr = swiftMethodsByName.get(node.name);
if (arr) arr.push(node);
else swiftMethodsByName.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) {
if (RN_EMITTER_BUILTINS.has(exp.jsName)) continue;
// 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]);
}
// Swift-backed module: the shim names the class; the implementation is
// the Swift method of that name on the class or one of its extensions.
// Class-scoped, so a same-named method on another Swift type (a
// `syncSettings` on `CaptureSettings` beside the one on `CaptureView`)
// is never the answer.
if (/RCT_EXTERN_(?:REMAP_)?MODULE\b/.test(source)) {
for (const ext of parseObjcRNExterns(source)) {
if (RN_EMITTER_BUILTINS.has(ext.jsName)) continue;
const candidates = (swiftMethodsByName.get(ext.nativeSelectorFirstKw) ?? [])
.filter((c) => c.qualifiedName.split('::').includes(ext.className))
.sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
const node = candidates[0];
if (!node) continue;
const entry: NativeMethod = { moduleName: ext.moduleName, jsName: ext.jsName, node };
const arr = byJsName.get(ext.jsName);
if (arr) arr.push(entry);
else byJsName.set(ext.jsName, [entry]);
}
}
}
// JS side: the local names bound to `NativeModules.<Module>`.
if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
const source = context.readFile(file);
if (source && source.includes('NativeModules')) {
collectNativeModuleAliases(source, aliases, ambiguousAliases);
}
}
// 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) {
if (RN_EMITTER_BUILTINS.has(exp.jsName)) continue;
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) {
if (RN_EMITTER_BUILTINS.has(methodName)) continue;
// 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, aliases };
nativeMethodMaps.set(context, result);
return result;
}
// ─── Resolver ───────────────────────────────────────────────────────────────
export const reactNativeBridgeResolver: FrameworkResolver = {
name: 'react-native-bridge',
// objc/mm included so `extract()` sees the native files — `resolve()` still
// only redirects JS callers (it returns null for native languages).
languages: ['javascript', 'typescript', 'tsx', 'jsx', 'objc'],
/**
* Extract `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` declarations as method
* nodes. These macros parse as a macro-expression (an ERROR node), NOT a
* `method_definition`, so the ObjC extractor never made a node for them — the
* iOS half of a native module was invisible, so a JS call couldn't resolve to
* it and the cross-platform pairing had nothing to pair. The node is named by
* the JS-visible name (the selector's first keyword, or the explicit
* `RCT_REMAP_METHOD` JS name) so it matches the Android `@ReactMethod` method.
*/
extract(filePath, source) {
if (!filePath.endsWith('.m') && !filePath.endsWith('.mm')) return { nodes: [], references: [] };
if (!/RCT_EXPORT_MODULE\b/.test(source)) return { nodes: [], references: [] };
const exports = parseObjcRNExports(source, findObjcClassName(source));
const now = Date.now();
const nodes: Node[] = [];
const seen = new Set<string>();
for (const e of exports) {
if (seen.has(e.jsName)) continue;
seen.add(e.jsName);
nodes.push({
id: `rn-export:${filePath}:${e.moduleName}.${e.jsName}`,
kind: 'method',
name: e.jsName,
qualifiedName: `${filePath}::${e.moduleName}.${e.jsName}`,
filePath,
language: 'objc',
startLine: e.line,
endLine: e.line,
startColumn: 0,
endColumn: 0,
isExported: true,
docstring: `RCT_EXPORT_METHOD ${e.nativeSelectorFirstKw} (module ${e.moduleName})`,
signature: `RCT_EXPORT_METHOD(${e.nativeSelectorFirstKw}:…)`,
updatedAt: now,
});
}
return { nodes, references: [] };
},
/**
* Detect: package.json depends on `react-native`, OR any source file
* uses the `RCT_EXPORT_MODULE` / `RCT_EXTERN_MODULE` /
* `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|RCT_EXTERN_(?:REMAP_)?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 the receiver to
// get the JS-visible method name — and keep it, as evidence.
const raw = ref.referenceName;
const lastDot = raw.lastIndexOf('.');
const name = lastDot >= 0 ? raw.slice(lastDot + 1) : raw;
const maps = buildRNMaps(context);
const entries = maps.byJsName.get(name);
if (!entries || entries.length === 0) return null;
// iOS first — the conventional first-class platform for RN library docs
// and most graph queries; one edge is recorded either way.
const pick = (list: NativeMethod[]): NativeMethod | undefined =>
list.find((e) => e.node.language === 'objc') ??
list.find((e) => e.node.language === 'swift') ??
list[0];
// Receiver evidence: `NativeModules.Mod.method`, or an alias the project
// bound to `NativeModules.Mod`. The module is named, so the match is to
// THAT module's method at a confidence the import resolver cannot beat
// — and a module that has no such method is not ours to guess at.
if (lastDot >= 0) {
const receiver = raw.slice(0, lastDot);
const direct = receiver.match(/^NativeModules\.([A-Z][\w$]*)$/);
const moduleName = direct ? direct[1]! : maps.aliases.get(receiver) ?? null;
if (moduleName !== null) {
const exact = pick(entries.filter((e) => e.moduleName === moduleName));
if (!exact) return null;
return {
original: ref,
targetNodeId: exact.node.id,
confidence: 0.95,
resolvedBy: 'framework',
metadata: { bridge: 'react-native', module: moduleName },
};
}
}
const target = pick(entries);
if (!target) return null;
return {
original: ref,
targetNodeId: target.node.id,
confidence: 0.6,
resolvedBy: 'framework',
metadata: { bridge: 'react-native', module: target.moduleName },
};
},
};