feat(ui): read-only JSON API over the index for the viewer (CG-42)

Six endpoints under `/api/`, one per screen, each answering in a single
round-trip in the spirit of `codegraph_explore` — the viewer should never
have to ask a follow-up question to finish drawing a pane:

    /api/stats                     index state, graph counts, frameworks
    /api/search?q=                 ranked, kind-grouped symbol search
    /api/node/<id>                 rails, members, tests, blast radius
    /api/source?file=&from=&to=    verbatim source + a drift verdict
    /api/file/<path>               outline and import rails
    /api/routes                    URL -> handler, when there is one

It is a reader of the existing schema: no extraction or resolution changes.
It mounts on the `api` seam `startUiServer` already exposed, so it sits
behind the CG-41 loopback boundary — Host allowlist, no CORS headers,
GET/HEAD only — and every read out of the repository goes through
`resolveProjectFile`, ahead of the index lookup so a traversal is refused
as a traversal rather than reported as "not indexed".

Three properties the endpoints are built around:

- No N+1. The engine's busiest symbol has 545 incoming edges; resolving
  those one `getNode` at a time is 545 queries. Every edge list is
  resolved with one batched lookup, which needed four additive read-only
  query methods (`getNodesByIds`/`getFanIn`/`getFanOut` on `CodeGraph`,
  plus batched outgoing/incoming edge fetches and unresolved-reference
  reads). `/api/node` on `LRUCache.get` answers in ~10 ms.

- Capped lists, honest totals. 545 callers cannot all be rows, so caller
  groups cap at 300 — but `total` is always the real number, and the
  ordering puts the useful end first (same file, then production code,
  then tests). Every count in the payload is the length of a list the
  same payload returns, so a badge and its rail cannot disagree.

- Nothing overclaims. Source that drifted on disk since the last index
  sync is omitted rather than sliced at line ranges that may now point at
  a different symbol; calls that leave the index are counted instead of
  silently shortening the callee rail; imports that never resolved are
  named; and a test-coverage claim reports whether its search actually
  finished. `/api/routes` says a project simply is not routed, and
  refuses a `limit` below three because the engine's manifest would
  answer that question wrongly.

Tests: 45 against a real indexed fixture over a real loopback server,
covering every endpoint's shape, the drift verdict in all three places it
surfaces, search ranking and the filter grammar, the refusals, and the
capping/latency behaviour at 500 callers. The issue's own acceptance case
— `lru-cache.ts` `get` under 100 ms — runs against this repo's index when
one is present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-26 23:33:30 -05:00
co-authored by Claude Opus 5
parent 41a90c6ba4
commit 951ba3678a
15 changed files with 3300 additions and 5 deletions
+947
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -23,6 +23,7 @@ import {
TaskContext,
BuildContextOptions,
FindRelevantContextOptions,
UnresolvedReference,
} from './types';
import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db';
import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve';
@@ -1323,6 +1324,59 @@ export class CodeGraph {
return this.queries.getNodeById(id);
}
/**
* Get many nodes by id in ONE round-trip (LRU-cache aware).
*
* The batch form of {@link getNode}. Anything resolving a list of edges to
* their endpoints — a caller list, a callee rail, an impact set — must use
* this rather than a `getNode` per edge: a symbol with 500 callers is 500
* queries otherwise. Ids that name nothing are simply absent from the map.
*/
getNodesByIds(ids: readonly string[]): Map<string, Node> {
return this.queries.getNodesByIds(ids);
}
/**
* Outgoing edges for many source nodes at once — the batch form of
* {@link getOutgoingEdges}. See {@link QueryBuilder.getOutgoingEdgesFrom}.
*/
getOutgoingEdgesFrom(nodeIds: readonly string[], kinds?: Edge['kind'][]): Edge[] {
return this.queries.getOutgoingEdgesFrom(nodeIds, kinds);
}
/**
* Fan-in (incoming edge count) for many nodes at once — the "hub" signal,
* without a query per node. See {@link QueryBuilder.countIncomingEdges}.
*/
getFanIn(ids: readonly string[]): Map<string, number> {
return this.queries.countIncomingEdges(ids);
}
/**
* Incoming edges for many target nodes at once — the mirror of
* {@link getOutgoingEdgesFrom}. See {@link QueryBuilder.getIncomingEdgesTo}.
*/
getIncomingEdgesTo(nodeIds: readonly string[], kinds?: Edge['kind'][]): Edge[] {
return this.queries.getIncomingEdgesTo(nodeIds, kinds);
}
/**
* Fan-out (outgoing edge count) for many nodes at once — the mirror of
* {@link getFanIn}. See {@link QueryBuilder.countOutgoingEdges}.
*/
getFanOut(ids: readonly string[]): Map<string, number> {
return this.queries.countOutgoingEdges(ids);
}
/**
* References from a symbol that never resolved to an indexed node — the
* calls and type mentions that leave the index. Lets a reader account for
* the call sites that have no callee row instead of implying there are none.
*/
getUnresolvedReferencesFrom(nodeId: string): UnresolvedReference[] {
return this.queries.getUnresolvedReferencesFrom(nodeId);
}
/**
* Get all nodes in a file
*/
+247
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,330 @@
/**
* The wire shapes the viewer reads, and the rules for producing them.
*
* Two ideas run through this file:
*
* 1. **One round-trip per screen.** Every endpoint returns everything a screen
* draws, in the spirit of `codegraph_explore`: the Symbol view never has to
* ask a follow-up question to render a rail, a badge or a count.
* 2. **Capped lists, honest totals.** A symbol with 545 callers cannot ship 545
* rows, but it must never claim it has fewer. Every capped list carries the
* true `total` beside the `shown` slice, so the UI can say "+N more" rather
* than quietly truncating.
*
* Nothing here reads the filesystem — that lives in `source.ts`, behind
* `resolveProjectFile`.
*/
import type { Edge, EdgeKind, Language, Node, NodeKind } from '../../types';
import { isTestFile } from '../../search/query-utils';
// =============================================================================
// Caps and thresholds
// =============================================================================
/**
* Fan-in at or above which a symbol is a "hub" — changing it is a
* repo-wide event. Matches the threshold the Symbol view's `hub · N` badge
* uses (design spec §3.2).
*/
export const HUB_THRESHOLD = 40;
/**
* Below this resolution confidence an edge is a name-only guess. The viewer
* folds these away behind "Uncertain · N name-only matches, confidence < 0.6"
* rather than mixing them into the rails as if they were resolved.
*/
export const UNCERTAIN_BELOW = 0.6;
/** Caller groups (one per calling symbol) returned for a node. */
export const MAX_INCOMING_GROUPS = 300;
/** Callee groups (one per called symbol) returned for a node. */
export const MAX_OUTGOING_GROUPS = 200;
/** Edges kept inside a single group — one symbol calling another 400 times. */
export const MAX_EDGES_PER_GROUP = 40;
/** Test files named in a node's test-caller summary (explore uses the same shape). */
export const MAX_TEST_FILES = 6;
/** Caller hops walked looking for a test. Mirrors `codegraph_explore`'s "tests:" line. */
export const TEST_CALLER_HOPS = 3;
/** `getCallers` lookups the test walk may spend, so a god-symbol can't stall a request. */
export const TEST_CALLER_BUDGET = 64;
/** Unresolved references listed by name before the payload just counts them. */
export const MAX_OUTSIDE_INDEX_SAMPLES = 40;
/** Symbols in a file outline. Beyond this the outline is truncated, not dropped. */
export const MAX_OUTLINE_NODES = 3000;
/** Files listed in each direction of the File view's import rails. */
export const MAX_IMPORT_FILES = 300;
// =============================================================================
// Node shapes
// =============================================================================
/**
* A symbol as it appears in a rail, an outline or a search result: enough to
* draw a row and navigate to it, and nothing else. Deliberately excludes the
* docstring — a 300-caller rail would otherwise ship 300 docstrings.
*/
export interface WireNodeRef {
id: string;
kind: NodeKind;
name: string;
qualifiedName: string;
/** Project-relative, forward slashes on every platform. */
file: string;
line: number;
endLine: number;
language: Language;
signature?: string;
exported?: boolean;
/** The file this symbol lives in looks like test/fixture code. */
test: boolean;
}
/** The focal symbol of a Symbol view — the ref, plus everything the header shows. */
export interface WireNodeDetail extends WireNodeRef {
startColumn: number;
endColumn: number;
docstring?: string;
visibility?: string;
async?: boolean;
static?: boolean;
abstract?: boolean;
decorators?: string[];
typeParameters?: string[];
returnType?: string;
/** `endLine - line + 1`, so the header can print "N lines" without the source. */
lines: number;
}
const rel = (p: string): string => p.replace(/\\/g, '/');
export function toNodeRef(node: Node): WireNodeRef {
const file = rel(node.filePath);
const ref: WireNodeRef = {
id: node.id,
kind: node.kind,
name: node.name,
qualifiedName: node.qualifiedName,
file,
line: node.startLine,
endLine: node.endLine,
language: node.language,
test: isTestFile(file),
};
if (node.signature) ref.signature = node.signature;
if (node.isExported) ref.exported = true;
return ref;
}
export function toNodeDetail(node: Node): WireNodeDetail {
const detail: WireNodeDetail = {
...toNodeRef(node),
startColumn: node.startColumn,
endColumn: node.endColumn,
lines: Math.max(1, node.endLine - node.startLine + 1),
};
if (node.docstring) detail.docstring = node.docstring;
if (node.visibility) detail.visibility = node.visibility;
if (node.isAsync) detail.async = true;
if (node.isStatic) detail.static = true;
if (node.isAbstract) detail.abstract = true;
if (node.decorators?.length) detail.decorators = node.decorators;
if (node.typeParameters?.length) detail.typeParameters = node.typeParameters;
if (node.returnType) detail.returnType = node.returnType;
return detail;
}
// =============================================================================
// Edge shapes
// =============================================================================
/**
* One edge, flattened.
*
* `metadata` is a free-form JSON blob in the schema; the fields lifted out here
* are the ones the viewer draws with — confidence decides the uncertain fold,
* `provenance`/`synthesizedBy`/`via`/`registeredAt` decide how a connector is
* dashed and what the "via <mechanism>" pill says, `valueRef` distinguishes
* "passes as value" from "calls". Anything else in the blob stays out: it is
* resolver bookkeeping, not something a reader can act on.
*/
export interface WireEdge {
kind: EdgeKind;
line?: number;
col?: number;
confidence?: number;
resolvedBy?: string;
provenance?: string;
synthesizedBy?: string;
via?: string;
registeredAt?: string;
valueRef?: boolean;
}
export function toWireEdge(edge: Edge): WireEdge {
const meta = (edge.metadata ?? {}) as Record<string, unknown>;
const wire: WireEdge = { kind: edge.kind };
if (typeof edge.line === 'number') wire.line = edge.line;
if (typeof edge.column === 'number') wire.col = edge.column;
if (typeof meta.confidence === 'number') wire.confidence = meta.confidence;
if (typeof meta.resolvedBy === 'string') wire.resolvedBy = meta.resolvedBy;
if (edge.provenance) wire.provenance = edge.provenance;
if (typeof meta.synthesizedBy === 'string') wire.synthesizedBy = meta.synthesizedBy;
if (typeof meta.via === 'string') wire.via = meta.via;
if (typeof meta.registeredAt === 'string') wire.registeredAt = meta.registeredAt;
if (meta.valueRef === true) wire.valueRef = true;
return wire;
}
// =============================================================================
// Relations — edges grouped by the symbol at the other end
// =============================================================================
/**
* Every edge between the focal symbol and ONE other symbol, as a single row.
*
* Grouping is what makes the rails readable: a helper called from eleven lines
* of the same function is one row with eleven call-site chips, not eleven rows.
*/
export interface WireRelation {
node: WireNodeRef;
/** Distinct edge kinds between the two, in first-seen order. */
edgeKinds: EdgeKind[];
/** Up to {@link MAX_EDGES_PER_GROUP} edges, ordered by line. */
edges: WireEdge[];
/** True number of edges, even when `edges` was capped. */
edgeCount: number;
/** Distinct call-site lines, ascending — what the gutter ports anchor to. */
lines: number[];
/** Highest confidence any edge in the group carries; null when none does. */
confidence: number | null;
/** The whole group is a name-only guess (see {@link UNCERTAIN_BELOW}). */
uncertain: boolean;
/** At least one edge was synthesized rather than parsed (dynamic dispatch). */
synthesized: boolean;
/** Fan-in of the other symbol — the `hub · N` pill. Only filled where the UI shows it. */
fanIn?: number;
hub?: boolean;
}
/** A capped list that still knows how long it really is. */
export interface WireList<T> {
total: number;
shown: number;
truncated: boolean;
items: T[];
}
export function wireList<T>(items: T[], total: number): WireList<T> {
return { total, shown: items.length, truncated: items.length < total, items };
}
/**
* Fold edges into one relation per counterpart symbol.
*
* @param edges edges all sharing the focal node at one end
* @param endpoint which end of each edge names the OTHER symbol
* @param nodes batch-resolved endpoint nodes (never a lookup per edge)
*/
export function groupRelations(
edges: readonly Edge[],
endpoint: (edge: Edge) => string,
nodes: Map<string, Node>
): WireRelation[] {
const byNode = new Map<string, Edge[]>();
for (const edge of edges) {
const id = endpoint(edge);
const bucket = byNode.get(id);
if (bucket) bucket.push(edge);
else byNode.set(id, [edge]);
}
const relations: WireRelation[] = [];
for (const [id, group] of byNode) {
const node = nodes.get(id);
// An edge whose endpoint is missing from `nodes` means the graph and the
// node table disagree — skip it rather than invent a row. Callers still see
// it in the totals they computed from the raw edge list.
if (!node) continue;
const ordered = [...group].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
const wireEdges = ordered.slice(0, MAX_EDGES_PER_GROUP).map(toWireEdge);
const edgeKinds: EdgeKind[] = [];
for (const edge of ordered) if (!edgeKinds.includes(edge.kind)) edgeKinds.push(edge.kind);
const lines = [
...new Set(ordered.map((e) => e.line).filter((l): l is number => typeof l === 'number' && l > 0)),
].sort((a, b) => a - b);
let confidence: number | null = null;
let synthesized = false;
for (const edge of ordered) {
const value = (edge.metadata as Record<string, unknown> | undefined)?.confidence;
if (typeof value === 'number' && (confidence === null || value > confidence)) confidence = value;
if (edge.provenance === 'heuristic') synthesized = true;
}
relations.push({
node: toNodeRef(node),
edgeKinds,
edges: wireEdges,
edgeCount: ordered.length,
lines,
confidence,
// No confidence recorded is NOT uncertain: tree-sitter edges extracted
// straight from the AST carry none precisely because they are certain.
uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
synthesized,
});
}
return relations;
}
/** First call-site line of a relation, for line-anchored ordering. Unlined rows sort last. */
export function firstLine(relation: WireRelation): number {
return relation.lines[0] ?? Number.MAX_SAFE_INTEGER;
}
/** Node kinds that count as "a type" for the Symbol view's "types used" chips. */
export const TYPE_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
'interface',
'type_alias',
'class',
'struct',
'enum',
'union',
'trait',
'protocol',
]);
/** Container kinds whose members the outline nests one level deeper. */
export const CONTAINER_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
'file',
'module',
'namespace',
'class',
'struct',
'interface',
'trait',
'protocol',
'enum',
'union',
]);
/** The four edge kinds `getCallers` treats as "reaches this symbol". */
export const CALLER_EDGE_KINDS: ReadonlySet<EdgeKind> = new Set<EdgeKind>([
'calls',
'references',
'imports',
'instantiates',
]);
export { rel as toPosixPath };
+14 -3
View File
@@ -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)) {
if (jsonNamespace) {
sendJson(res, 404, { error: 'Not found', code: 'not-found' }, method);
} else {
sendText(res, 404, 'Not found', method);
}
return;
}