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.
This commit is contained in:
Colby McHenry
2026-08-28 09:46:50 -05:00
parent f0eafe31f9
commit 873f133c96
36 changed files with 3711 additions and 73 deletions
+27 -4
View File
@@ -1406,10 +1406,13 @@ async function vueTemplateEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
* 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.
* event name. A NAMED handler (`addListener('x', handleX)`) is the target
* when it is a node; an unnamed one — a parameter passed through, or an
* inline `(data) => {…}` written in a `useEffect` — is attributed to the
* enclosing function, where the event demonstrably lands. (The in-language
* synthesizer stays named-only; this channel pairs across a language
* boundary on a literal, which is the evidence that makes the wider
* attribution safe.)
*
* Provenance `'heuristic'`, synthesizedBy `'rn-event-channel'`.
*/
@@ -1518,6 +1521,9 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
// 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;
// The inline form: `.addListener('x', (data) => {…})` / `function () {…}`.
const ADDLISTENER_INLINE =
/\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:async\s*)?(?:\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>|function\s*\()/g;
ADDLISTENER_ANY.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ADDLISTENER_ANY.exec(content))) {
@@ -1561,6 +1567,23 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
map.set(targetId, `${file}:${lineOf(m.index)}`);
jsHandlersByEvent.set(event, map);
}
// Inline listeners — the shape a React component registers inside a
// `useEffect`: `nativeEmitter.addListener('onCaptureComplete', (data) =>
// { … router.push('/review') })`. There is no handler symbol to name,
// so the subscription is attributed to the enclosing function exactly
// as the unnamed-argument form above is: the native event lands in that
// component, and its body is where a reader looks next. This channel
// only — the in-language synthesizer keeps its named-handler policy.
ADDLISTENER_INLINE.lastIndex = 0;
while ((m = ADDLISTENER_INLINE.exec(content))) {
const event = m[1];
if (!event) continue;
const enclosing = enclosingFn(nodesInFile, lineOf(m.index));
if (!enclosing) continue;
const map = jsHandlersByEvent.get(event) ?? new Map<string, string>();
if (!map.has(enclosing.id)) map.set(enclosing.id, `${file}:${lineOf(m.index)}`);
jsHandlersByEvent.set(event, map);
}
}
}
+174 -16
View File
@@ -31,6 +31,25 @@
* 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
@@ -59,7 +78,7 @@ interface NativeMethod {
/** Per-context lazy map cache. */
const nativeMethodMaps: WeakMap<
ResolutionContext,
{ byJsName: Map<string, NativeMethod[]> }
{ byJsName: Map<string, NativeMethod[]>; aliases: Map<string, string> }
> = new WeakMap();
// ─── Native-side extraction ─────────────────────────────────────────────────
@@ -154,6 +173,87 @@ function findObjcClassName(source: string): string | null {
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
@@ -260,16 +360,19 @@ const RN_EMITTER_BUILTINS = new Set([
'stopObserving',
]);
function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, NativeMethod[]> } {
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;
@@ -282,6 +385,10 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
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]);
}
}
@@ -306,6 +413,33 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
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.
@@ -352,7 +486,7 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
}
}
const result = { byJsName };
const result = { byJsName, aliases };
nativeMethodMaps.set(context, result);
return result;
}
@@ -406,7 +540,7 @@ export const reactNativeBridgeResolver: FrameworkResolver = {
/**
* Detect: package.json depends on `react-native`, OR any source file
* uses the `RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` /
* 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.
@@ -423,7 +557,7 @@ export const reactNativeBridgeResolver: FrameworkResolver = {
if (!f) continue;
if (f.endsWith('.mm') || f.endsWith('.m')) {
const src = context.readFile(f);
if (src && /RCT_EXPORT_MODULE\b/.test(src)) return true;
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);
@@ -454,28 +588,52 @@ export const reactNativeBridgeResolver: FrameworkResolver = {
}
// JS callsites of `obj.method()` reach the resolver as either
// `obj.method` (qualified) or `method` (bare). Strip a single dot
// prefix to get the JS-visible method name.
const name = ref.referenceName.includes('.')
? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
: ref.referenceName;
// `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;
// Prefer the iOS (ObjC) target over Android when both exist — iOS is
// the conventional first-class platform for RN library docs and most
// graph queries. We still record only one edge; a JVM-only resolution
// is fine when no ObjC target exists.
const objc = entries.find((e) => e.node.language === 'objc');
const target = objc ?? entries[0];
// 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 },
};
},
};
+109 -3
View File
@@ -79,6 +79,27 @@ interface FileExportIndex {
byName: Map<string, Node>;
defaultComponent: Node | undefined;
defaultFnClass: Node | undefined;
/**
* The node an `export default NAME` statement names, exported at its
* declaration or not — the precise answer where `defaultFnClass` is a
* guess. `const Home = () => …; export default Home` and the namespace
* object `const UploadApi = { uploadARCapture }; export default UploadApi`
* are both invisible to the `isExported` index above: neither declaration
* has an `export_statement` ancestor.
*/
defaultBinding: Node | undefined;
}
const DEFAULT_BINDING_KINDS = new Set<string>(['function', 'class', 'component', 'constant', 'variable']);
const DEFAULT_EXPORT_BINDING_RE = /^[ \t]*export\s+default\s+([A-Za-z_$][\w$]*)\s*;?[ \t]*$/m;
const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/;
/** The identifier `export default NAME` names in a JS-family file, or null. */
function defaultExportBinding(filePath: string, context: ResolutionContext): string | null {
if (!JS_FAMILY_FILE.test(filePath)) return null;
const source = context.readFile(filePath);
if (!source || !source.includes('export default')) return null;
return source.match(DEFAULT_EXPORT_BINDING_RE)?.[1] ?? null;
}
const fileExportIndexes = new WeakMap<ResolutionContext, Map<string, FileExportIndex>>();
@@ -90,13 +111,20 @@ function getFileExportIndex(filePath: string, context: ResolutionContext): FileE
}
let idx = perFile.get(filePath);
if (!idx) {
idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined };
for (const n of context.getNodesInFile(filePath)) {
idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined, defaultBinding: undefined };
const nodesInFile = context.getNodesInFile(filePath);
for (const n of nodesInFile) {
if (!n.isExported) continue;
if (!idx.byName.has(n.name)) idx.byName.set(n.name, n);
if (idx.defaultComponent === undefined && n.kind === 'component') idx.defaultComponent = n;
if (idx.defaultFnClass === undefined && (n.kind === 'function' || n.kind === 'class')) idx.defaultFnClass = n;
}
const bound = defaultExportBinding(filePath, context);
if (bound !== null) {
idx.defaultBinding = nodesInFile
.filter((n) => n.name === bound && DEFAULT_BINDING_KINDS.has(n.kind))
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
}
perFile.set(filePath, idx);
}
return idx;
@@ -1558,6 +1586,8 @@ export function resolveViaImport(
if (member) {
const literalMember = resolveObjectLiteralMember(targetNode, member, ref, context, 0.9, 'import');
if (literalMember) return literalMember;
const aliasMember = resolveObjectLiteralAlias(targetNode, member, ref, context);
if (aliasMember) return aliasMember;
}
}
// An imported VALUE (singleton constant / shared instance) called
@@ -1717,6 +1747,80 @@ function resolveLuaRequire(ref: UnresolvedRef, context: ResolutionContext): Reso
return null;
}
/**
* `UploadApi.uploadARCapture()` where `UploadApi` is a NAMESPACE OBJECT — the
* default-export façade most React Native API layers are written as:
*
* import { uploadARCapture } from './frames'
* const UploadApi = { uploadARCapture, createFolder }
* export default UploadApi
*
* The member is a shorthand (or `key: ident`) property whose value is a
* binding of the object's file, not a function defined inside the literal,
* so containment (`resolveObjectLiteralMember`) finds nothing and the call
* landed on the constant — every cross-file caller of the API function went
* missing. Read the literal's source, take the binding the member names, and
* resolve it where the object's file would: a symbol declared there, else
* through its own imports. Calls accept callable targets only.
*/
function resolveObjectLiteralAlias(
container: Node,
member: string,
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
if (container.kind !== 'constant' && container.kind !== 'variable') return null;
if (!JS_FAMILY_FILE.test(container.filePath)) return null;
if (!/^[A-Za-z_$][\w$]*$/.test(member)) return null;
const lines = context.getFileLines?.(container.filePath) ?? context.readFile(container.filePath)?.split('\n');
if (!lines) return null;
const extent = lines.slice(container.startLine - 1, container.endLine).join('\n');
const brace = extent.indexOf('{');
if (brace < 0) return null;
const body = extent.slice(brace);
const keyed = new RegExp(`[{,\\s]${member}\\s*:\\s*([A-Za-z_$][\\w$]*)\\s*[,}]`);
const shorthand = new RegExp(`[{,\\s]${member}\\s*[,}]`);
const k = body.match(keyed);
const binding = k ? k[1]! : shorthand.test(body) ? member : null;
if (binding === null) return null;
const callable = (n: Node) => n.kind === 'function' || n.kind === 'method' || n.kind === 'class';
const accepts =
ref.referenceKind === 'calls'
? callable
: (n: Node) => callable(n) || n.kind === 'constant' || n.kind === 'variable' || n.kind === 'component';
// Declared in the object's own file, outside the literal.
const local = context
.getNodesInFile(container.filePath)
.filter((n) => n.name === binding && n.id !== container.id && accepts(n))
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
if (local) return { original: ref, targetNodeId: local.id, confidence: 0.9, resolvedBy: 'import' };
// Imported into the object's file.
for (const imp of context.getImportMappings(container.filePath, container.language)) {
if (imp.localName !== binding || imp.isNamespace) continue;
const resolvedPath = resolveImportPath(imp.source, container.filePath, container.language, context);
if (!resolvedPath) continue;
const target = findExportedSymbol(
resolvedPath,
{
isDefault: imp.isDefault,
isNamespace: false,
exportedName: imp.isDefault ? 'default' : imp.exportedName,
memberName: null,
},
container.language,
context,
new Set()
);
if (target && accepts(target)) {
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
}
}
return null;
}
function resolveModuleImportToFile(
ref: UnresolvedRef,
imports: ImportMapping[],
@@ -2152,7 +2256,9 @@ function findExportedSymbolWalk(
// `.ts`/`.tsx` `export default fn`/`class` case. Without the component
// branch, an `export { default as X } from './X.svelte'` barrel never
// resolves and the component shows a false 0 callers (#629).
const direct = exportIndex.defaultComponent ?? exportIndex.defaultFnClass;
// A component file IS its default export; otherwise the statement that
// names the binding beats the first-exported-function guess.
const direct = exportIndex.defaultComponent ?? exportIndex.defaultBinding ?? exportIndex.defaultFnClass;
if (direct) return direct;
} else if (want.isNamespace && want.memberName) {
const direct = exportIndex.byName.get(want.memberName);