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
+9
View File
@@ -57,6 +57,7 @@ import { buildEntryPoints } from './entrypoints';
import { buildNodeRefs } from './nodes';
import { buildMap } from './map';
import { buildScreens } from './screens';
import { buildSteps } from './steps';
import { buildDeadCode } from './deadcode';
import { buildFlow } from './flow';
import { buildTrails, removeTrail, saveTrail, type TrailsOptions } from './trails';
@@ -202,6 +203,12 @@ const API_INDEX = {
description: 'The app as screens and the transitions between them, each with the conditions it runs under.',
params: [],
},
{
path: '/api/steps',
description:
'What happens from a screen or a symbol: screens, handlers, native bridge calls and events, store writes and calls that leave the index, as typed steps with the conditions between them.',
params: ['anchor', 'symbol', 'depth', 'limit'],
},
{
path: '/api/flow',
description: 'The call path between symbols: one hop per card, opened at the calling line.',
@@ -269,6 +276,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
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/steps':
return ok(res, await buildSteps(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
case '/api/deadcode':
return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
case '/api/entrypoints':
+4 -32
View File
@@ -28,10 +28,8 @@
*/
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 type { Edge, Node } from '../../types';
import { createWhenReader } from './when';
import { toNodeRef, type WireNodeRef } from './wire';
// =============================================================================
@@ -183,7 +181,8 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
screenOfComponent.set(component.id, edge.source);
}
const whenAt = makeWhenReader(cg, projectRoot);
const readWhen = createWhenReader(cg, projectRoot, MAX_WHEN_SITES);
const whenAt = (caller: Node, edge: Edge): Promise<string> => readWhen(caller, { line: edge.line, column: edge.column });
const links = new Map<string, WireScreenLink>();
const origins = new Map<string, WireScreenOrigin>();
const counts = new Map<string, { incoming: number; outgoing: number }>();
@@ -468,33 +467,6 @@ function complementary(a: string, b: string): boolean {
return flips === 1;
}
function makeWhenReader(cg: CodeGraph, projectRoot: string) {
const files = new Map<string, { abs: string; language: Language } | null>();
let sites = 0;
return async (caller: Node, edge: Edge): Promise<string> => {
if (!edge.line || sites >= MAX_WHEN_SITES || !supportsBranchGuards(caller.language)) return '';
const posix = toPosix(caller.filePath);
let file = files.get(posix);
if (file === undefined) {
file = null;
const found = findIndexedFile(cg, posix);
if (found && !hasDriftedOnDisk(projectRoot, found.storedPath, found.record)) {
try {
file = { abs: resolveProjectFile(projectRoot, found.storedPath), language: found.record.language as Language };
} catch {
file = null;
}
}
files.set(posix, file);
}
if (!file) return '';
sites++;
const site = { line: edge.line, column: typeof edge.column === 'number' ? edge.column : null };
const g = (await guardsForFile(file.abs, file.language, [site])).get(siteKey(site));
return g ? guardLabel(g) : '';
};
}
function toPosix(p: string): string {
return p.replace(/\\/g, '/');
}
+723
View File
@@ -0,0 +1,723 @@
/**
* `GET /api/steps` — what happens from here: a screen, a handler or any
* symbol as the ANCHOR, and everything it sets in motion drawn as typed steps.
*
* The Screens view (`screens.ts`) is already a picture of steps with one step
* type: it folds `HomeScreen → ItemsGrid → ItemCard → openObjectDetail` into
* one arrow labelled with its condition, because the reader wants the
* transition, not the plumbing. This endpoint keeps that fold and widens the
* set of things worth a box. Walking FORWARD from the anchor over calls,
* renders, handler bindings and navigations, a node is a step when it is:
*
* - a **screen** (a route reached over a `navigates` edge),
* - a **trigger** — a function wired as a value (`onPress={handleX}`,
* `addListener('x', handleX)`), the user's or the platform's way in,
* - a **bridge** call — the language changes under the call, JS → native
* (the React Native bridge resolver's edges, or any family crossing),
* - a native **event** landing back in JS (`sendEvent(withName:)` → the
* listener, via the RN event channel),
* - a **store** action — a function in a store file, the state it writes,
* - an **effect** — a call that leaves the index into the network, storage,
* the device or telemetry, drawn as its own box beside the function that
* makes it.
*
* Everything else — hooks, helpers, services, the components between a
* screen and its handlers — is `via`: listed on the link, never a box. The
* branch conditions along the folded chain join into the link's `when`, read
* from the source at request time exactly as the Screens view reads them.
*
* The picture is finite because it is ANCHORED and CAPPED, not because the
* graph is small: a bounded depth in steps, a bounded fan-out per node, a
* bounded number of nodes folded per step, and hubs and shared chrome (a top
* bar rendered on ten screens) are dead ends rather than paths. Every cap
* that fired is reported on the step it fired at, so a short picture never
* reads as "nothing else happens here".
*
* Read from the graph at request time, never cached: the `when` labels and
* the effect sites are read from the source as it stands.
*/
import type CodeGraph from '../../index';
import type { Edge, Language, Node, UnresolvedReference } from '../../types';
import { badRequest, intParam, notFound } from './respond';
import { createWhenReader } from './when';
import { HUB_THRESHOLD, UNCERTAIN_BELOW, toNodeRef, type WireNodeRef } from './wire';
// =============================================================================
// Wire shapes
// =============================================================================
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;
/** `/capture/review`, `handleApproveAllImages`, `client.post`. */
label: string;
/** The component for a screen, the file for a symbol, the category and caller for an effect. */
sub: string;
/** Steps from the anchor: the row. */
depth: number;
/**
* Why the walk did not go on from this step, when it did not: a cap it hit
* (`depth`, `fan-out`, `folded`, `steps`), or `screen` — another screen is
* a chapter of its own, drawn but not entered unless `through` asks.
*/
cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
/** The event name a native event step arrived on (`onZipComplete`) — the first, when several land here. */
event?: string;
/** Every event that lands on this step, in the order the walk met them. */
events?: string[];
/** For a screen: its path and the component that renders it. */
screen?: { path: string; component: WireNodeRef | null };
/**
* For an effect: the calls one function makes into one category — `api` is
* the first, `apis` all of them — and the function that makes them.
*/
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 — `via rn-event-channel · registered at file:line`. */
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 not added because the picture reached `limit`. */
steps: number;
/** Folded walks that stopped at a hub (fan-in ≥ the hub threshold). */
hubs: number;
/** Folded walks that stopped at shared chrome (a component rendered by several screens). */
chrome: number;
};
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number };
}
// =============================================================================
// Caps
// =============================================================================
export const DEFAULT_DEPTH = 8;
export const MAX_DEPTH = 14;
export const DEFAULT_LIMIT = 120;
export const MAX_LIMIT = 400;
/** Nodes folded while exploring from ONE step before the walk stops. */
const MAX_FOLDED_PER_STEP = 300;
/** Hops of folded plumbing between two steps. */
const MAX_FOLD_DEPTH = 7;
/** Outgoing edges followed from one node; past this the node is a god function and the rest is announced. */
const MAX_FANOUT = 80;
/** Unresolved-reference scans (for effects) per request. */
const MAX_EFFECT_SCANS = 800;
/** Call sites labelled with conditions per request. */
const MAX_WHEN_SITES = 800;
/**
* A component rendered by this many distinct parents is chrome (a top bar, a
* button), not a screen's own behaviour. Higher than the Screens view's 3: that
* one attributes navigations, where three screens sharing a link is already
* chrome; this one decides what to WALK INTO, and a capture component shared
* by three capture flows is the screen's whole body.
*/
const SHARED_CHROME_MIN = 5;
/** Edges walked forward. `contains` only function → function (a hook's handlers); `references` only function-as-value. */
const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'navigates', 'references', 'contains'];
// =============================================================================
// Classification
// =============================================================================
const JS_FAMILY: ReadonlySet<Language> = new Set<Language>(['javascript', 'typescript', 'tsx', 'jsx']);
const NATIVE_FAMILY: ReadonlySet<Language> = new Set<Language>(['swift', 'objc', 'java', 'kotlin']);
/** JS → native is a bridge call; native → JS is an event. Anything else is one family. */
export function crossing(from: Language, to: Language): 'bridge' | 'event' | null {
if (JS_FAMILY.has(from) && NATIVE_FAMILY.has(to)) return 'bridge';
if (NATIVE_FAMILY.has(from) && JS_FAMILY.has(to)) return 'event';
return null;
}
/**
* A file that holds state: a store, a slice, a reducer. The graph has no
* "store" kind — a Zustand action is an ordinary function node — so the file
* is the evidence, and the legend says so.
*/
export const STORE_FILE = /(?:^|\/)(?:stores?|storage|state|slices?|reducers?)\/|\.(?:store|storage|slice|reducer)\.[cm]?[jt]sx?$/i;
export function isStoreFile(file: string): boolean {
return STORE_FILE.test(file.replace(/\\/g, '/'));
}
/**
* Calls that leave the index and change something outside the process. A
* curated table, deliberately: "any call into a package" is every `Date` and
* `Math.max`, and a box for each would bury the ones that matter. Matched on
* the reference text as written at the call.
*/
export const EFFECTS: ReadonlyArray<{ category: string; test: RegExp }> = [
{
category: 'network',
test: /^(?:fetch|axios|ky|got|superagent|XMLHttpRequest|WebSocket)$|^(?:axios|api|client|http|https|httpClient|apiClient|instance|request|agent|graphql|apollo|supabase)\.(?:get|post|put|patch|delete|head|request|query|mutate|rpc|invoke)$|^URLSession(?:\.|$)|^(?:Alamofire|AF)\.|\.(?:dataTask|uploadTask|downloadTask)$/,
},
{
category: 'storage',
test: /^(?:AsyncStorage|SecureStore|MMKV|localStorage|sessionStorage|indexedDB|UserDefaults|Keychain|KeychainAccess|FileSystem|RNFS|FileManager|fs|fsp)\b/,
},
{
category: 'device',
test: /^(?:Linking|Share|Clipboard|Notifications|Camera|ImagePicker|MediaLibrary|Haptics|Alert|Vibration|Location|Geolocation|Permissions|UIApplication|AVCaptureSession|AVAudioSession|CLLocationManager|UNUserNotificationCenter)\b/,
},
{
category: 'telemetry',
test: /^(?:DdRum|DdLogs|DdTrace|DdSdkReactNative|CustomerIO|Sentry|Bugsnag|analytics|Analytics|crashlytics|Crashlytics|mixpanel|Mixpanel|amplitude|Amplitude|posthog|PostHog|LDClient|ldClient)\b/,
},
];
export function effectCategory(referenceName: string): string | null {
for (const e of EFFECTS) if (e.test.test(referenceName)) return e.category;
return null;
}
// =============================================================================
// The endpoint
// =============================================================================
interface Fold {
node: Node;
/** [first folded node, …, this node]; empty for the step's own root. */
chain: Node[];
whens: string[];
}
interface StepRecord extends WireStep {
/** Where exploration from this step begins: a screen's component, otherwise the node itself. */
root: Node | null;
}
export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLSearchParams): Promise<WireStepsPayload> {
const started = Date.now();
const depthCap = intParam(query, 'depth', { min: 1, max: MAX_DEPTH, default: DEFAULT_DEPTH });
const limit = intParam(query, 'limit', { min: 20, max: MAX_LIMIT, default: DEFAULT_LIMIT });
const through = query.get('through') === '1';
const stats = cg.getStats();
const index = { lastIndexedAt: cg.getLastIndexedAt() ?? null, edges: stats.edgeCount, files: stats.fileCount };
const { anchor, ambiguous } = resolveAnchor(cg, query);
// Route → the component it renders, and the routes by id.
const routes = cg.getNodesByKind('route');
const renders = routes.length === 0 ? [] : cg.getOutgoingEdgesFrom(routes.map((r) => r.id), ['calls', 'instantiates']);
const componentOf = new Map<string, Node>();
if (renders.length > 0) {
const components = cg.getNodesByIds(renders.map((e) => e.target));
for (const edge of renders) {
const c = components.get(edge.target);
if (c && !componentOf.has(edge.source)) componentOf.set(edge.source, c);
}
}
const readWhen = createWhenReader(cg, projectRoot, MAX_WHEN_SITES);
const whenAt = (caller: Node, site: { line?: number; column?: number }) => readWhen(caller, site);
const steps = new Map<string, StepRecord>();
const links = new Map<string, WireStepLink>();
const truncated = { steps: 0, hubs: 0, chrome: 0 };
let effectScans = 0;
const fanIn = new Map<string, number>();
const chromeParents = new Map<string, number>();
const fileScopeRefs = new Map<string, Edge[]>();
const stepFor = (node: Node, kind: WireStepKind, depth: number, extra: Partial<WireStep> = {}): StepRecord | null => {
const existing = steps.get(node.id);
if (existing) {
// A listener the screen registers is a handler when first met, and the
// native event's landing when the walk arrives from the other side —
// the second is the fuller fact, and it names the event.
if (existing.kind === 'trigger' && kind === 'event') {
existing.kind = 'event';
if (extra.event) existing.event = extra.event;
}
if (kind === 'event' && extra.event) {
existing.events = existing.events ?? (existing.event ? [existing.event] : []);
if (!existing.events.includes(extra.event)) existing.events.push(extra.event);
}
return existing;
}
if (steps.size >= limit) {
truncated.steps++;
return null;
}
const isRoute = node.kind === 'route';
const record: StepRecord = {
id: node.id,
kind: isRoute ? 'screen' : kind,
anchor: false,
node: toNodeRef(node),
label: isRoute ? node.name : node.name,
sub: isRoute ? (componentOf.get(node.id)?.name ?? posix(node.filePath)) : posix(node.filePath),
depth,
cut: null,
...extra,
root: isRoute ? (componentOf.get(node.id) ?? null) : node,
};
if (kind === 'event' && extra.event) record.events = [extra.event];
if (isRoute) record.screen = { path: node.name, component: componentOf.has(node.id) ? toNodeRef(componentOf.get(node.id)!) : null };
steps.set(node.id, record);
return record;
};
// One box per (function, category): `uploadARCapture` makes one network
// call, three storage calls and three telemetry calls — three boxes, each
// listing its calls, not seven.
const effectStep = (by: Node, ref: { referenceName: string; line: number }, category: string, depth: number): StepRecord | null => {
const id = `effect:${by.id}:${category}`;
const existing = steps.get(id);
if (existing) {
const apis = existing.effect!.apis;
if (!apis.includes(ref.referenceName)) {
apis.push(ref.referenceName);
existing.label = `${apis[0]} +${apis.length - 1}`;
}
return existing;
}
if (steps.size >= limit) {
truncated.steps++;
return null;
}
const record: StepRecord = {
id,
kind: 'effect',
anchor: false,
node: null,
label: ref.referenceName,
sub: `${category} · ${by.name}`,
depth,
cut: null,
effect: { api: ref.referenceName, apis: [ref.referenceName], category, by: toNodeRef(by), line: ref.line },
root: null,
};
steps.set(id, record);
return record;
};
const link = (
from: StepRecord,
to: StepRecord,
kind: WireStepLinkKind,
chain: Node[],
whens: string[],
site: WireStepSite,
edge: Edge | null
): void => {
const meta = (edge?.metadata ?? {}) as Record<string, unknown>;
const synthesized = edge?.provenance === 'heuristic';
const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
const via = chain.map(toNodeRef);
const viaKey = via.map((v) => v.id).join('>');
const id = `${from.id} ${to.id} ${viaKey}`;
const when = whens.filter((w, i) => w && whens.indexOf(w) === i).join(' && ');
const existing = links.get(id);
if (existing) {
if (!existing.sites.some((s) => s.file === site.file && s.line === site.line)) existing.sites.push(site);
if (when !== existing.when) {
if (!when || !existing.when) existing.when = '';
else if (!existing.when.split(' || ').includes(when)) existing.when = `${existing.when} || ${when}`;
}
return;
}
links.set(id, {
id,
from: from.id,
to: to.id,
kind,
via,
when,
label: hopLabel(meta, synthesized),
synthesized,
uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
sites: [site],
});
};
// The anchor: a screen keeps its kind and explores from its component.
const first = stepFor(anchor, 'anchor', 0)!;
first.anchor = true;
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]);
while (queue.length > 0) {
const step = queue.shift()!;
if (step.root === null) continue;
// Another screen is a chapter of its own: the Screens view draws the way
// between screens, and a picture that walked on through Home would be the
// whole app. Drawn as a boundary, entered on request.
if (step.kind === 'screen' && !step.anchor && !through) {
step.cut = 'screen';
continue;
}
// A native event that lands in a COMPONENT — the capture overlay taking
// `onCaptureProgress` — lands on another screen's body: its picture is
// that screen's, not this one's. A boundary too, entered on request.
if (step.kind === 'event' && !step.anchor && !through && looksLikeComponent(step.root)) {
step.cut = 'component';
continue;
}
if (step.depth >= depthCap) {
// Something to explore, and no room in the picture for it.
if (cg.getOutgoingEdgesFrom([step.root.id], WALK_KINDS).length > 0) step.cut = 'depth';
continue;
}
// Breadth-first through the plumbing until the next steps.
const visited = new Set<string>([step.root.id]);
let frontier: Fold[] = [{ node: step.root, chain: [], whens: [] }];
for (let hop = 0; hop <= MAX_FOLD_DEPTH && frontier.length > 0; hop++) {
const next: Fold[] = [];
const ids = frontier.map((f) => f.node.id);
const outgoing = cg.getOutgoingEdgesFrom(ids, WALK_KINDS);
const bySource = new Map<string, Edge[]>();
for (const e of outgoing) {
const list = bySource.get(e.source) ?? [];
list.push(e);
bySource.set(e.source, list);
}
// `const Memoized = memo(CaptureComponent)`: the wrapper is a component
// node with no edges of its own — the inner component is referenced
// from the FILE scope, at the wrapper's line. Lend the wrapper those
// references, so the screen that renders `<Memoized/>` walks on into
// what the component does.
for (const fold of frontier) {
if (fold.node.kind !== 'component' || (bySource.get(fold.node.id)?.length ?? 0) > 0) continue;
for (const e of fileScopeFnRefsWithin(cg, fold.node, fileScopeRefs)) {
const list = bySource.get(fold.node.id) ?? [];
list.push({ ...e, source: fold.node.id });
bySource.set(fold.node.id, list);
}
}
const targetIds = new Set<string>();
for (const list of bySource.values()) for (const e of list) targetIds.add(e.target);
const targets = targetIds.size === 0 ? new Map<string, Node>() : cg.getNodesByIds([...targetIds]);
// Hubs and chrome are judged on the nodes about to be entered.
const unknownFanIn = [...targetIds].filter((id) => !fanIn.has(id));
if (unknownFanIn.length > 0) for (const [id, n] of cg.getFanIn(unknownFanIn)) fanIn.set(id, n);
for (const fold of frontier) {
// Effects made by this node, folded or not.
if (effectScans < MAX_EFFECT_SCANS) {
effectScans++;
let refs: UnresolvedReference[] = [];
try {
refs = cg.getUnresolvedReferencesFrom(fold.node.id);
} catch {
refs = [];
}
for (const ref of [...refs].sort((a, b) => a.line - b.line || a.column - b.column)) {
if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'instantiates') continue;
const category = effectCategory(ref.referenceName);
if (category === null) continue;
const target = effectStep(fold.node, ref, category, step.depth + 1);
if (target === null) continue;
const when = await whenAt(fold.node, { line: ref.line, column: ref.column });
link(step, target, 'effect', fold.chain, [...fold.whens, when], { file: posix(fold.node.filePath), line: ref.line, text: ref.referenceName }, null);
}
}
let edges = (bySource.get(fold.node.id) ?? []).slice();
edges = edges.filter((e) => {
const meta = (e.metadata ?? {}) as Record<string, unknown>;
if (e.kind === 'references') return meta.fnRef === true;
if (e.kind === 'contains') {
const t = targets.get(e.target);
return (fold.node.kind === 'function' || fold.node.kind === 'method') && !!t && (t.kind === 'function' || t.kind === 'method');
}
return true;
});
edges.sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || a.target.localeCompare(b.target));
if (edges.length > MAX_FANOUT) {
step.cut = 'fan-out';
edges = edges.slice(0, MAX_FANOUT);
}
// Two passes: first every edge that arrives at a step, then the rest —
// so a node that IS a step (a handler wired to a tap) is never also
// folded as plumbing by the `contains` edge from the same component.
interface Arrival {
e: Edge;
target: Node;
meta: Record<string, unknown>;
site: WireStepSite;
kind: WireStepKind | null;
linkKind: WireStepLinkKind;
extra: Partial<WireStep>;
}
const arrivals: Arrival[] = [];
for (const e of edges) {
const target = targets.get(e.target);
if (!target || target.kind === 'file' || target.id === fold.node.id) continue;
const meta = (e.metadata ?? {}) as Record<string, unknown>;
const site: WireStepSite = {
file: posix(fold.node.filePath),
line: e.line ?? fold.node.startLine,
text: siteText(e, meta, target),
};
// What kind of step, if any, this edge arrives at.
let kind: WireStepKind | null = null;
let linkKind: WireStepLinkKind = 'calls';
const extra: Partial<WireStep> = {};
if (target.kind === 'route') {
kind = 'screen';
linkKind = 'navigates';
} else {
// A language change under the code is a step only on evidence: a
// bridge resolver's edge (`bridge`, or a framework resolution), or
// a synthesized channel's. A plain name-matched call across the
// families (`arr.flat()` landing on a Swift `flat`) is noise, and
// is neither drawn nor walked.
const cross = crossing(fold.node.language, target.language);
const evidenced = e.provenance === 'heuristic' || meta.bridge === 'react-native' || meta.resolvedBy === 'framework';
if (cross !== null && !evidenced) continue;
if (cross === 'event') {
kind = 'event';
linkKind = 'event';
if (typeof meta.event === 'string') extra.event = meta.event;
} else if (cross === 'bridge') {
kind = 'bridge';
linkKind = 'bridge';
} else if (e.kind === 'references' && meta.fnRef === true && !looksLikeComponent(target)) {
// A function passed as a value is a handler — unless it is a
// component (`memo(CaptureComponent)`, `component={Home}`),
// which is a render hop and folds like one.
kind = 'trigger';
linkKind = 'handler';
} else if (
(target.kind === 'function' || target.kind === 'method') &&
isStoreFile(target.filePath) &&
!isStoreFile(fold.node.filePath)
) {
kind = 'store';
linkKind = 'store';
}
}
arrivals.push({ e, target, meta, site, kind, linkKind, extra });
}
for (const a of arrivals) {
if (a.kind === null) continue;
const to = stepFor(a.target, a.kind, step.depth + 1, a.extra);
if (to === null) continue;
const when = await whenAt(fold.node, { line: a.e.line, column: a.e.column });
link(step, to, a.linkKind, fold.chain, [...fold.whens, when], a.site, a.e);
if (to.root !== null && !explored.has(to.id)) {
explored.add(to.id);
queue.push(to);
}
}
for (const a of arrivals) {
if (a.kind !== null) continue;
const { e, target, meta } = a;
// A call through a VALUE the effect table knows — `client.post` on
// the axios instance the project made itself resolves to the
// `client` constant, not to anything outside the index. The call
// text is the evidence: the call is the effect, the constant is not
// a place to walk into.
if (e.kind === 'calls' && (target.kind === 'constant' || target.kind === 'variable')) {
const api = typeof meta.refName === 'string' ? meta.refName : null;
const category = api === null ? null : effectCategory(api);
if (api !== null && category !== null) {
const to = effectStep(fold.node, { referenceName: api, line: e.line ?? fold.node.startLine }, category, step.depth + 1);
if (to === null) continue;
const when = await whenAt(fold.node, { line: e.line, column: e.column });
link(step, to, 'effect', fold.chain, [...fold.whens, when], { file: posix(fold.node.filePath), line: e.line ?? fold.node.startLine, text: api }, null);
continue;
}
}
// Already a step, reached here by a plain call: a link, not a fold.
const known = steps.get(target.id);
if (known) {
if (known.id !== step.id) {
const when = await whenAt(fold.node, { line: e.line, column: e.column });
link(step, known, 'calls', fold.chain, [...fold.whens, when], a.site, e);
}
continue;
}
// Plumbing: fold it and keep walking, unless it is a dead end.
if (visited.has(target.id)) continue;
if ((fanIn.get(target.id) ?? 0) >= HUB_THRESHOLD) {
truncated.hubs++;
continue;
}
if (meta.synthesizedBy === 'jsx-render' && isSharedChrome(cg, target, chromeParents)) {
truncated.chrome++;
continue;
}
if (visited.size >= MAX_FOLDED_PER_STEP) {
step.cut = step.cut ?? 'folded';
continue;
}
visited.add(target.id);
const when = await whenAt(fold.node, { line: e.line, column: e.column });
next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, when] });
}
}
frontier = next;
}
}
const ordered = [...steps.values()].sort((a, b) => a.depth - b.depth || a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
return {
anchor: toNodeRef(anchor),
ambiguous,
steps: ordered.map(({ root: _root, ...step }) => step),
links: [...links.values()].sort((a, b) => a.id.localeCompare(b.id)),
depth: depthCap,
limit,
through,
truncated,
index,
timing: { elapsedMs: Date.now() - started },
};
}
// =============================================================================
// Helpers
// =============================================================================
/**
* The anchor: `anchor=<id>`, or `symbol=<name>` resolved to the most
* screen-like symbol of that name — a route first, then a component or
* function, then a method — with the rest reported as `ambiguous`.
*/
function resolveAnchor(cg: CodeGraph, query: URLSearchParams): { anchor: Node; ambiguous: WireNodeRef[] } {
const id = query.get('anchor');
if (id !== null && id.trim() !== '') {
const node = cg.getNode(id);
if (!node) throw notFound(`No symbol with id "${id}" in this index.`, 'It may have moved in a re-index; open it from search or the Screens view.');
return { anchor: node, ambiguous: [] };
}
const name = query.get('symbol');
if (name === null || name.trim() === '') throw badRequest('Give the picture an anchor: ?anchor=<node id> or ?symbol=<name>.');
const rank: Record<string, number> = { route: 0, component: 1, function: 2, method: 3, class: 4, constant: 5, variable: 6 };
const matches = cg
.getNodesByName(name.trim())
.filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
.sort((a, b) => (rank[a.kind] ?? 9) - (rank[b.kind] ?? 9) || a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
const anchor = matches[0];
if (!anchor) throw notFound(`Nothing in this index is named "${name}".`, 'Try the search box; names are matched exactly.');
return { anchor, ambiguous: matches.slice(1, 9).map(toNodeRef) };
}
/** How many distinct parents render this node as a JSX child. Memoised per request. */
function renderParents(cg: CodeGraph, node: Node, memo: Map<string, number>): number {
let parents = memo.get(node.id);
if (parents === undefined) {
const incoming = cg.getIncomingEdgesTo([node.id], ['calls']);
const sources = new Set<string>();
for (const e of incoming) {
if ((e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'jsx-render') sources.add(e.source);
}
parents = sources.size;
memo.set(node.id, parents);
}
return parents;
}
/** A component rendered by several distinct parents is chrome. */
function isSharedChrome(cg: CodeGraph, component: Node, memo: Map<string, number>): boolean {
return renderParents(cg, component, memo) >= SHARED_CHROME_MIN;
}
/** A React component, by the convention that names one: a PascalCase function in a JS-family file. */
function looksLikeComponent(node: Node): boolean {
if (node.kind === 'component') return true;
if (node.kind !== 'function') return false;
return JS_FAMILY.has(node.language) && /^[A-Z]/.test(node.name);
}
/**
* Function-as-value references made at a file's top level within a node's
* lines — what `const Memoized = memo(CaptureComponent)` leaves behind: the
* reference belongs to the file scope, the wrapper node spans the line.
*/
function fileScopeFnRefsWithin(cg: CodeGraph, node: Node, memo: Map<string, Edge[]>): Edge[] {
let refs = memo.get(node.filePath);
if (refs === undefined) {
const file = cg.getNodesInFile(node.filePath).find((n) => n.kind === 'file');
refs = file
? cg.getOutgoingEdgesFrom([file.id], ['references']).filter((e) => (e.metadata as Record<string, unknown> | undefined)?.fnRef === true)
: [];
memo.set(node.filePath, refs);
}
return refs.filter((e) => typeof e.line === 'number' && e.line >= node.startLine && e.line <= node.endLine);
}
/** `push /capture`, `renders <Button>`, `via rn-event-channel`, `calls`. */
function siteText(edge: Edge, meta: Record<string, unknown>, target: Node): string {
if (edge.kind === 'navigates') {
const method = edge.provenance === 'heuristic' ? 'returns' : typeof meta.navMethod === 'string' ? meta.navMethod : 'push';
return `${method} ${typeof meta.href === 'string' ? meta.href : target.name}`;
}
if (meta.synthesizedBy === 'jsx-render') return `renders <${target.name}>`;
if (edge.kind === 'references') return `passes ${target.name}`;
if (edge.kind === 'contains') return `defines ${target.name}`;
if (edge.kind === 'instantiates') return `new ${target.name}`;
if (meta.bridge === 'react-native') return `bridge ${typeof meta.module === 'string' ? meta.module + '.' : ''}${target.name}`;
if (typeof meta.synthesizedBy === 'string') return `via ${meta.synthesizedBy}`;
return `calls ${target.name}`;
}
/** The words on a hop that was not a plain call — the Flow strip's connector label, in short. */
function hopLabel(meta: Record<string, unknown>, synthesized: boolean): string {
const parts: string[] = [];
if (typeof meta.synthesizedBy === 'string') parts.push(`via ${meta.synthesizedBy}`);
else if (synthesized) parts.push('inferred');
if (typeof meta.event === 'string') parts.push(`event ${meta.event}`);
if (meta.bridge === 'react-native') parts.push(`React Native bridge${typeof meta.module === 'string' ? ` · ${meta.module}` : ''}`);
if (typeof meta.registeredAt === 'string') parts.push(`registered at ${meta.registeredAt}`);
return parts.join(' · ');
}
function posix(p: string): string {
return p.replace(/\\/g, '/');
}
+38
View File
@@ -73,3 +73,41 @@ export async function annotateWhen(cg: CodeGraph, projectRoot: string, batches:
}
}
}
/**
* A per-request reader of the conditions ONE call site sits under, for the
* endpoints that walk chains rather than annotate rails (the Screens view's
* transitions, the Steps view's links). Files are resolved once, drifted
* files yield no label, and the count of sites labelled is bounded so a wide
* walk cannot turn one request into a parse of the repository.
*/
export function createWhenReader(
cg: CodeGraph,
projectRoot: string,
maxSites = 600
): (caller: { filePath: string; language: Language }, site: { line?: number; column?: number }) => Promise<string> {
const files = new Map<string, { abs: string; language: Language } | null>();
let sites = 0;
return async (caller, site): Promise<string> => {
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return '';
const posix = caller.filePath.replace(/\\/g, '/');
let file = files.get(posix);
if (file === undefined) {
file = null;
const found = findIndexedFile(cg, posix);
if (found && !hasDriftedOnDisk(projectRoot, found.storedPath, found.record)) {
try {
file = { abs: resolveProjectFile(projectRoot, found.storedPath), language: found.record.language as Language };
} catch {
file = null;
}
}
files.set(posix, file);
}
if (!file) return '';
sites++;
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
const g = (await guardsForFile(file.abs, file.language, [key])).get(siteKey(key));
return g ? guardLabel(g) : '';
};
}