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