From 951ba3678a7a492280d94aa3b30399e52f4980e6 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 26 Aug 2026 23:33:30 -0500 Subject: [PATCH] feat(ui): read-only JSON API over the index for the viewer (CG-42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ rails, members, tests, blast radius /api/source?file=&from=&to= verbatim source + a drift verdict /api/file/ 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 --- __tests__/ui-server-api.test.ts | 947 ++++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 14 +- src/db/queries.ts | 128 +++++ src/index.ts | 54 ++ src/ui-server/api/file.ts | 247 +++++++++ src/ui-server/api/index.ts | 155 ++++++ src/ui-server/api/node.ts | 453 +++++++++++++++ src/ui-server/api/respond.ts | 159 ++++++ src/ui-server/api/routes.ts | 94 ++++ src/ui-server/api/search.ts | 233 ++++++++ src/ui-server/api/session.ts | 132 +++++ src/ui-server/api/source.ts | 277 ++++++++++ src/ui-server/api/stats.ts | 63 +++ src/ui-server/api/wire.ts | 330 +++++++++++ src/ui-server/index.ts | 19 +- 15 files changed, 3300 insertions(+), 5 deletions(-) create mode 100644 __tests__/ui-server-api.test.ts create mode 100644 src/ui-server/api/file.ts create mode 100644 src/ui-server/api/index.ts create mode 100644 src/ui-server/api/node.ts create mode 100644 src/ui-server/api/respond.ts create mode 100644 src/ui-server/api/routes.ts create mode 100644 src/ui-server/api/search.ts create mode 100644 src/ui-server/api/session.ts create mode 100644 src/ui-server/api/source.ts create mode 100644 src/ui-server/api/stats.ts create mode 100644 src/ui-server/api/wire.ts diff --git a/__tests__/ui-server-api.test.ts b/__tests__/ui-server-api.test.ts new file mode 100644 index 0000000..9212ed0 --- /dev/null +++ b/__tests__/ui-server-api.test.ts @@ -0,0 +1,947 @@ +/** + * The `codegraph ui` read-only JSON API (CG-42). + * + * Everything runs against a real indexed fixture project over a real loopback + * server — no mocks — because the properties worth pinning are the ones that + * only exist end to end: the drift verdict comes from hashing bytes on disk + * against what the index stored, the refusals come from the same chokepoint the + * static server uses, and the caps only matter once a symbol really does have + * hundreds of callers. + * + * The fixture is built to produce each of those: a call chain three deep, a + * test file that reaches it, a type used only as a type, an import that cannot + * resolve, and one deliberately hot function with 500 callers. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as http from 'http'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server'; + +interface Response { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +let server: UiServerHandle; +let api: GraphApi; +let tempDir: string; +let projectRoot: string; +let viewerDir: string; + +/** + * One request against a live server, with the loopback `Host` the boundary + * wants. Written with `http.request` rather than `fetch` so the `Host` header + * is ours to set — undici treats it as forbidden. + */ +function requestOn(port: number, requestPath: string, method = 'GET'): Promise { + 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 { + 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 { + 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 { + 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(); + 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'), '
'); + + 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/', + '/api/source', + '/api/file/', + '/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/', () => { + 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(); + 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/ — 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/', () => { + 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 => + 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 }); + } + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 2f5d90c..bd2a404 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1919,7 +1919,14 @@ ${BROWSER_ENV}=none to never open one. process.exit(1); } - const { startUiServer, openBrowser, ViewerMissingError } = await import('../ui-server'); + const { startUiServer, openBrowser, createGraphApi, ViewerMissingError } = await import( + '../ui-server' + ); + + // The read-only JSON API the viewer reads its screens from. It opens the + // index lazily on the first request, so a slow first paint is the only cost + // of mounting it here rather than after the browser connects. + const api = createGraphApi({ projectRoot: projectPath }); let handle: UiServerHandle; try { @@ -1927,8 +1934,10 @@ ${BROWSER_ENV}=none to never open one. projectRoot: projectPath, port: requestedPort, portFallback: requestedPort === undefined, + api: api.handler, }); } catch (err) { + api.close(); // Both failure modes here (viewer assets missing, no port available) carry // their own remediation — print it plainly, never a stack trace. error(err instanceof ViewerMissingError || err instanceof Error ? err.message : String(err)); @@ -1954,6 +1963,9 @@ ${BROWSER_ENV}=none to never open one. // The http server keeps the event loop alive on its own; these just make // Ctrl-C hang up live sockets instead of waiting on browser keep-alives. const shutdown = (): void => { + // Release the SQLite handle before the socket: the process should never + // exit with a live connection to the user's index. + api.close(); void handle.close().then(() => process.exit(0)); }; process.once('SIGINT', shutdown); diff --git a/src/db/queries.ts b/src/db/queries.ts index af19b14..ba4140a 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -245,6 +245,7 @@ export class QueryBuilder { deleteEdgesByTarget?: SqliteStatement; getEdgesBySource?: SqliteStatement; getEdgesByTarget?: SqliteStatement; + getUnresolvedFromNode?: SqliteStatement; insertFile?: SqliteStatement; updateFile?: SqliteStatement; deleteFile?: SqliteStatement; @@ -1862,6 +1863,133 @@ export class QueryBuilder { return rows.map(rowToEdge); } + /** + * Outgoing edges for MANY source nodes in one query. + * + * The batch form of {@link getOutgoingEdges}. Building a nested outline needs + * the `contains` edges of every container in a file at once; doing that one + * source at a time is a query per symbol on files that have hundreds. + */ + getOutgoingEdgesFrom(sourceIds: readonly string[], kinds?: EdgeKind[]): Edge[] { + if (sourceIds.length === 0) return []; + const unique = [...new Set(sourceIds)]; + const out: Edge[] = []; + for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + let sql = `SELECT * FROM edges WHERE source IN (${placeholders})`; + const params: string[] = [...chunk]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + const rows = this.db.prepare(sql).all(...params) as EdgeRow[]; + for (const row of rows) out.push(rowToEdge(row)); + } + return out; + } + + /** + * Fan-in (total incoming edge count) for MANY nodes in one query. + * + * The per-node alternative — `getIncomingEdges(id).length` — is an indexed + * lookup each, but a symbol screen rendering a couple of hundred callees + * would issue a couple of hundred of them. Ids with no incoming edges are + * absent from the map rather than present as 0, so callers can tell "no + * edges" from "not asked about". + */ + countIncomingEdges(ids: readonly string[]): Map { + const out = new Map(); + 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 { + const out = new Map(); + 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. diff --git a/src/index.ts b/src/index.ts index 90397c5..2ff4ce7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { TaskContext, BuildContextOptions, FindRelevantContextOptions, + UnresolvedReference, } from './types'; import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db'; import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve'; @@ -1323,6 +1324,59 @@ export class CodeGraph { return this.queries.getNodeById(id); } + /** + * Get many nodes by id in ONE round-trip (LRU-cache aware). + * + * The batch form of {@link getNode}. Anything resolving a list of edges to + * their endpoints — a caller list, a callee rail, an impact set — must use + * this rather than a `getNode` per edge: a symbol with 500 callers is 500 + * queries otherwise. Ids that name nothing are simply absent from the map. + */ + getNodesByIds(ids: readonly string[]): Map { + 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 { + 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 { + 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 */ diff --git a/src/ui-server/api/file.ts b/src/ui-server/api/file.ts new file mode 100644 index 0000000..b387387 --- /dev/null +++ b/src/ui-server/api/file.ts @@ -0,0 +1,247 @@ +/** + * `GET /api/file/` — 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(); + 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(); + 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, + fileNodeId: string | undefined +): string | null { + const parent = parentOf.get(id); + if (!parent || parent === fileNodeId) return null; + return parent; +} + +function depthOf( + id: string, + parentOf: Map, + 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 +): WireImportRow[] { + const byFile = new Map>(); + 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(); + 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 []; + } +} diff --git a/src/ui-server/api/index.ts b/src/ui-server/api/index.ts new file mode 100644 index 0000000..2ae574d --- /dev/null +++ b/src/ui-server/api/index.ts @@ -0,0 +1,155 @@ +/** + * The read-only JSON API the viewer reads its screens from. + * + * Six endpoints, one per screen, each answering in a single round-trip — the + * same principle as `codegraph_explore`: return enough that the caller does not + * have to ask a follow-up question. Everything here is a *reader* of the + * existing schema; nothing indexes, resolves, or writes. + * + * ``` + * GET /api/stats what this index is and how much to trust it + * GET /api/search?q= the search palette + * GET /api/node/ the Symbol view: rails, members, tests, blast radius + * GET /api/source?file=&from=&to= verbatim source, with a drift verdict + * GET /api/file/ 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/', 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/', 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[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/.'); + 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/.'); + 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; +} diff --git a/src/ui-server/api/node.ts b/src/ui-server/api/node.ts new file mode 100644 index 0000000..381f0fd --- /dev/null +++ b/src/ui-server/api/node.ts @@ -0,0 +1,453 @@ +/** + * `GET /api/node/` — 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(); + 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(); + 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 +): { 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(); + 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; + 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 = {}; + 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(); + 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); +} diff --git a/src/ui-server/api/respond.ts b/src/ui-server/api/respond.ts new file mode 100644 index 0000000..0064e1c --- /dev/null +++ b/src/ui-server/api/respond.ts @@ -0,0 +1,159 @@ +/** + * How the JSON API answers — success, refusal, and every failure in between. + * + * The viewer is the only client, and it runs on the same machine as the index, + * so an error here is a message to a developer looking at their own project, + * not information to withhold from a prober. Every failure therefore says what + * went wrong and — where there is one — what to do about it, exactly the way + * the CLI and the MCP tools do. What it never does is leak a stack trace. + */ + +import type { ServerResponse } from 'http'; +import { sendJson } from '../static'; + +/** + * Machine-readable failure reasons. The viewer switches on these rather than + * on prose, so renaming a message never breaks a screen. + */ +export type ApiErrorCode = + | 'bad-request' + | 'not-found' + | 'refused' + | 'no-index' + | 'index-unusable' + | 'internal'; + +const STATUS: Record = { + '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; +} diff --git a/src/ui-server/api/routes.ts b/src/ui-server/api/routes.ts new file mode 100644 index 0000000..083421a --- /dev/null +++ b/src/ui-server/api/routes.ts @@ -0,0 +1,94 @@ +/** + * `GET /api/routes` — the URL to handler map, when the project has one. + * + * The engine's routing manifest is a flat list of (url, handler, file, line) + * rows; it deliberately carries no node ids, because its own consumer (the MCP + * context builder) renders text. A reader needs to *navigate*, so each entry is + * matched back to its handler's node id here — batched by file, never a lookup + * per route. + * + * `null` from the engine means "fewer than three real routes", i.e. this + * project is not a routed app. That is reported as an empty manifest with + * `routed: false` rather than as an error: "this isn't a web app" is an + * answer, not a failure. + * + * Two things about the manifest shape the numbers here have to work around. + * Its `limit` is applied in SQL *before* the three-route test, so asking for + * fewer than three would make every routed project look unrouted — hence the + * floor on the parameter. And its own `totalRoutes` counts only the rows inside + * that window, so the headline count comes from the graph's `route` nodes + * instead, which is the number a reader means by "how many routes are there". + */ + +import type { CodeGraph } from '../../index'; +import { intParam } from './respond'; +import { toPosixPath } from './wire'; + +/** Distinct handler files we will resolve node ids for. */ +const MAX_HANDLER_FILES = 60; + +/** + * The engine needs three surviving rows to call a project routed, and applies + * `limit` before that test — so anything below three is a question that cannot + * be answered truthfully rather than a small page. + */ +const MIN_LIMIT = 3; + +export function buildRoutes(cg: CodeGraph, query: URLSearchParams): unknown { + const limit = intParam(query, 'limit', { min: MIN_LIMIT, max: 500, default: 200 }); + + // One row over the limit, purely to learn whether there were more. + const manifest = cg.getRoutingManifest(limit + 1); + const routeCount = cg.getStats().nodesByKind.route ?? 0; + + if (!manifest) { + return { + routed: false, + routeCount, + shown: 0, + truncated: false, + topHandlerFile: null, + topHandlerFileCount: 0, + entries: [], + }; + } + + const truncated = manifest.entries.length > limit; + const rows = manifest.entries.slice(0, limit); + + // One `getNodesInFile` per distinct handler file — typically one or two, and + // capped so a project that scatters handlers across hundreds of files cannot + // turn one request into hundreds of queries. + const handlerFiles = [...new Set(rows.map((e) => e.handlerFile))].slice(0, MAX_HANDLER_FILES); + const byFileLineName = new Map(); + 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, + }; +} diff --git a/src/ui-server/api/search.ts b/src/ui-server/api/search.ts new file mode 100644 index 0000000..cf2a7c0 --- /dev/null +++ b/src/ui-server/api/search.ts @@ -0,0 +1,233 @@ +/** + * `GET /api/search?q=` — the search palette's one round-trip. + * + * Three lookups feed it, because no single one covers what a person types into + * a palette: + * + * - `getNodesByNameSubstring` — case-insensitive, catches the exact, prefix and + * mid-name matches (`profileInfo` inside `getProfileInfoV2`) that FTS tokens + * cannot. + * - `searchNodes` — FTS5, plus the engine's own LIKE and fuzzy fallbacks, and + * the `kind:` / `lang:` / `path:` / `name:` filter grammar for free. + * - `getNodesByName` — every symbol with exactly that name, uncapped, so a + * heavily-overloaded name never loses its definitions below a search cut. + * + * They are then merged and ranked by HOW the name matched — exact, prefix, + * substring, qualified name, file path — rather than by any single engine's + * score, because those scores are not comparable with each other. Results are + * grouped by kind: "did I mean the class or the method" is the question a + * palette actually has to answer. + */ + +import type { CodeGraph } from '../../index'; +import type { Node, NodeKind } from '../../types'; +import { parseQuery, type ParsedQuery } from '../../search/query-parser'; +import { intParam, optionalTextParam } from './respond'; +import { toNodeRef, wireList, type WireNodeRef } from './wire'; + +/** How a result's text matched the query. Also the primary sort key. */ +export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related'; + +const MATCH_RANK: Record = { + 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(); + 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(); + 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([], 0), + groups: [], + }; +} diff --git a/src/ui-server/api/session.ts b/src/ui-server/api/session.ts new file mode 100644 index 0000000..5c12095 --- /dev/null +++ b/src/ui-server/api/session.ts @@ -0,0 +1,132 @@ +/** + * The one open handle on the project's index. + * + * `CodeGraph.openSync` costs tens of milliseconds and runs pending migrations, + * so it happens once for the life of the server rather than once per request. + * That leaves two things this module has to get right: + * + * - **A missing index is guidance, not a stack trace.** `codegraph ui` refuses + * to start without one, but a user can delete `.codegraph/` while the viewer + * is open, so every endpoint has to be able to say so in the same words the + * CLI does. + * - **A re-index must not be served from a phantom database.** `codegraph init` + * on an already-indexed project *replaces the database file* (see + * `CodeGraph.recreate`). On POSIX our handle would keep reading the unlinked + * inode and happily serve a graph that no longer exists on disk. So the file + * identity is re-checked on acquisition — one `stat` — and a swapped file + * reopens the connection. + */ + +import * as fs from 'fs'; +import { CodeGraph } from '../../index'; +import { getDatabasePath } from '../../db'; +import { isInitialized } from '../../directory'; +import { ApiError } from './respond'; + +/** Identity of the database file, so a swap underneath us is detectable. */ +interface FileIdentity { + ino: number; + birthtimeMs: number; +} + +function identify(dbPath: string): FileIdentity | null { + try { + const st = fs.statSync(dbPath); + return { ino: st.ino, birthtimeMs: st.birthtimeMs }; + } catch { + return null; + } +} + +function sameFile(a: FileIdentity | null, b: FileIdentity | null): boolean { + if (a === null || b === null) return false; + // `ino` is 0 on a few Windows filesystems; birthtime alone still catches a + // recreate there, and a false "changed" only costs one reopen. + return a.ino === b.ino && a.birthtimeMs === b.birthtimeMs; +} + +/** + * Guidance shown when there is no index to read. Deliberately the same three + * facts the CLI prints: the viewer never creates an index, `codegraph init` + * does, and you can point the viewer somewhere already indexed. + */ +function noIndexError(projectRoot: string): ApiError { + return new ApiError( + 'no-index', + `No CodeGraph index found for ${projectRoot}.`, + 'The viewer reads an index that already exists — it never creates one. ' + + 'Run "codegraph init" in that project, or start the viewer against a project ' + + 'that has one: codegraph ui /path/to/indexed/project' + ); +} + +/** + * Holds the project's `CodeGraph` open for the life of the server. + * + * Not thread-safe and does not need to be: `node:http` dispatches on one + * thread, and every read below is synchronous. + */ +export class GraphSession { + readonly projectRoot: string; + private readonly dbPath: string; + private cg: CodeGraph | null = null; + private identity: FileIdentity | null = null; + + constructor(projectRoot: string) { + this.projectRoot = projectRoot; + this.dbPath = getDatabasePath(projectRoot); + } + + /** + * The open graph, opening (or reopening) it if needed. + * + * @throws {ApiError} `no-index` when the project has no index, + * `index-unusable` when it has one that will not open. + */ + acquire(): CodeGraph { + const current = identify(this.dbPath); + + if (this.cg !== null) { + if (sameFile(this.identity, current)) return this.cg; + // The database was replaced (a re-index) or removed. Drop the stale + // handle; falling through re-opens against whatever is there now. + this.closeQuietly(); + } + + if (!isInitialized(this.projectRoot)) throw noIndexError(this.projectRoot); + + try { + this.cg = CodeGraph.openSync(this.projectRoot); + } catch (err) { + this.cg = null; + this.identity = null; + throw new ApiError( + 'index-unusable', + `The CodeGraph index for ${this.projectRoot} could not be opened: ` + + (err instanceof Error ? err.message : String(err)), + 'If another CodeGraph process is rebuilding it, wait for that to finish. ' + + 'If the index is damaged, rebuild it with "codegraph init".' + ); + } + this.identity = current ?? identify(this.dbPath); + return this.cg; + } + + /** Release the handle. Idempotent — the CLI calls it on Ctrl-C. */ + close(): void { + this.closeQuietly(); + } + + private closeQuietly(): void { + const cg = this.cg; + this.cg = null; + this.identity = null; + if (!cg) return; + try { + cg.close(); + } catch { + // A close that fails has nothing left to release — the process is either + // exiting or the file is already gone. Never let it fail a request. + } + } +} diff --git a/src/ui-server/api/source.ts b/src/ui-server/api/source.ts new file mode 100644 index 0000000..650b1a2 --- /dev/null +++ b/src/ui-server/api/source.ts @@ -0,0 +1,277 @@ +/** + * `GET /api/source?file=&from=&to=` — verbatim source, or an honest refusal. + * + * This is the one endpoint that reads the user's repository, so two rules + * govern it and neither is negotiable. + * + * **Every read goes through `resolveProjectFile`.** That is the chokepoint from + * `security.ts` — traversal, in-tree symlinks pointing out of the root, + * absolute paths, sensitive system directories. Without it, + * `?file=../../.ssh/id_rsa` is a credential leak over a port the user opened to + * read their own code. + * + * **A file that changed on disk since it was indexed is never sliced.** The + * viewer asks for line ranges the *index* recorded; if the file moved on since, + * those ranges can point at a different symbol's body, which would be served + * under the requested name and look perfectly plausible. So the bytes are + * hashed and compared against `files.content_hash`, and on a mismatch the slice + * is omitted with `drift: true` — the same call `codegraph_node` makes when it + * says "changed on disk after the last index sync". + * + * Only files that are IN the index are served. That is a tighter boundary than + * the MCP tools take, and it costs the viewer nothing (it only ever renders + * indexed symbols) while making the drift verdict meaningful for every answer: + * there is always a hash to compare against. + */ + +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { FileRecord } from '../../types'; +import type { CodeGraph } from '../../index'; +import { resolveProjectFile } from '../security'; +import { ApiError, badRequest, intParam, notFound, textParam } from './respond'; + +/** + * Largest file we will read to answer a source request. + * + * The whole file has to be read to hash it, so this bounds the work one request + * can cause. Well above the 1 MB ceiling extraction itself applies, so anything + * actually in the index is comfortably inside it. + */ +export const MAX_SOURCE_BYTES = 8 * 1024 * 1024; + +/** Lines returned in one response. The Symbol view asks for windows, not files. */ +export const MAX_SOURCE_LINES = 4000; + +/** + * Look up a file record by a viewer-supplied path, WITHOUT validating it. + * + * Indexed paths are normalized to forward slashes at extraction time, so that + * is the form tried first; the platform-separator form is a fallback for an + * index written before that normalization. + * + * Callers that go on to READ the file must use {@link resolveRequestedFile} + * instead — it puts the path through the security chokepoint first. This one is + * for endpoints that only need the record (a drift flag on a path the index + * itself handed us). + */ +export function findIndexedFile( + cg: CodeGraph, + requested: string +): { record: FileRecord; storedPath: string } | null { + const posix = toRequestPath(requested); + const record = cg.getFile(posix); + if (record) return { record, storedPath: posix }; + + const native = posix.split('/').join(path.sep); + if (native !== posix) { + const legacy = cg.getFile(native); + if (legacy) return { record: legacy, storedPath: native }; + } + return null; +} + +/** + * Forward slashes and no leading `./` — the form indexed paths are stored in. + * + * A LEADING SLASH IS LEFT ALONE on purpose. Stripping it would quietly turn + * `/etc/passwd` into the project-relative `etc/passwd` and answer "not in this + * index" — reinterpreting the request instead of refusing it, and leaving the + * chokepoint's absolute-path rule with nothing to catch. + */ +export function toRequestPath(requested: string): string { + return requested.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +/** + * Validate a viewer-supplied path, THEN look it up in the index. + * + * The order is the point. `resolveProjectFile` runs first, so a traversal, an + * absolute path or a sensitive system directory is refused as what it is, + * before the index is consulted — a 403 that says "outside the project", not a + * 404 that says "not indexed" and quietly depends on the index lookup missing. + * It also means the absolute path every reader uses has already been through + * the chokepoint by construction, rather than by remembering to call it. + * + * @throws {PathRefusalError} the path is not one we would ever read. + * @throws {ApiError} `not-found` when it is fine but not in the index. + */ +export function resolveRequestedFile( + cg: CodeGraph, + projectRoot: string, + requested: string +): { record: FileRecord; storedPath: string; absolute: string } { + const posix = toRequestPath(requested); + // Refusals happen here, ahead of everything. + const absolute = resolveProjectFile(projectRoot, posix); + + const found = findIndexedFile(cg, posix); + if (!found) throw notIndexedError(posix); + return { ...found, absolute }; +} + +export function notIndexedError(file: string): ApiError { + return notFound( + `${file} is not in this CodeGraph index.`, + 'The viewer only reads files the index knows about. If the file is new, ' + + 'it appears after the next sync; if it is excluded (gitignored, generated, ' + + 'or too large to parse), it will not appear at all.' + ); +} + +/** + * Split source the way the index counted it. + * + * Rows are `\n`-delimited — that is how tree-sitter numbers them — so a CRLF + * file has the same line numbers here as in the graph. The trailing `\r` is + * dropped per line so it does not render as a stray glyph. + */ +export function splitLines(content: string): string[] { + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] as string; + if (line.endsWith('\r')) lines[i] = line.slice(0, -1); + } + // A file ending in a newline splits to a final empty string that is not a + // line of source. Every other trailing empty line IS one. + if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop(); + return lines; +} + +/** + * Whether an indexed file has changed on disk since it was indexed — the same + * verdict `/api/source` returns, for endpoints that must *flag* drift without + * serving source (a symbol header, a file outline). + * + * Cheap first: size plus floored mtime is the identical freshness test the sync + * fast path uses, so an untouched file costs one `stat`. Only a stat mismatch + * pays for a hash, which is what keeps a `touch` or a checkout that rewrote + * identical bytes from reading as drift. + * + * Any failure answers `false`. A wrong "stale" flag would put a warning banner + * over correct source; the cases that would trip it (missing record, unreadable + * file) have their own handling in the endpoints that actually read. + */ +export function hasDriftedOnDisk( + projectRoot: string, + storedPath: string, + record: FileRecord +): boolean { + try { + const absolute = resolveProjectFile(projectRoot, storedPath); + const stats = fs.statSync(absolute); + if (stats.size === record.size && Math.floor(stats.mtimeMs) === Math.floor(record.modifiedAt)) { + return false; + } + if (stats.size > MAX_SOURCE_BYTES) return true; + const content = fs.readFileSync(absolute, 'utf-8'); + return createHash('sha256').update(content).digest('hex') !== record.contentHash; + } catch { + return false; + } +} + +export interface SourceResult { + file: string; + language: string; + /** The file on disk differs from what was indexed — no slice is served. */ + drift: boolean; + contentHash: string; + indexedAt: number; + generated: boolean; + totalLines: number | null; + from?: number; + to?: number; + lines?: string[]; + truncated?: boolean; + reason?: string; +} + +export function buildSource( + cg: CodeGraph, + projectRoot: string, + query: URLSearchParams +): SourceResult { + const requested = textParam(query, 'file'); + // Refusal first, index lookup second — see `resolveRequestedFile`. + const { record, storedPath, absolute } = resolveRequestedFile(cg, projectRoot, requested); + + const from = intParam(query, 'from', { min: 1, max: 5_000_000, default: 1 }); + const to = intParam(query, 'to', { min: 1, max: 5_000_000, default: 0 }); + if (to !== 0 && to < from) { + throw badRequest(`Parameter "to" (${to}) must not be before "from" (${from}).`); + } + + const base: SourceResult = { + file: storedPath.replace(/\\/g, '/'), + language: record.language, + drift: false, + contentHash: record.contentHash, + indexedAt: record.indexedAt, + generated: record.generated === true, + totalLines: null, + }; + + let stats: fs.Stats; + try { + stats = fs.statSync(absolute); + } catch { + // Indexed but gone. That IS drift, and the strongest kind: nothing on disk + // corresponds to the ranges the graph holds. + return { ...base, drift: true, reason: 'The file is in the index but no longer on disk.' }; + } + if (stats.size > MAX_SOURCE_BYTES) { + throw badRequest( + `${base.file} is ${Math.round(stats.size / 1024 / 1024)} MB — too large to serve as source.` + ); + } + + let content: string; + try { + content = fs.readFileSync(absolute, 'utf-8'); + } catch (err) { + throw new ApiError( + 'internal', + `Could not read ${base.file}: ${err instanceof Error ? err.message : String(err)}` + ); + } + + // Byte-identical to extraction's `hashContent` (sha256 over the utf-8 + // string). A touch or a checkout that rewrote the same bytes must not count + // as drift, which is exactly what hashing content rather than mtime buys. + const hash = createHash('sha256').update(content).digest('hex'); + if (hash !== record.contentHash) { + return { + ...base, + drift: true, + reason: + 'This file changed on disk after the last index sync, so the indexed line ' + + 'ranges no longer reliably match. Source is omitted rather than risk showing ' + + "a different symbol's code; it returns after the next sync.", + }; + } + + const all = splitLines(content); + // Past the end of the file `from` names nothing, which is a caller bug worth + // surfacing rather than answering with the last line as if that were meant. + // `to` past the end is different — "line 30 to the end, whatever that is" is + // an ordinary way to ask, so it clamps. + if (from > all.length) { + throw badRequest( + `Parameter "from" (${from}) is past the end of ${base.file}, which has ${all.length} lines.` + ); + } + const start = from; + const requestedEnd = to === 0 ? all.length : Math.min(to, all.length); + const end = Math.min(requestedEnd, start + MAX_SOURCE_LINES - 1); + + return { + ...base, + totalLines: all.length, + from: start, + to: end, + lines: all.slice(start - 1, end), + truncated: end < requestedEnd, + }; +} diff --git a/src/ui-server/api/stats.ts b/src/ui-server/api/stats.ts new file mode 100644 index 0000000..bf30315 --- /dev/null +++ b/src/ui-server/api/stats.ts @@ -0,0 +1,63 @@ +/** + * `GET /api/stats` — what this index is, and how much to trust it. + * + * The viewer's top bar shows a couple of numbers from here, but the reason the + * endpoint carries more than that is honesty: an index can be truncated + * (`state: "indexing"` after a killed run), built by an older extractor, or + * simply old. A reader that draws confident graphs over a half-built index is + * the failure mode worth designing against, so the state travels with the + * counts rather than being something the UI has to ask for separately. + */ + +import * as path from 'path'; +import type { CodeGraph } from '../../index'; +import { HUB_THRESHOLD, UNCERTAIN_BELOW } from './wire'; + +export function buildStats(cg: CodeGraph, projectRoot: string): unknown { + const stats = cg.getStats(); + const build = cg.getIndexBuildInfo(); + + return { + project: { + root: projectRoot, + name: path.basename(projectRoot) || projectRoot, + }, + index: { + /** + * `complete` is the only good value. `indexing` means a run was killed + * part-way and the graph on disk is a truncated one; `partial`/`failed` + * mean the run finished but dropped files. `null` predates the marker. + */ + state: cg.getIndexState(), + lastIndexedAt: cg.getLastIndexedAt(), + /** Built by an older extractor — a re-index would add data no migration can. */ + stale: cg.isIndexStale(), + version: build.version, + extractionVersion: build.extractionVersion, + backend: cg.getBackend(), + journalMode: cg.getJournalMode(), + /** References still waiting to resolve; > 0 means edges are still missing. */ + pendingReferences: cg.getPendingReferenceCount(), + generatedFiles: cg.getGeneratedFileCount(), + watching: cg.isWatching(), + watcherDegraded: cg.isWatcherDegraded(), + }, + graph: { + nodes: stats.nodeCount, + edges: stats.edgeCount, + files: stats.fileCount, + nodesByKind: stats.nodesByKind, + edgesByKind: stats.edgesByKind, + filesByLanguage: stats.filesByLanguage, + dbSizeBytes: stats.dbSizeBytes, + walSizeBytes: stats.walSizeBytes, + }, + frameworks: cg.getDetectedFrameworks(), + /** + * The thresholds the API itself applied, so the viewer's copy ("hub · N", + * "confidence < 0.6") stays in step with the data instead of hard-coding a + * second copy of the same numbers. + */ + thresholds: { hub: HUB_THRESHOLD, uncertainBelow: UNCERTAIN_BELOW }, + }; +} diff --git a/src/ui-server/api/wire.ts b/src/ui-server/api/wire.ts new file mode 100644 index 0000000..8e4184e --- /dev/null +++ b/src/ui-server/api/wire.ts @@ -0,0 +1,330 @@ +/** + * The wire shapes the viewer reads, and the rules for producing them. + * + * Two ideas run through this file: + * + * 1. **One round-trip per screen.** Every endpoint returns everything a screen + * draws, in the spirit of `codegraph_explore`: the Symbol view never has to + * ask a follow-up question to render a rail, a badge or a count. + * 2. **Capped lists, honest totals.** A symbol with 545 callers cannot ship 545 + * rows, but it must never claim it has fewer. Every capped list carries the + * true `total` beside the `shown` slice, so the UI can say "+N more" rather + * than quietly truncating. + * + * Nothing here reads the filesystem — that lives in `source.ts`, behind + * `resolveProjectFile`. + */ + +import type { Edge, EdgeKind, Language, Node, NodeKind } from '../../types'; +import { isTestFile } from '../../search/query-utils'; + +// ============================================================================= +// Caps and thresholds +// ============================================================================= + +/** + * Fan-in at or above which a symbol is a "hub" — changing it is a + * repo-wide event. Matches the threshold the Symbol view's `hub · N` badge + * uses (design spec §3.2). + */ +export const HUB_THRESHOLD = 40; + +/** + * Below this resolution confidence an edge is a name-only guess. The viewer + * folds these away behind "Uncertain · N name-only matches, confidence < 0.6" + * rather than mixing them into the rails as if they were resolved. + */ +export const UNCERTAIN_BELOW = 0.6; + +/** Caller groups (one per calling symbol) returned for a node. */ +export const MAX_INCOMING_GROUPS = 300; + +/** Callee groups (one per called symbol) returned for a node. */ +export const MAX_OUTGOING_GROUPS = 200; + +/** Edges kept inside a single group — one symbol calling another 400 times. */ +export const MAX_EDGES_PER_GROUP = 40; + +/** Test files named in a node's test-caller summary (explore uses the same shape). */ +export const MAX_TEST_FILES = 6; + +/** Caller hops walked looking for a test. Mirrors `codegraph_explore`'s "tests:" line. */ +export const TEST_CALLER_HOPS = 3; + +/** `getCallers` lookups the test walk may spend, so a god-symbol can't stall a request. */ +export const TEST_CALLER_BUDGET = 64; + +/** Unresolved references listed by name before the payload just counts them. */ +export const MAX_OUTSIDE_INDEX_SAMPLES = 40; + +/** Symbols in a file outline. Beyond this the outline is truncated, not dropped. */ +export const MAX_OUTLINE_NODES = 3000; + +/** Files listed in each direction of the File view's import rails. */ +export const MAX_IMPORT_FILES = 300; + +// ============================================================================= +// Node shapes +// ============================================================================= + +/** + * A symbol as it appears in a rail, an outline or a search result: enough to + * draw a row and navigate to it, and nothing else. Deliberately excludes the + * docstring — a 300-caller rail would otherwise ship 300 docstrings. + */ +export interface WireNodeRef { + id: string; + kind: NodeKind; + name: string; + qualifiedName: string; + /** Project-relative, forward slashes on every platform. */ + file: string; + line: number; + endLine: number; + language: Language; + signature?: string; + exported?: boolean; + /** The file this symbol lives in looks like test/fixture code. */ + test: boolean; +} + +/** The focal symbol of a Symbol view — the ref, plus everything the header shows. */ +export interface WireNodeDetail extends WireNodeRef { + startColumn: number; + endColumn: number; + docstring?: string; + visibility?: string; + async?: boolean; + static?: boolean; + abstract?: boolean; + decorators?: string[]; + typeParameters?: string[]; + returnType?: string; + /** `endLine - line + 1`, so the header can print "N lines" without the source. */ + lines: number; +} + +const rel = (p: string): string => p.replace(/\\/g, '/'); + +export function toNodeRef(node: Node): WireNodeRef { + const file = rel(node.filePath); + const ref: WireNodeRef = { + id: node.id, + kind: node.kind, + name: node.name, + qualifiedName: node.qualifiedName, + file, + line: node.startLine, + endLine: node.endLine, + language: node.language, + test: isTestFile(file), + }; + if (node.signature) ref.signature = node.signature; + if (node.isExported) ref.exported = true; + return ref; +} + +export function toNodeDetail(node: Node): WireNodeDetail { + const detail: WireNodeDetail = { + ...toNodeRef(node), + startColumn: node.startColumn, + endColumn: node.endColumn, + lines: Math.max(1, node.endLine - node.startLine + 1), + }; + if (node.docstring) detail.docstring = node.docstring; + if (node.visibility) detail.visibility = node.visibility; + if (node.isAsync) detail.async = true; + if (node.isStatic) detail.static = true; + if (node.isAbstract) detail.abstract = true; + if (node.decorators?.length) detail.decorators = node.decorators; + if (node.typeParameters?.length) detail.typeParameters = node.typeParameters; + if (node.returnType) detail.returnType = node.returnType; + return detail; +} + +// ============================================================================= +// Edge shapes +// ============================================================================= + +/** + * One edge, flattened. + * + * `metadata` is a free-form JSON blob in the schema; the fields lifted out here + * are the ones the viewer draws with — confidence decides the uncertain fold, + * `provenance`/`synthesizedBy`/`via`/`registeredAt` decide how a connector is + * dashed and what the "via " 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; + 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 { + total: number; + shown: number; + truncated: boolean; + items: T[]; +} + +export function wireList(items: T[], total: number): WireList { + 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 +): WireRelation[] { + const byNode = new Map(); + 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 | 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 = new Set([ + 'interface', + 'type_alias', + 'class', + 'struct', + 'enum', + 'union', + 'trait', + 'protocol', +]); + +/** Container kinds whose members the outline nests one level deeper. */ +export const CONTAINER_KINDS: ReadonlySet = new Set([ + '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 = new Set([ + 'calls', + 'references', + 'imports', + 'instantiates', +]); + +export { rel as toPosixPath }; diff --git a/src/ui-server/index.ts b/src/ui-server/index.ts index a453826..be60d85 100644 --- a/src/ui-server/index.ts +++ b/src/ui-server/index.ts @@ -2,9 +2,10 @@ * The `codegraph ui` server. * * A loopback-only, read-only `node:http` server that hands the browser the - * built viewer (`dist/viewer/`) and — once the JSON API lands on the `api` seam - * below — a read-only view of one indexed project. No framework, no new - * dependency: it answers GET, serves files, and refuses everything else. + * built viewer (`dist/viewer/`) and, through the JSON API mounted on the `api` + * seam below (`./api`), a read-only view of one indexed project. No framework, + * no new dependency: it answers GET, serves files, and refuses everything + * else. * * The interesting part is not the routing, it is the boundary in `security.ts`. * Read that first. @@ -43,6 +44,8 @@ export { } from './security'; export { browserOpenCommand, openBrowser } from './open-browser'; export { contentTypeFor, cacheControlFor } from './static'; +export { createGraphApi, GraphSession, ApiError } from './api'; +export type { GraphApi, GraphApiOptions } from './api'; /** @@ -251,8 +254,16 @@ async function handleRequest( // Checked on the RAW url, before WHATWG parsing folds `..` segments away. const rawPath = (req.url ?? '/').split(/[?#]/)[0] ?? '/'; + // The `/api/` namespace answers JSON for EVERY outcome, refusals included: + // the viewer parses these responses, and a text/plain body here would surface + // as a parse error instead of the refusal it actually is. + const jsonNamespace = rawPath === '/api' || rawPath.startsWith('/api/'); if (!isSafeRequestPath(rawPath)) { - sendText(res, 404, 'Not found', method); + if (jsonNamespace) { + sendJson(res, 404, { error: 'Not found', code: 'not-found' }, method); + } else { + sendText(res, 404, 'Not found', method); + } return; }