mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 11:24:49 +08:00
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.
276 lines
11 KiB
TypeScript
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;
|
|
}
|