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
+54
View File
@@ -23,6 +23,7 @@ import {
TaskContext,
BuildContextOptions,
FindRelevantContextOptions,
UnresolvedReference,
} from './types';
import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db';
import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve';
@@ -1323,6 +1324,59 @@ export class CodeGraph {
return this.queries.getNodeById(id);
}
/**
* Get many nodes by id in ONE round-trip (LRU-cache aware).
*
* The batch form of {@link getNode}. Anything resolving a list of edges to
* their endpoints — a caller list, a callee rail, an impact set — must use
* this rather than a `getNode` per edge: a symbol with 500 callers is 500
* queries otherwise. Ids that name nothing are simply absent from the map.
*/
getNodesByIds(ids: readonly string[]): Map<string, Node> {
return this.queries.getNodesByIds(ids);
}
/**
* Outgoing edges for many source nodes at once — the batch form of
* {@link getOutgoingEdges}. See {@link QueryBuilder.getOutgoingEdgesFrom}.
*/
getOutgoingEdgesFrom(nodeIds: readonly string[], kinds?: Edge['kind'][]): Edge[] {
return this.queries.getOutgoingEdgesFrom(nodeIds, kinds);
}
/**
* Fan-in (incoming edge count) for many nodes at once — the "hub" signal,
* without a query per node. See {@link QueryBuilder.countIncomingEdges}.
*/
getFanIn(ids: readonly string[]): Map<string, number> {
return this.queries.countIncomingEdges(ids);
}
/**
* Incoming edges for many target nodes at once — the mirror of
* {@link getOutgoingEdgesFrom}. See {@link QueryBuilder.getIncomingEdgesTo}.
*/
getIncomingEdgesTo(nodeIds: readonly string[], kinds?: Edge['kind'][]): Edge[] {
return this.queries.getIncomingEdgesTo(nodeIds, kinds);
}
/**
* Fan-out (outgoing edge count) for many nodes at once — the mirror of
* {@link getFanIn}. See {@link QueryBuilder.countOutgoingEdges}.
*/
getFanOut(ids: readonly string[]): Map<string, number> {
return this.queries.countOutgoingEdges(ids);
}
/**
* References from a symbol that never resolved to an indexed node — the
* calls and type mentions that leave the index. Lets a reader account for
* the call sites that have no callee row instead of implying there are none.
*/
getUnresolvedReferencesFrom(nodeId: string): UnresolvedReference[] {
return this.queries.getUnresolvedReferencesFrom(nodeId);
}
/**
* Get all nodes in a file
*/