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

Introduce Expo Router integration: a new framework resolver, route-based screen nodes, and navigates edges, plus a /api/screens endpoint and a Screens UI view. Adds branch-guard-driven labeling of edges, resolution logic, and tests to cover extraction, resolution, and end-to-end flow. This enables CodeGraph UI to surface screens and transitions from Expo Router apps.
This commit is contained in:
Colby McHenry
2026-08-27 22:52:18 -05:00
parent ac9580544b
commit 70fd5fefc2
42 changed files with 4257 additions and 33 deletions
+7
View File
@@ -35,6 +35,7 @@ import type {
WireFileCodePayload,
WireFlowPayload,
WireMapPayload,
WireScreensPayload,
WireNodeRefs,
WireRoutes,
WireSearch,
@@ -210,6 +211,8 @@ export interface GraphAdapter {
flow(request: FlowRequest, signal?: AbortSignal): Promise<WireFlowPayload>;
/** The repository at module granularity, layered. */
map(request?: MapRequest, signal?: AbortSignal): Promise<WireMapPayload>;
/** The app's screens and the transitions between them, with their conditions. */
screens(signal?: AbortSignal): Promise<WireScreensPayload>;
/** The URL → handler map. */
routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
/** Where a reader starts: routes, files that run something, tests, hubs. */
@@ -393,6 +396,10 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
return getJson<WireRoutes>(`api/routes${query(params)}`, signal);
},
screens(signal) {
return getJson<WireScreensPayload>('api/screens', signal);
},
entryPoints(request = {}, signal) {
const params = new URLSearchParams();
if (request.limit) params.set('limit', String(request.limit));
+5
View File
@@ -19,6 +19,7 @@ import type {
WireFileCodePayload,
WireFlowPayload,
WireMapPayload,
WireScreensPayload,
WireNodeRefs,
WireRoutes,
WireSearch,
@@ -141,6 +142,10 @@ export function fetchSource(
* is how many path segments under it name a module. Omitting `root` lets the
* adapter pick the repository's source directory.
*/
export function fetchScreens(signal?: AbortSignal): Promise<WireScreensPayload> {
return getGraphAdapter().screens(signal);
}
export function fetchMap(
opts: { root?: string | null; depth?: number } = {},
signal?: AbortSignal
+3 -1
View File
@@ -243,7 +243,9 @@ export function buildEntryPanel(entries: WireEntryPoints | null): EntryPanel {
section(
'routes',
'Routes',
'A request from outside arrives here — the URL, and the symbol that serves it.',
entries.routes.items.items.every((r) => !r.method)
? 'A screen of the app — its path, and the component that renders it.'
: 'A request from outside arrives here — the URL, and the symbol that serves it.',
entries.routes.items,
groupRows(
entries.routes.items.items.map((route) => ({
+28 -3
View File
@@ -186,6 +186,22 @@ export interface MapLayout {
export interface MapLayoutOptions {
includeTests: boolean;
/** Override the hidden-link floor; 0 draws every link (the Screens view). */
minWeight?: number;
/**
* The two lines a box is sized for. The Map's boxes show the module id and
* its counts; a view that shows something else (a screen's path and its
* component) must size for what it draws, or an opaque id decides the width.
*/
sizing?: (module: WireMapModule, island: boolean) => { label: string; meta: string };
/**
* Replace longest-path layering. Receives every module id and the acyclic
* links (mutual pairs already broken); returns each id's layer, 0 at the
* BOTTOM. The Screens view lays out by distance from the entry screen,
* where "one layer above what it depends on" would put the head of the
* longest chain of screens above the login page.
*/
layering?: (ids: string[], links: ReadonlyArray<{ source: string; target: string }>) => Map<string, number>;
}
export function strokeWidthFor(count: number): number {
@@ -212,7 +228,7 @@ export function buildMapLayout(
// depends on is depended on, whatever this screen is currently showing.
const depended = new Set(payload.links.map((l) => l.target));
const links = payload.links.filter((l) => present.has(l.source) && present.has(l.target));
const minWeight = options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT;
const minWeight = options.minWeight ?? (options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT);
const declaredLinks = links.filter((l) => l.declared > 0);
const useDeclared =
@@ -246,7 +262,12 @@ export function buildMapLayout(
for (const list of out.values()) list.sort();
const layer = new Map<string, number>();
for (const module of modules) longestPath(module.id, out, layer, new Set());
if (options.layering) {
for (const [id, value] of options.layering(modules.map((m) => m.id), acyclic)) layer.set(id, value);
for (const module of modules) if (!layer.has(module.id)) layer.set(module.id, 0);
} else {
for (const module of modules) longestPath(module.id, out, layer, new Set());
}
const layerCount = Math.max(1, ...[...layer.values()].map((v) => v + 1));
const rows: string[][] = Array.from({ length: layerCount }, () => []);
@@ -282,7 +303,11 @@ export function buildMapLayout(
// --- placement -----------------------------------------------------------
const islands = new Set(modules.filter((m) => !depended.has(m.id)).map((m) => m.id));
const widths = new Map(
modules.map((m) => [m.id, nodeWidth(m.id, moduleMetaLabel(m, islands.has(m.id)))])
modules.map((m) => {
const island = islands.has(m.id);
const lines = options.sizing?.(m, island) ?? { label: m.id, meta: moduleMetaLabel(m, island) };
return [m.id, nodeWidth(lines.label, lines.meta)];
})
);
const rowSums = rows.map((row) => row.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0));
// Natural span = the boxes shoulder to shoulder. The content width is the
+9
View File
@@ -68,6 +68,7 @@ export interface NavigationDriver {
mapHref(opts?: MapHrefOptions): string;
flowHref(opts?: FlowHrefOptions): string;
entryHref(): string;
screensHref(): string;
deadHref(opts?: DeadCodeHrefOptions): string;
/** Go to an href this driver built. */
navigate(href: string, opts?: { replace?: boolean }): void;
@@ -133,6 +134,10 @@ export const hashNavigation: NavigationDriver = {
return '#/entry';
},
screensHref() {
return '#/screens';
},
deadHref(opts = {}) {
const params = new URLSearchParams();
if (opts.exported) params.set('exported', '1');
@@ -212,6 +217,10 @@ export function entryHref(): string {
return driver.entryHref();
}
export function screensHref(): string {
return driver.screensHref();
}
export function deadHref(opts: DeadCodeHrefOptions = {}): string {
return driver.deadHref(opts);
}
+5
View File
@@ -11,6 +11,7 @@
* #/map module map (?root=&depth=&tests=1)
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
* #/entry entry points (where a flow starts)
* #/screens screens (the app's screens and transitions)
* #/dead dead code (?exported=1 widens the claim)
*
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
@@ -40,6 +41,7 @@ export {
hashNavigation,
mapHref,
navigate,
screensHref,
setNavigationDriver,
symbolHref,
} from './navigation';
@@ -74,6 +76,7 @@ export type Route =
trail: string | null;
}
| { view: 'entry' }
| { view: 'screens' }
| {
view: 'dead';
/** Symbols reachable from outside the index are on the list. */
@@ -136,6 +139,8 @@ export function parseHash(hash: string): RouterLocation {
};
} else if (head === 'entry' && rest.length === 0) {
route = { view: 'entry' };
} else if (head === 'screens' && rest.length === 0) {
route = { view: 'screens' };
} else if (head === 'dead' && rest.length === 0) {
// The widening travels in the URL like the map's shape does: a link to
// "including exported symbols" has to reopen the same list.
+277
View File
@@ -0,0 +1,277 @@
/**
* The Screens view's model — the app's screens and the transitions between
* them, laid out so that a screen sits above the screens it opens.
*
* The layout is the Map's (`buildMapLayout`): the same longest-path layering,
* the same barycenter ordering, the same ports, the same determinism. A
* screen graph is a module graph with different words — nodes with names,
* weighted links that mostly point one way, a few cycles (Home ↔ Capture)
* that become dashed back-edges rather than being straightened into a lie.
* Reusing it means a reader who learned the Map reads this without learning
* anything new, and means this file is mostly translation, not geometry.
*
* What is this file's own: which links share a pair (several transitions from
* Home to Capture, each with its own condition, draw as ONE edge whose label
* counts them), the words on that edge, and the two lists the side panel
* shows for a selected screen.
*/
import type { WireMapLink, WireMapModule, WireScreen, WireScreenLink, WireScreensPayload } from './wire';
import { buildMapLayout, linkId, type MapLayout } from './map-model';
/** The longest `when` a connector prints before an ellipsis; the tooltip has the rest. */
const EDGE_LABEL_MAX = 30;
export interface ScreenNodeInfo {
id: string;
/** `/object-detail`, or a function name for an origin. */
label: string;
/** The component's name for a screen; the file for an origin. */
sub: string;
screen: WireScreen | null;
/** A navigation that could not be attributed to a screen. */
origin: boolean;
entry: boolean;
/** No path of transitions leads here from the entry screen. */
unreached: boolean;
}
export interface ScreenEdgeInfo {
id: string;
from: string;
to: string;
/** Every transition between the pair — one connector, several stories. */
links: WireScreenLink[];
/** The connector's short label: the condition, or how many transitions. */
label: string;
synthesized: boolean;
}
export interface ScreensModel {
layout: MapLayout;
nodes: Map<string, ScreenNodeInfo>;
/** Keyed by the layout edge's id (see `linkId`). */
edges: Map<string, ScreenEdgeInfo>;
/** Screens no chain of transitions reaches from the entry. */
unreached: number;
}
/**
* Layer = distance from the entry screen: the entry on top, each row down one
* more transition away. Origins (chrome, triggers outside any screen) count
* as reachable seeds too, so what they open is placed below them. Whatever
* nothing reaches sits in a band at the bottom, layered among itself by the
* same rule from its own sources — a screen the graph cannot see anyone open
* is a fact worth a place, not a crash.
*/
export function entryLayering(entry: string | null, seeds: readonly string[]) {
return (ids: string[], links: ReadonlyArray<{ source: string; target: string }>): Map<string, number> => {
const out = new Map<string, string[]>(ids.map((id) => [id, []]));
const indeg = new Map<string, number>(ids.map((id) => [id, 0]));
for (const l of links) {
out.get(l.source)?.push(l.target);
indeg.set(l.target, (indeg.get(l.target) ?? 0) + 1);
}
const depth = new Map<string, number>();
const bfs = (starts: string[]) => {
let frontier = starts.filter((s) => !depth.has(s));
for (const s of frontier) depth.set(s, 0);
let d = 0;
while (frontier.length > 0) {
d++;
const next: string[] = [];
for (const id of frontier) {
for (const t of out.get(id) ?? []) {
if (depth.has(t)) continue;
depth.set(t, d);
next.push(t);
}
}
frontier = next;
}
};
const roots = [entry, ...seeds].filter((s): s is string => s !== null && ids.includes(s));
bfs(roots);
const reachedMax = Math.max(0, ...[...depth.values()]);
// The unreached band: its own sources first, then whatever they open.
const rest = ids.filter((id) => !depth.has(id));
const restDepth = new Map<string, number>();
if (rest.length > 0) {
const restSources = rest.filter((id) => (indeg.get(id) ?? 0) === 0);
const seedsRest = restSources.length > 0 ? restSources : [rest[0]!];
let frontier = seedsRest;
for (const s of frontier) restDepth.set(s, 0);
let d = 0;
while (frontier.length > 0) {
d++;
const next: string[] = [];
for (const id of frontier) {
for (const t of out.get(id) ?? []) {
if (restDepth.has(t) || depth.has(t)) continue;
restDepth.set(t, d);
next.push(t);
}
}
frontier = next;
}
for (const id of rest) if (!restDepth.has(id)) restDepth.set(id, 0);
}
const restMax = Math.max(0, ...[...restDepth.values()]);
// Layer 0 is the bottom. Unreached band occupies [0, restMax]; reached
// screens sit above it, the entry highest, with one empty row between.
const base = rest.length > 0 ? restMax + 2 : 0;
const layer = new Map<string, number>();
for (const [id, d] of depth) layer.set(id, base + reachedMax - d);
for (const [id, d] of restDepth) layer.set(id, restMax - d);
return layer;
};
}
/** What the connector says. Empty when unconditional and single. */
export function edgeLabel(links: readonly WireScreenLink[]): string {
if (links.length === 1) {
const when = links[0]!.when;
if (!when) return '';
return when.length > EDGE_LABEL_MAX ? `${when.slice(0, EDGE_LABEL_MAX - 1)}…` : when;
}
const conditional = links.filter((l) => l.when).length;
return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
}
export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
const nodes = new Map<string, ScreenNodeInfo>();
const modules: WireMapModule[] = [];
const used = new Set<string>();
for (const link of payload.links) {
used.add(link.from);
used.add(link.to);
}
for (const screen of payload.screens) {
const info: ScreenNodeInfo = {
id: screen.id,
label: screen.path,
sub: screen.component?.name ?? screen.file,
screen,
origin: false,
entry: payload.entry === screen.id,
unreached: false,
};
nodes.set(screen.id, info);
modules.push(moduleFor(info, screen.incoming + screen.outgoing));
}
for (const origin of payload.origins) {
const info: ScreenNodeInfo = {
id: origin.id,
label: origin.node.kind === 'component' ? `<${origin.node.name}>` : `${origin.node.name}()`,
sub: origin.sharedBy ? `on ${origin.sharedBy} screens` : origin.node.file,
screen: null,
origin: true,
entry: false,
unreached: false,
};
nodes.set(origin.id, info);
modules.push(moduleFor(info, origin.outgoing));
}
// One layout link per (from, to); the transitions behind it stay listed.
const byPair = new Map<string, WireScreenLink[]>();
for (const link of payload.links) {
const key = linkId({ source: link.from, target: link.to });
const list = byPair.get(key) ?? [];
list.push(link);
byPair.set(key, list);
}
const links: WireMapLink[] = [];
const edges = new Map<string, ScreenEdgeInfo>();
for (const [key, group] of byPair) {
const first = group[0]!;
if (!nodes.has(first.from) || !nodes.has(first.to)) continue;
// A screen that reopens itself (a retry) is a fact for the panel, not an
// arrow the layout can draw.
if (first.from === first.to) continue;
links.push({
source: first.from,
target: first.to,
count: group.length,
declared: group.length,
byKind: [{ kind: 'navigates', count: group.length }],
topPairs: [],
});
edges.set(key, {
id: key,
from: first.from,
to: first.to,
links: group,
label: edgeLabel(group),
synthesized: group.every((l) => l.synthesized),
});
}
// Reachability from the entry (and from the origins, which are entries of
// a kind: chrome is on the screen the user is on).
const seeds = payload.origins.map((o) => o.id);
const reachable = new Set<string>();
{
const out = new Map<string, string[]>();
for (const l of payload.links) out.set(l.from, [...(out.get(l.from) ?? []), l.to]);
const stack = [payload.entry, ...seeds].filter((s): s is string => s !== null);
while (stack.length > 0) {
const id = stack.pop()!;
if (reachable.has(id)) continue;
reachable.add(id);
for (const t of out.get(id) ?? []) stack.push(t);
}
}
let unreached = 0;
for (const info of nodes.values()) {
if (!info.origin && !reachable.has(info.id)) {
info.unreached = true;
unreached++;
}
}
const layout = buildMapLayout(
{ modules, links },
{
includeTests: true,
minWeight: 0,
sizing: (m) => {
const info = nodes.get(m.id);
return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
},
layering: entryLayering(payload.entry, seeds),
}
);
return { layout, nodes, edges, unreached };
}
function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
return {
id: info.id,
label: info.label,
files: 1,
symbols,
languages: [],
test: false,
generated: 0,
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [info.screen?.file ?? info.sub] },
};
}
/** The side panel's two lists for a selected node. */
export function neighbourhood(
payload: WireScreensPayload,
id: string
): { opensFrom: WireScreenLink[]; goesTo: WireScreenLink[] } {
const opensFrom = payload.links.filter((l) => l.to === id);
const goesTo = payload.links.filter((l) => l.from === id);
return { opensFrom, goesTo };
}
/** `ItemCard → openObjectDetail`, or '' when the screen's own component navigates. */
export function viaText(link: WireScreenLink): string {
return link.via.map((v) => v.name).join(' → ');
}
+3 -1
View File
@@ -269,7 +269,9 @@ export function buildEntryPalette(
if (entries.routes.routed && entries.routes.items.items.length > 0) {
sections.push({
title: 'Routes',
note: 'A request from outside arrives here.',
note: entries.routes.items.items.every((r) => !r.method)
? 'A screen of the app.'
: 'A request from outside arrives here.',
items: take(entries.routes.items.items).map((route) => ({
type: 'route' as const,
id: `route:${route.routeId}`,
+18
View File
@@ -65,6 +65,8 @@ export function edgeWord(edge: WireEdge): string {
return '';
case 'instantiates':
return 'creates';
case 'navigates':
return 'navigates to';
case 'references':
return edge.valueRef ? 'passes as value' : 'uses type';
default:
@@ -82,6 +84,16 @@ export function relationWords(relation: WireRelation): string[] {
return words;
}
/** The distinct branch conditions across a relation's edges, at most three. */
export function relationWhens(relation: WireRelation): string[] {
const out: string[] = [];
for (const edge of relation.edges) {
if (edge.when && !out.includes(edge.when)) out.push(edge.when);
if (out.length === 3) break;
}
return out;
}
/** The synthesizer that produced this relation's edge, when one did. */
export function synthesizedBy(relation: WireRelation): string | null {
if (!relation.synthesized) return null;
@@ -335,6 +347,8 @@ export interface CalleeRow {
lines: number[];
words: string[];
via: string | null;
/** `when` conditions, distinct, for the meta line. */
when: string[];
}
export interface CalleeRailModel {
@@ -357,6 +371,7 @@ export function buildCalleeRail(payload: WireSymbolPayload): CalleeRailModel {
lines: relation.lines,
words: relationWords(relation),
via: synthesizedBy(relation),
when: relationWhens(relation),
};
if (relation.uncertain) uncertain.push(row);
else rows.push(row);
@@ -380,6 +395,8 @@ export interface CallerRow {
/** Call-site lines in the CALLER's file — the `:4657` chips. */
lines: number[];
via: string | null;
/** `when` conditions, distinct, for the meta line. */
when: string[];
}
export interface CallerFileGroup {
@@ -420,6 +437,7 @@ export function buildCallerRail(payload: WireSymbolPayload): CallerRailModel {
words: relationWords(relation),
lines: relation.lines,
via: synthesizedBy(relation),
when: relationWhens(relation),
};
// Uncertainty wins over test-ness: a name-only guess is a claim about the
// edge, and burying it in the tests fold would present it as established.
+52
View File
@@ -117,6 +117,8 @@ export interface WireEdge {
via?: string;
registeredAt?: string;
valueRef?: boolean;
/** Branch conditions the call site runs under — `!isUploading && isCollected`. */
when?: string;
}
/** Every edge between the focal symbol and ONE other symbol, as a single row. */
@@ -642,6 +644,56 @@ export interface WireMapPayload {
timing: { elapsedMs: number; cached: boolean };
}
/* ---------------------------------------------------------------- screens -- */
export interface WireScreen {
id: string;
path: string;
file: string;
line: number;
component: WireNodeRef | null;
incoming: number;
outgoing: number;
}
export interface WireScreenOrigin {
id: string;
node: WireNodeRef;
outgoing: number;
/** Shared chrome: how many screens render it. */
sharedBy?: number;
}
export interface WireScreenSite {
file: string;
line: number;
href: string;
method: string;
when: string;
}
export interface WireScreenLink {
id: string;
from: string;
to: string;
fromOrigin: boolean;
via: WireNodeRef[];
when: string;
sites: WireScreenSite[];
synthesized: boolean;
}
export interface WireScreensPayload {
routed: boolean;
entry: string | null;
screens: WireScreen[];
origins: WireScreenOrigin[];
links: WireScreenLink[];
dropped: number;
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number };
}
/* -------------------------------------------------------------- dead code -- */
/** One symbol nothing in the index reaches. */