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:
@@ -34,7 +34,8 @@
|
||||
*/
|
||||
|
||||
import type CodeGraph from '../../index';
|
||||
import type { Edge, Node } from '../../types';
|
||||
import type { Edge, Language, Node } from '../../types';
|
||||
import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
|
||||
import {
|
||||
resolveNamedSymbolFlow,
|
||||
normalizeToken,
|
||||
@@ -344,6 +345,8 @@ function toFlowEdge(edge: Edge, upward: boolean): WireFlowEdge {
|
||||
interface FileCache {
|
||||
lines: string[] | null;
|
||||
language: string;
|
||||
/** Absolute path, when the file was read — what branch-guard parsing needs. */
|
||||
abs?: string;
|
||||
drift: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
@@ -378,6 +381,7 @@ function loadFile(
|
||||
entry = {
|
||||
lines: splitLines(fs.readFileSync(absolute, 'utf-8')),
|
||||
language: found.record.language,
|
||||
abs: absolute,
|
||||
drift: false,
|
||||
};
|
||||
} catch {
|
||||
@@ -444,6 +448,23 @@ async function windowFor(
|
||||
};
|
||||
}
|
||||
|
||||
/** The branch label for `edge`'s call site in `siteNode`'s file, or ''. */
|
||||
async function whenAt(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
cache: Map<string, FileCache>,
|
||||
siteNode: Node,
|
||||
edge: Edge
|
||||
): Promise<string> {
|
||||
if (!edge.line || !supportsBranchGuards(siteNode.language)) return '';
|
||||
const file = loadFile(cg, projectRoot, cache, siteNode.filePath);
|
||||
if (!file || file.drift || !file.abs) return '';
|
||||
const site = { line: edge.line, column: typeof edge.column === 'number' ? edge.column : null };
|
||||
const guards = await guardsForFile(file.abs, file.language as Language, [site]);
|
||||
const g = guards.get(siteKey(site));
|
||||
return g ? guardLabel(g) : '';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Building the flows
|
||||
// =============================================================================
|
||||
@@ -494,9 +515,20 @@ async function toWireFlow(
|
||||
backwards: true,
|
||||
};
|
||||
}
|
||||
// The connector's condition: the call site is in the caller's file — the
|
||||
// previous card going down, this card itself when the reader stepped up.
|
||||
const wireEdge = step.edge === null ? null : toFlowEdge(step.edge, step.upward);
|
||||
if (wireEdge && step.edge?.line) {
|
||||
const siteNode = step.upward ? step.node : previous?.node;
|
||||
const when = siteNode ? await whenAt(cg, projectRoot, cache, siteNode, step.edge) : '';
|
||||
if (when) {
|
||||
wireEdge.when = when;
|
||||
wireEdge.label = `${wireEdge.label} · when ${when}`;
|
||||
}
|
||||
}
|
||||
hops.push({
|
||||
node: toNodeRef(step.node),
|
||||
edge: step.edge === null ? null : toFlowEdge(step.edge, step.upward),
|
||||
edge: wireEdge,
|
||||
callRef,
|
||||
source: await windowFor(
|
||||
cg,
|
||||
|
||||
@@ -56,6 +56,7 @@ import { buildRoutes } from './routes';
|
||||
import { buildEntryPoints } from './entrypoints';
|
||||
import { buildNodeRefs } from './nodes';
|
||||
import { buildMap } from './map';
|
||||
import { buildScreens } from './screens';
|
||||
import { buildDeadCode } from './deadcode';
|
||||
import { buildFlow } from './flow';
|
||||
import { buildTrails, removeTrail, saveTrail, type TrailsOptions } from './trails';
|
||||
@@ -196,6 +197,11 @@ const API_INDEX = {
|
||||
description: 'The repository at module granularity: modules, cross-module links, cycles.',
|
||||
params: ['root', 'depth'],
|
||||
},
|
||||
{
|
||||
path: '/api/screens',
|
||||
description: 'The app as screens and the transitions between them, each with the conditions it runs under.',
|
||||
params: [],
|
||||
},
|
||||
{
|
||||
path: '/api/flow',
|
||||
description: 'The call path between symbols: one hop per card, opened at the calling line.',
|
||||
@@ -261,6 +267,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
|
||||
return ok(res, buildRoutes(session.acquire(), ctx.query), ctx.method);
|
||||
case '/api/map':
|
||||
return ok(res, buildMap(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
|
||||
case '/api/screens':
|
||||
return ok(res, await buildScreens(session.acquire(), ctx.projectRoot), ctx.method);
|
||||
case '/api/deadcode':
|
||||
return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
|
||||
case '/api/entrypoints':
|
||||
@@ -278,7 +286,7 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
|
||||
// the socket open, so it never goes through `ok()`.
|
||||
return events.subscribe(req, res, ctx.method);
|
||||
default:
|
||||
return dispatchPathRoutes(route, res, ctx, session);
|
||||
return await dispatchPathRoutes(route, res, ctx, session);
|
||||
}
|
||||
} catch (err) {
|
||||
// A refusal from the read chokepoint is a 403 with the reason attached —
|
||||
@@ -352,16 +360,16 @@ async function dispatchWrite(
|
||||
* straight to an exact lookup, and anything that names nothing is a 404. File
|
||||
* paths go through the read chokepoint before anything is opened.
|
||||
*/
|
||||
function dispatchPathRoutes(
|
||||
async function dispatchPathRoutes(
|
||||
route: string,
|
||||
res: Parameters<UiApiHandler>[1],
|
||||
ctx: UiRequestContext,
|
||||
session: GraphSession
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
const nodeId = suffixAfter(route, '/api/node/');
|
||||
if (nodeId !== null) {
|
||||
if (nodeId === '') throw badRequest('No symbol id was given. Use /api/node/<id>.');
|
||||
return ok(res, buildNode(session.acquire(), ctx.projectRoot, nodeId), ctx.method);
|
||||
return ok(res, await buildNode(session.acquire(), ctx.projectRoot, nodeId), ctx.method);
|
||||
}
|
||||
|
||||
// Before `/api/file/`: that prefix is not a prefix of this route, but keeping
|
||||
|
||||
@@ -53,6 +53,7 @@ export const MAP_EDGE_KINDS: readonly EdgeKind[] = [
|
||||
'instantiates',
|
||||
'extends',
|
||||
'implements',
|
||||
'navigates',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -61,7 +62,7 @@ export const MAP_EDGE_KINDS: readonly EdgeKind[] = [
|
||||
* A `references` edge to a type is real traffic but "Config → Config" is not
|
||||
* an interesting row; calls and imports are what a reader wants named.
|
||||
*/
|
||||
const PAIR_EDGE_KINDS: readonly EdgeKind[] = ['calls', 'imports', 'instantiates'];
|
||||
const PAIR_EDGE_KINDS: readonly EdgeKind[] = ['calls', 'imports', 'instantiates', 'navigates'];
|
||||
|
||||
/** Symbol pairs kept per link — the tooltip shows four (design spec §3.6). */
|
||||
const TOP_PAIRS_PER_LINK = 4;
|
||||
@@ -261,12 +262,17 @@ export function pickDefaultRoot(
|
||||
if (total === 0) return '';
|
||||
let best = '';
|
||||
let bestSymbols = 0;
|
||||
let second = 0;
|
||||
for (const [dir, symbols] of [...byDir].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
if (symbols > bestSymbols) {
|
||||
second = bestSymbols;
|
||||
best = dir;
|
||||
bestSymbols = symbols;
|
||||
}
|
||||
} else if (symbols > second) second = symbols;
|
||||
}
|
||||
// A second root holding a fifth of the code (a React Native app's `ios/`
|
||||
// beside its `src/`) belongs on the picture: map the whole project.
|
||||
if (second * 5 >= total) return '';
|
||||
return bestSymbols * 2 > total ? best : '';
|
||||
}
|
||||
|
||||
@@ -310,7 +316,7 @@ export function parseMapQuery(query: URLSearchParams): { root: string | null; de
|
||||
|
||||
export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload {
|
||||
const started = Date.now();
|
||||
const { root: requestedRoot, depth } = parseMapQuery(query);
|
||||
let { root: requestedRoot, depth } = parseMapQuery(query);
|
||||
|
||||
const fileRecords = cg.getFiles().map((file) => {
|
||||
const path = toPosixPath(file.path);
|
||||
@@ -324,6 +330,10 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
|
||||
});
|
||||
|
||||
const root = requestedRoot ?? pickDefaultRoot(fileRecords);
|
||||
// Left to choose, and choosing the whole project (two substantial roots):
|
||||
// one level deeper, so the boxes are `src/app` and `ios/CaptureView`, not
|
||||
// `src` and `ios`.
|
||||
if (requestedRoot === null && root === '' && !query.has('depth')) depth = 2;
|
||||
const stats = cg.getStats();
|
||||
const key = [
|
||||
projectRoot,
|
||||
|
||||
@@ -26,6 +26,7 @@ import { isTestFile } from '../../search/query-utils';
|
||||
import { buildHierarchy, type WireOverride } from './hierarchy';
|
||||
import { notFound } from './respond';
|
||||
import { findIndexedFile, hasDriftedOnDisk } from './source';
|
||||
import { annotateWhen } from './when';
|
||||
import {
|
||||
BLAST_DEPTH,
|
||||
CALLER_EDGE_KINDS,
|
||||
@@ -73,7 +74,7 @@ export interface WireMember extends WireNodeRef {
|
||||
overrides?: WireOverride;
|
||||
}
|
||||
|
||||
export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): unknown {
|
||||
export async function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): Promise<unknown> {
|
||||
const node = cg.getNode(nodeId);
|
||||
if (!node) {
|
||||
throw notFound(
|
||||
@@ -146,6 +147,13 @@ export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): u
|
||||
const shownIncoming = incomingGroups.slice(0, MAX_INCOMING_GROUPS);
|
||||
const shownOutgoing = outgoingGroups.slice(0, MAX_OUTGOING_GROUPS);
|
||||
|
||||
// Branch conditions per call site: the right rail's sites are all in this
|
||||
// file; the left rail's are in each caller's own file.
|
||||
await annotateWhen(cg, projectRoot, [
|
||||
{ file: focalFile, edges: shownOutgoing.flatMap((r) => r.edges) },
|
||||
...shownIncoming.map((r) => ({ file: r.node.file, edges: r.edges })),
|
||||
]);
|
||||
|
||||
// Fan-in for the rail pills ("hub · N"), for the rows actually returned —
|
||||
// one query, not one per row.
|
||||
const fanInOf = cg.getFanIn([
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* `GET /api/screens` — the app as a reader experiences it: screens, and the
|
||||
* transitions between them, each labelled with what has to be true for it to
|
||||
* happen.
|
||||
*
|
||||
* The graph already holds the pieces: a `route` node per screen file (Expo
|
||||
* Router, and any framework that binds a route to the component that renders
|
||||
* it), and a `navigates` edge from the function that pushes a path to the
|
||||
* route it names. What a reader wants is neither of those nodes — it is
|
||||
* "from the Home screen, tapping an object card opens Object Detail, but only
|
||||
* for a collected object". That sentence is three hops away from the edge:
|
||||
*
|
||||
* HomeScreen ─renders→ ItemsGrid ─renders→ ItemCard ─calls→ openObjectDetail ─navigates→ /object-detail
|
||||
*
|
||||
* So for every `navigates` edge this walks BACKWARDS from its source through
|
||||
* `calls` edges (the JSX-render synthesizer's edges among them) until it
|
||||
* reaches a component that a route renders. That component's screen is where
|
||||
* the transition starts; the nodes passed on the way are the `via` chain, and
|
||||
* the branch conditions at each call site along it (`graph/branch-guards.ts`)
|
||||
* are joined into the link's `when`. A navigation whose walk reaches no screen
|
||||
* within the hop cap — a store action, a service that runs after login — is
|
||||
* kept as an `origin` rather than dropped: it is a real transition with a real
|
||||
* trigger, just not a screen.
|
||||
*
|
||||
* Read from the graph at request time, never cached: the `when` labels are
|
||||
* read from the source as it stands. Seventy-odd transitions and a few
|
||||
* hundred guarded call sites resolve in tens of milliseconds.
|
||||
*/
|
||||
|
||||
import type CodeGraph from '../../index';
|
||||
import type { Edge, Language, Node } from '../../types';
|
||||
import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
|
||||
import { resolveProjectFile } from '../security';
|
||||
import { findIndexedFile, hasDriftedOnDisk } from './source';
|
||||
import { toNodeRef, type WireNodeRef } from './wire';
|
||||
|
||||
// =============================================================================
|
||||
// Wire shapes
|
||||
// =============================================================================
|
||||
|
||||
export interface WireScreen {
|
||||
/** The route node's id — what a link's `from`/`to` name. */
|
||||
id: string;
|
||||
/** The screen's path: `/object-detail`, `/item/[id]`. */
|
||||
path: string;
|
||||
file: string;
|
||||
line: number;
|
||||
/** The component the route renders, when the graph bound one. */
|
||||
component: WireNodeRef | null;
|
||||
/** Transitions into and out of this screen. */
|
||||
incoming: number;
|
||||
outgoing: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A navigation whose start is not one screen: a function no screen reaches
|
||||
* (a store action after login), or a component so many screens render (a
|
||||
* top bar) that attributing its navigation to each of them would draw the
|
||||
* same three arrows from every box.
|
||||
*/
|
||||
export interface WireScreenOrigin {
|
||||
id: string;
|
||||
node: WireNodeRef;
|
||||
outgoing: number;
|
||||
/** For shared chrome: how many screens render it. */
|
||||
sharedBy?: number;
|
||||
}
|
||||
|
||||
export interface WireScreenSite {
|
||||
file: string;
|
||||
line: number;
|
||||
/** The href as written at the call, `${…}` for interpolations. */
|
||||
href: string;
|
||||
/** `push`, `replace`, `navigate`, or `return` for a helper's return value. */
|
||||
method: string;
|
||||
/** Branch conditions at this site alone. */
|
||||
when: string;
|
||||
}
|
||||
|
||||
export interface WireScreenLink {
|
||||
id: string;
|
||||
/** A screen id, or an origin id. */
|
||||
from: string;
|
||||
/** Always a screen id. */
|
||||
to: string;
|
||||
/** True when `from` is an origin, not a screen. */
|
||||
fromOrigin: boolean;
|
||||
/**
|
||||
* The symbols the transition passes through, from just below the screen's
|
||||
* component down to the one that holds the navigation call. Empty when the
|
||||
* screen's own component navigates.
|
||||
*/
|
||||
via: WireNodeRef[];
|
||||
/** Conditions along the whole chain, joined; '' when unconditional. */
|
||||
when: string;
|
||||
/** Every call site behind this link (same screen, same chain end). */
|
||||
sites: WireScreenSite[];
|
||||
/**
|
||||
* The destination was inferred, not written at the call: it came back from
|
||||
* a helper's return value. (A synthesized render hop on the way — every
|
||||
* parent → child component step is one — does not count: that would dash
|
||||
* nearly every arrow.)
|
||||
*/
|
||||
synthesized: boolean;
|
||||
}
|
||||
|
||||
export interface WireScreensPayload {
|
||||
/** False when the graph holds no screen navigation at all. */
|
||||
routed: boolean;
|
||||
/** The route named `/`, when there is one. */
|
||||
entry: string | null;
|
||||
screens: WireScreen[];
|
||||
origins: WireScreenOrigin[];
|
||||
links: WireScreenLink[];
|
||||
/** Navigations dropped because the backwards walk hit a cap. */
|
||||
dropped: number;
|
||||
index: { lastIndexedAt: number | null; edges: number; files: number };
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Caps
|
||||
// =============================================================================
|
||||
|
||||
/** Hops walked back from a navigation call before giving up on a screen. */
|
||||
const MAX_DEPTH = 7;
|
||||
/** Callers expanded per node — a hub (`useToast`) is a dead end, not a path. */
|
||||
const MAX_CALLERS_PER_NODE = 30;
|
||||
/** Nodes visited per navigation. */
|
||||
const MAX_VISITED = 800;
|
||||
/** Call sites labelled with conditions per request. */
|
||||
const MAX_WHEN_SITES = 600;
|
||||
|
||||
/**
|
||||
* Edges walked backwards from a navigation call. `contains` because a handler
|
||||
* declared inside a screen component (`function handleContinue() {…}` in the
|
||||
* body) is reached from the component by containment, not by a call; a
|
||||
* `references` edge is followed only when it passes the function as a value
|
||||
* (`onPress={handleContinue}`), never for a type mention.
|
||||
*/
|
||||
const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'contains', 'references'];
|
||||
|
||||
/** A component rendered by at least this many screens is chrome, not a screen's own behaviour. */
|
||||
const SHARED_CHROME_MIN = 3;
|
||||
|
||||
// =============================================================================
|
||||
// The endpoint
|
||||
// =============================================================================
|
||||
|
||||
export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<WireScreensPayload> {
|
||||
const started = Date.now();
|
||||
const stats = cg.getStats();
|
||||
const index = { lastIndexedAt: cg.getLastIndexedAt() ?? null, edges: stats.edgeCount, files: stats.fileCount };
|
||||
|
||||
const routes = cg.getNodesByKind('route');
|
||||
const routeIds = routes.map((r) => r.id);
|
||||
const navEdges = routeIds.length === 0 ? [] : cg.getIncomingEdgesTo(routeIds, ['navigates']);
|
||||
if (navEdges.length === 0) {
|
||||
return {
|
||||
routed: false,
|
||||
entry: null,
|
||||
screens: [],
|
||||
origins: [],
|
||||
links: [],
|
||||
dropped: 0,
|
||||
index,
|
||||
timing: { elapsedMs: Date.now() - started },
|
||||
};
|
||||
}
|
||||
|
||||
// Route → the component it renders; component → its route.
|
||||
const routeById = new Map(routes.map((r) => [r.id, r]));
|
||||
const routeByFile = new Map(routes.map((r) => [r.filePath, r.id]));
|
||||
const renders = cg.getOutgoingEdgesFrom(routeIds, ['calls', 'instantiates']);
|
||||
const componentIds = new Set(renders.map((e) => e.target));
|
||||
const nodesById = cg.getNodesByIds([...componentIds, ...navEdges.map((e) => e.source)]);
|
||||
const componentOf = new Map<string, Node>();
|
||||
const screenOfComponent = new Map<string, string>();
|
||||
for (const edge of renders) {
|
||||
const component = nodesById.get(edge.target);
|
||||
if (!component || componentOf.has(edge.source)) continue;
|
||||
componentOf.set(edge.source, component);
|
||||
screenOfComponent.set(component.id, edge.source);
|
||||
}
|
||||
|
||||
const whenAt = makeWhenReader(cg, projectRoot);
|
||||
const links = new Map<string, WireScreenLink>();
|
||||
const origins = new Map<string, WireScreenOrigin>();
|
||||
const counts = new Map<string, { incoming: number; outgoing: number }>();
|
||||
const bump = (id: string, key: 'incoming' | 'outgoing') => {
|
||||
const c = counts.get(id) ?? { incoming: 0, outgoing: 0 };
|
||||
c[key]++;
|
||||
counts.set(id, c);
|
||||
};
|
||||
let dropped = 0;
|
||||
|
||||
for (const nav of navEdges) {
|
||||
const holder = nodesById.get(nav.source);
|
||||
const target = routeById.get(nav.target);
|
||||
if (!holder || !target) continue;
|
||||
const meta = (nav.metadata ?? {}) as Record<string, unknown>;
|
||||
const site: WireScreenSite = {
|
||||
file: toPosix(holder.filePath),
|
||||
line: nav.line ?? holder.startLine,
|
||||
href: typeof meta.href === 'string' ? meta.href : target.name,
|
||||
method: nav.provenance === 'heuristic' ? 'return' : typeof meta.navMethod === 'string' ? meta.navMethod : 'push',
|
||||
when: nav.provenance === 'heuristic' ? '' : await whenAt(holder, nav),
|
||||
};
|
||||
|
||||
let starts = await attribute(cg, holder, screenOfComponent, routeByFile, nodesById);
|
||||
if (starts === null) {
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
starts = collapseSharedChrome(starts, origins);
|
||||
const attributions =
|
||||
starts.length > 0
|
||||
? starts
|
||||
: [{ screenId: null as string | null, path: [{ node: holder, edge: null }] as Array<{ node: Node; edge: Edge | null }> }];
|
||||
|
||||
for (const start of attributions) {
|
||||
let fromId: string;
|
||||
let fromOrigin = false;
|
||||
if (start.screenId !== null) fromId = start.screenId;
|
||||
else {
|
||||
// The origin is the chain's head: the holder itself, or the shared
|
||||
// component the chain was collapsed onto.
|
||||
const head = start.path[0]!.node;
|
||||
fromId = head.id;
|
||||
fromOrigin = true;
|
||||
if (!origins.has(head.id)) origins.set(head.id, { id: head.id, node: toNodeRef(head), outgoing: 0 });
|
||||
}
|
||||
|
||||
// `path` is [screen component, …, holder]; `path[i].edge` is the call
|
||||
// from `path[i-1]` into `path[i]`, so its site is in `path[i-1]`'s file.
|
||||
// The component itself is not "via" — it IS the screen.
|
||||
const via = start.path.slice(1).map((h) => toNodeRef(h.node));
|
||||
const whens: string[] = [];
|
||||
const synthesized = nav.provenance === 'heuristic';
|
||||
for (let i = 1; i < start.path.length; i++) {
|
||||
const edge = start.path[i]!.edge;
|
||||
if (!edge) continue;
|
||||
const w = await whenAt(start.path[i - 1]!.node, edge);
|
||||
if (w && !whens.includes(w)) whens.push(w);
|
||||
}
|
||||
if (site.when && !whens.includes(site.when)) whens.push(site.when);
|
||||
|
||||
const viaKey = via.map((v) => v.id).join('>');
|
||||
if (fromOrigin && start.path[0]!.node.id !== holder.id) {
|
||||
// A collapsed chain: the origin's own name is not "via".
|
||||
}
|
||||
const id = `${fromId} | ||||