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
@@ -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 };
|
||||
Reference in New Issue
Block a user