feat(ui): read-only JSON API over the index for the viewer (CG-42)

Six endpoints under `/api/`, one per screen, each answering in a single
round-trip in the spirit of `codegraph_explore` — the viewer should never
have to ask a follow-up question to finish drawing a pane:

    /api/stats                     index state, graph counts, frameworks
    /api/search?q=                 ranked, kind-grouped symbol search
    /api/node/<id>                 rails, members, tests, blast radius
    /api/source?file=&from=&to=    verbatim source + a drift verdict
    /api/file/<path>               outline and import rails
    /api/routes                    URL -> handler, when there is one

It is a reader of the existing schema: no extraction or resolution changes.
It mounts on the `api` seam `startUiServer` already exposed, so it sits
behind the CG-41 loopback boundary — Host allowlist, no CORS headers,
GET/HEAD only — and every read out of the repository goes through
`resolveProjectFile`, ahead of the index lookup so a traversal is refused
as a traversal rather than reported as "not indexed".

Three properties the endpoints are built around:

- No N+1. The engine's busiest symbol has 545 incoming edges; resolving
  those one `getNode` at a time is 545 queries. Every edge list is
  resolved with one batched lookup, which needed four additive read-only
  query methods (`getNodesByIds`/`getFanIn`/`getFanOut` on `CodeGraph`,
  plus batched outgoing/incoming edge fetches and unresolved-reference
  reads). `/api/node` on `LRUCache.get` answers in ~10 ms.

- Capped lists, honest totals. 545 callers cannot all be rows, so caller
  groups cap at 300 — but `total` is always the real number, and the
  ordering puts the useful end first (same file, then production code,
  then tests). Every count in the payload is the length of a list the
  same payload returns, so a badge and its rail cannot disagree.

- Nothing overclaims. Source that drifted on disk since the last index
  sync is omitted rather than sliced at line ranges that may now point at
  a different symbol; calls that leave the index are counted instead of
  silently shortening the callee rail; imports that never resolved are
  named; and a test-coverage claim reports whether its search actually
  finished. `/api/routes` says a project simply is not routed, and
  refuses a `limit` below three because the engine's manifest would
  answer that question wrongly.

Tests: 45 against a real indexed fixture over a real loopback server,
covering every endpoint's shape, the drift verdict in all three places it
surfaces, search ranking and the filter grammar, the refusals, and the
capping/latency behaviour at 500 callers. The issue's own acceptance case
— `lru-cache.ts` `get` under 100 ms — runs against this repo's index when
one is present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-26 23:33:30 -05:00
co-authored by Claude Opus 5
parent 41a90c6ba4
commit 951ba3678a
15 changed files with 3300 additions and 5 deletions
+247
View File
@@ -0,0 +1,247 @@
/**
* `GET /api/file/<path>` — the File view in one round-trip.
*
* Three panes: what imports this file, the file's own outline in source order,
* and what this file imports. All of it comes from four batched queries — the
* file's nodes, their `contains` edges, their `imports` edges in each
* direction — never a query per symbol.
*
* Two things worth knowing about `imports` edges before reading the mapping
* below. First, they point at the *symbol* that was imported, not at the file
* holding it, so file granularity means mapping each edge's endpoint through
* `nodes.file_path`. Second, plenty of them stay inside one file (an import
* declaration is a node in the importing file), so the same-file ones have to
* be dropped or every file appears to import itself.
*
* The rails would still read as broken without the third piece: imports that
* never resolved. A file importing `react`, `fs` and one local module would
* otherwise show a single row, silently implying the other two do not exist.
* They are listed separately, as what they are — outside the index.
*/
import type { CodeGraph } from '../../index';
import type { Edge, Node } from '../../types';
import { isTestFile } from '../../search/query-utils';
import { hasDriftedOnDisk, resolveRequestedFile } from './source';
import {
MAX_IMPORT_FILES,
MAX_OUTLINE_NODES,
toNodeRef,
toPosixPath,
wireList,
type WireNodeRef,
} from './wire';
/** Symbols named per import row before it just counts them. */
const MAX_SYMBOLS_PER_IMPORT = 12;
/** Unresolved imports listed by name. */
const MAX_UNRESOLVED_IMPORTS = 60;
/** A row in the file outline. */
export interface WireOutlineEntry extends WireNodeRef {
/** Containing symbol within this file, or null for a top-level one. */
parentId: string | null;
/** Nesting depth from the top level of the file, starting at 0. */
depth: number;
/** Incoming / outgoing edge counts — the `← in → out` column. */
fanIn: number;
fanOut: number;
}
/** One end of the File view's import rails. */
export interface WireImportRow {
file: string;
test: boolean;
/** Which symbols the edges name, capped. */
symbols: Array<{ id: string; name: string; kind: string; line: number }>;
symbolCount: number;
}
export function buildFile(cg: CodeGraph, projectRoot: string, requested: string): unknown {
// Refusal first, index lookup second — a traversal out of the project is a
// refusal, not "no such file". See `resolveRequestedFile`.
const { record, storedPath } = resolveRequestedFile(cg, projectRoot, requested);
const nodes = cg.getNodesInFile(storedPath);
const nodeIds = nodes.map((n) => n.id);
const inThisFile = new Set(nodeIds);
const fileNode = nodes.find((n) => n.kind === 'file') ?? null;
// ---------------------------------------------------------------------------
// Outline
// ---------------------------------------------------------------------------
const containsEdges = cg.getOutgoingEdgesFrom(nodeIds, ['contains']);
const parentOf = new Map<string, string>();
for (const edge of containsEdges) {
// Only nesting *within* this file: a `contains` edge reaching out of it is
// not something a file outline can draw.
if (inThisFile.has(edge.target) && !parentOf.has(edge.target)) {
parentOf.set(edge.target, edge.source);
}
}
const fanIn = cg.getFanIn(nodeIds);
const fanOut = cg.getFanOut(nodeIds);
const outlineNodes = nodes
// The file node is the subject of the screen, not a row in its own outline;
// import declarations get their own rail and would otherwise be most of it.
.filter((n) => n.kind !== 'file' && n.kind !== 'import')
.sort((a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name));
const outline: WireOutlineEntry[] = outlineNodes
.slice(0, MAX_OUTLINE_NODES)
.map((node) => ({
...toNodeRef(node),
parentId: resolveOutlineParent(node.id, parentOf, fileNode?.id),
depth: depthOf(node.id, parentOf, fileNode?.id),
fanIn: fanIn.get(node.id) ?? 0,
fanOut: fanOut.get(node.id) ?? 0,
}));
// ---------------------------------------------------------------------------
// Import rails
// ---------------------------------------------------------------------------
const importsOut = cg.getOutgoingEdgesFrom(nodeIds, ['imports']);
const importsIn = cg.getIncomingEdgesTo(nodeIds, ['imports']);
const endpointIds = new Set<string>();
for (const edge of importsOut) if (!inThisFile.has(edge.target)) endpointIds.add(edge.target);
for (const edge of importsIn) if (!inThisFile.has(edge.source)) endpointIds.add(edge.source);
const endpoints = cg.getNodesByIds([...endpointIds]);
const imports = groupByFile(
importsOut.filter((e) => !inThisFile.has(e.target)),
(e) => e.target,
endpoints
);
const importedBy = groupByFile(
importsIn.filter((e) => !inThisFile.has(e.source)),
(e) => e.source,
endpoints
);
// Import statements that never resolved — the third-party packages and
// runtime builtins. Attributed to the file node, which is where extraction
// records a file-level import.
const unresolvedImports = fileNode ? unresolvedImportsOf(cg, fileNode.id) : [];
return {
file: {
path: toPosixPath(storedPath),
language: record.language,
size: record.size,
modifiedAt: record.modifiedAt,
indexedAt: record.indexedAt,
contentHash: record.contentHash,
nodeCount: record.nodeCount,
generated: record.generated === true,
test: isTestFile(toPosixPath(storedPath)),
errors: record.errors ?? [],
/** The file node itself, so the viewer can navigate to it as a symbol. */
id: fileNode?.id ?? null,
},
/** The file changed on disk since it was indexed — the outline's lines may be shifted. */
drift: hasDriftedOnDisk(projectRoot, storedPath, record),
outline: wireList(outline, outlineNodes.length),
imports: wireList(imports.slice(0, MAX_IMPORT_FILES), imports.length),
importedBy: wireList(importedBy.slice(0, MAX_IMPORT_FILES), importedBy.length),
unresolvedImports,
/**
* The broader relationship: every file this one has a cross-file edge into,
* and every file that has one into it — calls and type references, not just
* import statements. `imports` alone understates both, badly in languages
* where symbols resolve without an explicit import.
*/
dependencies: cg.getFileDependencies(storedPath).map(toPosixPath).sort(),
dependents: cg.getFileDependents(storedPath).map(toPosixPath).sort(),
};
}
/**
* The outline parent of a symbol: its container within the file, or null when
* that container is the file node itself (a top-level symbol has no parent row).
*/
function resolveOutlineParent(
id: string,
parentOf: Map<string, string>,
fileNodeId: string | undefined
): string | null {
const parent = parentOf.get(id);
if (!parent || parent === fileNodeId) return null;
return parent;
}
function depthOf(
id: string,
parentOf: Map<string, string>,
fileNodeId: string | undefined
): number {
let depth = 0;
let current = id;
// Bounded by the number of links so a cyclic `contains` chain — which should
// be impossible, but is one bad index away — cannot spin here.
for (let guard = 0; guard < 32; guard++) {
const parent = parentOf.get(current);
if (!parent || parent === fileNodeId) return depth;
depth++;
current = parent;
}
return depth;
}
/** Fold edges into one row per file at the far end, ordered by symbol count. */
function groupByFile(
edges: readonly Edge[],
endpoint: (edge: Edge) => string,
nodes: Map<string, Node>
): WireImportRow[] {
const byFile = new Map<string, Map<string, Node>>();
for (const edge of edges) {
const node = nodes.get(endpoint(edge));
if (!node) continue;
const file = toPosixPath(node.filePath);
let bucket = byFile.get(file);
if (!bucket) {
bucket = new Map<string, Node>();
byFile.set(file, bucket);
}
bucket.set(node.id, node);
}
return [...byFile.entries()]
.map(([file, symbols]) => {
const ordered = [...symbols.values()].sort(
(a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name)
);
return {
file,
test: isTestFile(file),
symbols: ordered.slice(0, MAX_SYMBOLS_PER_IMPORT).map((n) => ({
id: n.id,
name: n.name,
kind: n.kind,
line: n.startLine,
})),
symbolCount: ordered.length,
};
})
.sort((a, b) => b.symbolCount - a.symbolCount || a.file.localeCompare(b.file));
}
function unresolvedImportsOf(
cg: CodeGraph,
fileNodeId: string
): Array<{ name: string; line: number }> {
try {
return cg
.getUnresolvedReferencesFrom(fileNodeId)
.filter((ref) => ref.referenceKind === 'imports')
.sort((a, b) => a.line - b.line || a.referenceName.localeCompare(b.referenceName))
.slice(0, MAX_UNRESOLVED_IMPORTS)
.map((ref) => ({ name: ref.referenceName, line: ref.line }));
} catch {
return [];
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* The read-only JSON API the viewer reads its screens from.
*
* Six 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. 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, tests, blast radius
* 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/routes the URL to handler map, when there is one
* ```
*
* 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 { buildRoutes } from './routes';
export { GraphSession } from './session';
export { ApiError } from './respond';
export * from './wire';
/**
* 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, tests, blast radius.' },
{
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/routes', description: 'URL to handler map, when the project is a routed app.', params: ['limit'] },
],
};
export function createGraphApi(options: GraphApiOptions): GraphApi {
const session = new GraphSession(options.projectRoot);
const handler: UiApiHandler = (_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/source':
return ok(res, buildSource(session.acquire(), ctx.projectRoot, ctx.query), 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: () => 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);
}
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/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;
}
+453
View File
@@ -0,0 +1,453 @@
/**
* `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 { notFound } from './respond';
import { findIndexedFile, hasDriftedOnDisk } from './source';
import {
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';
/** Depth the blast-radius summary walks. Matches `codegraph_explore`'s claim. */
const BLAST_DEPTH = 3;
/** 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;
}
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
// ---------------------------------------------------------------------------
const members = buildMembers(cg, node, containsOut, endpoints);
// ---------------------------------------------------------------------------
// 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),
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>
): { 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)
);
return {
items: all.slice(0, MAX_OUTLINE_NODES).map((entry) => ({
...toNodeRef(entry.node),
parentId: entry.parentId,
depth: entry.depth,
})),
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);
}
+159
View File
@@ -0,0 +1,159 @@
/**
* How the JSON API answers — success, refusal, and every failure in between.
*
* The viewer is the only client, and it runs on the same machine as the index,
* so an error here is a message to a developer looking at their own project,
* not information to withhold from a prober. Every failure therefore says what
* went wrong and — where there is one — what to do about it, exactly the way
* the CLI and the MCP tools do. What it never does is leak a stack trace.
*/
import type { ServerResponse } from 'http';
import { sendJson } from '../static';
/**
* Machine-readable failure reasons. The viewer switches on these rather than
* on prose, so renaming a message never breaks a screen.
*/
export type ApiErrorCode =
| 'bad-request'
| 'not-found'
| 'refused'
| 'no-index'
| 'index-unusable'
| 'internal';
const STATUS: Record<ApiErrorCode, number> = {
'bad-request': 400,
'not-found': 404,
// A path refusal, not an authentication failure — the request asked for
// something outside the project (traversal, an absolute path, a sensitive
// directory) and there is no version of it we would serve.
refused: 403,
// The index is missing or unusable. 503 rather than 404: the endpoint is
// real, the data behind it is not there *yet* — `codegraph init` fixes it.
'no-index': 503,
'index-unusable': 503,
internal: 500,
};
/** An error that already carries a user-facing message and a status. */
export class ApiError extends Error {
readonly code: ApiErrorCode;
/** Optional second line: what the user can do about it. */
readonly hint: string | undefined;
constructor(code: ApiErrorCode, message: string, hint?: string) {
super(message);
this.name = 'ApiError';
this.code = code;
this.hint = hint;
}
}
export function badRequest(message: string, hint?: string): ApiError {
return new ApiError('bad-request', message, hint);
}
export function notFound(message: string, hint?: string): ApiError {
return new ApiError('not-found', message, hint);
}
/** Send a successful payload. */
export function ok(res: ServerResponse, payload: unknown, method: string): true {
sendJson(res, 200, payload, method);
return true;
}
/** Send a failure. Anything that is not an {@link ApiError} becomes a 500. */
export function fail(res: ServerResponse, err: unknown, method: string): true {
if (err instanceof ApiError) {
const body: { error: string; code: ApiErrorCode; hint?: string } = {
error: err.message,
code: err.code,
};
if (err.hint) body.hint = err.hint;
sendJson(res, STATUS[err.code], body, method);
return true;
}
sendJson(
res,
500,
{
error: err instanceof Error ? err.message : String(err),
code: 'internal' satisfies ApiErrorCode,
},
method
);
return true;
}
// =============================================================================
// Query parameters
// =============================================================================
/** A required, non-empty string parameter. */
export function requiredParam(query: URLSearchParams, name: string): string {
const raw = query.get(name);
if (raw === null || raw.trim() === '') {
throw badRequest(`Missing required parameter "${name}".`);
}
return raw;
}
/**
* A bounded integer parameter.
*
* Out-of-range values are an error rather than silently clamped: a viewer
* asking for line 10 000 000 of a 200-line file has a bug, and answering it
* with line 200 would hide that.
*/
export function intParam(
query: URLSearchParams,
name: string,
opts: { min: number; max: number; default?: number }
): number {
const raw = query.get(name);
if (raw === null || raw.trim() === '') {
if (opts.default !== undefined) return opts.default;
throw badRequest(`Missing required parameter "${name}".`);
}
const value = Number(raw);
if (!Number.isInteger(value) || value < opts.min || value > opts.max) {
throw badRequest(
`Parameter "${name}" must be a whole number between ${opts.min} and ${opts.max} (got "${raw}").`
);
}
return value;
}
/**
* Free-form text input, bounded.
*
* The same reasoning as the MCP tools' input ceiling: a huge string is never a
* real query, and letting one through means a full-table LIKE scan or an FTS5
* parse over megabytes.
*/
export const MAX_QUERY_LENGTH = 2_000;
export function textParam(query: URLSearchParams, name: string): string {
const raw = requiredParam(query, name);
return boundLength(raw, name);
}
/**
* Text that must be PRESENT but may be empty — a search box the user has
* cleared. Absent is still an error; empty is a legitimate state.
*/
export function optionalTextParam(query: URLSearchParams, name: string): string {
const raw = query.get(name);
if (raw === null) throw badRequest(`Missing required parameter "${name}".`);
return boundLength(raw, name);
}
function boundLength(raw: string, name: string): string {
if (raw.length > MAX_QUERY_LENGTH) {
throw badRequest(`Parameter "${name}" is too long (max ${MAX_QUERY_LENGTH} characters).`);
}
return raw;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* `GET /api/routes` — the URL to handler map, when the project has one.
*
* The engine's routing manifest is a flat list of (url, handler, file, line)
* rows; it deliberately carries no node ids, because its own consumer (the MCP
* context builder) renders text. A reader needs to *navigate*, so each entry is
* matched back to its handler's node id here — batched by file, never a lookup
* per route.
*
* `null` from the engine means "fewer than three real routes", i.e. this
* project is not a routed app. That is reported as an empty manifest with
* `routed: false` rather than as an error: "this isn't a web app" is an
* answer, not a failure.
*
* Two things about the manifest shape the numbers here have to work around.
* Its `limit` is applied in SQL *before* the three-route test, so asking for
* fewer than three would make every routed project look unrouted — hence the
* floor on the parameter. And its own `totalRoutes` counts only the rows inside
* that window, so the headline count comes from the graph's `route` nodes
* instead, which is the number a reader means by "how many routes are there".
*/
import type { CodeGraph } from '../../index';
import { intParam } from './respond';
import { toPosixPath } from './wire';
/** Distinct handler files we will resolve node ids for. */
const MAX_HANDLER_FILES = 60;
/**
* The engine needs three surviving rows to call a project routed, and applies
* `limit` before that test — so anything below three is a question that cannot
* be answered truthfully rather than a small page.
*/
const MIN_LIMIT = 3;
export function buildRoutes(cg: CodeGraph, query: URLSearchParams): unknown {
const limit = intParam(query, 'limit', { min: MIN_LIMIT, max: 500, default: 200 });
// One row over the limit, purely to learn whether there were more.
const manifest = cg.getRoutingManifest(limit + 1);
const routeCount = cg.getStats().nodesByKind.route ?? 0;
if (!manifest) {
return {
routed: false,
routeCount,
shown: 0,
truncated: false,
topHandlerFile: null,
topHandlerFileCount: 0,
entries: [],
};
}
const truncated = manifest.entries.length > limit;
const rows = manifest.entries.slice(0, limit);
// One `getNodesInFile` per distinct handler file — typically one or two, and
// capped so a project that scatters handlers across hundreds of files cannot
// turn one request into hundreds of queries.
const handlerFiles = [...new Set(rows.map((e) => e.handlerFile))].slice(0, MAX_HANDLER_FILES);
const byFileLineName = new Map<string, string>();
for (const file of handlerFiles) {
for (const node of cg.getNodesInFile(file)) {
// Keyed on what the manifest actually knows: file, line and name. Two
// symbols can share a line (a decorator and its method); the name breaks
// the tie, and a miss simply leaves that entry unlinked.
byFileLineName.set(`${node.filePath} ${node.startLine} ${node.name}`, node.id);
}
}
const entries = rows.map((entry) => ({
url: entry.url,
handler: entry.handler,
handlerKind: entry.handlerKind,
file: toPosixPath(entry.handlerFile),
line: entry.handlerLine,
handlerId:
byFileLineName.get(`${entry.handlerFile} ${entry.handlerLine} ${entry.handler}`) ?? null,
}));
return {
routed: true,
/** Every URL the index holds, whether or not its handler resolved. */
routeCount,
/** Rows in `entries` — the ones whose handler the manifest could name. */
shown: entries.length,
truncated,
topHandlerFile: manifest.topHandlerFile ? toPosixPath(manifest.topHandlerFile) : null,
topHandlerFileCount: manifest.topHandlerFileCount,
entries,
};
}
+233
View File
@@ -0,0 +1,233 @@
/**
* `GET /api/search?q=` — the search palette's one round-trip.
*
* Three lookups feed it, because no single one covers what a person types into
* a palette:
*
* - `getNodesByNameSubstring` — case-insensitive, catches the exact, prefix and
* mid-name matches (`profileInfo` inside `getProfileInfoV2`) that FTS tokens
* cannot.
* - `searchNodes` — FTS5, plus the engine's own LIKE and fuzzy fallbacks, and
* the `kind:` / `lang:` / `path:` / `name:` filter grammar for free.
* - `getNodesByName` — every symbol with exactly that name, uncapped, so a
* heavily-overloaded name never loses its definitions below a search cut.
*
* They are then merged and ranked by HOW the name matched — exact, prefix,
* substring, qualified name, file path — rather than by any single engine's
* score, because those scores are not comparable with each other. Results are
* grouped by kind: "did I mean the class or the method" is the question a
* palette actually has to answer.
*/
import type { CodeGraph } from '../../index';
import type { Node, NodeKind } from '../../types';
import { parseQuery, type ParsedQuery } from '../../search/query-parser';
import { intParam, optionalTextParam } from './respond';
import { toNodeRef, wireList, type WireNodeRef } from './wire';
/** How a result's text matched the query. Also the primary sort key. */
export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
const MATCH_RANK: Record<MatchKind, number> = {
exact: 0,
prefix: 1,
substring: 2,
qualified: 3,
file: 4,
// Matched by FTS through a signature, docstring or fuzzy neighbour — real,
// but never what someone typing a name is looking for first.
related: 5,
};
/** Candidates pulled from each source before ranking trims to `limit`. */
const CANDIDATE_POOL = 400;
export interface WireSearchResult extends WireNodeRef {
matchKind: MatchKind;
}
/**
* Tie-break inside a match tier: the kinds someone navigates to, before the
* kinds that merely mention a name.
*/
function kindRank(kind: NodeKind): number {
switch (kind) {
case 'function':
case 'method':
case 'class':
case 'component':
case 'interface':
case 'struct':
case 'trait':
case 'protocol':
case 'enum':
case 'union':
case 'type_alias':
case 'route':
return 0;
case 'constant':
case 'property':
case 'field':
case 'variable':
case 'enum_member':
return 1;
case 'file':
case 'module':
case 'namespace':
return 2;
default:
// import / export / parameter — a mention, not a definition.
return 3;
}
}
function classify(node: Node, needle: string): MatchKind | null {
const name = node.name.toLowerCase();
if (name === needle) return 'exact';
if (name.startsWith(needle)) return 'prefix';
if (name.includes(needle)) return 'substring';
if (node.qualifiedName.toLowerCase().includes(needle)) return 'qualified';
if (node.filePath.toLowerCase().replace(/\\/g, '/').includes(needle)) return 'file';
return null;
}
export function buildSearch(cg: CodeGraph, query: URLSearchParams): unknown {
const raw = optionalTextParam(query, 'q');
const limit = intParam(query, 'limit', { min: 1, max: 200, default: 60 });
// An empty search box is the palette's resting state, not a mistake — it
// answers with nothing rather than with an error the viewer has to special-
// case. A MISSING `q` is still a 400: that is a caller bug.
if (raw.trim() === '') return emptySearch(raw);
// The filter grammar (`kind:function auth`) belongs to `searchNodes`; the
// name lookups only ever want the free-text part of what was typed.
const parsed = parseQuery(raw);
const text = parsed.text.trim();
const needle = text.toLowerCase();
const candidates = new Map<string, Node>();
const remember = (node: Node): void => {
if (!candidates.has(node.id)) candidates.set(node.id, node);
};
if (text.length > 0) {
for (const node of cg.getNodesByName(text)) remember(node);
for (const node of cg.getNodesByNameSubstring(text, { limit: CANDIDATE_POOL })) remember(node);
}
for (const result of cg.searchNodes(raw, { limit: CANDIDATE_POOL })) remember(result.node);
const scored: Array<{ node: Node; match: MatchKind }> = [];
for (const node of candidates.values()) {
// `searchNodes` applies the filter grammar to its own results, but the two
// direct name lookups above know nothing about it — so `kind:class Cache`
// would otherwise pull in `CacheKey` and every `Cache` method through the
// substring lookup. The gate belongs to the merged candidate set.
if (!matchesFilters(node, parsed)) continue;
// An empty text portion means the query was pure filters (`kind:route`);
// everything `searchNodes` returned already satisfies them, so there is no
// name match to grade and every row is equally "related".
const match = needle.length === 0 ? 'related' : classify(node, needle) ?? 'related';
scored.push({ node, match });
}
scored.sort((a, b) => {
const byMatch = MATCH_RANK[a.match] - MATCH_RANK[b.match];
if (byMatch !== 0) return byMatch;
const byKind = kindRank(a.node.kind) - kindRank(b.node.kind);
if (byKind !== 0) return byKind;
// Production code before tests and fixtures: both are real answers, but one
// of them is the one someone searching for a symbol usually means.
const aTest = isTestPath(a.node.filePath);
const bTest = isTestPath(b.node.filePath);
if (aTest !== bTest) return aTest ? 1 : -1;
// Shorter names are closer to what was typed (`get` before `getOrCreate`).
const byLength = a.node.name.length - b.node.name.length;
if (byLength !== 0) return byLength;
return (
a.node.filePath.localeCompare(b.node.filePath) || a.node.startLine - b.node.startLine
);
});
const top = scored.slice(0, limit);
const results: WireSearchResult[] = top.map(({ node, match }) => ({
...toNodeRef(node),
matchKind: match,
}));
// Groups keep the ranked order: a group appears where its best result did, so
// flattening the groups reproduces the flat ranking for keyboard navigation.
const groups: Array<{ kind: NodeKind; count: number; items: WireSearchResult[] }> = [];
const byKind = new Map<NodeKind, WireSearchResult[]>();
for (const result of results) {
const bucket = byKind.get(result.kind);
if (bucket) {
bucket.push(result);
} else {
const created = [result];
byKind.set(result.kind, created);
groups.push({ kind: result.kind, count: 0, items: created });
}
}
for (const group of groups) group.count = group.items.length;
return {
query: raw,
text,
filters: {
kinds: parsed.kinds,
languages: parsed.languages,
paths: parsed.pathFilters,
names: parsed.nameFilters,
},
results: wireList(results, scored.length),
groups,
};
}
/**
* Deliberately a plain path check rather than the engine's `isTestFile`: this
* is a ranking nudge inside one tier, and `isTestFile` also treats `examples/`,
* `benchmarks/` and `fixtures/` as tests — pushing a legitimately-searched
* example below an unrelated production symbol.
*/
function isTestPath(filePath: string): boolean {
const lower = filePath.toLowerCase().replace(/\\/g, '/');
return (
/(^|\/)(tests?|specs?|__tests__)\//.test(lower) ||
/[._-](test|tests|spec|specs)\.[a-z0-9]+$/.test(lower)
);
}
/**
* The hard gate the `kind:` / `lang:` / `path:` / `name:` grammar asks for.
*
* Deliberately the same predicates `searchNodes` uses internally — kinds and
* languages exact, paths and names case-insensitive substrings, each list OR'd
* within itself and AND'd across lists — so a filtered search means the same
* thing whichever lookup a result came from.
*/
function matchesFilters(node: Node, parsed: ParsedQuery): boolean {
if (parsed.kinds.length > 0 && !parsed.kinds.includes(node.kind)) return false;
if (parsed.languages.length > 0 && !parsed.languages.includes(node.language)) return false;
if (parsed.pathFilters.length > 0) {
const file = node.filePath.toLowerCase();
if (!parsed.pathFilters.some((p) => file.includes(p.toLowerCase()))) return false;
}
if (parsed.nameFilters.length > 0) {
const name = node.name.toLowerCase();
if (!parsed.nameFilters.some((n) => name.includes(n.toLowerCase()))) return false;
}
return true;
}
/** The resting state of the palette: the shape of a real answer, with nothing in it. */
function emptySearch(raw: string): unknown {
return {
query: raw,
text: '',
filters: { kinds: [], languages: [], paths: [], names: [] },
results: wireList<WireSearchResult>([], 0),
groups: [],
};
}
+132
View File
@@ -0,0 +1,132 @@
/**
* The one open handle on the project's index.
*
* `CodeGraph.openSync` costs tens of milliseconds and runs pending migrations,
* so it happens once for the life of the server rather than once per request.
* That leaves two things this module has to get right:
*
* - **A missing index is guidance, not a stack trace.** `codegraph ui` refuses
* to start without one, but a user can delete `.codegraph/` while the viewer
* is open, so every endpoint has to be able to say so in the same words the
* CLI does.
* - **A re-index must not be served from a phantom database.** `codegraph init`
* on an already-indexed project *replaces the database file* (see
* `CodeGraph.recreate`). On POSIX our handle would keep reading the unlinked
* inode and happily serve a graph that no longer exists on disk. So the file
* identity is re-checked on acquisition — one `stat` — and a swapped file
* reopens the connection.
*/
import * as fs from 'fs';
import { CodeGraph } from '../../index';
import { getDatabasePath } from '../../db';
import { isInitialized } from '../../directory';
import { ApiError } from './respond';
/** Identity of the database file, so a swap underneath us is detectable. */
interface FileIdentity {
ino: number;
birthtimeMs: number;
}
function identify(dbPath: string): FileIdentity | null {
try {
const st = fs.statSync(dbPath);
return { ino: st.ino, birthtimeMs: st.birthtimeMs };
} catch {
return null;
}
}
function sameFile(a: FileIdentity | null, b: FileIdentity | null): boolean {
if (a === null || b === null) return false;
// `ino` is 0 on a few Windows filesystems; birthtime alone still catches a
// recreate there, and a false "changed" only costs one reopen.
return a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
}
/**
* Guidance shown when there is no index to read. Deliberately the same three
* facts the CLI prints: the viewer never creates an index, `codegraph init`
* does, and you can point the viewer somewhere already indexed.
*/
function noIndexError(projectRoot: string): ApiError {
return new ApiError(
'no-index',
`No CodeGraph index found for ${projectRoot}.`,
'The viewer reads an index that already exists — it never creates one. ' +
'Run "codegraph init" in that project, or start the viewer against a project ' +
'that has one: codegraph ui /path/to/indexed/project'
);
}
/**
* Holds the project's `CodeGraph` open for the life of the server.
*
* Not thread-safe and does not need to be: `node:http` dispatches on one
* thread, and every read below is synchronous.
*/
export class GraphSession {
readonly projectRoot: string;
private readonly dbPath: string;
private cg: CodeGraph | null = null;
private identity: FileIdentity | null = null;
constructor(projectRoot: string) {
this.projectRoot = projectRoot;
this.dbPath = getDatabasePath(projectRoot);
}
/**
* The open graph, opening (or reopening) it if needed.
*
* @throws {ApiError} `no-index` when the project has no index,
* `index-unusable` when it has one that will not open.
*/
acquire(): CodeGraph {
const current = identify(this.dbPath);
if (this.cg !== null) {
if (sameFile(this.identity, current)) return this.cg;
// The database was replaced (a re-index) or removed. Drop the stale
// handle; falling through re-opens against whatever is there now.
this.closeQuietly();
}
if (!isInitialized(this.projectRoot)) throw noIndexError(this.projectRoot);
try {
this.cg = CodeGraph.openSync(this.projectRoot);
} catch (err) {
this.cg = null;
this.identity = null;
throw new ApiError(
'index-unusable',
`The CodeGraph index for ${this.projectRoot} could not be opened: ` +
(err instanceof Error ? err.message : String(err)),
'If another CodeGraph process is rebuilding it, wait for that to finish. ' +
'If the index is damaged, rebuild it with "codegraph init".'
);
}
this.identity = current ?? identify(this.dbPath);
return this.cg;
}
/** Release the handle. Idempotent — the CLI calls it on Ctrl-C. */
close(): void {
this.closeQuietly();
}
private closeQuietly(): void {
const cg = this.cg;
this.cg = null;
this.identity = null;
if (!cg) return;
try {
cg.close();
} catch {
// A close that fails has nothing left to release — the process is either
// exiting or the file is already gone. Never let it fail a request.
}
}
}
+277
View File
@@ -0,0 +1,277 @@
/**
* `GET /api/source?file=&from=&to=` — verbatim source, or an honest refusal.
*
* This is the one endpoint that reads the user's repository, so two rules
* govern it and neither is negotiable.
*
* **Every read goes through `resolveProjectFile`.** That is the chokepoint from
* `security.ts` — traversal, in-tree symlinks pointing out of the root,
* absolute paths, sensitive system directories. Without it,
* `?file=../../.ssh/id_rsa` is a credential leak over a port the user opened to
* read their own code.
*
* **A file that changed on disk since it was indexed is never sliced.** The
* viewer asks for line ranges the *index* recorded; if the file moved on since,
* those ranges can point at a different symbol's body, which would be served
* under the requested name and look perfectly plausible. So the bytes are
* hashed and compared against `files.content_hash`, and on a mismatch the slice
* is omitted with `drift: true` — the same call `codegraph_node` makes when it
* says "changed on disk after the last index sync".
*
* Only files that are IN the index are served. That is a tighter boundary than
* the MCP tools take, and it costs the viewer nothing (it only ever renders
* indexed symbols) while making the drift verdict meaningful for every answer:
* there is always a hash to compare against.
*/
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import type { FileRecord } from '../../types';
import type { CodeGraph } from '../../index';
import { resolveProjectFile } from '../security';
import { ApiError, badRequest, intParam, notFound, textParam } from './respond';
/**
* Largest file we will read to answer a source request.
*
* The whole file has to be read to hash it, so this bounds the work one request
* can cause. Well above the 1 MB ceiling extraction itself applies, so anything
* actually in the index is comfortably inside it.
*/
export const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
/** Lines returned in one response. The Symbol view asks for windows, not files. */
export const MAX_SOURCE_LINES = 4000;
/**
* Look up a file record by a viewer-supplied path, WITHOUT validating it.
*
* Indexed paths are normalized to forward slashes at extraction time, so that
* is the form tried first; the platform-separator form is a fallback for an
* index written before that normalization.
*
* Callers that go on to READ the file must use {@link resolveRequestedFile}
* instead — it puts the path through the security chokepoint first. This one is
* for endpoints that only need the record (a drift flag on a path the index
* itself handed us).
*/
export function findIndexedFile(
cg: CodeGraph,
requested: string
): { record: FileRecord; storedPath: string } | null {
const posix = toRequestPath(requested);
const record = cg.getFile(posix);
if (record) return { record, storedPath: posix };
const native = posix.split('/').join(path.sep);
if (native !== posix) {
const legacy = cg.getFile(native);
if (legacy) return { record: legacy, storedPath: native };
}
return null;
}
/**
* Forward slashes and no leading `./` — the form indexed paths are stored in.
*
* A LEADING SLASH IS LEFT ALONE on purpose. Stripping it would quietly turn
* `/etc/passwd` into the project-relative `etc/passwd` and answer "not in this
* index" — reinterpreting the request instead of refusing it, and leaving the
* chokepoint's absolute-path rule with nothing to catch.
*/
export function toRequestPath(requested: string): string {
return requested.replace(/\\/g, '/').replace(/^\.\//, '');
}
/**
* Validate a viewer-supplied path, THEN look it up in the index.
*
* The order is the point. `resolveProjectFile` runs first, so a traversal, an
* absolute path or a sensitive system directory is refused as what it is,
* before the index is consulted — a 403 that says "outside the project", not a
* 404 that says "not indexed" and quietly depends on the index lookup missing.
* It also means the absolute path every reader uses has already been through
* the chokepoint by construction, rather than by remembering to call it.
*
* @throws {PathRefusalError} the path is not one we would ever read.
* @throws {ApiError} `not-found` when it is fine but not in the index.
*/
export function resolveRequestedFile(
cg: CodeGraph,
projectRoot: string,
requested: string
): { record: FileRecord; storedPath: string; absolute: string } {
const posix = toRequestPath(requested);
// Refusals happen here, ahead of everything.
const absolute = resolveProjectFile(projectRoot, posix);
const found = findIndexedFile(cg, posix);
if (!found) throw notIndexedError(posix);
return { ...found, absolute };
}
export function notIndexedError(file: string): ApiError {
return notFound(
`${file} is not in this CodeGraph index.`,
'The viewer only reads files the index knows about. If the file is new, ' +
'it appears after the next sync; if it is excluded (gitignored, generated, ' +
'or too large to parse), it will not appear at all.'
);
}
/**
* Split source the way the index counted it.
*
* Rows are `\n`-delimited — that is how tree-sitter numbers them — so a CRLF
* file has the same line numbers here as in the graph. The trailing `\r` is
* dropped per line so it does not render as a stray glyph.
*/
export function splitLines(content: string): string[] {
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i] as string;
if (line.endsWith('\r')) lines[i] = line.slice(0, -1);
}
// A file ending in a newline splits to a final empty string that is not a
// line of source. Every other trailing empty line IS one.
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
return lines;
}
/**
* Whether an indexed file has changed on disk since it was indexed — the same
* verdict `/api/source` returns, for endpoints that must *flag* drift without
* serving source (a symbol header, a file outline).
*
* Cheap first: size plus floored mtime is the identical freshness test the sync
* fast path uses, so an untouched file costs one `stat`. Only a stat mismatch
* pays for a hash, which is what keeps a `touch` or a checkout that rewrote
* identical bytes from reading as drift.
*
* Any failure answers `false`. A wrong "stale" flag would put a warning banner
* over correct source; the cases that would trip it (missing record, unreadable
* file) have their own handling in the endpoints that actually read.
*/
export function hasDriftedOnDisk(
projectRoot: string,
storedPath: string,
record: FileRecord
): boolean {
try {
const absolute = resolveProjectFile(projectRoot, storedPath);
const stats = fs.statSync(absolute);
if (stats.size === record.size && Math.floor(stats.mtimeMs) === Math.floor(record.modifiedAt)) {
return false;
}
if (stats.size > MAX_SOURCE_BYTES) return true;
const content = fs.readFileSync(absolute, 'utf-8');
return createHash('sha256').update(content).digest('hex') !== record.contentHash;
} catch {
return false;
}
}
export interface SourceResult {
file: string;
language: string;
/** The file on disk differs from what was indexed — no slice is served. */
drift: boolean;
contentHash: string;
indexedAt: number;
generated: boolean;
totalLines: number | null;
from?: number;
to?: number;
lines?: string[];
truncated?: boolean;
reason?: string;
}
export function buildSource(
cg: CodeGraph,
projectRoot: string,
query: URLSearchParams
): SourceResult {
const requested = textParam(query, 'file');
// Refusal first, index lookup second — see `resolveRequestedFile`.
const { record, storedPath, absolute } = resolveRequestedFile(cg, projectRoot, requested);
const from = intParam(query, 'from', { min: 1, max: 5_000_000, default: 1 });
const to = intParam(query, 'to', { min: 1, max: 5_000_000, default: 0 });
if (to !== 0 && to < from) {
throw badRequest(`Parameter "to" (${to}) must not be before "from" (${from}).`);
}
const base: SourceResult = {
file: storedPath.replace(/\\/g, '/'),
language: record.language,
drift: false,
contentHash: record.contentHash,
indexedAt: record.indexedAt,
generated: record.generated === true,
totalLines: null,
};
let stats: fs.Stats;
try {
stats = fs.statSync(absolute);
} catch {
// Indexed but gone. That IS drift, and the strongest kind: nothing on disk
// corresponds to the ranges the graph holds.
return { ...base, drift: true, reason: 'The file is in the index but no longer on disk.' };
}
if (stats.size > MAX_SOURCE_BYTES) {
throw badRequest(
`${base.file} is ${Math.round(stats.size / 1024 / 1024)} MB — too large to serve as source.`
);
}
let content: string;
try {
content = fs.readFileSync(absolute, 'utf-8');
} catch (err) {
throw new ApiError(
'internal',
`Could not read ${base.file}: ${err instanceof Error ? err.message : String(err)}`
);
}
// Byte-identical to extraction's `hashContent` (sha256 over the utf-8
// string). A touch or a checkout that rewrote the same bytes must not count
// as drift, which is exactly what hashing content rather than mtime buys.
const hash = createHash('sha256').update(content).digest('hex');
if (hash !== record.contentHash) {
return {
...base,
drift: true,
reason:
'This file changed on disk after the last index sync, so the indexed line ' +
'ranges no longer reliably match. Source is omitted rather than risk showing ' +
"a different symbol's code; it returns after the next sync.",
};
}
const all = splitLines(content);
// Past the end of the file `from` names nothing, which is a caller bug worth
// surfacing rather than answering with the last line as if that were meant.
// `to` past the end is different — "line 30 to the end, whatever that is" is
// an ordinary way to ask, so it clamps.
if (from > all.length) {
throw badRequest(
`Parameter "from" (${from}) is past the end of ${base.file}, which has ${all.length} lines.`
);
}
const start = from;
const requestedEnd = to === 0 ? all.length : Math.min(to, all.length);
const end = Math.min(requestedEnd, start + MAX_SOURCE_LINES - 1);
return {
...base,
totalLines: all.length,
from: start,
to: end,
lines: all.slice(start - 1, end),
truncated: end < requestedEnd,
};
}
+63
View File
@@ -0,0 +1,63 @@
/**
* `GET /api/stats` — what this index is, and how much to trust it.
*
* The viewer's top bar shows a couple of numbers from here, but the reason the
* endpoint carries more than that is honesty: an index can be truncated
* (`state: "indexing"` after a killed run), built by an older extractor, or
* simply old. A reader that draws confident graphs over a half-built index is
* the failure mode worth designing against, so the state travels with the
* counts rather than being something the UI has to ask for separately.
*/
import * as path from 'path';
import type { CodeGraph } from '../../index';
import { HUB_THRESHOLD, UNCERTAIN_BELOW } from './wire';
export function buildStats(cg: CodeGraph, projectRoot: string): unknown {
const stats = cg.getStats();
const build = cg.getIndexBuildInfo();
return {
project: {
root: projectRoot,
name: path.basename(projectRoot) || projectRoot,
},
index: {
/**
* `complete` is the only good value. `indexing` means a run was killed
* part-way and the graph on disk is a truncated one; `partial`/`failed`
* mean the run finished but dropped files. `null` predates the marker.
*/
state: cg.getIndexState(),
lastIndexedAt: cg.getLastIndexedAt(),
/** Built by an older extractor — a re-index would add data no migration can. */
stale: cg.isIndexStale(),
version: build.version,
extractionVersion: build.extractionVersion,
backend: cg.getBackend(),
journalMode: cg.getJournalMode(),
/** References still waiting to resolve; > 0 means edges are still missing. */
pendingReferences: cg.getPendingReferenceCount(),
generatedFiles: cg.getGeneratedFileCount(),
watching: cg.isWatching(),
watcherDegraded: cg.isWatcherDegraded(),
},
graph: {
nodes: stats.nodeCount,
edges: stats.edgeCount,
files: stats.fileCount,
nodesByKind: stats.nodesByKind,
edgesByKind: stats.edgesByKind,
filesByLanguage: stats.filesByLanguage,
dbSizeBytes: stats.dbSizeBytes,
walSizeBytes: stats.walSizeBytes,
},
frameworks: cg.getDetectedFrameworks(),
/**
* The thresholds the API itself applied, so the viewer's copy ("hub · N",
* "confidence < 0.6") stays in step with the data instead of hard-coding a
* second copy of the same numbers.
*/
thresholds: { hub: HUB_THRESHOLD, uncertainBelow: UNCERTAIN_BELOW },
};
}
+330
View File
@@ -0,0 +1,330 @@
/**
* The wire shapes the viewer reads, and the rules for producing them.
*
* Two ideas run through this file:
*
* 1. **One round-trip per screen.** Every endpoint returns everything a screen
* draws, in the spirit of `codegraph_explore`: the Symbol view never has to
* ask a follow-up question to render a rail, a badge or a count.
* 2. **Capped lists, honest totals.** A symbol with 545 callers cannot ship 545
* rows, but it must never claim it has fewer. Every capped list carries the
* true `total` beside the `shown` slice, so the UI can say "+N more" rather
* than quietly truncating.
*
* Nothing here reads the filesystem — that lives in `source.ts`, behind
* `resolveProjectFile`.
*/
import type { Edge, EdgeKind, Language, Node, NodeKind } from '../../types';
import { isTestFile } from '../../search/query-utils';
// =============================================================================
// Caps and thresholds
// =============================================================================
/**
* Fan-in at or above which a symbol is a "hub" — changing it is a
* repo-wide event. Matches the threshold the Symbol view's `hub · N` badge
* uses (design spec §3.2).
*/
export const HUB_THRESHOLD = 40;
/**
* Below this resolution confidence an edge is a name-only guess. The viewer
* folds these away behind "Uncertain · N name-only matches, confidence < 0.6"
* rather than mixing them into the rails as if they were resolved.
*/
export const UNCERTAIN_BELOW = 0.6;
/** Caller groups (one per calling symbol) returned for a node. */
export const MAX_INCOMING_GROUPS = 300;
/** Callee groups (one per called symbol) returned for a node. */
export const MAX_OUTGOING_GROUPS = 200;
/** Edges kept inside a single group — one symbol calling another 400 times. */
export const MAX_EDGES_PER_GROUP = 40;
/** Test files named in a node's test-caller summary (explore uses the same shape). */
export const MAX_TEST_FILES = 6;
/** Caller hops walked looking for a test. Mirrors `codegraph_explore`'s "tests:" line. */
export const TEST_CALLER_HOPS = 3;
/** `getCallers` lookups the test walk may spend, so a god-symbol can't stall a request. */
export const TEST_CALLER_BUDGET = 64;
/** Unresolved references listed by name before the payload just counts them. */
export const MAX_OUTSIDE_INDEX_SAMPLES = 40;
/** Symbols in a file outline. Beyond this the outline is truncated, not dropped. */
export const MAX_OUTLINE_NODES = 3000;
/** Files listed in each direction of the File view's import rails. */
export const MAX_IMPORT_FILES = 300;
// =============================================================================
// Node shapes
// =============================================================================
/**
* A symbol as it appears in a rail, an outline or a search result: enough to
* draw a row and navigate to it, and nothing else. Deliberately excludes the
* docstring — a 300-caller rail would otherwise ship 300 docstrings.
*/
export interface WireNodeRef {
id: string;
kind: NodeKind;
name: string;
qualifiedName: string;
/** Project-relative, forward slashes on every platform. */
file: string;
line: number;
endLine: number;
language: Language;
signature?: string;
exported?: boolean;
/** The file this symbol lives in looks like test/fixture code. */
test: boolean;
}
/** The focal symbol of a Symbol view — the ref, plus everything the header shows. */
export interface WireNodeDetail extends WireNodeRef {
startColumn: number;
endColumn: number;
docstring?: string;
visibility?: string;
async?: boolean;
static?: boolean;
abstract?: boolean;
decorators?: string[];
typeParameters?: string[];
returnType?: string;
/** `endLine - line + 1`, so the header can print "N lines" without the source. */
lines: number;
}
const rel = (p: string): string => p.replace(/\\/g, '/');
export function toNodeRef(node: Node): WireNodeRef {
const file = rel(node.filePath);
const ref: WireNodeRef = {
id: node.id,
kind: node.kind,
name: node.name,
qualifiedName: node.qualifiedName,
file,
line: node.startLine,
endLine: node.endLine,
language: node.language,
test: isTestFile(file),
};
if (node.signature) ref.signature = node.signature;
if (node.isExported) ref.exported = true;
return ref;
}
export function toNodeDetail(node: Node): WireNodeDetail {
const detail: WireNodeDetail = {
...toNodeRef(node),
startColumn: node.startColumn,
endColumn: node.endColumn,
lines: Math.max(1, node.endLine - node.startLine + 1),
};
if (node.docstring) detail.docstring = node.docstring;
if (node.visibility) detail.visibility = node.visibility;
if (node.isAsync) detail.async = true;
if (node.isStatic) detail.static = true;
if (node.isAbstract) detail.abstract = true;
if (node.decorators?.length) detail.decorators = node.decorators;
if (node.typeParameters?.length) detail.typeParameters = node.typeParameters;
if (node.returnType) detail.returnType = node.returnType;
return detail;
}
// =============================================================================
// Edge shapes
// =============================================================================
/**
* One edge, flattened.
*
* `metadata` is a free-form JSON blob in the schema; the fields lifted out here
* are the ones the viewer draws with — confidence decides the uncertain fold,
* `provenance`/`synthesizedBy`/`via`/`registeredAt` decide how a connector is
* dashed and what the "via <mechanism>" pill says, `valueRef` distinguishes
* "passes as value" from "calls". Anything else in the blob stays out: it is
* resolver bookkeeping, not something a reader can act on.
*/
export interface WireEdge {
kind: EdgeKind;
line?: number;
col?: number;
confidence?: number;
resolvedBy?: string;
provenance?: string;
synthesizedBy?: string;
via?: string;
registeredAt?: string;
valueRef?: boolean;
}
export function toWireEdge(edge: Edge): WireEdge {
const meta = (edge.metadata ?? {}) as Record<string, unknown>;
const wire: WireEdge = { kind: edge.kind };
if (typeof edge.line === 'number') wire.line = edge.line;
if (typeof edge.column === 'number') wire.col = edge.column;
if (typeof meta.confidence === 'number') wire.confidence = meta.confidence;
if (typeof meta.resolvedBy === 'string') wire.resolvedBy = meta.resolvedBy;
if (edge.provenance) wire.provenance = edge.provenance;
if (typeof meta.synthesizedBy === 'string') wire.synthesizedBy = meta.synthesizedBy;
if (typeof meta.via === 'string') wire.via = meta.via;
if (typeof meta.registeredAt === 'string') wire.registeredAt = meta.registeredAt;
if (meta.valueRef === true) wire.valueRef = true;
return wire;
}
// =============================================================================
// Relations — edges grouped by the symbol at the other end
// =============================================================================
/**
* Every edge between the focal symbol and ONE other symbol, as a single row.
*
* Grouping is what makes the rails readable: a helper called from eleven lines
* of the same function is one row with eleven call-site chips, not eleven rows.
*/
export interface WireRelation {
node: WireNodeRef;
/** Distinct edge kinds between the two, in first-seen order. */
edgeKinds: EdgeKind[];
/** Up to {@link MAX_EDGES_PER_GROUP} edges, ordered by line. */
edges: WireEdge[];
/** True number of edges, even when `edges` was capped. */
edgeCount: number;
/** Distinct call-site lines, ascending — what the gutter ports anchor to. */
lines: number[];
/** Highest confidence any edge in the group carries; null when none does. */
confidence: number | null;
/** The whole group is a name-only guess (see {@link UNCERTAIN_BELOW}). */
uncertain: boolean;
/** At least one edge was synthesized rather than parsed (dynamic dispatch). */
synthesized: boolean;
/** Fan-in of the other symbol — the `hub · N` pill. Only filled where the UI shows it. */
fanIn?: number;
hub?: boolean;
}
/** A capped list that still knows how long it really is. */
export interface WireList<T> {
total: number;
shown: number;
truncated: boolean;
items: T[];
}
export function wireList<T>(items: T[], total: number): WireList<T> {
return { total, shown: items.length, truncated: items.length < total, items };
}
/**
* Fold edges into one relation per counterpart symbol.
*
* @param edges edges all sharing the focal node at one end
* @param endpoint which end of each edge names the OTHER symbol
* @param nodes batch-resolved endpoint nodes (never a lookup per edge)
*/
export function groupRelations(
edges: readonly Edge[],
endpoint: (edge: Edge) => string,
nodes: Map<string, Node>
): WireRelation[] {
const byNode = new Map<string, Edge[]>();
for (const edge of edges) {
const id = endpoint(edge);
const bucket = byNode.get(id);
if (bucket) bucket.push(edge);
else byNode.set(id, [edge]);
}
const relations: WireRelation[] = [];
for (const [id, group] of byNode) {
const node = nodes.get(id);
// An edge whose endpoint is missing from `nodes` means the graph and the
// node table disagree — skip it rather than invent a row. Callers still see
// it in the totals they computed from the raw edge list.
if (!node) continue;
const ordered = [...group].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
const wireEdges = ordered.slice(0, MAX_EDGES_PER_GROUP).map(toWireEdge);
const edgeKinds: EdgeKind[] = [];
for (const edge of ordered) if (!edgeKinds.includes(edge.kind)) edgeKinds.push(edge.kind);
const lines = [
...new Set(ordered.map((e) => e.line).filter((l): l is number => typeof l === 'number' && l > 0)),
].sort((a, b) => a - b);
let confidence: number | null = null;
let synthesized = false;
for (const edge of ordered) {
const value = (edge.metadata as Record<string, unknown> | undefined)?.confidence;
if (typeof value === 'number' && (confidence === null || value > confidence)) confidence = value;
if (edge.provenance === 'heuristic') synthesized = true;
}
relations.push({
node: toNodeRef(node),
edgeKinds,
edges: wireEdges,
edgeCount: ordered.length,
lines,
confidence,
// No confidence recorded is NOT uncertain: tree-sitter edges extracted
// straight from the AST carry none precisely because they are certain.
uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
synthesized,
});
}
return relations;
}
/** First call-site line of a relation, for line-anchored ordering. Unlined rows sort last. */
export function firstLine(relation: WireRelation): number {
return relation.lines[0] ?? Number.MAX_SAFE_INTEGER;
}
/** Node kinds that count as "a type" for the Symbol view's "types used" chips. */
export const TYPE_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
'interface',
'type_alias',
'class',
'struct',
'enum',
'union',
'trait',
'protocol',
]);
/** Container kinds whose members the outline nests one level deeper. */
export const CONTAINER_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
'file',
'module',
'namespace',
'class',
'struct',
'interface',
'trait',
'protocol',
'enum',
'union',
]);
/** The four edge kinds `getCallers` treats as "reaches this symbol". */
export const CALLER_EDGE_KINDS: ReadonlySet<EdgeKind> = new Set<EdgeKind>([
'calls',
'references',
'imports',
'instantiates',
]);
export { rel as toPosixPath };