feat(steps): lay out screen pictures by region and render region captions

Adds region-based layout support for screens: steps now carry region information, and the server packs regions into dedicated bands with per-region captions. UI changes introduce RegionCaption and region-aware step rendering; StepsModel and related views (StepsView) consume region data, while the region-aware layout keeps anchor and region boundaries intact. Tests and docs updated to reflect region-driven organization and visualization of screen regions. This enables visualizing a screen’s picture as region-based columns rather than a single distance-driven row.
This commit is contained in:
Colby McHenry
2026-08-31 15:38:13 -05:00
parent 6f4887db80
commit 882ea143e8
18 changed files with 1079 additions and 57 deletions
+1 -1
View File
@@ -21,4 +21,4 @@
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 25;
export const EXTRACTION_VERSION = 26;
+1 -1
View File
@@ -3137,7 +3137,7 @@ async function nixOptionPathEdges(queries: QueryBuilder, onYield: MaybeYield): P
// own namespace (`attrsOf (submodule { options = ...; })`) — its internals
// are not globally addressable, so the sentinel blocks registration below it
// while still excluding the region from write candidates.
const SUBMODULE = 'submodule';
const SUBMODULE = '\u0000submodule';
const decls = new Map<string, Rec[]>();
const writes: Rec[] = [];
const register = (path: string[], rec: Rec) => {
+21 -4
View File
@@ -82,7 +82,7 @@ export async function expoRouterReturnEdges(ctx: ResolutionContext, onYield: May
// 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>();
const screensByHelper = new Map<string, Array<{ node: Node; href: string; line: number; column: number }> | null>();
for (const site of sites) {
await onYield();
const helper = resolveHelper(site, ctx);
@@ -102,6 +102,11 @@ export async function expoRouterReturnEdges(ctx: ResolutionContext, onYield: May
target: s.node.id,
kind: 'navigates',
line: s.line,
// The literal's own column, not the line's start: the two arms of
// `return (await seen()) ? '/home/' : '/welcome/'` share a line, and
// only the column lets the guard reader say WHICH arm each edge is —
// without it both drew as `always`.
column: s.column,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'expo-router-return',
@@ -152,15 +157,23 @@ function screensInBody(
helper: Node,
ctx: ResolutionContext,
table: ReturnType<typeof routeTable>
): Array<{ node: Node; href: string; line: number }> | null {
): Array<{ node: Node; href: string; line: number; column: 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++) {
// Scan the BODY, not the signature: a return type of literal routes —
// `async (): Promise<'/welcome/' | '/home/'> => …` — is string literals
// too, and they come FIRST, so first-occurrence-wins kept the annotation's
// positions: inside no branch, so both navigations read as `always`. The
// body starts at the arrow, or at the first brace for a declaration.
const arrow = body.indexOf('=>');
const brace = body.indexOf('{');
const scanFrom = arrow >= 0 && (brace < 0 || arrow < brace) ? arrow + 2 : brace >= 0 ? brace + 1 : 0;
const found = new Map<string, { node: Node; href: string; line: number; column: number }>();
for (let i = scanFrom; i < body.length; i++) {
const ch = body[i];
if (ch !== '"' && ch !== "'" && ch !== '`') continue;
const start = i;
@@ -173,10 +186,14 @@ function screensInBody(
if (segs === null) continue;
const route = matchRoute(segs, table);
if (!route || found.has(route.id)) continue;
// `body` begins at column 0 of the helper's first line and the comment
// stripper preserves offsets, so the literal's column is exact.
const lineStart = body.lastIndexOf('\n', start - 1) + 1;
found.set(route.id, {
node: route,
href: href.display,
line: helper.startLine + body.slice(0, start).split('\n').length - 1,
column: start - lineStart,
});
if (found.size > MAX_SCREENS_PER_HELPER) return null;
}
+1 -1
View File
@@ -238,7 +238,7 @@ export class Telemetry {
const day = this.utcDay();
const cn = client?.name?.slice(0, 64);
const cv = client?.version?.slice(0, 32);
const key = [day, kind, name, cn ?? '', cv ?? ''].join('');
const key = [day, kind, name, cn ?? '', cv ?? ''].join('\u0000');
const line = this.counts.get(key);
if (line) {
line.c += 1;
+37 -1
View File
@@ -150,6 +150,41 @@ const MAX_MENTION_FILE_BYTES = 256 * 1024;
*/
const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'contains', 'references'];
/**
* An edge that arrives from another execution context, never from a caller.
*
* Walked FORWARDS these are the point of the synthesizers — a flow question
* follows a native event or an HTTP call to the code that runs next. Walked
* BACKWARDS they answer a different question than this walk asks. "Which
* screen is this navigation written on" is about where the reader is standing;
* "what could have triggered the event that got us here" is about a chain that
* already left the screen, the language and the process.
*
* Following one costs an answer that is not merely vague but wrong. In an Expo
* app, `CaptureComponent` — the body of `app/capture/index.tsx`, whose sibling
* `ARCapturePage` the `/capture` route renders — is reached by nothing but six
* Swift `emit` calls: the walk skips `file` nodes, and `memo(CaptureComponent)`
* leaves no edge from the memo to the function. So every `router.push` written
* in that file escaped through the bridge, wandered back down into whichever
* screen had started the round trip, and was attributed there — putting four
* of `/capture`'s navigations on `/capture/review`, leaving `/capture/review`
* fed only by itself, and dropping it into the unreached band. It also carried
* the Swift guards home: `Thread.isMainThread` printed as a condition on a
* JavaScript navigation.
*
* Stopping here leaves the walk with no callers at all, which is the honest
* result — and the file fallback below then answers from the holder's own
* file, which is where the push is actually written.
*/
function arrivesFromAnotherContext(edge: Edge): boolean {
const meta = edge.metadata as Record<string, unknown> | undefined;
if (!meta) return false;
// The cross-tier synthesizer marks every edge it makes with the tier it
// crossed (a client's fetch onto its route, a queue job onto its consumer).
if (meta.tier !== undefined) return true;
return meta.synthesizedBy === 'rn-event-channel';
}
/** A component rendered by at least this many screens is chrome, not a screen's own behaviour. */
const SHARED_CHROME_MIN = 3;
@@ -322,7 +357,7 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
if (fromOrigin && start.path[0]!.node.id !== holder.id) {
// A collapsed chain: the origin's own name is not "via".
}
const id = `${fromId}${target.id}${viaKey}`;
const id = `${fromId}\u0000${target.id}\u0000${viaKey}`;
const existing = links.get(id);
if (existing) {
existing.sites.push(site);
@@ -422,6 +457,7 @@ async function attribute(
const byTarget = new Map<string, Edge[]>();
for (const e of incoming) {
if (e.kind === 'references' && (e.metadata as Record<string, unknown> | undefined)?.fnRef !== true) continue;
if (arrivesFromAnotherContext(e)) continue;
const list = byTarget.get(e.target) ?? [];
list.push(e);
byTarget.set(e.target, list);
+102 -8
View File
@@ -123,6 +123,17 @@ export interface WireStep {
* counting before that site. The viewer lays the row out in it.
*/
order?: number;
/**
* For a SCREEN anchor's picture: the region of the screen this step belongs
* to — the top-level component (or hook) of the screen's tree the walk first
* reached it through, the screen's own component for a call written in the
* screen body, and the first-reaching parent's region for everything deeper.
* The viewer lays a screen's picture out by these: a screen is a set of
* handlers with no order between them, so distance alone put ninety boxes on
* one row. Absent for an endpoint's or a function's picture, whose rows
* already read in the code's order.
*/
region?: { id: string; label: string };
/**
* For a screen or an endpoint: its path and the symbol that serves it — the
* component a screen renders, the handler an endpoint runs. `endpoint` when
@@ -602,6 +613,43 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
const fileScopeRefs = new Map<string, Edge[]>();
const fileScopeUnresolved = new Map<string, UnresolvedReference[]>();
/**
* The spans of the calls that became effect steps, per function — and the
* step a binding written inside one arrives from. `Alert.prompt('Add
* Folder', …, [{ onPress: (name) => createBackgroundFolder(name) }])` is
* two facts: the prompt is a device box, and the prompt's button FIRES the
* handler — so the handler's line belongs to the prompt, not to the screen
* the prompt is written on, which fires everything and says nothing. A site
* is rewired only when its own trigger names the call (`onPress ·
* Alert.prompt(…)`) and its position falls inside that call's span in the
* same function; the innermost such span wins. An `onSubmit · useFormik(…)`
* names no effect and stays where it was.
*/
const firedSpans = new Map<
string,
Array<{ start: { line: number; column: number }; end: { line: number; column: number }; step: StepRecord }>
>();
const firedByEffect = (fnId: string, at: { line?: number; column?: number }, of: string): StepRecord | null => {
if (at.line === undefined) return null;
const spans = firedSpans.get(fnId);
if (!spans) return null;
const last = (n: string) => n.replace(/\([^()]*\)/g, '').split(/[.:]/).pop() ?? n;
const want = last(of);
const line = at.line;
const column = at.column ?? 0;
let best: (typeof spans)[number] | null = null;
for (const s of spans) {
if (line < s.start.line || line > s.end.line) continue;
if (line === s.start.line && column < s.start.column) continue;
if (line === s.end.line && column > s.end.column) continue;
if (!s.step.effect?.apis.some((api) => last(api) === want)) continue;
if (best === null || s.start.line > best.start.line || (s.start.line === best.start.line && s.start.column > best.start.column)) {
best = s;
}
}
return best?.step ?? null;
};
const stepFor = (node: Node, kind: WireStepKind, depth: number, extra: Partial<WireStep> = {}): StepRecord | null => {
const existing = steps.get(node.id);
if (existing) {
@@ -758,7 +806,12 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
effect.category === 'response'
? (responseStatus(text, args, ref.referenceKind) ?? (usable && typeof site.status === 'number' ? site.status : null) ?? implicitResponseStatus(text))
: null;
const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, step.depth + 1, status);
// What fires this call, read before its box is made: a call bound inside
// ANOTHER effect's arguments — the axios.delete in a confirm dialog's
// button — hangs off that box, one step deeper, not off the screen.
const fired = trigger ?? (await triggerAt(fold.node, at));
const from = fired?.of ? (firedByEffect(fold.node.id, at, fired.of) ?? step) : step;
const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, from.depth + 1, status);
if (target === null) return true;
const guards = await guardsAt(fold.node, at);
const when = guardLabel(guards);
@@ -775,10 +828,16 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
end: site?.span?.end ?? { line: ref.line, column: ref.column ?? 0 },
within: site?.within ?? null,
};
// This call's own span, for the bindings written inside its arguments.
if (usable && site?.span) {
const list = firedSpans.get(fold.node.id) ?? [];
list.push({ start: { line: local.line, column: local.column }, end: local.end, step: target });
firedSpans.set(fold.node.id, list);
}
const hop: HopSite = fold.first ?? local;
if (!target.first) target.first = hop;
const fired = trigger ?? (await triggerAt(fold.node, at));
const id = link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, fired, hop.within);
if (!target.region) target.region = regionOf(from, fold.chain);
const id = link(from, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, fired, hop.within);
record(fold.node, local, guards, { step: target.id, link: id }, fired, await loopsAt(fold.node, at));
return true;
};
@@ -860,6 +919,23 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
const t = await consumerTrigger(anchor);
if (t) first.trigger = t;
}
// A screen's picture is laid out by REGION — the part of the screen each
// step belongs to. The evidence is the walk's own: a step reached out of the
// anchor descends through the fold's chain, whose FIRST node is the
// top-level component (or hook) of the screen's tree; a chain of nothing is
// a call written in the screen body itself; and a step reached from any
// other step belongs where its first-reaching parent does. First reach wins,
// as `first` does — a shared store is one box, in the region that got there
// first, and every other region's way in is a link. An endpoint or a
// function reads in the code's order and carries none of this.
const regions = first.kind === 'screen' && !first.screen?.endpoint;
const regionOf = (from: StepRecord, chain: readonly Node[]): WireStep['region'] => {
if (!regions) return undefined;
if (!from.anchor) return from.region;
const head = chain[0];
if (head) return { id: head.id, label: head.name };
return { id: from.root?.id ?? from.id, label: from.root?.name ?? from.label };
};
const queue: StepRecord[] = [first];
/** Steps whose exploration has been queued — each is explored once, from the first row it appears on. */
const explored = new Set<string>([first.id]);
@@ -1159,14 +1235,19 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
for (const a of arrivals) {
if (a.kind === null) continue;
const at = { line: a.e.line, column: a.e.column };
// A binding written inside an effect call's arguments — the dialog's
// `onPress` — arrives from that box, not from the step that owns
// the fold: the prompt fires it.
const firedBy = a.trigger?.of ? firedByEffect(fold.node.id, at, a.trigger.of) : null;
const from = firedBy ?? step;
const fresh = !steps.has(a.target.id);
const to = stepFor(a.target, a.kind, step.depth + 1, a.extra);
const to = stepFor(a.target, a.kind, from.depth + 1, a.extra);
if (to === null) continue;
if (fresh && !to.trigger) {
const t = a.target.kind === 'route' ? await requestTrigger(a.target, to.root) : await consumerTrigger(a.target);
if (t) to.trigger = t;
}
const at = { line: a.e.line, column: a.e.column };
const guards = await guardsAt(fold.node, at);
const when = guardLabel(guards);
// A call-shaped hop says what it passes; a navigation already says
@@ -1178,14 +1259,26 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
if (typeof a.meta.channel === 'string' && a.meta.channel !== 'server-action') {
const written = await callAt(fold.node, at);
site = written && written.callee ? { ...a.site, text: written.callee, args: written.args } : await withArgs(a.site, fold.node, at);
} else if (a.linkKind === 'bridge' || a.linkKind === 'store' || a.linkKind === 'calls') site = await withArgs(a.site, fold.node, at);
} else if (
a.linkKind === 'bridge' ||
a.linkKind === 'store' ||
a.linkKind === 'calls' ||
// A handler BOUND passes nothing (`onPress={handleX}`), but a
// handler CALLED from under a binding is a call like any other —
// and `tryCatchSync(onClosePress)`'s argument is the whole answer
// to what a wrapper wraps.
(a.linkKind === 'handler' && (a.e.kind === 'calls' || a.e.kind === 'instantiates'))
) {
site = await withArgs(a.site, fold.node, at);
}
// Where this step is first reached from: the hop out of the root
// this fold descends from, else this site — its position orders the row.
const isCallHop = a.e.kind === 'calls' || a.e.kind === 'instantiates' || a.e.kind === 'navigates';
const local = isCallHop ? await hopAt(fold.node, at, a.target.name) : pointHop(fold.node, at);
const hop = fold.first ?? local;
if (!to.first) to.first = hop;
const id = link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger, hop.within);
if (!to.region) to.region = regionOf(from, fold.chain);
const id = link(from, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger, hop.within);
record(fold.node, local, guards, { step: to.id, link: id }, a.trigger, await loopsAt(fold.node, at));
if (to.root !== null && !explored.has(to.id)) {
explored.add(to.id);
@@ -1220,10 +1313,11 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
if (known) {
if (known.id !== step.id) {
const at = { line: e.line, column: e.column };
const firedBy = a.trigger?.of ? firedByEffect(fold.node.id, at, a.trigger.of) : null;
const guards = await guardsAt(fold.node, at);
const local = await hopAt(fold.node, at, target.name);
const hop = fold.first ?? local;
const id = link(step, known, 'calls', fold.chain, [...fold.whens, guardLabel(guards)], await withArgs(a.site, fold.node, at), e, a.trigger, hop.within);
const id = link(firedBy ?? step, known, 'calls', fold.chain, [...fold.whens, guardLabel(guards)], await withArgs(a.site, fold.node, at), e, a.trigger, hop.within);
record(fold.node, local, guards, { step: known.id, link: id }, a.trigger, await loopsAt(fold.node, at));
}
continue;