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:
1 parent
ac9580544b
commit
70fd5fefc2
42 files changed
+4257
-33
No files matched your search
@@ -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) },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}) },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user