feat(expo-router): add Expo Router support for screens and navigates

Introduce Expo Router integration: a new framework resolver, route-based screen nodes, and navigates edges, plus a /api/screens endpoint and a Screens UI view. Adds branch-guard-driven labeling of edges, resolution logic, and tests to cover extraction, resolution, and end-to-end flow. This enables CodeGraph UI to surface screens and transitions from Expo Router apps.
This commit is contained in:
Colby McHenry
2026-08-27 22:52:18 -05:00
parent ac9580544b
commit 70fd5fefc2
42 changed files with 4257 additions and 33 deletions
+673
View File
@@ -0,0 +1,673 @@
/**
* Expo Router (React Native) — file-based screens and string-keyed navigation.
*
* Two things static extraction cannot see on its own, and that together are
* most of what "how does the app flow" means in an Expo app:
*
* 1. **A screen is a file, not a symbol.** Every file under `app/` (or
* `src/app/`) is a route: `app/object-detail.tsx` is `/object-detail`,
* `app/capture/index.tsx` is `/capture`, `app/item/[id].tsx` is `/item/[id]`,
* and `(group)` directories are invisible in the URL. `extract()` emits one
* `route` node per screen file, named by its path, with a `calls` ref to the
* file's default export so the route reaches the component that renders it.
*
* 2. **Navigation is a string.** `router.push('/object-detail?…')`,
* `router.navigate({ pathname: '/item/[id]', params })`, a template literal
* with the params interpolated — the extractor records each as a `calls` ref
* named `router.push` that resolves to nothing, because the target is a
* path, not an identifier. `resolve()` claims those refs, reads the argument
* off the source lines, matches it against the route table, and returns a
* **`navigates`** edge to the route node, carrying the href it read.
*
* Between them the graph gains `ItemCard → openObjectDetail → /object-detail →
* ObjectDetail`, which is the chain a reader asking "where does tapping an
* object go" needs and previously got "no path" for.
*
* Precision rests on the string resolving to a real screen file, not on the
* receiver being called `router`: `nav.push('/x')` from `const nav =
* useRouter()` binds, `list.push('/x')` where no such route exists does not.
* Anything the resolver cannot bind to exactly one screen — a computed path,
* an ambiguous dynamic match, a relative href from a non-screen file — is left
* unresolved rather than guessed. Silent beats wrong.
*
* Not covered yet (each needs the enclosing component, which `extract()` does
* not receive): `<Link href>`, `<Redirect href>`, and `Stack.Screen` /
* `Tabs.Screen` `name` props. `router.back()` / `dismiss()` have no target and
* are correctly skipped.
*/
import type { Language, Node } from '../../types';
import type {
FrameworkResolver,
ResolutionContext,
ResolvedRef,
UnresolvedRef,
} from '../types';
import { stripCommentsForRegex } from '../strip-comments';
// =============================================================================
// Route files
// =============================================================================
const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx'];
/** The app directory: `app/` at the project root, or `src/app/`. First match wins. */
const APP_DIR = /(?:^|\/)(?:src\/)?app\//;
/** A screen file's extension, with an optional platform suffix (`.ios.tsx`). */
const ROUTE_EXT = /\.(?:(?:ios|android|native|web)\.)?(tsx|ts|jsx|js|mjs|cjs)$/;
/** Files under `app/` that define a screen (not a layout, not test, not html). */
export function routePathForFile(filePath: string): string | null {
const dir = APP_DIR.exec(filePath);
if (!dir) return null;
const rel = filePath.slice(dir.index + dir[0].length);
const ext = ROUTE_EXT.exec(rel);
if (!ext) return null;
const bare = rel.slice(0, ext.index);
if (bare.endsWith('.d') || /\.(?:test|spec|stories)$/.test(bare)) return null;
const segs = bare.split('/');
if (segs.includes('__tests__') || segs.includes('__mocks__')) return null;
const base = segs[segs.length - 1]!;
// `_layout` (and any other `_`-prefixed file) is not navigable. `+not-found`
// is a real screen; the other `+` files (`+html`, `+native-intent`) are not.
if (base.startsWith('_')) return null;
if (base.startsWith('+') && base !== '+not-found') return null;
const kept = segs.filter((s) => !(s.startsWith('(') && s.endsWith(')')));
if (kept[kept.length - 1] === 'index') kept.pop();
return '/' + kept.join('/');
}
function languageForFile(filePath: string): Language {
const ext = ROUTE_EXT.exec(filePath)?.[1];
switch (ext) {
case 'tsx':
return 'tsx';
case 'jsx':
return 'jsx';
case 'ts':
return 'typescript';
default:
return 'javascript';
}
}
const IDENT = '[A-Za-z_$][\\w$]*';
/**
* The name the file exports as its screen. Expo renders the DEFAULT export,
* so that is the only binding that matters; a wrapper (`memo(Screen)`,
* `observer(Screen)`, `React.forwardRef(Screen)`) is looked through to its
* first identifier argument. Anonymous defaults (`export default () => …`)
* have no name to bind and yield null.
*/
export function defaultExportName(stripped: string): { name: string; index: number } | null {
const patterns: RegExp[] = [
new RegExp(`export\\s+default\\s+(?:async\\s+)?function\\s*\\*?\\s*(${IDENT})`),
new RegExp(`export\\s+default\\s+class\\s+(${IDENT})`),
new RegExp(`export\\s+default\\s+(?:${IDENT}\\.)?${IDENT}\\s*\\(\\s*(${IDENT})\\s*[,)]`),
new RegExp(`export\\s+default\\s+(${IDENT})\\s*;?\\s*$`, 'm'),
new RegExp(`export\\s*\\{\\s*(${IDENT})\\s+as\\s+default\\s*\\}`),
];
for (const re of patterns) {
const m = re.exec(stripped);
if (m && m[1] && m[1] !== 'default') return { name: m[1], index: m.index };
}
return null;
}
// =============================================================================
// Reading the href out of a navigation call
// =============================================================================
/**
* The `router` methods that take a destination (`back`/`dismiss` take none),
* and a project's own wrappers around them: `safePush('/x')`,
* `guardedNavigate('/x')` — a camelCase name ending in the verb. A wrapper
* usually defers the real call through state the graph cannot follow
* (`pendingNav = { method, href }` … `router[method](href)`), so its NAME is
* the only static evidence; the argument resolving to a real screen is what
* makes the claim safe. Second group: the verb, when it came from a wrapper.
*/
export const NAV_METHOD = /(?:^|\.)(push|replace|navigate|dismissTo)$|^[a-z][A-Za-z]*(Push|Replace|Navigate)$/;
/** The verb a NAV_METHOD match names, lower-cased: `safePush` → `push`. */
export function navVerb(name: string): string | null {
const m = NAV_METHOD.exec(name);
if (!m) return null;
// A router method is already the verb (`dismissTo`); a wrapper's suffix
// (`safePush` → `Push`) is lower-cased to name the verb it stands in for.
return m[1] ?? m[2]!.toLowerCase();
}
/** Lines a single navigation call is allowed to span. */
const MAX_CALL_LINES = 12;
/** Placeholder for an interpolated `${…}` inside a template-literal href. */
const HOLE = '\u0000';
/** Index of the `)` matching the `(` at `open`, skipping string bodies; -1 if unbalanced. */
function matchParen(s: string, open: number): number {
let depth = 0;
for (let i = open; i < s.length; i++) {
const ch = s[i];
if (ch === '"' || ch === "'") {
const q = ch;
i++;
while (i < s.length && s[i] !== q) {
if (s[i] === '\\') i++;
i++;
}
continue;
}
if (ch === '`') {
i = skipTemplate(s, i);
continue;
}
if (ch === '(') depth++;
else if (ch === ')') {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
/** Index of the closing backtick for the template starting at `open`. */
function skipTemplate(s: string, open: number): number {
let i = open + 1;
while (i < s.length) {
const ch = s[i];
if (ch === '\\') {
i += 2;
continue;
}
if (ch === '`') return i;
if (ch === '$' && s[i + 1] === '{') {
let depth = 0;
for (i = i + 1; i < s.length; i++) {
if (s[i] === '{') depth++;
else if (s[i] === '}') {
depth--;
if (depth === 0) break;
} else if (s[i] === '`') i = skipTemplate(s, i);
}
}
i++;
}
return s.length;
}
/** Index of the quote that closes the string literal opening at `at` (or the end of `s`). */
export function stringEnd(s: string, at: number): number {
const q = s[at];
if (q === '`') return skipTemplate(s, at);
for (let i = at + 1; i < s.length; i++) {
if (s[i] === '\\') i++;
else if (s[i] === q) return i;
}
return s.length;
}
/**
* A string literal (`'…'`, `"…"`, or a template) starting at `at`, as text
* with every `${…}` replaced by {@link HOLE}. Null when `at` is not a string.
*/
export function readStringAt(s: string, at: number): string | null {
const q = s[at];
if (q === '"' || q === "'") {
let out = '';
for (let i = at + 1; i < s.length; i++) {
const ch = s[i]!;
if (ch === '\\') {
out += s[i + 1] ?? '';
i++;
continue;
}
if (ch === q) return out;
out += ch;
}
return null;
}
if (q === '`') {
const end = skipTemplate(s, at);
let out = '';
for (let i = at + 1; i < end; i++) {
const ch = s[i]!;
if (ch === '\\') {
out += s[i + 1] ?? '';
i++;
continue;
}
if (ch === '$' && s[i + 1] === '{') {
let depth = 0;
for (i = i + 1; i < end; i++) {
if (s[i] === '{') depth++;
else if (s[i] === '}') {
depth--;
if (depth === 0) break;
}
}
out += HOLE;
continue;
}
out += ch;
}
return out;
}
return null;
}
export interface HrefLiteral {
/** The path part, `${…}` holes kept as {@link HOLE}; query and hash removed. */
path: string;
/** The literal as written, holes rendered as `${…}` — for the edge metadata. */
display: string;
/** The other arm of a `cond ? a : b` argument, when the argument was one. */
alternate?: HrefLiteral;
}
/** Index of the first `ch` at bracket depth 0 and outside strings, or -1. */
function indexAtDepth0(s: string, ch: string, from: number): number {
let depth = 0;
for (let i = from; i < s.length; i++) {
const c = s[i];
if (c === '"' || c === "'") {
const q = c;
i++;
while (i < s.length && s[i] !== q) {
if (s[i] === '\\') i++;
i++;
}
continue;
}
if (c === '`') {
i = skipTemplate(s, i);
continue;
}
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
else if (c === '?' && (s[i + 1] === '.' || s[i + 1] === '?')) i++; // `?.` / `??`
else if (depth === 0 && c === ch) return i;
}
return -1;
}
export function toHref(literal: string | null): HrefLiteral | null {
if (literal === null || literal.length === 0) return null;
const cut = literal.search(/[?#]/);
const path = cut < 0 ? literal : literal.slice(0, cut);
if (path.length === 0) return null;
return { path, display: literal.split(HOLE).join('${…}') };
}
/**
* The href in one argument expression: a string, a template, an `Href` object
* with a literal `pathname`, or a conditional whose two arms are each one of
* those (`cond ? \`/x?id=${id}\` : '/x'`). Anything else is not static.
*/
function parseHrefExpression(expr: string): HrefLiteral | null {
// `expr as any` / `expr satisfies Href` — a cast says nothing about the value.
let args = expr.trim().replace(/\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/, '');
// `(a ? b : c)` — unwrap one layer of grouping parens.
while (args.startsWith('(') && matchParen(args, 0) === args.length - 1) {
args = args.slice(1, -1).trim().replace(/\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/, '');
}
if (args.length === 0) return null;
const q = indexAtDepth0(args, '?', 0);
if (q > 0) {
const colon = indexAtDepth0(args, ':', q + 1);
if (colon > q) {
const yes = parseHrefExpression(args.slice(q + 1, colon));
const no = parseHrefExpression(args.slice(colon + 1));
if (yes && no) return { ...yes, alternate: no };
return null;
}
}
if (args[0] === '{') {
const key = /\bpathname\s*:\s*/.exec(args);
return key ? toHref(readStringAt(args, key.index + key[0].length)) : null;
}
return toHref(readStringAt(args, 0));
}
/**
* The destination of the navigation call at (`line`, `column`) in `lines`.
*
* Handles the three shapes Expo Router accepts: a string, a template literal
* (static prefix kept, interpolations become holes), and an `Href` object
* whose `pathname` is one of those. Anything else — a variable, a call, a
* spread — is not a literal and returns null.
*/
export function readHrefArgument(
lines: readonly string[],
line: number,
column: number,
method: string
): HrefLiteral | null {
const arg = firstArgumentText(lines, line, column, method);
return arg === null ? null : parseHrefExpression(arg);
}
/** The source text of the navigation call's first argument, or null when there is no call there. */
function firstArgumentText(
lines: readonly string[],
line: number,
column: number,
method: string
): string | null {
const first = line - 1;
if (first < 0 || first >= lines.length) return null;
const text = lines.slice(first, first + MAX_CALL_LINES).join('\n');
const nameAt = text.indexOf(method, Math.max(0, column));
if (nameAt < 0) return null;
let open = nameAt + method.length;
while (open < text.length && /\s/.test(text[open]!)) open++;
if (text[open] !== '(') return null;
const close = matchParen(text, open);
const args = text.slice(open + 1, close < 0 ? undefined : close);
// Only the first argument: a `,` at depth 0 ends it (`push(href, opts)`).
const comma = indexAtDepth0(args, ',', 0);
return comma < 0 ? args : args.slice(0, comma);
}
const CAST_TAIL = /\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/;
/** A line that continues the previous statement rather than starting a new one. */
const CONTINUATION = /^[?:.)\]}`'"+&|]/;
/**
* The href a navigation call reaches through a local variable:
*
* const href = params.length ? `/barcode-scan?${q}` : '/barcode-scan'
* router.navigate(href as any)
*
* When the argument is a bare identifier, its most recent `const`/`let`
* declaration between `enclosingStart` and the call is read and its
* initializer parsed exactly like a literal argument would be. A reassignment
* in between, or an initializer that is not static, yields null.
*/
export function readHrefViaLocal(
lines: readonly string[],
line: number,
column: number,
method: string,
enclosingStart: number
): HrefLiteral | null {
const arg = firstArgumentText(lines, line, column, method);
if (arg === null) return null;
const ident = arg.trim().replace(CAST_TAIL, '');
if (!/^[A-Za-z_$][\w$]*$/.test(ident)) return null;
const decl = new RegExp(`\\b(?:const|let|var)\\s+${ident.replace(/\$/g, '\\$')}\\s*(?::[^=]*?)?=(?!=)`);
const reassign = new RegExp(`(?:^|[^.\\w$])${ident.replace(/\$/g, '\\$')}\\s*=(?!=)`);
const from = Math.max(0, enclosingStart - 1);
for (let i = line - 2; i >= from; i--) {
const text = lines[i]!;
const m = decl.exec(text);
if (!m) {
// The variable assigned again between declaration and use — not static.
if (reassign.test(text)) return null;
continue;
}
// The initializer: the rest of this line, plus continuation lines.
let init = text.slice(m.index + m[0].length);
for (let j = i + 1; j < line - 1 && j < i + MAX_CALL_LINES; j++) {
const next = lines[j]!;
if (!CONTINUATION.test(next.trimStart()) && balanced(init)) break;
init += '\n' + next;
}
return parseHrefExpression(init);
}
return null;
}
/** True when every bracket and template opened in `s` is closed. */
function balanced(s: string): boolean {
let depth = 0;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '"' || c === "'" || c === '`') {
const end = stringEnd(s, i);
if (end >= s.length) return false;
i = end;
continue;
}
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
}
return depth <= 0;
}
// =============================================================================
// Route table
// =============================================================================
interface RouteEntry {
node: Node;
segs: string[];
}
export interface RouteTable {
/** Identity of the node array the table was built from — rebuild when it changes. */
source: readonly Node[];
exact: Map<string, Node>;
dynamic: RouteEntry[];
}
const tables = new Map<string, RouteTable>();
export function routeTable(context: ResolutionContext): RouteTable {
const all = context.getNodesByKind('route');
const key = context.getProjectRoot();
const cached = tables.get(key);
if (cached && cached.source === all) return cached;
const exact = new Map<string, Node>();
const dynamic: RouteEntry[] = [];
for (const node of all) {
// Only this framework's own route nodes: the ones whose name IS the path
// derived from their file. Express/SvelteKit routes in the same project
// name themselves differently and never match.
if (routePathForFile(node.filePath) !== node.name) continue;
exact.set(node.name, node);
if (node.name.includes('[')) dynamic.push({ node, segs: node.name.split('/').slice(1) });
}
const table = { source: all, exact, dynamic };
tables.set(key, table);
return table;
}
/**
* Normalize an href path to the form route names use: leading `/`, no
* trailing `/`, no `(group)` segments, holes as a whole-segment `*`.
* A relative href is resolved against the screen the call sits in; from a
* non-screen file it has no base and returns null.
*/
export function normalizeHrefPath(path: string, fromFile: string): string[] | null {
let p = path;
if (!p.startsWith('/')) {
// Expo resolves `./x` and bare `x` against the DIRECTORY of the screen file
// the call sits in — `/capture` for both `capture/index.tsx` and
// `capture/review.tsx` — which is the route of that directory's index.
if (routePathForFile(fromFile) === null) return null;
const dirRoute = routePathForFile(fromFile.slice(0, fromFile.lastIndexOf('/') + 1) + 'index.tsx');
if (dirRoute === null) return null;
const parent = dirRoute.split('/').slice(1);
if (p.startsWith('./')) p = '/' + [...parent, p.slice(2)].join('/');
else if (p.startsWith('../')) {
const up: string[] = [...parent];
while (p.startsWith('../')) {
up.pop();
p = p.slice(3);
}
p = '/' + [...up, p].join('/');
} else p = '/' + [...parent, p].join('/');
}
const segs = p
.split('/')
.slice(1)
.filter((s) => s.length > 0 && !(s.startsWith('(') && s.endsWith(')')))
.map((s) => (s.includes(HOLE) ? '*' : decodeSegment(s)));
return segs;
}
function decodeSegment(s: string): string {
try {
return decodeURIComponent(s);
} catch {
return s;
}
}
/**
* The single route the href segments denote, or null when none or several do.
*
* Scored so that a literal segment beats a wildcard for the same slot and a
* `[param]` slot accepts either; a tie between two routes is ambiguity, and
* ambiguity is a null, not a coin flip.
*/
export function matchRoute(segs: string[], table: RouteTable): Node | null {
const exact = table.exact.get('/' + segs.join('/'));
if (exact) return exact;
let best: { node: Node; score: number } | null = null;
let tied = false;
for (const entry of table.dynamic) {
const score = scoreMatch(segs, entry.segs);
if (score === null) continue;
if (best === null || score > best.score) {
best = { node: entry.node, score };
tied = false;
} else if (score === best.score) tied = true;
}
return best && !tied ? best.node : null;
}
function scoreMatch(href: string[], route: string[]): number | null {
let score = 0;
let i = 0;
for (let r = 0; r < route.length; r++) {
const seg = route[r]!;
if (seg.startsWith('[...') && seg.endsWith(']')) {
// Catch-all: needs at least one segment and takes the rest.
if (i >= href.length) return null;
score += href.length - i;
i = href.length;
continue;
}
if (i >= href.length) return null;
const h = href[i]!;
if (seg.startsWith('[') && seg.endsWith(']')) score += 2;
else if (h === seg) score += 3;
else if (h === '*') score += 1;
else return null;
i++;
}
return i === href.length ? score : null;
}
// =============================================================================
// The resolver
// =============================================================================
export const expoRouterResolver: FrameworkResolver = {
name: 'expo-router',
languages: [...ROUTE_LANGUAGES],
detect(context: ResolutionContext): boolean {
const packageJson = context.readFile('package.json');
if (packageJson) {
try {
const pkg = JSON.parse(packageJson);
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (deps['expo-router']) return true;
} catch {
// Not JSON — fall through to the layout check.
}
}
const files = context.getAllFiles();
const hasLayout = files.some((f) => /(?:^|\/)(?:src\/)?app\/_layout\.(?:tsx|jsx|ts|js)$/.test(f));
const hasExpoConfig = files.some((f) => /^app\.(?:json|config\.(?:js|ts))$/.test(f));
return hasLayout && hasExpoConfig;
},
claimsReference(name: string): boolean {
return NAV_METHOD.test(name);
},
extract(filePath: string, content: string) {
const routePath = routePathForFile(filePath);
if (routePath === null) return { nodes: [], references: [] };
const language = languageForFile(filePath);
const node: Node = {
id: `route:${filePath}:${routePath}`,
kind: 'route',
name: routePath,
qualifiedName: `${filePath}::route:${routePath}`,
filePath,
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
language,
isExported: true,
updatedAt: Date.now(),
};
const references: UnresolvedRef[] = [];
const stripped = stripCommentsForRegex(content, 'typescript');
const screen = defaultExportName(stripped);
if (screen) {
references.push({
fromNodeId: node.id,
referenceName: screen.name,
referenceKind: 'calls',
line: stripped.slice(0, screen.index).split('\n').length,
column: 0,
filePath,
language,
candidates: [screen.name],
});
}
return { nodes: [node], references };
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
if (ref.referenceKind !== 'calls') return null;
const method = navVerb(ref.referenceName);
if (!method) return null;
if (!ROUTE_LANGUAGES.includes(ref.language)) return null;
// The name to find on the line: `navigate` in `router.navigate(`, or the
// wrapper's own name in `safePush(`.
const callee = ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1);
const lines =
context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
if (!lines) return null;
let href = readHrefArgument(lines, ref.line, ref.column, callee);
if (!href) {
const enclosing = context.getNodeById?.(ref.fromNodeId);
const start = enclosing && enclosing.filePath === ref.filePath ? enclosing.startLine : Math.max(1, ref.line - 40);
href = readHrefViaLocal(lines, ref.line, ref.column, callee, start);
}
if (!href) return null;
const table = routeTable(context);
const segs = normalizeHrefPath(href.path, ref.filePath);
if (segs === null) return null;
const target = matchRoute(segs, table);
if (!target) return null;
if (href.alternate) {
// `cond ? a : b` — one edge can carry one destination. Both arms
// reaching the same screen (a query-string difference, typically) is a
// confident bind; two different screens is a fork this ref can't record.
const altSegs = normalizeHrefPath(href.alternate.path, ref.filePath);
if (altSegs === null || matchRoute(altSegs, table)?.id !== target.id) return null;
}
return {
original: ref,
targetNodeId: target.id,
confidence: 0.95,
resolvedBy: 'framework',
edgeKind: 'navigates',
metadata: { href: href.display, navMethod: method, ...(callee !== method ? { via: callee } : {}) },
};
},
};