Files
codegraph/src/ui-server/api/node.ts
T
Colby McHenry 70fd5fefc2 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.
2026-08-27 22:52:18 -05:00

502 lines
19 KiB
TypeScript

/**
* `GET /api/node/<id>` — everything the Symbol view draws, in one round-trip.
*
* The Symbol view is three panes and a strip: callers on the left, the verbatim
* body in the middle with a port per call site, callees on the right anchored
* to those lines, and a blast-radius summary underneath. Splitting that across
* five endpoints would mean five waterfalls before the screen settles, and the
* screen is the product. So this endpoint answers all of it.
*
* Two properties it has to hold, and the reasons they are not obvious:
*
* **No N+1, anywhere.** The engine's own busiest symbol has 545 incoming edges.
* Resolving those one `getNode` at a time is 545 queries and blows the budget on
* its own; so every edge list is resolved with one batched `getNodesByIds`, and
* fan-in for the rail pills comes from one batched `getFanIn`.
*
* **Capped lists that still tell the truth.** 545 callers cannot all be rows,
* but the payload must never suggest there are fewer. Every list carries the
* true `total` beside the `shown` slice, and the ordering is chosen so the
* slice is the useful end: same file first, then production code, then tests.
*/
import type { CodeGraph } from '../../index';
import type { Edge, Node, NodeKind } from '../../types';
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,
CONTAINER_KINDS,
HUB_THRESHOLD,
MAX_INCOMING_GROUPS,
MAX_OUTGOING_GROUPS,
MAX_OUTLINE_NODES,
MAX_OUTSIDE_INDEX_SAMPLES,
MAX_TEST_FILES,
TEST_CALLER_BUDGET,
TEST_CALLER_HOPS,
TYPE_KINDS,
firstLine,
groupRelations,
toNodeDetail,
toNodeRef,
toPosixPath,
wireList,
type WireNodeRef,
} from './wire';
/** A member row in the focal symbol's outline, with its place in the tree. */
export interface WireMember extends WireNodeRef {
/** The container this member belongs to — the focal node, or one of its children. */
parentId: string;
/** 1 = direct member, 2 = a member of a member (a class's method inside a file). */
depth: number;
/**
* Edges in and out of this member — the outline's `← in → out` columns.
*
* A container's own fan-out is usually zero (a class calls nothing; its
* methods do), so without these an outline of a 700-line class says nothing
* about which member is load-bearing and which is a getter. Edge counts, not
* distinct counterparts: the column is a weight, and it sits beside a
* signature rather than beside a caller list it could contradict.
*/
fanIn: number;
fanOut: number;
/**
* This member redeclares one an ancestor type declares — a name match inside
* a chain the graph already links, not an `overrides` edge (nothing emits
* one). Absent for every member that declares something new.
*/
overrides?: WireOverride;
}
export async function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): Promise<unknown> {
const node = cg.getNode(nodeId);
if (!node) {
throw notFound(
'No symbol with that id is in this index.',
'Symbol ids change whenever the file is re-indexed — search for the symbol by ' +
'name instead of reusing an id from an older session.'
);
}
const incomingAll = cg.getIncomingEdges(nodeId);
const outgoingAll = cg.getOutgoingEdges(nodeId);
// `contains` is structure, not dependency: upward it is the parent (already in
// `ancestors`), downward it is the members outline. Leaving it in the rails
// would put a symbol's own class in its caller list.
const incoming = incomingAll.filter((e) => e.kind !== 'contains');
const outgoingRest: Edge[] = [];
const containsOut: Edge[] = [];
for (const edge of outgoingAll) {
if (edge.kind === 'contains') containsOut.push(edge);
else outgoingRest.push(edge);
}
const ancestors = cg.getAncestors(nodeId);
// ---------------------------------------------------------------------------
// One batched resolve for every endpoint this payload names.
// ---------------------------------------------------------------------------
const endpointIds = new Set<string>();
for (const edge of incoming) endpointIds.add(edge.source);
for (const edge of outgoingRest) endpointIds.add(edge.target);
for (const edge of containsOut) endpointIds.add(edge.target);
const endpoints = cg.getNodesByIds([...endpointIds]);
// A `references` edge into a type is "uses type X", not "calls X" — the
// header shows those as chips rather than as callee rows. Split at the EDGE
// level so a class that is both instantiated and named as a type appears in
// both places, which is what the source actually says.
const calleeEdges: Edge[] = [];
const typeRefs: Edge[] = [];
for (const edge of outgoingRest) {
const target = endpoints.get(edge.target);
if (edge.kind === 'references' && target && TYPE_KINDS.has(target.kind)) typeRefs.push(edge);
else calleeEdges.push(edge);
}
// ---------------------------------------------------------------------------
// Rails
// ---------------------------------------------------------------------------
const focalFile = toPosixPath(node.filePath);
const incomingGroups = groupRelations(incoming, (e) => e.source, endpoints);
incomingGroups.sort((a, b) => {
// The symbol's own file first ("same file" in the left rail), then
// production code, then tests — so a cap trims the least useful end.
const aSame = a.node.file === focalFile ? 0 : 1;
const bSame = b.node.file === focalFile ? 0 : 1;
if (aSame !== bSame) return aSame - bSame;
if (a.node.test !== b.node.test) return a.node.test ? 1 : -1;
return a.node.file.localeCompare(b.node.file) || firstLine(a) - firstLine(b);
});
const outgoingGroups = groupRelations(calleeEdges, (e) => e.target, endpoints);
// The right rail is line-anchored: rows sit beside the line that calls them.
outgoingGroups.sort((a, b) => firstLine(a) - firstLine(b) || a.node.name.localeCompare(b.node.name));
const typeGroups = groupRelations(typeRefs, (e) => e.target, endpoints);
typeGroups.sort((a, b) => firstLine(a) - firstLine(b) || a.node.name.localeCompare(b.node.name));
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([
...shownIncoming.map((r) => r.node.id),
...shownOutgoing.map((r) => r.node.id),
...typeGroups.map((r) => r.node.id),
]);
for (const relation of [...shownIncoming, ...shownOutgoing, ...typeGroups]) {
const count = fanInOf.get(relation.node.id) ?? 0;
relation.fanIn = count;
relation.hub = count >= HUB_THRESHOLD;
}
// ---------------------------------------------------------------------------
// Members outline
// ---------------------------------------------------------------------------
// The type hierarchy, and the override marks it puts on the outline. Gated
// to types inside `buildHierarchy`, so a function costs one kind test.
const hierarchy = buildHierarchy(cg, node);
const members = buildMembers(cg, node, containsOut, endpoints, hierarchy?.overrides);
// ---------------------------------------------------------------------------
// Counts, tests, what leaves the index, blast radius
// ---------------------------------------------------------------------------
const directCallers: Node[] = [];
const seenCaller = new Set<string>();
for (const edge of incoming) {
if (!CALLER_EDGE_KINDS.has(edge.kind) || seenCaller.has(edge.source)) continue;
seenCaller.add(edge.source);
const source = endpoints.get(edge.source);
if (source) directCallers.push(source);
}
const drift = driftFor(cg, projectRoot, node.filePath);
return {
node: toNodeDetail(node),
/** Outermost first: file, then module/class, then the symbol's own parent. */
ancestors: [...ancestors].reverse().map(toNodeRef),
members: wireList(members.items, members.total),
/**
* Ancestors, subtypes and the dispatch fan — `null` for anything that is
* not a type, and for a type with no hierarchy at all.
*/
hierarchy: hierarchy?.wire ?? null,
incoming: wireList(shownIncoming, incomingGroups.length),
outgoing: wireList(shownOutgoing, outgoingGroups.length),
/** `references` edges into a type — the header's "uses types …" chips. */
typesUsed: typeGroups,
counts: {
// Every count below is the length of a list this payload also returns, so
// a badge and the rail beneath it can never disagree.
/** Distinct symbols that reach this one — `incoming.total`. Drives `hub`. */
callers: incomingGroups.length,
/** Distinct symbols this one calls — `outgoing.total`. Types are counted separately. */
callees: outgoingGroups.length,
/** Distinct types this symbol names — `typesUsed.length`. */
typesUsed: typeGroups.length,
/** EDGE counts, which run higher: one caller can call from many lines. */
fanIn: incoming.length,
fanOut: outgoingRest.length,
members: members.total,
hub: incomingGroups.length >= HUB_THRESHOLD,
},
tests: summarizeTestCallers(cg, directCallers),
outsideIndex: summarizeOutsideIndex(cg, nodeId),
blast: summarizeBlast(cg, node, incomingGroups.length),
/** The symbol's file changed on disk since the index — line ranges may be shifted. */
drift,
};
}
// =============================================================================
// Members
// =============================================================================
/**
* The focal symbol's members, in source order, one level of nesting deep.
*
* A file's outline is file → class → method, so direct children alone would
* show a class and nothing inside it. The grandchildren come from ONE batched
* `getOutgoingEdgesFrom` over the container children, never a query per child.
*/
function buildMembers(
cg: CodeGraph,
focal: Node,
containsOut: readonly Edge[],
endpoints: Map<string, Node>,
overrides?: Map<string, WireOverride>
): { items: WireMember[]; total: number } {
const direct: Array<{ node: Node; parentId: string; depth: number }> = [];
for (const edge of containsOut) {
const child = endpoints.get(edge.target);
if (child) direct.push({ node: child, parentId: focal.id, depth: 1 });
}
const containerIds = direct
.filter((entry) => CONTAINER_KINDS.has(entry.node.kind))
.map((entry) => entry.node.id);
const nested: Array<{ node: Node; parentId: string; depth: number }> = [];
if (containerIds.length > 0) {
const grandEdges = cg.getOutgoingEdgesFrom(containerIds, ['contains']);
const grandNodes = cg.getNodesByIds(grandEdges.map((e) => e.target));
for (const edge of grandEdges) {
const child = grandNodes.get(edge.target);
if (child) nested.push({ node: child, parentId: edge.source, depth: 2 });
}
}
const all = [...direct, ...nested].sort(
(a, b) => a.node.startLine - b.node.startLine || a.node.name.localeCompare(b.node.name)
);
const shown = all.slice(0, MAX_OUTLINE_NODES);
// Two queries for the whole outline, not two per row: a file with 400
// symbols would otherwise be 800 lookups behind one screen.
const memberIds = shown.map((entry) => entry.node.id);
const fanIn = cg.getFanIn(memberIds);
const fanOut = cg.getFanOut(memberIds);
return {
items: shown.map((entry) => {
const member: WireMember = {
...toNodeRef(entry.node),
parentId: entry.parentId,
depth: entry.depth,
fanIn: fanIn.get(entry.node.id) ?? 0,
fanOut: fanOut.get(entry.node.id) ?? 0,
};
const override = overrides?.get(entry.node.id);
if (override) member.overrides = override;
return member;
}),
total: all.length,
};
}
// =============================================================================
// Test coverage
// =============================================================================
export interface WireTestSummary {
/** A test file reaches this symbol within {@link TEST_CALLER_HOPS} caller hops. */
reached: boolean;
/** How many hops away the nearest test was. 1 = a test calls it directly. */
hops: number | null;
fileCount: number;
files: string[];
/**
* The search finished rather than running out of budget. `false` weakens the
* claim from "no test reaches this within 3 hops" to "no test calls this
* directly", which is all that was actually checked.
*/
exhaustive: boolean;
hopsSearched: number;
}
/**
* Which tests reach this symbol — the same question, and the same method,
* behind `codegraph_explore`'s "tests:" line.
*
* Direct test callers first; failing that, walk up to two more caller hops,
* because a helper called only by production code is still tested through
* whatever calls it. The budget bounds a god-symbol, and running out of it is
* reported rather than papered over: claiming "no test reaches this" after an
* incomplete search would be exactly the kind of confident wrong answer the
* viewer exists to avoid.
*/
function summarizeTestCallers(cg: CodeGraph, directCallers: readonly Node[]): WireTestSummary {
const directFiles = [
...new Set(directCallers.map((n) => toPosixPath(n.filePath)).filter(isTestFile)),
];
if (directFiles.length > 0) {
return {
reached: true,
hops: 1,
fileCount: directFiles.length,
files: directFiles.slice(0, MAX_TEST_FILES),
exhaustive: true,
hopsSearched: 1,
};
}
let budget = TEST_CALLER_BUDGET;
const visited = new Set(directCallers.map((n) => n.id));
let frontier: Node[] = [...directCallers];
let hopsSearched = 1;
for (let hop = 2; hop <= TEST_CALLER_HOPS && frontier.length > 0 && budget > 0; hop++) {
hopsSearched = hop;
const next: Node[] = [];
const found = new Set<string>();
for (const current of frontier) {
if (budget-- <= 0) break;
let callers: Array<{ node: Node }>;
try {
callers = cg.getCallers(current.id) as Array<{ node: Node }>;
} catch {
continue;
}
for (const caller of callers) {
const source = caller?.node;
if (!source || visited.has(source.id)) continue;
visited.add(source.id);
const file = toPosixPath(source.filePath);
if (isTestFile(file)) found.add(file);
else next.push(source);
}
}
if (found.size > 0) {
const files = [...found];
return {
reached: true,
hops: hop,
fileCount: files.length,
files: files.slice(0, MAX_TEST_FILES),
exhaustive: true,
hopsSearched: hop,
};
}
frontier = next;
}
return {
reached: false,
hops: null,
fileCount: 0,
files: [],
exhaustive: budget > 0,
hopsSearched,
};
}
// =============================================================================
// References that leave the index
// =============================================================================
/**
* Calls and type mentions from this symbol that never resolved to a node — a
* third-party package, a runtime builtin, a construct extraction doesn't model.
*
* Without this the callee rail would silently be shorter than the body's call
* sites, which reads as "nothing else happens here". Saying "+N calls into
* symbols outside the index" is the honest version of the same screen.
*/
function summarizeOutsideIndex(
cg: CodeGraph,
nodeId: string
): {
total: number;
byKind: Record<string, number>;
samples: Array<{ name: string; kind: string; line: number; col: number }>;
} {
let refs;
try {
refs = cg.getUnresolvedReferencesFrom(nodeId);
} catch {
return { total: 0, byKind: {}, samples: [] };
}
const byKind: Record<string, number> = {};
for (const ref of refs) byKind[ref.referenceKind] = (byKind[ref.referenceKind] ?? 0) + 1;
const samples = [...refs]
.sort((a, b) => a.line - b.line || a.column - b.column)
.slice(0, MAX_OUTSIDE_INDEX_SAMPLES)
.map((ref) => ({
name: ref.referenceName,
kind: ref.referenceKind,
line: ref.line,
col: ref.column,
}));
return { total: refs.length, byKind, samples };
}
// =============================================================================
// Blast radius
// =============================================================================
export interface WireBlastSummary {
/** Distinct symbols that depend on this one directly. */
direct: number;
/** Distinct symbols reached within {@link BLAST_DEPTH} dependency hops. */
withinHops: number;
hops: number;
files: number;
testFiles: number;
routes: number;
/** Up to 40 of the dependent files, most-affected first, for the "what would need re-checking" fold. */
topFiles: Array<{ file: string; symbols: number; test: boolean }>;
}
/**
* What would need re-checking if this symbol changed.
*
* `getImpactRadius` at depth 3 is the engine's own answer to that question —
* incoming dependencies only, `contains` excluded upward so a leaf symbol does
* not explode into its whole class, container members expanded downward so
* callers of a class's methods count against the class.
*/
function summarizeBlast(cg: CodeGraph, node: Node, direct: number): WireBlastSummary | null {
let subgraph;
try {
subgraph = cg.getImpactRadius(node.id, BLAST_DEPTH);
} catch {
return null;
}
const perFile = new Map<string, number>();
let routes = 0;
for (const [id, dependent] of subgraph.nodes) {
if (id === node.id) continue;
const file = toPosixPath(dependent.filePath);
perFile.set(file, (perFile.get(file) ?? 0) + 1);
if (dependent.kind === ('route' as NodeKind)) routes++;
}
const testFiles = [...perFile.keys()].filter(isTestFile).length;
const topFiles = [...perFile.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 40)
.map(([file, symbols]) => ({ file, symbols, test: isTestFile(file) }));
return {
direct,
withinHops: Math.max(0, subgraph.nodes.size - 1),
hops: BLAST_DEPTH,
files: perFile.size,
testFiles,
routes,
topFiles,
};
}
// =============================================================================
// Drift
// =============================================================================
function driftFor(cg: CodeGraph, projectRoot: string, filePath: string): boolean {
const found = findIndexedFile(cg, filePath);
if (!found) return false;
return hasDriftedOnDisk(projectRoot, found.storedPath, found.record);
}