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
+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 };