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 committed 2026-08-27 22:52:18 -05:00
1 parent ac9580544b
commit 70fd5fefc2
42 files changed
+4257 -33

No files matched your search

+3
View File
@@ -28,6 +28,7 @@ import { isGeneratedFile } from '../extraction/generated-detection';
import { stripCommentsForRegex } from './strip-comments';
import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
import { goframeRouteEdges } from './goframe-synthesizer';
import { expoRouterReturnEdges } from './expo-router-synthesizer';
import { createYielder, type MaybeYield } from './cooperative-yield';
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
@@ -3610,6 +3611,8 @@ export const SYNTH_PASSES: SynthPassDef[] = [
run: (q, c, y, sub) => cFnPointerDispatchEdges(q, c, y, sub),
},
{ name: 'goframeEdges', gate: (has) => has('go'), run: (_q, c, y) => goframeRouteEdges(c, y) },
// `router.push(await helper())` — the helper's return literals are the screens.
{ name: 'expoRouterReturnEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => expoRouterReturnEdges(c, y) },
{ name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) },
];
+184
View File
@@ -0,0 +1,184 @@
/**
* Expo Router — navigation whose destination comes back from a helper.
*
* router.push(await resolvePostLoginRoute())
*
* The argument is a call, not a string, so the resolver in
* `frameworks/expo-router.ts` (which binds literal hrefs) correctly leaves the
* `router.push` ref unresolved. But the destination is still static — it is
* written down inside the helper:
*
* const resolvePostLoginRoute = async () =>
* (await hasSeenWelcome()) ? '/home/' : '/welcome/'
*
* This pass finds every navigation call whose argument is a call to a project
* function, reads the screen-path literals out of that function's body, and
* synthesizes one `navigates` edge from the HELPER to each screen. The push
* site already has a plain `calls` edge to the helper, so the flow reads
* `fetchUser → resolvePostLoginRoute → /home`, and the fork the helper decides
* shows up as its two (or three) outgoing screens — which is the answer to
* "where does the app go after login".
*
* Edges are `provenance:'heuristic'`, `synthesizedBy:'expo-router-return'`,
* with `registeredAt` = the push site that made the helper's return value a
* destination. A helper is only read because a navigation call consumes it;
* a function that merely contains path-like strings is never touched. Nothing
* here runs on a project with no Expo Router screens.
*/
import type { Edge, Language, Node } from '../types';
import type { ResolutionContext } from './types';
import type { MaybeYield } from './cooperative-yield';
import { stripCommentsForRegex } from './strip-comments';
import {
matchRoute,
normalizeHrefPath,
readStringAt,
routeTable,
stringEnd,
toHref,
} from './frameworks/expo-router';
const JS_LANGS: ReadonlySet<Language> = new Set(['typescript', 'javascript', 'tsx', 'jsx']);
const JS_FILE = /\.(?:[cm]?[jt]sx?)$/;
/** `.push(await helper(` / `.navigate(obj.helper(` — a navigation call fed by a call. */
const NAV_FED_BY_CALL = /\.(push|replace|navigate|dismissTo)\(\s*(?:await\s+)?([A-Za-z_$][\w$.]*)\s*\(/g;
/** A helper yielding more distinct screens than this is a table, not a decision. */
const MAX_SCREENS_PER_HELPER = 8;
const HELPER_KINDS: ReadonlySet<string> = new Set(['function', 'method']);
interface NavSite {
file: string;
line: number;
method: string;
callee: string;
}
export async function expoRouterReturnEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
const table = routeTable(ctx);
if (table.exact.size === 0) return [];
// 1. Every navigation call whose argument is a call.
const sites: NavSite[] = [];
let scanned = 0;
for (const file of ctx.getAllFiles()) {
if (!JS_FILE.test(file)) continue;
if ((++scanned & 63) === 0) await onYield();
const source = ctx.readFile(file);
if (!source || !/\.(?:push|replace|navigate|dismissTo)\(/.test(source)) continue;
const stripped = stripCommentsForRegex(source, 'typescript');
NAV_FED_BY_CALL.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = NAV_FED_BY_CALL.exec(stripped)) !== null) {
const line = stripped.slice(0, m.index).split('\n').length;
sites.push({ file, line, method: m[1]!, callee: m[2]! });
}
}
if (sites.length === 0) return [];
// 2. Each callee → the project function it names → the screens in its body.
const edges: Edge[] = [];
const seen = new Set<string>();
const screensByHelper = new Map<string, Array<{ node: Node; href: string; line: number }> | null>();
for (const site of sites) {
await onYield();
const helper = resolveHelper(site, ctx);
if (!helper) continue;
let screens = screensByHelper.get(helper.id);
if (screens === undefined) {
screens = screensInBody(helper, ctx, table);
screensByHelper.set(helper.id, screens);
}
if (!screens) continue;
for (const s of screens) {
const key = `${helper.id}>${s.node.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: helper.id,
target: s.node.id,
kind: 'navigates',
line: s.line,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'expo-router-return',
href: s.href,
navMethod: site.method,
registeredAt: `${site.file}:${site.line}`,
},
});
}
}
return edges;
}
/**
* The function `site.callee` names, seen from `site.file`: same file first,
* then the file its import points at, then a unique project-wide match.
* Ambiguity is a null — an edge onto the wrong `load()` is worse than none.
*/
function resolveHelper(site: NavSite, ctx: ResolutionContext): Node | null {
const segs = site.callee.split('.');
const bare = segs[segs.length - 1]!;
const head = segs[0]!;
const candidates = ctx
.getNodesByName(bare)
.filter((n) => HELPER_KINDS.has(n.kind) && JS_LANGS.has(n.language));
if (candidates.length === 0) return null;
const local = candidates.filter((n) => n.filePath === site.file);
if (local.length === 1) return local[0]!;
if (local.length > 1) return null;
const lang: Language = site.file.endsWith('x') ? 'tsx' : 'typescript';
const imported = ctx
.getImportMappings(site.file, lang)
.find((im) => im.localName === head || im.localName === bare);
if (imported?.resolvedPath) {
const viaImport = candidates.filter((n) => n.filePath === imported.resolvedPath);
if (viaImport.length === 1) return viaImport[0]!;
}
return candidates.length === 1 ? candidates[0]! : null;
}
/**
* The screens named by string literals in the helper's body — each literal
* that begins with `/`, resolved like an href would be. Null when the body
* cannot be read or names too many screens to be a decision.
*/
function screensInBody(
helper: Node,
ctx: ResolutionContext,
table: ReturnType<typeof routeTable>
): Array<{ node: Node; href: string; line: number }> | null {
const lines = ctx.getFileLines?.(helper.filePath) ?? ctx.readFile(helper.filePath)?.split(/\r?\n/);
if (!lines) return null;
const body = stripCommentsForRegex(
lines.slice(helper.startLine - 1, helper.endLine).join('\n'),
'typescript'
);
const found = new Map<string, { node: Node; href: string; line: number }>();
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '"' && ch !== "'" && ch !== '`') continue;
const start = i;
const literal = readStringAt(body, i);
i = stringEnd(body, i);
if (literal === null || !literal.startsWith('/')) continue;
const href = toHref(literal);
if (!href) continue;
const segs = normalizeHrefPath(href.path, helper.filePath);
if (segs === null) continue;
const route = matchRoute(segs, table);
if (!route || found.has(route.id)) continue;
found.set(route.id, {
node: route,
href: href.display,
line: helper.startLine + body.slice(0, start).split('\n').length - 1,
});
if (found.size > MAX_SCREENS_PER_HELPER) return null;
}
return found.size > 0 ? [...found.values()] : null;
}
+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 } : {}) },
};
},
};
+4
View File
@@ -26,6 +26,7 @@ import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
import { swiftObjcBridgeResolver } from './swift-objc';
import { reactNativeBridgeResolver } from './react-native';
import { expoModulesResolver } from './expo-modules';
import { expoRouterResolver } from './expo-router';
import { fabricViewResolver } from './fabric';
import { cicsResolver } from './cics';
import { terraformResolver } from './terraform';
@@ -70,6 +71,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
reactNativeBridgeResolver,
// Expo Modules — Function/AsyncFunction/Property DSL on Swift/Kotlin
expoModulesResolver,
// Expo Router — `app/` screen files → route nodes; `router.push('/x')` → navigates edges
expoRouterResolver,
// React Native Fabric / Codegen view components — TS spec → component nodes
fabricViewResolver,
// CICS pseudo-conversational TRANSID hops (COBOL)
@@ -151,4 +154,5 @@ export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
export { swiftObjcBridgeResolver } from './swift-objc';
export { reactNativeBridgeResolver } from './react-native';
export { expoModulesResolver } from './expo-modules';
export { expoRouterResolver } from './expo-router';
export { fabricViewResolver } from './fabric';
+3 -1
View File
@@ -1068,7 +1068,8 @@ export class ReferenceResolver {
// traverse `references`, so registration sites surface with no
// graph-layer changes.
let kind: Edge['kind'] =
ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind;
ref.edgeKind ??
(ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind);
// Promote "extends" to "implements" when a class/struct targets an interface
if (kind === 'extends') {
@@ -1103,6 +1104,7 @@ export class ReferenceResolver {
line: ref.original.line,
column: ref.original.column,
metadata: {
...(ref.metadata ?? {}),
confidence: ref.confidence,
resolvedBy: ref.resolvedBy,
// The ORIGINAL reference text (and kind, when edge-kind promotion
+10 -1
View File
@@ -4,7 +4,7 @@
* Types for the reference resolution system.
*/
import { Language, Node, ReferenceKind } from '../types';
import { EdgeKind, Language, Node, ReferenceKind } from '../types';
/**
* An unresolved reference from extraction
@@ -43,6 +43,15 @@ export interface ResolvedRef {
confidence: number;
/** How it was resolved */
resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref';
/**
* Edge kind the edge should carry when it is NOT the ref's own kind — a
* framework that turns a `calls` ref into a `navigates` edge, for example.
* The original kind is still recorded on the edge as `metadata.refKind`, so
* re-resolution after a target is removed reconstructs the ref faithfully.
*/
edgeKind?: EdgeKind;
/** Extra metadata the strategy wants persisted on the edge (`href`, …). */
metadata?: Record<string, unknown>;
}
/**