| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493 |
- /**
- * `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 {
- 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 function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): 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);
- // 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);
- }
|