Преглед на файлове

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>
Colby McHenry преди 1 седмица
родител
ревизия
951ba3678a

+ 947 - 0
__tests__/ui-server-api.test.ts

@@ -0,0 +1,947 @@
+/**
+ * The `codegraph ui` read-only JSON API (CG-42).
+ *
+ * Everything runs against a real indexed fixture project over a real loopback
+ * server — no mocks — because the properties worth pinning are the ones that
+ * only exist end to end: the drift verdict comes from hashing bytes on disk
+ * against what the index stored, the refusals come from the same chokepoint the
+ * static server uses, and the caps only matter once a symbol really does have
+ * hundreds of callers.
+ *
+ * The fixture is built to produce each of those: a call chain three deep, a
+ * test file that reaches it, a type used only as a type, an import that cannot
+ * resolve, and one deliberately hot function with 500 callers.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+
+interface Response {
+  status: number;
+  headers: http.IncomingHttpHeaders;
+  body: string;
+}
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+let viewerDir: string;
+
+/**
+ * One request against a live server, with the loopback `Host` the boundary
+ * wants. Written with `http.request` rather than `fetch` so the `Host` header
+ * is ours to set — undici treats it as forbidden.
+ */
+function requestOn(port: number, requestPath: string, method = 'GET'): Promise<Response> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port,
+        path: requestPath,
+        method,
+        headers: { Host: `127.0.0.1:${port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            headers: res.headers,
+            body: Buffer.concat(chunks).toString('utf-8'),
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+/** The same, against the main fixture's server. */
+function request(requestPath: string, method = 'GET'): Promise<Response> {
+  return requestOn(server.port, requestPath, method);
+}
+
+/**
+ * Payloads are read as `any` on purpose: these tests assert the JSON contract
+ * the viewer sees over the wire, so typing them against the server's own
+ * interfaces would only prove the server agrees with itself.
+ */
+async function getJson(requestPath: string): Promise<any> {
+  const res = await request(requestPath);
+  expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
+  return JSON.parse(res.body);
+}
+
+async function getStatusAndJson(requestPath: string): Promise<{ status: number; body: any }> {
+  const res = await request(requestPath);
+  expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
+  return { status: res.status, body: JSON.parse(res.body) };
+}
+
+/** Find a symbol in the fixture by name, through the API itself. */
+async function idOf(name: string, kind?: string): Promise<string> {
+  const search = await getJson(`/api/search?q=${encodeURIComponent(name)}`);
+  const hit = search.results.items.find(
+    (r: any) => r.name === name && (kind === undefined || r.kind === kind)
+  );
+  expect(hit, `no ${kind ?? 'symbol'} named ${name} in the fixture`).toBeTruthy();
+  return hit.id as string;
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-api-'));
+  projectRoot = path.join(tempDir, 'project');
+  const srcDir = path.join(projectRoot, 'src');
+  const testsDir = path.join(projectRoot, '__tests__');
+  fs.mkdirSync(srcDir, { recursive: true });
+  fs.mkdirSync(testsDir, { recursive: true });
+
+  fs.writeFileSync(
+    path.join(srcDir, 'types.ts'),
+    `export interface Config {
+  ttlMs: number;
+  label: string;
+}
+
+export type CacheKey = string;
+`
+  );
+
+  fs.writeFileSync(
+    path.join(srcDir, 'cache.ts'),
+    `import { Config, CacheKey } from './types';
+
+export class Cache {
+  private store = new Map<string, string>();
+  private config: Config;
+
+  constructor(config: Config) {
+    this.config = config;
+  }
+
+  read(key: CacheKey): string | undefined {
+    return this.store.get(key);
+  }
+
+  write(key: CacheKey, value: string): void {
+    this.store.set(key, value);
+  }
+}
+`
+  );
+
+  fs.writeFileSync(
+    path.join(srcDir, 'service.ts'),
+    `import { Cache } from './cache';
+import { Config } from './types';
+// Not in the index: a package that was never installed here.
+import { serialize } from 'some-external-package';
+
+export class Service {
+  private cache: Cache;
+
+  constructor(config: Config) {
+    this.cache = new Cache(config);
+  }
+
+  load(key: string): string {
+    const hit = this.cache.read(key);
+    if (hit !== undefined) return hit;
+    const fresh = serialize(key);
+    this.cache.write(key, fresh);
+    return fresh;
+  }
+}
+`
+  );
+
+  fs.writeFileSync(
+    path.join(srcDir, 'handler.ts'),
+    `import { Service } from './service';
+
+export function handleRequest(service: Service, key: string): string {
+  return service.load(key);
+}
+`
+  );
+
+  // 500 callers into one function: the N+1 and capping behaviour only shows up
+  // at this scale, and the fixture keeps CI honest without needing the engine's
+  // own index to be present.
+  const callers = Array.from(
+    { length: 500 },
+    (_, i) => `export function caller${i}(): number {\n  return hot(${i});\n}`
+  ).join('\n\n');
+  fs.writeFileSync(
+    path.join(srcDir, 'hot.ts'),
+    `export function hot(n: number): number {
+  return n * 2;
+}
+
+${callers}
+`
+  );
+
+  fs.writeFileSync(
+    path.join(testsDir, 'service.test.ts'),
+    `import { Service } from '../src/service';
+
+export function testLoadsThroughCache(): void {
+  const service = new Service({ ttlMs: 1, label: 'x' });
+  service.load('k');
+}
+`
+  );
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  // Hand the index over: the API opens its own read-only connection, which is
+  // also what happens in production (the CLI never shares an instance).
+  cg.close();
+
+  viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  await server?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('GET /api', () => {
+  it('lists the endpoints it answers', async () => {
+    const body = await getJson('/api');
+    expect(body.readOnly).toBe(true);
+    const paths = body.endpoints.map((e: any) => e.path);
+    expect(paths).toEqual(
+      expect.arrayContaining([
+        '/api/stats',
+        '/api/search',
+        '/api/node/<id>',
+        '/api/source',
+        '/api/file/<path>',
+        '/api/routes',
+      ])
+    );
+  });
+
+  it('404s an unknown endpoint as JSON, never as the app shell', async () => {
+    const { status, body } = await getStatusAndJson('/api/nope');
+    expect(status).toBe(404);
+    expect(body.code).toBe('not-found');
+  });
+
+  it('answers HEAD with the headers and no body', async () => {
+    const res = await request('/api/stats', 'HEAD');
+    expect(res.status).toBe(200);
+    expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
+    expect(Number(res.headers['content-length'])).toBeGreaterThan(0);
+    expect(res.body).toBe('');
+  });
+});
+
+describe('GET /api/stats', () => {
+  it('reports the project, the index state and the graph counts', async () => {
+    const body = await getJson('/api/stats');
+
+    expect(body.project.root).toBe(projectRoot);
+    expect(body.project.name).toBe('project');
+
+    expect(body.index.state).toBe('complete');
+    expect(body.index.stale).toBe(false);
+    expect(typeof body.index.lastIndexedAt).toBe('number');
+    expect(body.index.backend).toBe('node-sqlite');
+    expect(typeof body.index.extractionVersion).toBe('number');
+
+    expect(body.graph.nodes).toBeGreaterThan(0);
+    expect(body.graph.edges).toBeGreaterThan(0);
+    expect(body.graph.files).toBeGreaterThanOrEqual(6);
+    expect(body.graph.nodesByKind.class).toBeGreaterThanOrEqual(2);
+    expect(body.graph.filesByLanguage.typescript).toBeGreaterThanOrEqual(6);
+
+    // The thresholds travel with the data so the viewer's copy cannot drift.
+    expect(body.thresholds).toEqual({ hub: 40, uncertainBelow: 0.6 });
+  });
+});
+
+describe('GET /api/search', () => {
+  it('ranks exact over prefix over substring, and groups by kind', async () => {
+    const body = await getJson('/api/search?q=Cache');
+
+    const first = body.results.items[0];
+    expect(first.name).toBe('Cache');
+    expect(first.kind).toBe('class');
+    expect(first.matchKind).toBe('exact');
+
+    const ranks = body.results.items.map((r: any) => r.matchKind);
+    const order = ['exact', 'prefix', 'substring', 'qualified', 'file', 'related'];
+    const asNumbers = ranks.map((r: string) => order.indexOf(r));
+    expect(asNumbers).toEqual([...asNumbers].sort((a, b) => a - b));
+
+    // Flattening the groups reproduces the flat ranking, so the palette can use
+    // either without them disagreeing.
+    const flattened = body.groups.flatMap((g: any) => g.items.map((i: any) => i.id));
+    expect(new Set(flattened)).toEqual(new Set(body.results.items.map((r: any) => r.id)));
+    for (const group of body.groups) expect(group.count).toBe(group.items.length);
+  });
+
+  it('returns a signature and a file:line for every result', async () => {
+    const body = await getJson('/api/search?q=handleRequest');
+    const hit = body.results.items.find((r: any) => r.name === 'handleRequest');
+    expect(hit.file).toBe('src/handler.ts');
+    expect(hit.line).toBeGreaterThan(0);
+    expect(hit.endLine).toBeGreaterThanOrEqual(hit.line);
+    expect(hit.signature).toContain('service');
+    expect(hit.qualifiedName).toBeTruthy();
+    expect(hit.language).toBe('typescript');
+  });
+
+  it('finds a mid-name match FTS tokens cannot', async () => {
+    const body = await getJson('/api/search?q=quest');
+    const names = body.results.items.map((r: any) => r.name);
+    expect(names).toContain('handleRequest');
+    const hit = body.results.items.find((r: any) => r.name === 'handleRequest');
+    expect(hit.matchKind).toBe('substring');
+  });
+
+  it('honours the kind: filter grammar', async () => {
+    const body = await getJson('/api/search?q=' + encodeURIComponent('kind:class Cache'));
+    expect(body.filters.kinds).toEqual(['class']);
+    expect(body.results.items.every((r: any) => r.kind === 'class')).toBe(true);
+  });
+
+  it('marks test files so the palette can rank them down', async () => {
+    const body = await getJson('/api/search?q=testLoadsThroughCache');
+    const hit = body.results.items.find((r: any) => r.name === 'testLoadsThroughCache');
+    expect(hit.test).toBe(true);
+  });
+
+  it('answers an empty search box with nothing, and a missing q with 400', async () => {
+    const empty = await getStatusAndJson('/api/search?q=');
+    expect(empty.status).toBe(200);
+    expect(empty.body.results.total).toBe(0);
+    expect(empty.body.groups).toEqual([]);
+
+    const missing = await getStatusAndJson('/api/search');
+    expect(missing.status).toBe(400);
+    expect(missing.body.code).toBe('bad-request');
+  });
+
+  it('returns an empty result set for a name nothing has', async () => {
+    const body = await getJson('/api/search?q=zzznotasymbolanywhere');
+    expect(body.results.total).toBe(0);
+  });
+});
+
+describe('GET /api/node/<id>', () => {
+  it('returns the symbol, its ancestors and its members in source order', async () => {
+    const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
+
+    expect(body.node.name).toBe('Cache');
+    expect(body.node.kind).toBe('class');
+    expect(body.node.file).toBe('src/cache.ts');
+    expect(body.node.lines).toBe(body.node.endLine - body.node.line + 1);
+    expect(body.node.exported).toBe(true);
+
+    // Outermost first: the file, then anything between it and the symbol.
+    expect(body.ancestors[0].kind).toBe('file');
+    expect(body.ancestors[0].file).toBe('src/cache.ts');
+
+    const members = body.members.items.map((m: any) => m.name);
+    expect(members).toEqual(expect.arrayContaining(['read', 'write', 'store', 'config']));
+    const lines = body.members.items.map((m: any) => m.line);
+    expect(lines).toEqual([...lines].sort((a, b) => a - b));
+    for (const member of body.members.items) {
+      expect(member.parentId).toBe(body.node.id);
+      expect(member.depth).toBe(1);
+    }
+    expect(body.members.total).toBe(body.members.shown);
+  });
+
+  it('nests a file outline one level deeper, so a class shows its methods', async () => {
+    const body = await getJson(`/api/node/${await idOf('cache.ts', 'file')}`);
+    const byDepth = new Map<number, string[]>();
+    for (const member of body.members.items) {
+      byDepth.set(member.depth, [...(byDepth.get(member.depth) ?? []), member.name]);
+    }
+    expect(byDepth.get(1)).toContain('Cache');
+    expect(byDepth.get(2)).toEqual(expect.arrayContaining(['read', 'write']));
+  });
+
+  it('groups incoming edges by the calling symbol, with their call sites', async () => {
+    const readId = await idOf('read', 'method');
+    const body = await getJson(`/api/node/${readId}`);
+
+    const fromLoad = body.incoming.items.find((r: any) => r.node.name === 'load');
+    expect(fromLoad, 'Service.load should call Cache.read').toBeTruthy();
+    expect(fromLoad.node.file).toBe('src/service.ts');
+    expect(fromLoad.edgeKinds).toContain('calls');
+    expect(fromLoad.edgeCount).toBeGreaterThanOrEqual(1);
+    expect(fromLoad.lines.length).toBeGreaterThanOrEqual(1);
+    expect(fromLoad.lines).toEqual([...fromLoad.lines].sort((a: number, b: number) => a - b));
+    expect(typeof fromLoad.fanIn).toBe('number');
+    expect(fromLoad.hub).toBe(false);
+  });
+
+  it('carries every edge attribute the viewer draws with', async () => {
+    const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
+    const relation = body.incoming.items.find((r: any) => r.node.name === 'load');
+    const edge = relation.edges[0];
+
+    expect(edge.kind).toBe('calls');
+    expect(typeof edge.line).toBe('number');
+    expect(typeof edge.col).toBe('number');
+    expect(typeof edge.confidence).toBe('number');
+    expect(typeof edge.resolvedBy).toBe('string');
+    // Confidence decides the uncertain fold; the group agrees with its edges.
+    expect(relation.confidence).toBe(
+      Math.max(...relation.edges.map((e: any) => e.confidence ?? -1))
+    );
+    expect(relation.uncertain).toBe(relation.confidence < 0.6);
+    expect(relation.synthesized).toBe(false);
+  });
+
+  it('groups outgoing edges by the called symbol, ordered by call site', async () => {
+    const body = await getJson(`/api/node/${await idOf('load', 'method')}`);
+    const names = body.outgoing.items.map((r: any) => r.node.name);
+    expect(names).toEqual(expect.arrayContaining(['read', 'write']));
+
+    const firstLines = body.outgoing.items
+      .map((r: any) => r.lines[0])
+      .filter((l: number | undefined) => l !== undefined);
+    expect(firstLines).toEqual([...firstLines].sort((a, b) => a - b));
+  });
+
+  it('splits type references out of the callee rail', async () => {
+    // Type edges attach to the MEMBER that names the type, not to its class:
+    // `Service`'s constructor is where `Config` and `new Cache(...)` both live,
+    // which makes it the one place both halves of the split are visible.
+    const service = await getJson(`/api/node/${await idOf('Service', 'class')}`);
+    const ctor = service.members.items.find((m: any) => m.name === 'constructor');
+    const body = await getJson(`/api/node/${ctor.id}`);
+
+    const typeNames = body.typesUsed.map((t: any) => t.node.name);
+    expect(typeNames).toContain('Config');
+    expect(body.typesUsed.every((t: any) => t.edgeKinds.includes('references'))).toBe(true);
+
+    // A type reference is not also a callee row...
+    expect(body.outgoing.items.map((r: any) => r.node.name)).not.toContain('Config');
+    // ...but a class reached by any other edge kind still is: `new Cache(...)`
+    // is an `instantiates` edge, and moving it would hide a real dependency.
+    const instantiated = body.outgoing.items.find((r: any) => r.node.name === 'Cache');
+    expect(instantiated).toBeTruthy();
+    expect(instantiated.edgeKinds).toContain('instantiates');
+  });
+
+  it('summarizes which tests reach the symbol', async () => {
+    const reached = await getJson(`/api/node/${await idOf('load', 'method')}`);
+    expect(reached.tests.reached).toBe(true);
+    expect(reached.tests.hops).toBe(1);
+    expect(reached.tests.files).toContain('__tests__/service.test.ts');
+    expect(reached.tests.fileCount).toBeGreaterThanOrEqual(1);
+    expect(reached.tests.files.length).toBeLessThanOrEqual(6);
+    expect(reached.tests.exhaustive).toBe(true);
+
+    const unreached = await getJson(`/api/node/${await idOf('hot', 'function')}`);
+    expect(unreached.tests.reached).toBe(false);
+    expect(unreached.tests.hops).toBeNull();
+    expect(unreached.tests.files).toEqual([]);
+  });
+
+  it('counts the calls that leave the index instead of hiding them', async () => {
+    const body = await getJson(`/api/node/${await idOf('load', 'method')}`);
+    expect(body.outsideIndex.total).toBeGreaterThan(0);
+    const names = body.outsideIndex.samples.map((s: any) => s.name);
+    expect(names).toContain('serialize');
+    for (const sample of body.outsideIndex.samples) {
+      expect(typeof sample.line).toBe('number');
+      expect(typeof sample.kind).toBe('string');
+    }
+  });
+
+  it('summarizes the blast radius at three hops', async () => {
+    const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
+    expect(body.blast.hops).toBe(3);
+    expect(body.blast.direct).toBe(body.counts.callers);
+    // load → handleRequest / the test both sit inside three hops of Cache.read.
+    expect(body.blast.withinHops).toBeGreaterThan(body.blast.direct);
+    expect(body.blast.files).toBeGreaterThanOrEqual(2);
+    expect(body.blast.testFiles).toBeGreaterThanOrEqual(1);
+    expect(body.blast.routes).toBe(0);
+    expect(body.blast.topFiles[0].symbols).toBeGreaterThanOrEqual(1);
+  });
+
+  it('keeps every count equal to the list it labels', async () => {
+    const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
+    expect(body.counts.callers).toBe(body.incoming.total);
+    expect(body.counts.callees).toBe(body.outgoing.total);
+    expect(body.counts.typesUsed).toBe(body.typesUsed.length);
+    expect(body.counts.members).toBe(body.members.total);
+    expect(body.blast.direct).toBe(body.counts.callers);
+  });
+
+  it('reports fan-in, fan-out and the hub flag', async () => {
+    const quiet = await getJson(`/api/node/${await idOf('write', 'method')}`);
+    expect(quiet.counts.hub).toBe(false);
+    expect(quiet.counts.callers).toBeLessThan(40);
+    expect(quiet.counts.fanIn).toBeGreaterThanOrEqual(quiet.counts.callers);
+
+    const hot = await getJson(`/api/node/${await idOf('hot', 'function')}`);
+    expect(hot.counts.hub).toBe(true);
+    expect(hot.counts.callers).toBeGreaterThanOrEqual(500);
+  });
+
+  it('flags nothing as drifted while the fixture is untouched', async () => {
+    const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
+    expect(body.drift).toBe(false);
+  });
+
+  it('404s an id that names nothing, and 400s an empty one', async () => {
+    const missing = await getStatusAndJson('/api/node/method:notarealid');
+    expect(missing.status).toBe(404);
+    expect(missing.body.code).toBe('not-found');
+    expect(missing.body.hint).toBeTruthy();
+
+    const empty = await getStatusAndJson('/api/node/');
+    expect(empty.status).toBe(400);
+  });
+});
+
+describe('GET /api/node/<id> — the busiest symbol', () => {
+  it('caps the caller list, keeps the true total, and stays fast', async () => {
+    const hotId = await idOf('hot', 'function');
+    await request(`/api/node/${hotId}`); // warm the connection and the caches
+
+    const started = performance.now();
+    const res = await request(`/api/node/${hotId}`);
+    const elapsed = performance.now() - started;
+    expect(res.status).toBe(200);
+
+    const body = JSON.parse(res.body);
+    expect(body.incoming.total).toBeGreaterThanOrEqual(500);
+    expect(body.incoming.shown).toBe(300);
+    expect(body.incoming.truncated).toBe(true);
+    expect(body.incoming.items).toHaveLength(300);
+    // Grouped: one row per calling symbol, each carrying its own call sites.
+    expect(new Set(body.incoming.items.map((r: any) => r.node.id)).size).toBe(300);
+    expect(body.counts.callers).toBeGreaterThanOrEqual(500);
+    expect(body.blast.direct).toBe(body.counts.callers);
+
+    // 500 callers resolved one query at a time would be nowhere near this.
+    expect(elapsed).toBeLessThan(100);
+  });
+});
+
+describe('GET /api/source', () => {
+  it('returns the requested slice with the index line numbering', async () => {
+    const body = await getJson('/api/source?file=src/cache.ts&from=1&to=3');
+    expect(body.drift).toBe(false);
+    expect(body.file).toBe('src/cache.ts');
+    expect(body.language).toBe('typescript');
+    expect(body.from).toBe(1);
+    expect(body.to).toBe(3);
+    expect(body.lines).toHaveLength(3);
+    expect(body.lines[0]).toContain("import { Config, CacheKey } from './types'");
+    expect(body.totalLines).toBeGreaterThan(3);
+    expect(body.truncated).toBe(false);
+  });
+
+  it('serves the whole file when no range is given', async () => {
+    const body = await getJson('/api/source?file=src/handler.ts');
+    expect(body.from).toBe(1);
+    expect(body.to).toBe(body.totalLines);
+    expect(body.lines).toHaveLength(body.totalLines);
+  });
+
+  it('slices exactly the lines a symbol claims', async () => {
+    const node = await getJson(`/api/node/${await idOf('handleRequest', 'function')}`);
+    const body = await getJson(
+      `/api/source?file=${node.node.file}&from=${node.node.line}&to=${node.node.endLine}`
+    );
+    expect(body.lines[0]).toContain('handleRequest');
+    expect(body.lines).toHaveLength(node.node.lines);
+  });
+
+  it('refuses to slice a file that changed on disk after the last sync', async () => {
+    const target = path.join(projectRoot, 'src', 'handler.ts');
+    const original = fs.readFileSync(target);
+    try {
+      fs.writeFileSync(target, Buffer.concat([Buffer.from('// a new first line\n'), original]));
+
+      const body = await getJson('/api/source?file=src/handler.ts&from=1&to=3');
+      expect(body.drift).toBe(true);
+      // The whole point: no slice, rather than a slice of the wrong lines.
+      expect(body.lines).toBeUndefined();
+      expect(body.reason).toContain('changed on disk after the last index sync');
+
+      // And every screen that renders indexed line ranges is told.
+      const node = await getJson(`/api/node/${await idOf('handleRequest', 'function')}`);
+      expect(node.drift).toBe(true);
+      const file = await getJson('/api/file/src/handler.ts');
+      expect(file.drift).toBe(true);
+    } finally {
+      fs.writeFileSync(target, original);
+    }
+  });
+
+  it('does not call an identical rewrite drift', async () => {
+    const target = path.join(projectRoot, 'src', 'handler.ts');
+    const original = fs.readFileSync(target);
+    // Same bytes, new mtime — what a checkout or a formatter no-op looks like.
+    fs.writeFileSync(target, original);
+
+    const body = await getJson('/api/source?file=src/handler.ts&from=1&to=2');
+    expect(body.drift).toBe(false);
+    expect(body.lines).toHaveLength(2);
+  });
+
+  it('refuses a path that escapes the project', async () => {
+    const traversal = await getStatusAndJson(
+      '/api/source?file=' + encodeURIComponent('../../../etc/passwd')
+    );
+    expect(traversal.status).toBe(403);
+    expect(traversal.body.code).toBe('refused');
+
+    const absolute = await getStatusAndJson(
+      '/api/source?file=' + encodeURIComponent('/etc/passwd')
+    );
+    expect(absolute.status).toBe(403);
+    expect(absolute.body.code).toBe('refused');
+    expect(absolute.body.error).toContain('absolute');
+  });
+
+  it('refuses a NUL byte in the path', async () => {
+    const { status, body } = await getStatusAndJson(
+      '/api/source?file=' + encodeURIComponent('src/cache.ts\u0000.png')
+    );
+    expect(status).toBe(403);
+    expect(body.code).toBe('refused');
+  });
+
+  it('404s a file that exists but is not indexed', async () => {
+    fs.writeFileSync(path.join(projectRoot, 'notes.md'), '# not indexed\n');
+    const { status, body } = await getStatusAndJson('/api/source?file=notes.md');
+    expect(status).toBe(404);
+    expect(body.code).toBe('not-found');
+    expect(body.hint).toContain('index');
+  });
+
+  it('rejects a range that names nothing', async () => {
+    const past = await getStatusAndJson('/api/source?file=src/handler.ts&from=99999');
+    expect(past.status).toBe(400);
+    expect(past.body.error).toContain('past the end');
+
+    const backwards = await getStatusAndJson('/api/source?file=src/handler.ts&from=10&to=4');
+    expect(backwards.status).toBe(400);
+
+    const nonNumeric = await getStatusAndJson('/api/source?file=src/handler.ts&from=abc');
+    expect(nonNumeric.status).toBe(400);
+  });
+});
+
+describe('GET /api/file/<path>', () => {
+  it('returns the file record and its outline in source order', async () => {
+    const body = await getJson('/api/file/src/cache.ts');
+
+    expect(body.file.path).toBe('src/cache.ts');
+    expect(body.file.language).toBe('typescript');
+    expect(body.file.size).toBeGreaterThan(0);
+    expect(body.file.contentHash).toMatch(/^[0-9a-f]{64}$/);
+    expect(body.file.generated).toBe(false);
+    expect(body.file.test).toBe(false);
+    expect(body.file.id).toMatch(/^file:/);
+    expect(body.drift).toBe(false);
+
+    const lines = body.outline.items.map((o: any) => o.line);
+    expect(lines).toEqual([...lines].sort((a, b) => a - b));
+
+    const cacheRow = body.outline.items.find((o: any) => o.name === 'Cache');
+    expect(cacheRow.depth).toBe(0);
+    expect(cacheRow.parentId).toBeNull();
+
+    const readRow = body.outline.items.find((o: any) => o.name === 'read');
+    expect(readRow.depth).toBe(1);
+    expect(readRow.parentId).toBe(cacheRow.id);
+    expect(readRow.fanIn).toBeGreaterThanOrEqual(1);
+    expect(typeof readRow.fanOut).toBe('number');
+
+    // The file node is the subject, not a row; imports have their own rail.
+    expect(body.outline.items.some((o: any) => o.kind === 'file')).toBe(false);
+    expect(body.outline.items.some((o: any) => o.kind === 'import')).toBe(false);
+  });
+
+  it('maps imports and imported-by to files', async () => {
+    const body = await getJson('/api/file/src/cache.ts');
+
+    const importedByFiles = body.importedBy.items.map((r: any) => r.file);
+    expect(importedByFiles).toContain('src/service.ts');
+
+    const importFiles = body.imports.items.map((r: any) => r.file);
+    expect(importFiles).toContain('src/types.ts');
+
+    // Never itself: same-file `imports` edges (the import declarations) are dropped.
+    expect(importFiles).not.toContain('src/cache.ts');
+    expect(importedByFiles).not.toContain('src/cache.ts');
+
+    const typesRow = body.imports.items.find((r: any) => r.file === 'src/types.ts');
+    expect(typesRow.symbolCount).toBeGreaterThanOrEqual(1);
+    expect(typesRow.symbols[0].name).toBeTruthy();
+    expect(typesRow.symbols[0].id).toBeTruthy();
+    expect(typesRow.test).toBe(false);
+  });
+
+  it('names the imports that never resolved rather than dropping them', async () => {
+    const body = await getJson('/api/file/src/service.ts');
+    const names = body.unresolvedImports.map((u: any) => u.name);
+    expect(names).toContain('some-external-package');
+  });
+
+  it('reports the wider cross-file relationship too', async () => {
+    const body = await getJson('/api/file/src/cache.ts');
+    expect(body.dependents).toContain('src/service.ts');
+    expect(body.dependencies).toContain('src/types.ts');
+  });
+
+  it('404s a file that is not in the index and refuses one outside the project', async () => {
+    const missing = await getStatusAndJson('/api/file/src/nope.ts');
+    expect(missing.status).toBe(404);
+    expect(missing.body.code).toBe('not-found');
+
+    const outside = await getStatusAndJson(
+      '/api/file/' + encodeURIComponent('/etc/passwd')
+    );
+    expect(outside.status).toBe(403);
+    expect(outside.body.code).toBe('refused');
+  });
+});
+
+describe('GET /api/routes', () => {
+  it('says plainly that this project is not a routed app', async () => {
+    const body = await getJson('/api/routes');
+    expect(body.routed).toBe(false);
+    expect(body.entries).toEqual([]);
+    expect(body.routeCount).toBe(0);
+    expect(body.shown).toBe(0);
+    expect(body.truncated).toBe(false);
+  });
+
+  it('refuses a limit the manifest cannot answer truthfully', async () => {
+    // Below three, the engine's manifest reports every routed project as
+    // unrouted — a wrong answer, so the parameter is refused instead.
+    for (const limit of ['0', '2', '-1', 'abc']) {
+      const { status, body } = await getStatusAndJson(`/api/routes?limit=${limit}`);
+      expect(status, `limit=${limit}`).toBe(400);
+      expect(body.code).toBe('bad-request');
+    }
+  });
+
+  describe('a project that IS routed', () => {
+    let routedApi: GraphApi;
+    let routedServer: UiServerHandle;
+
+    beforeAll(async () => {
+      const routedRoot = path.join(tempDir, 'routed');
+      fs.mkdirSync(path.join(routedRoot, 'src'), { recursive: true });
+      fs.writeFileSync(
+        path.join(routedRoot, 'src', 'routes.ts'),
+        `import express from 'express';
+
+const app = express();
+
+export function listUsers(req: any, res: any): void { res.json([]); }
+export function getUser(req: any, res: any): void { res.json({}); }
+export function createUser(req: any, res: any): void { res.json({}); }
+export function deleteUser(req: any, res: any): void { res.json({}); }
+
+app.get('/users', listUsers);
+app.get('/users/:id', getUser);
+app.post('/users', createUser);
+app.delete('/users/:id', deleteUser);
+
+export default app;
+`
+      );
+      const routedCg = CodeGraph.initSync(routedRoot, {
+        config: { include: ['src/**/*.ts'], exclude: [] },
+      });
+      await routedCg.indexAll();
+      routedCg.resolveReferences();
+      routedCg.close();
+
+      routedApi = createGraphApi({ projectRoot: routedRoot });
+      routedServer = await startUiServer({
+        projectRoot: routedRoot,
+        viewerDir,
+        port: 0,
+        api: routedApi.handler,
+      });
+    }, 120_000);
+
+    afterAll(async () => {
+      routedApi?.close();
+      await routedServer?.close();
+    });
+
+    it('maps each URL to its handler, with a node id to navigate to', async () => {
+      const res = await requestOn(routedServer.port, '/api/routes');
+      const body = JSON.parse(res.body);
+
+      expect(body.routed).toBe(true);
+      expect(body.routeCount).toBe(4);
+      expect(body.shown).toBe(4);
+      expect(body.truncated).toBe(false);
+      expect(body.topHandlerFile).toBe('src/routes.ts');
+      expect(body.topHandlerFileCount).toBe(4);
+
+      const urls = body.entries.map((e: any) => e.url);
+      expect(urls).toEqual(
+        expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id'])
+      );
+
+      const listUsers = body.entries.find((e: any) => e.url === 'GET /users');
+      expect(listUsers.handler).toBe('listUsers');
+      expect(listUsers.handlerKind).toBe('function');
+      expect(listUsers.file).toBe('src/routes.ts');
+      expect(listUsers.line).toBeGreaterThan(0);
+
+      // The manifest carries no ids of its own; resolving them is what makes a
+      // route row clickable, so it has to actually resolve.
+      expect(listUsers.handlerId).toBeTruthy();
+      const handler = JSON.parse(
+        (await requestOn(routedServer.port, `/api/node/${listUsers.handlerId}`)).body
+      );
+      expect(handler.node.name).toBe('listUsers');
+    });
+
+    it('honours the limit and says when it cut the list', async () => {
+      const res = await requestOn(routedServer.port, '/api/routes?limit=3');
+      const body = JSON.parse(res.body);
+      expect(body.routed).toBe(true);
+      expect(body.entries).toHaveLength(3);
+      expect(body.shown).toBe(3);
+      expect(body.truncated).toBe(true);
+      // The headline count is the whole graph's, not the page's.
+      expect(body.routeCount).toBe(4);
+    });
+  });
+});
+
+/**
+ * The acceptance bar from the issue, against the engine's OWN index rather than
+ * a fixture: `LRUCache.get` in `src/resolution/lru-cache.ts`, 500+ callers.
+ *
+ * `.codegraph/` is gitignored, so this only runs on a machine that has indexed
+ * this repository. The fixture test above covers the same properties in CI; this
+ * one is the check against the real, messy graph the number came from.
+ */
+describe.runIf(CodeGraph.isInitialized(path.resolve(__dirname, '..')))(
+  "the engine's own busiest symbol",
+  () => {
+    const repoRoot = path.resolve(__dirname, '..');
+    let repoApi: GraphApi;
+    let repoServer: UiServerHandle;
+
+    beforeAll(async () => {
+      repoApi = createGraphApi({ projectRoot: repoRoot });
+      repoServer = await startUiServer({
+        projectRoot: repoRoot,
+        viewerDir,
+        port: 0,
+        api: repoApi.handler,
+      });
+    });
+
+    afterAll(async () => {
+      repoApi?.close();
+      await repoServer?.close();
+    });
+
+    const repoGet = (requestPath: string): Promise<Response> =>
+      requestOn(repoServer.port, requestPath);
+
+    it('answers in under 100 ms with grouped, capped lists and correct counts', async () => {
+      const search = JSON.parse(
+        (await repoGet('/api/search?q=' + encodeURIComponent('LRUCache.get'))).body
+      );
+      const hit = search.results.items.find(
+        (r: any) => r.name === 'get' && r.file.endsWith('src/resolution/lru-cache.ts')
+      );
+      expect(hit, 'LRUCache.get should be in the engine\'s own index').toBeTruthy();
+
+      await repoGet(`/api/node/${hit.id}`); // warm
+
+      const started = performance.now();
+      const res = await repoGet(`/api/node/${hit.id}`);
+      const elapsed = performance.now() - started;
+
+      expect(res.status).toBe(200);
+      const body = JSON.parse(res.body);
+
+      expect(body.counts.fanIn).toBeGreaterThanOrEqual(500);
+      expect(body.counts.hub).toBe(true);
+      // Grouped by calling symbol, so the row count is the distinct-caller
+      // count, never the edge count.
+      expect(body.incoming.items).toHaveLength(body.incoming.shown);
+      expect(body.incoming.shown).toBeLessThanOrEqual(300);
+      expect(body.incoming.shown).toBe(Math.min(300, body.incoming.total));
+      expect(body.incoming.truncated).toBe(body.incoming.total > 300);
+      expect(new Set(body.incoming.items.map((r: any) => r.node.id)).size).toBe(
+        body.incoming.shown
+      );
+      const edgesInRows = body.incoming.items.reduce(
+        (sum: number, r: any) => sum + r.edgeCount,
+        0
+      );
+      expect(edgesInRows).toBeLessThanOrEqual(body.counts.fanIn);
+      expect(body.blast.direct).toBe(body.counts.callers);
+      expect(body.tests.reached).toBe(true);
+
+      expect(elapsed).toBeLessThan(100);
+    });
+  }
+);
+
+describe('an index that is not there', () => {
+  it('answers with the same guidance the CLI prints, not a stack trace', async () => {
+    const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-noindex-'));
+    const detached = createGraphApi({ projectRoot: emptyRoot });
+    const detachedServer = await startUiServer({
+      projectRoot: emptyRoot,
+      viewerDir,
+      port: 0,
+      api: detached.handler,
+    });
+    try {
+      const res = await requestOn(detachedServer.port, '/api/stats');
+
+      expect(res.status).toBe(503);
+      const body = JSON.parse(res.body);
+      expect(body.code).toBe('no-index');
+      expect(body.error).toContain('No CodeGraph index found');
+      expect(body.hint).toContain('codegraph init');
+      expect(body.error).not.toContain('    at ');
+    } finally {
+      detached.close();
+      await detachedServer.close();
+      fs.rmSync(emptyRoot, { recursive: true, force: true });
+    }
+  });
+});

+ 13 - 1
src/bin/codegraph.ts

@@ -1919,7 +1919,14 @@ ${BROWSER_ENV}=none to never open one.
       process.exit(1);
     }
 
-    const { startUiServer, openBrowser, ViewerMissingError } = await import('../ui-server');
+    const { startUiServer, openBrowser, createGraphApi, ViewerMissingError } = await import(
+      '../ui-server'
+    );
+
+    // The read-only JSON API the viewer reads its screens from. It opens the
+    // index lazily on the first request, so a slow first paint is the only cost
+    // of mounting it here rather than after the browser connects.
+    const api = createGraphApi({ projectRoot: projectPath });
 
     let handle: UiServerHandle;
     try {
@@ -1927,8 +1934,10 @@ ${BROWSER_ENV}=none to never open one.
         projectRoot: projectPath,
         port: requestedPort,
         portFallback: requestedPort === undefined,
+        api: api.handler,
       });
     } catch (err) {
+      api.close();
       // Both failure modes here (viewer assets missing, no port available) carry
       // their own remediation — print it plainly, never a stack trace.
       error(err instanceof ViewerMissingError || err instanceof Error ? err.message : String(err));
@@ -1954,6 +1963,9 @@ ${BROWSER_ENV}=none to never open one.
     // The http server keeps the event loop alive on its own; these just make
     // Ctrl-C hang up live sockets instead of waiting on browser keep-alives.
     const shutdown = (): void => {
+      // Release the SQLite handle before the socket: the process should never
+      // exit with a live connection to the user's index.
+      api.close();
       void handle.close().then(() => process.exit(0));
     };
     process.once('SIGINT', shutdown);

+ 128 - 0
src/db/queries.ts

@@ -245,6 +245,7 @@ export class QueryBuilder {
     deleteEdgesByTarget?: SqliteStatement;
     getEdgesBySource?: SqliteStatement;
     getEdgesByTarget?: SqliteStatement;
+    getUnresolvedFromNode?: SqliteStatement;
     insertFile?: SqliteStatement;
     updateFile?: SqliteStatement;
     deleteFile?: SqliteStatement;
@@ -1862,6 +1863,133 @@ export class QueryBuilder {
     return rows.map(rowToEdge);
   }
 
+  /**
+   * Outgoing edges for MANY source nodes in one query.
+   *
+   * The batch form of {@link getOutgoingEdges}. Building a nested outline needs
+   * the `contains` edges of every container in a file at once; doing that one
+   * source at a time is a query per symbol on files that have hundreds.
+   */
+  getOutgoingEdgesFrom(sourceIds: readonly string[], kinds?: EdgeKind[]): Edge[] {
+    if (sourceIds.length === 0) return [];
+    const unique = [...new Set(sourceIds)];
+    const out: Edge[] = [];
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      let sql = `SELECT * FROM edges WHERE source IN (${placeholders})`;
+      const params: string[] = [...chunk];
+      if (kinds && kinds.length > 0) {
+        sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
+        params.push(...kinds);
+      }
+      const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
+      for (const row of rows) out.push(rowToEdge(row));
+    }
+    return out;
+  }
+
+  /**
+   * Fan-in (total incoming edge count) for MANY nodes in one query.
+   *
+   * The per-node alternative — `getIncomingEdges(id).length` — is an indexed
+   * lookup each, but a symbol screen rendering a couple of hundred callees
+   * would issue a couple of hundred of them. Ids with no incoming edges are
+   * absent from the map rather than present as 0, so callers can tell "no
+   * edges" from "not asked about".
+   */
+  countIncomingEdges(ids: readonly string[]): Map<string, number> {
+    const out = new Map<string, number>();
+    if (ids.length === 0) return out;
+    const unique = [...new Set(ids)];
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      const rows = this.db
+        .prepare(
+          `SELECT target, COUNT(*) AS count FROM edges WHERE target IN (${placeholders}) GROUP BY target`
+        )
+        .all(...chunk) as Array<{ target: string; count: number }>;
+      for (const row of rows) out.set(row.target, row.count);
+    }
+    return out;
+  }
+
+  /**
+   * Incoming edges for MANY target nodes in one query — the mirror of
+   * {@link getOutgoingEdgesFrom}. Needed wherever a whole file's inbound edges
+   * are wanted at once ("which files import anything in this one?").
+   */
+  getIncomingEdgesTo(targetIds: readonly string[], kinds?: EdgeKind[]): Edge[] {
+    if (targetIds.length === 0) return [];
+    const unique = [...new Set(targetIds)];
+    const out: Edge[] = [];
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      let sql = `SELECT * FROM edges WHERE target IN (${placeholders})`;
+      const params: string[] = [...chunk];
+      if (kinds && kinds.length > 0) {
+        sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
+        params.push(...kinds);
+      }
+      const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
+      for (const row of rows) out.push(rowToEdge(row));
+    }
+    return out;
+  }
+
+  /**
+   * Fan-out (total outgoing edge count) for MANY nodes in one query — the
+   * mirror of {@link countIncomingEdges}. Ids with no outgoing edges are absent
+   * from the map rather than present as 0.
+   */
+  countOutgoingEdges(ids: readonly string[]): Map<string, number> {
+    const out = new Map<string, number>();
+    if (ids.length === 0) return out;
+    const unique = [...new Set(ids)];
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      const rows = this.db
+        .prepare(
+          `SELECT source, COUNT(*) AS count FROM edges WHERE source IN (${placeholders}) GROUP BY source`
+        )
+        .all(...chunk) as Array<{ source: string; count: number }>;
+      for (const row of rows) out.set(row.source, row.count);
+    }
+    return out;
+  }
+
+  /**
+   * References recorded against a symbol that never resolved to a node — the
+   * calls and type mentions that leave the index (a third-party package, a
+   * runtime builtin, a language construct extraction doesn't model).
+   *
+   * Read-only. It exists so a reader can say "N calls into symbols outside the
+   * index" instead of silently showing a callee list shorter than the body's
+   * call sites, which reads as "nothing else happens here".
+   */
+  getUnresolvedReferencesFrom(fromNodeId: string): UnresolvedReference[] {
+    if (!this.stmts.getUnresolvedFromNode) {
+      this.stmts.getUnresolvedFromNode = this.db.prepare(
+        'SELECT * FROM unresolved_refs WHERE from_node_id = ?'
+      );
+    }
+    const rows = this.stmts.getUnresolvedFromNode.all(fromNodeId) as UnresolvedRefRow[];
+    return rows.map((row) => ({
+      fromNodeId: row.from_node_id,
+      referenceName: row.reference_name,
+      referenceKind: row.reference_kind as EdgeKind,
+      line: row.line,
+      column: row.col,
+      candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
+      filePath: row.file_path,
+      language: row.language as Language,
+      rowId: row.id,
+    }));
+  }
+
   /**
    * Find all edges where both source and target are in the given node set.
    * Useful for recovering inter-node connectivity after BFS.

+ 54 - 0
src/index.ts

@@ -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
    */

+ 247 - 0
src/ui-server/api/file.ts

@@ -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 - 0
src/ui-server/api/index.ts

@@ -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 - 0
src/ui-server/api/node.ts

@@ -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 - 0
src/ui-server/api/respond.ts

@@ -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 - 0
src/ui-server/api/routes.ts

@@ -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 - 0
src/ui-server/api/search.ts

@@ -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 - 0
src/ui-server/api/session.ts

@@ -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 - 0
src/ui-server/api/source.ts

@@ -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 - 0
src/ui-server/api/stats.ts

@@ -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 - 0
src/ui-server/api/wire.ts

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

+ 15 - 4
src/ui-server/index.ts

@@ -2,9 +2,10 @@
  * The `codegraph ui` server.
  *
  * A loopback-only, read-only `node:http` server that hands the browser the
- * built viewer (`dist/viewer/`) and — once the JSON API lands on the `api` seam
- * below — a read-only view of one indexed project. No framework, no new
- * dependency: it answers GET, serves files, and refuses everything else.
+ * built viewer (`dist/viewer/`) and, through the JSON API mounted on the `api`
+ * seam below (`./api`), a read-only view of one indexed project. No framework,
+ * no new dependency: it answers GET, serves files, and refuses everything
+ * else.
  *
  * The interesting part is not the routing, it is the boundary in `security.ts`.
  * Read that first.
@@ -43,6 +44,8 @@ export {
 } from './security';
 export { browserOpenCommand, openBrowser } from './open-browser';
 export { contentTypeFor, cacheControlFor } from './static';
+export { createGraphApi, GraphSession, ApiError } from './api';
+export type { GraphApi, GraphApiOptions } from './api';
 
 
 /**
@@ -251,8 +254,16 @@ async function handleRequest(
 
   // Checked on the RAW url, before WHATWG parsing folds `..` segments away.
   const rawPath = (req.url ?? '/').split(/[?#]/)[0] ?? '/';
+  // The `/api/` namespace answers JSON for EVERY outcome, refusals included:
+  // the viewer parses these responses, and a text/plain body here would surface
+  // as a parse error instead of the refusal it actually is.
+  const jsonNamespace = rawPath === '/api' || rawPath.startsWith('/api/');
   if (!isSafeRequestPath(rawPath)) {
-    sendText(res, 404, 'Not found', method);
+    if (jsonNamespace) {
+      sendJson(res, 404, { error: 'Not found', code: 'not-found' }, method);
+    } else {
+      sendText(res, 404, 'Not found', method);
+    }
     return;
   }