feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API

Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
This commit is contained in:
Colby McHenry
2026-08-28 09:46:50 -05:00
parent f0eafe31f9
commit 873f133c96
36 changed files with 3711 additions and 73 deletions
+26
View File
@@ -36,6 +36,7 @@ import type {
WireFlowPayload,
WireMapPayload,
WireScreensPayload,
WireStepsPayload,
WireNodeRefs,
WireRoutes,
WireSearch,
@@ -178,6 +179,16 @@ export interface LiveHandlers {
error(): void;
}
/** What happens from an anchor: by id, or by name (the first screen-like match). */
export interface StepsRequest {
anchor?: string;
symbol?: string;
depth?: number;
limit?: number;
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
through?: boolean;
}
/* -------------------------------------------------------------- adapter -- */
/**
@@ -213,6 +224,11 @@ export interface GraphAdapter {
map(request?: MapRequest, signal?: AbortSignal): Promise<WireMapPayload>;
/** The app's screens and the transitions between them, with their conditions. */
screens(signal?: AbortSignal): Promise<WireScreensPayload>;
/**
* What happens from a screen or a symbol, as typed steps. Optional: a host
* that has not wired it renders the Steps view as absent-and-explained.
*/
steps?(request: StepsRequest, signal?: AbortSignal): Promise<WireStepsPayload>;
/** The URL → handler map. */
routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
/** Where a reader starts: routes, files that run something, tests, hubs. */
@@ -400,6 +416,16 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
return getJson<WireScreensPayload>('api/screens', signal);
},
steps(request = {}, signal) {
const params = new URLSearchParams();
if (request.anchor) params.set('anchor', request.anchor);
else if (request.symbol) params.set('symbol', request.symbol);
if (request.depth) params.set('depth', String(request.depth));
if (request.limit) params.set('limit', String(request.limit));
if (request.through) params.set('through', '1');
return getJson<WireStepsPayload>(`api/steps${query(params)}`, signal);
},
entryPoints(request = {}, signal) {
const params = new URLSearchParams();
if (request.limit) params.set('limit', String(request.limit));
+21 -1
View File
@@ -20,6 +20,7 @@ import type {
WireFlowPayload,
WireMapPayload,
WireScreensPayload,
WireStepsPayload,
WireNodeRefs,
WireRoutes,
WireSearch,
@@ -28,7 +29,7 @@ import type {
WireSymbolPayload,
WireTrails,
} from './wire';
import type { SaveTrailRequest } from './adapter';
import type { SaveTrailRequest, StepsRequest } from './adapter';
export * from './wire';
export { ApiFailure } from './adapter';
@@ -44,6 +45,7 @@ export type {
SaveTrailRequest,
SearchRequest,
SourceRequest,
StepsRequest,
} from './adapter';
export function fetchStats(signal?: AbortSignal): Promise<WireStats> {
@@ -146,6 +148,24 @@ export function fetchScreens(signal?: AbortSignal): Promise<WireScreensPayload>
return getGraphAdapter().screens(signal);
}
/**
* What happens from an anchor — a screen, a handler, any symbol — as typed
* steps with the conditions between them. Refused, not thrown at random, by
* an adapter that never offered it (see {@link canDrawSteps}).
*/
export function fetchSteps(request: StepsRequest, signal?: AbortSignal): Promise<WireStepsPayload> {
const adapter = getGraphAdapter();
if (typeof adapter.steps !== 'function') {
return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot draw steps.', null));
}
return adapter.steps(request, signal);
}
/** Whether the installed adapter can answer {@link fetchSteps} at all. */
export function canDrawSteps(): boolean {
return typeof getGraphAdapter().steps === 'function';
}
export function fetchMap(
opts: { root?: string | null; depth?: number } = {},
signal?: AbortSignal
+24
View File
@@ -55,6 +55,16 @@ export interface FlowHrefOptions {
trail?: string;
}
export interface StepsHrefOptions {
/** A node id — a screen's route, a handler, any symbol. */
anchor?: string;
/** A name, when no id is at hand; the answering side picks the most screen-like match. */
symbol?: string;
depth?: number;
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
through?: boolean;
}
/**
* Where the components send the reader.
*
@@ -69,6 +79,7 @@ export interface NavigationDriver {
flowHref(opts?: FlowHrefOptions): string;
entryHref(): string;
screensHref(): string;
stepsHref(opts?: StepsHrefOptions): string;
deadHref(opts?: DeadCodeHrefOptions): string;
/** Go to an href this driver built. */
navigate(href: string, opts?: { replace?: boolean }): void;
@@ -138,6 +149,15 @@ export const hashNavigation: NavigationDriver = {
return '#/screens';
},
stepsHref(opts = {}) {
const params = new URLSearchParams();
if (opts.anchor) params.set('anchor', opts.anchor);
else if (opts.symbol) params.set('symbol', opts.symbol);
if (opts.depth) params.set('depth', String(opts.depth));
if (opts.through) params.set('through', '1');
return `#/steps${query(params)}`;
},
deadHref(opts = {}) {
const params = new URLSearchParams();
if (opts.exported) params.set('exported', '1');
@@ -221,6 +241,10 @@ export function screensHref(): string {
return driver.screensHref();
}
export function stepsHref(opts: StepsHrefOptions = {}): string {
return driver.stepsHref(opts);
}
export function deadHref(opts: DeadCodeHrefOptions = {}): string {
return driver.deadHref(opts);
}
+22
View File
@@ -12,6 +12,7 @@
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
* #/entry entry points (where a flow starts)
* #/screens screens (the app's screens and transitions)
* #/steps steps (?anchor=<id> | ?symbol=<name>: what happens from there)
* #/dead dead code (?exported=1 widens the claim)
*
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
@@ -43,6 +44,7 @@ export {
navigate,
screensHref,
setNavigationDriver,
stepsHref,
symbolHref,
} from './navigation';
export type {
@@ -51,6 +53,7 @@ export type {
FlowHrefOptions,
MapHrefOptions,
NavigationDriver,
StepsHrefOptions,
SymbolHrefOptions,
} from './navigation';
@@ -77,6 +80,14 @@ export type Route =
}
| { view: 'entry' }
| { view: 'screens' }
| {
view: 'steps';
/** The anchor by id; null with `symbol` set, or on the bare tab. */
anchor: string | null;
symbol: string | null;
depth: number | null;
through: boolean;
}
| {
view: 'dead';
/** Symbols reachable from outside the index are on the list. */
@@ -141,6 +152,17 @@ export function parseHash(hash: string): RouterLocation {
route = { view: 'entry' };
} else if (head === 'screens' && rest.length === 0) {
route = { view: 'screens' };
} else if (head === 'steps' && rest.length === 0) {
// The anchor travels in the URL, so "what happens on the review screen"
// is a link that reopens as the same picture.
const depth = Number.parseInt(params.get('depth') ?? '', 10);
route = {
view: 'steps',
anchor: params.get('anchor'),
symbol: params.get('symbol'),
depth: Number.isFinite(depth) && depth >= 1 && depth <= 14 ? depth : null,
through: params.get('through') === '1',
};
} 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.
+19 -6
View File
@@ -123,6 +123,19 @@ export interface Point {
y: number;
}
/**
* What the label placement and the pointer need from a picture: the Screens
* view's model, or any other drawn with its machinery (the Steps view draws
* typed steps with the same layout, curves, pills and hit-testing).
*/
export interface Picture {
layout: MapLayout;
layerGap: number;
edges: Map<string, { label: string }>;
curves: Map<string, Curve>;
polylines: Map<string, Point[]>;
}
/* ------------------------------------------------------------- layering -- */
/**
@@ -286,7 +299,7 @@ export function clauses(when: string): string[] {
* …` on both arms of a fork); the last clause is the one that tells the two
* apart, and the full text is a hover away.
*/
export function edgeLabel(links: readonly WireScreenLink[]): string {
export function edgeLabel(links: ReadonlyArray<{ when: string }>): string {
if (links.length === 1) {
const when = links[0]!.when;
if (!when) return '';
@@ -630,7 +643,7 @@ export interface EdgeHit {
* the smaller id, so two visits agree.
*/
export function nearestEdge(
model: ScreensModel,
model: Picture,
point: Point,
among: ReadonlySet<string> | null,
reach: number
@@ -713,7 +726,7 @@ export function laneCount(layerGap: number): number {
* the selected screen — `→` leaving it, `←` arriving — and the edge's label.
* Empty when the edge has nothing to say (a single, unconditional transition).
*/
export function pillText(info: ScreenEdgeInfo, edge: MapEdgeLayout, selected: string | null): string {
export function pillText(info: { label: string }, edge: MapEdgeLayout, selected: string | null): string {
if (!info.label) return '';
const arriving = selected !== null && edge.target === selected && edge.source !== selected;
return `${arriving ? '←' : '→'} ${info.label}`;
@@ -737,7 +750,7 @@ function intersects(a: Rect, b: Rect, gapX: number): boolean {
* lane is free — or when `lanes` is 1 and that lane is taken.
*/
function layPill(
model: ScreensModel,
model: Picture,
edge: MapEdgeLayout,
end: 'source' | 'target',
text: string,
@@ -781,7 +794,7 @@ function layPill(
* pill: the pill for a hovered edge that is not the selected screen's is
* placed separately by {@link hoverPill}.
*/
export function placeLabels(model: ScreensModel, selected: string | null): PillLayout {
export function placeLabels(model: Picture, selected: string | null): PillLayout {
const pills = new Map<string, PillPlacement>();
if (selected === null) return { pills, hidden: 0 };
const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
@@ -827,7 +840,7 @@ export function placeLabels(model: ScreensModel, selected: string | null): PillL
* with the whole condition, not the connector's short label.
*/
export function hoverPill(
model: ScreensModel,
model: Picture,
edgeId: string,
selected: string | null,
text?: string,
+238
View File
@@ -0,0 +1,238 @@
/**
* The Steps view's model — what happens from an anchor, as typed steps laid
* out so that a step sits above the steps it sets in motion.
*
* Everything geometric is the Screens view's (`screens-model.ts`): the Map's
* layout with directional ports, a curve per edge on a track of its own, the
* pills that label a selected step's links at the far end of each line, and
* the nearest-line pointer. What is this file's own is small: the row a step
* sits on is its distance from the anchor, which the server already counted
* (`WireStep.depth`), so the layering is a lookup rather than a search; the
* words in a box come from the step's kind; and the side panel's two lists
* are the links into and out of the selected step.
*/
import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepsPayload } from './wire';
import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
import {
edgeLabel,
samplePolyline,
trackedCurves,
SCREEN_LAYER_GAP,
type Curve,
type Picture,
type Point,
} from './screens-model';
export interface StepNodeInfo {
id: string;
step: WireStep;
/** What the box prints on its first line. */
label: string;
/** …and on its second. */
sub: string;
}
export interface StepEdgeInfo {
id: string;
from: string;
to: string;
/** Every link between the pair — one connector, several stories. */
links: WireStepLink[];
/** The connector's short label: the innermost condition, or how many links. */
label: string;
/** Every link behind it was synthesized (a dynamic-dispatch bridge). */
synthesized: boolean;
/** The kind the links agree on, or `calls` when they differ. */
kind: WireStepLink['kind'];
}
export interface StepsModel extends Picture {
layout: MapLayout;
nodes: Map<string, StepNodeInfo>;
edges: Map<string, StepEdgeInfo>;
layerGap: number;
curves: Map<string, Curve>;
polylines: Map<string, Point[]>;
/** Steps per kind, for the panel's summary. */
counts: Record<WireStep['kind'], number>;
}
/** Points a curve is sampled at for hit-testing (as the Screens view's). */
const HIT_SAMPLES = 24;
/* ---------------------------------------------------------------- words -- */
/** A short word for a step's kind, as the panel and the legend say it. */
export function kindWord(kind: WireStep['kind']): string {
switch (kind) {
case 'screen':
return 'screen';
case 'trigger':
return 'handler';
case 'bridge':
return 'native call';
case 'event':
return 'native event';
case 'store':
return 'store action';
case 'effect':
return 'outside the index';
default:
return 'start';
}
}
/** The first line of a step's box. Boundary crossings carry an arrow for which way the code goes. */
export function stepLabel(step: WireStep): string {
switch (step.kind) {
case 'bridge':
return `${step.label}`;
case 'event': {
const events = step.events ?? (step.event ? [step.event] : []);
if (events.length === 0) return `${step.label}`;
return events.length === 1 ? `${events[0]}` : `${events[0]} +${events.length - 1}`;
}
default:
return step.label;
}
}
/** The second line: what the step is, then where it is. */
export function stepSub(step: WireStep): string {
const file = step.node ? step.node.file.slice(step.node.file.lastIndexOf('/') + 1) : '';
switch (step.kind) {
case 'screen':
return step.sub;
case 'trigger':
return `handler · ${file}`;
case 'bridge':
return `native · ${file}`;
case 'event':
return `${step.label} · ${file}`;
case 'store':
return `store · ${file}`;
case 'effect':
return step.sub;
default:
return step.sub;
}
}
/* ---------------------------------------------------------------- build -- */
export function buildStepsModel(payload: WireStepsPayload): StepsModel {
const nodes = new Map<string, StepNodeInfo>();
const modules: WireMapModule[] = [];
const counts: Record<WireStep['kind'], number> = {
anchor: 0,
screen: 0,
trigger: 0,
bridge: 0,
event: 0,
store: 0,
effect: 0,
};
const degree = new Map<string, number>();
for (const link of payload.links) {
degree.set(link.from, (degree.get(link.from) ?? 0) + 1);
degree.set(link.to, (degree.get(link.to) ?? 0) + 1);
}
for (const step of payload.steps) {
counts[step.kind]++;
const info: StepNodeInfo = { id: step.id, step, label: stepLabel(step), sub: stepSub(step) };
nodes.set(step.id, info);
modules.push({
id: step.id,
label: info.label,
files: 1,
symbols: degree.get(step.id) ?? 0,
languages: [],
test: false,
generated: 0,
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
});
}
// One layout link per (from, to); the links behind it stay listed.
const byPair = new Map<string, WireStepLink[]>();
for (const link of payload.links) {
if (!nodes.has(link.from) || !nodes.has(link.to) || link.from === link.to) continue;
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, StepEdgeInfo>();
for (const [key, group] of byPair) {
const first = group[0]!;
links.push({
source: first.from,
target: first.to,
count: group.length,
declared: group.length,
byKind: [{ kind: 'calls', 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),
kind: group.every((l) => l.kind === first.kind) ? first.kind : 'calls',
});
}
// Layer = distance from the anchor, counted by the server. Layer 0 is the
// bottom, so the deepest row is 0 and the anchor is on top.
const depthOf = new Map(payload.steps.map((s) => [s.id, s.depth]));
const deepest = Math.max(0, ...payload.steps.map((s) => s.depth));
const layering = (ids: string[]): Map<string, number> =>
new Map(ids.map((id) => [id, deepest - (depthOf.get(id) ?? deepest)]));
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,
layerGap: SCREEN_LAYER_GAP,
portPitch: PORT_PITCH,
ports: 'directional',
}
);
const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
const polylines = new Map<string, Point[]>();
for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
}
/** The side panel's two lists for a selected step. */
export function stepNeighbourhood(
payload: WireStepsPayload,
id: string
): { arrivesFrom: WireStepLink[]; leadsTo: WireStepLink[] } {
return {
arrivesFrom: payload.links.filter((l) => l.to === id),
leadsTo: payload.links.filter((l) => l.from === id),
};
}
/** `useReviewHandlers → handleApproveAllImages`, or '' when nothing was folded. */
export function stepViaText(link: WireStepLink): string {
return link.via.map((v) => v.name).join(' → ');
}
/** The layout edge a link draws as, or null when it is a self-loop. */
export function stepPairId(link: WireStepLink): string | null {
return link.from === link.to ? null : linkId({ source: link.from, target: link.to });
}
+70
View File
@@ -694,6 +694,76 @@ export interface WireScreensPayload {
timing: { elapsedMs: number };
}
/* ------------------------------------------------------------------ steps -- */
export type WireStepKind = 'anchor' | 'screen' | 'trigger' | 'bridge' | 'event' | 'store' | 'effect';
export type WireStepLinkKind = 'calls' | 'navigates' | 'handler' | 'bridge' | 'event' | 'store' | 'effect';
export interface WireStepSite {
file: string;
line: number;
/** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
text: string;
}
export interface WireStep {
/** The node's id, or `effect:<function id>:<api>` for a call leaving the index. */
id: string;
kind: WireStepKind;
/** The step the picture starts from. A screen anchor keeps `kind: 'screen'`. */
anchor: boolean;
/** Null only for an effect, which is a call site rather than a symbol. */
node: WireNodeRef | null;
label: string;
sub: string;
/** Steps from the anchor: the row. */
depth: number;
/**
* Why the walk did not go on from this step: a cap (`depth`, `fan-out`,
* `folded`, `steps`), or `screen` — another screen, drawn as a boundary.
*/
cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
/** The event name a native event step arrived on — the first, when several land here. */
event?: string;
/** Every event that lands on this step. */
events?: string[];
screen?: { path: string; component: WireNodeRef | null };
/** The calls one function makes into one category, and the function. */
effect?: { api: string; apis: string[]; category: string; by: WireNodeRef; line: number };
}
export interface WireStepLink {
id: string;
from: string;
to: string;
kind: WireStepLinkKind;
/** The symbols folded between the two steps, in order. */
via: WireNodeRef[];
/** Conditions along the whole chain, joined; '' when unconditional. */
when: string;
/** How the last hop was established when it was not a plain call. */
label: string;
synthesized: boolean;
uncertain: boolean;
sites: WireStepSite[];
}
export interface WireStepsPayload {
anchor: WireNodeRef;
/** Other symbols that share the anchor's name, when it was given by name. */
ambiguous: WireNodeRef[];
steps: WireStep[];
links: WireStepLink[];
depth: number;
limit: number;
/** Screens reached from the anchor were entered rather than drawn as boundaries. */
through: boolean;
truncated: { steps: number; hubs: number; chrome: number };
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number };
}
/* -------------------------------------------------------------- dead code -- */
/** One symbol nothing in the index reaches. */