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:
co-authored by
Claude Opus 5
parent
41a90c6ba4
commit
951ba3678a
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user