Files
codegraph/src/ui-server/api/index.ts
T
Colby McHenry 2a0c6dc58f feat(ui): the type hierarchy — what a type is built on, and what dispatches through it (CG-58)
A vertical tree above the members outline for classes, interfaces, structs,
traits, protocols, enums, unions and type aliases: ancestors above (the whole
chain, not just the direct parent), the focus in accent, subtypes below indented
per level. `extends` draws solid, `implements` dashed; a synthesized edge — Go's
implicit interface satisfaction — draws dashed wider and carries the site it was
wired at, so a relation the resolver inferred never reads like one the source
wrote down. For an interface the fan below IS the set of runtime targets a call
can land on, and a type with eight or more implementers leads with that in a
sentence. Members that redeclare an ancestor's are marked in the outline.

The walk lives in `src/graph/type-hierarchy.ts`, following CG-50/CG-51: shared
computation in `src/graph/`, presentation in the caller. Its `countImplementers`
is now also what `ToolHandler.buildPolymorphicBoundaries` counts with, so "N
types implement X" is the same N whether an agent reads it or a person does.
`/api/node` carries the block as `hierarchy` rather than a second endpoint —
it is part of the Symbol view's first paint, and gated to types, so a function
costs one kind test.

Layout is arithmetic (24px rows, 22px indent, orthogonal connectors computed
from the two): no ResizeObserver, same payload → same picture. The header's
`extends X` / `implemented by …` chips are suppressed while the tree is on
screen — two renderings of one relation in one column is how a reader ends up
trusting neither.

`TypeHierarchy` is exported from `@colbymchenry/codegraph-ui` and takes its data
as a prop, so a host holding a `WireSymbolPayload` renders it without a second
read.
2026-08-27 07:14:55 -05:00

276 lines
11 KiB
TypeScript

/**
* The read-only JSON API the viewer reads its screens from.
*
* Eleven endpoints, one per screen, each answering in a single round-trip — the
* same principle as `codegraph_explore`: return enough that the caller does not
* have to ask a follow-up question — plus one that does not answer at all and
* stays open instead (`/api/events`), so a screen learns that its answer went
* stale rather than waiting to be asked again. Everything here is a *reader* of
* the existing schema; nothing indexes, resolves, or writes.
*
* ```
* GET /api/stats what this index is and how much to trust it
* GET /api/search?q= the search palette
* GET /api/node/<id> the Symbol view: rails, members, hierarchy, tests, blast
* GET /api/nodes?id=&id= names for ids you already have (the trail)
* GET /api/source?file=&from=&to= verbatim source, with a drift verdict
* GET /api/file/<path> the File view: outline and import rails
* GET /api/filecode/<path> the whole-file view: ports, arcs, callee rail
* GET /api/routes the URL to handler map, when there is one
* GET /api/entrypoints where to start reading: routes, roots, tests, hubs
* GET /api/map?root=&depth= the module map: modules, links, cycles
* GET /api/flow?from=&to= the flow strip: one card per hop
* GET /api/events the live channel (SSE): drift and refresh
* ```
*
* It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
* the loopback boundary in `security.ts`: the `Host` allowlist, the absence of
* CORS headers and the GET/HEAD restriction are already enforced by the time a
* handler here runs. The one obligation that remains ours is the read
* chokepoint — `resolveProjectFile` for anything that touches the repository —
* and it lives in `source.ts`, the only module here that opens a file.
*/
import type { UiApiHandler, UiRequestContext } from '../index';
import { PathRefusalError } from '../security';
import { GraphSession } from './session';
import { ApiError, badRequest, fail, notFound, ok } from './respond';
import { buildStats } from './stats';
import { buildSearch } from './search';
import { buildNode } from './node';
import { buildSource } from './source';
import { buildFile } from './file';
import { buildFileCode } from './filecode';
import { buildRoutes } from './routes';
import { buildEntryPoints } from './entrypoints';
import { buildNodeRefs } from './nodes';
import { buildMap } from './map';
import { buildFlow } from './flow';
import { EventHub } from './events';
export { GraphSession } from './session';
export { ApiError } from './respond';
export * from './wire';
export type {
WireEntryPoints,
WireEntryFile,
WireEntryTest,
WireEntryHub,
} from './entrypoints';
export type { WireRoute, WireRoutes } from './routes';
export type { WireHierarchy, WireHierarchyNode, WireOverride } from './hierarchy';
export { MAX_HIERARCHY_ANCESTORS, MAX_HIERARCHY_DESCENDANTS } from './hierarchy';
export type { WireNodeRefs } from './nodes';
export type {
WireFlowPayload,
WireFlow,
WireFlowHop,
WireFlowEdge,
WireFlowSource,
WireFlowCallRef,
WireFlowAmbiguity,
} from './flow';
export type {
WireFileCodePayload,
WireFileCall,
WireFileOutsideRef,
} from './filecode';
export { EventHub, MAX_EVENT_FILES, HEARTBEAT_MS } from './events';
export type {
WireEvent,
WireEventHello,
WireEventChanged,
WireEventIndex,
WireEventDegraded,
WireIndexRevision,
} from './events';
export type {
WireMapPayload,
WireMapModule,
WireMapLink,
WireMapCycle,
} from './map';
/**
* A mounted API, plus the handle it holds open.
*
* `close()` releases the index; the CLI calls it on Ctrl-C so the process does
* not exit with a live SQLite connection.
*/
export interface GraphApi {
handler: UiApiHandler;
close(): void;
}
export interface GraphApiOptions {
/** Absolute path of the indexed project to read. */
projectRoot: string;
}
/** What `GET /api` answers: the endpoint list, for anyone poking at it by hand. */
const API_INDEX = {
name: 'codegraph ui',
readOnly: true,
endpoints: [
{ path: '/api/stats', description: 'Index state, graph counts, detected frameworks.' },
{ path: '/api/search', description: 'Ranked symbol search.', params: ['q', 'limit'] },
{
path: '/api/node/<id>',
description:
'One symbol: callers, callees, members, type hierarchy, tests, blast radius.',
},
{ path: '/api/nodes', description: 'Names and locations for ids you already have.', params: ['id'] },
{
path: '/api/source',
description: 'Verbatim source for an indexed file, omitted when it has drifted on disk.',
params: ['file', 'from', 'to'],
},
{ path: '/api/file/<path>', description: 'One file: outline and import rails.' },
{
path: '/api/filecode/<path>',
description:
'One file, line by line: call sites, unresolved references and the calls that stay inside it.',
},
{ path: '/api/routes', description: 'URL to handler map, when the project is a routed app.', params: ['limit'] },
{
path: '/api/map',
description: 'The repository at module granularity: modules, cross-module links, cycles.',
params: ['root', 'depth'],
},
{
path: '/api/flow',
description: 'The call path between symbols: one hop per card, opened at the calling line.',
params: ['from', 'to', 'symbols', 'hop', 'limit'],
},
{
path: '/api/events',
description:
'Live channel (server-sent events): source files that changed on disk, and the index moving.',
},
{
path: '/api/entrypoints',
description: 'Where to start reading: routes, files that run something, and hubs.',
params: ['limit'],
},
],
};
export function createGraphApi(options: GraphApiOptions): GraphApi {
const session = new GraphSession(options.projectRoot);
// Watches nothing until a browser subscribes, and stops again when the last
// one goes away — mounting the API costs no watch descriptors.
const events = new EventHub(options.projectRoot, session);
// Async because `/api/source` highlights: everything else answers straight
// out of SQLite and resolves on the same tick.
const handler: UiApiHandler = async (req, res, ctx) => {
const route = normalize(ctx.pathname);
try {
switch (route) {
case '/api':
return ok(res, API_INDEX, ctx.method);
case '/api/stats':
return ok(res, buildStats(session.acquire(), ctx.projectRoot), ctx.method);
case '/api/search':
return ok(res, buildSearch(session.acquire(), ctx.query), ctx.method);
case '/api/routes':
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/entrypoints':
return ok(res, buildEntryPoints(session.acquire(), ctx.query), ctx.method);
case '/api/nodes':
return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
case '/api/source':
return ok(res, await buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
case '/api/flow':
return ok(res, await buildFlow(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
case '/api/events':
// Streams instead of answering: it writes its own headers and keeps
// the socket open, so it never goes through `ok()`.
return events.subscribe(req, res, ctx.method);
default:
return dispatchPathRoutes(route, res, ctx, session);
}
} catch (err) {
// A refusal from the read chokepoint is a 403 with the reason attached —
// the request asked for something outside the project, and there is no
// version of it we would serve.
if (err instanceof PathRefusalError) {
return fail(res, new ApiError('refused', err.message), ctx.method);
}
return fail(res, err, ctx.method);
}
};
return {
handler,
close: () => {
// Streams first: a client still attached would hold the socket open
// against the server's own close.
events.close();
session.close();
},
};
}
/**
* The two endpoints that carry their argument in the path.
*
* `ctx.pathname` is already percent-decoded, so a node id or a file path
* containing `/` (`file:src/a.ts`) arrives whole — the remainder after the
* prefix IS the argument, slashes and all. Node ids are opaque: they go
* 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(
route: string,
res: Parameters<UiApiHandler>[1],
ctx: UiRequestContext,
session: GraphSession
): 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);
}
// Before `/api/file/`: that prefix is not a prefix of this route, but keeping
// the more specific one first means adding another `/api/file…` sibling later
// cannot silently start matching the shorter one.
const codePath = suffixAfter(route, '/api/filecode/');
if (codePath !== null) {
return ok(res, buildFileCode(session.acquire(), ctx.projectRoot, codePath), ctx.method);
}
const filePath = suffixAfter(route, '/api/file/');
if (filePath !== null) {
if (filePath === '') throw badRequest('No file path was given. Use /api/file/<path>.');
return ok(res, buildFile(session.acquire(), ctx.projectRoot, filePath), ctx.method);
}
// `/api/node` and `/api/file` with no argument at all, so the message can say
// what the endpoint wants instead of falling through to a bare 404.
if (route === '/api/filecode') {
throw badRequest('/api/filecode needs an argument: /api/filecode/<path>.');
}
if (route === '/api/node' || route === '/api/file') {
throw badRequest(`${route} needs an argument: ${route}/<${route.endsWith('node') ? 'id' : 'path'}>.`);
}
throw notFound(
`No such endpoint: ${route}`,
'GET /api lists everything this server answers.'
);
}
/** Drop a single trailing slash, so `/api/stats/` and `/api/stats` are one route. */
function normalize(pathname: string): string {
return pathname.length > 4 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
}
function suffixAfter(route: string, prefix: string): string | null {
return route.startsWith(prefix) ? route.slice(prefix.length) : null;
}