From 87afc50e76ac71c8b1e6af0b9d355d43614c1241 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 27 Aug 2026 00:54:18 -0500 Subject: [PATCH] feat(ui): the search palette, entry points and a trail that survives the URL (CG-45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search: `/` or ⌘K focuses the box; results arrive grouped by kind with their glyph, signature and file:line, ↑/↓/Enter walk them, Esc dismisses. A group appears where its best result did, so flattening the groups reproduces the ranking the keyboard walks — the panel's flat item list IS that concatenation. A flow question ("how does X reach Y", "X -> Y") is recognised and searches both endpoints with a note, rather than offering a row that would land on the phase-2 Flow view. Entry points answer "where do I start" on the empty screen and in the resting palette, all derived from the graph: routes, files that run something at module level (the engine records a top-level statement as an edge out of the file node, which is what makes src/bin/codegraph.ts the root of the CLI flow — ranked by calls x the files they reach, so a registration table calling into itself does not outrank the CLI), and the most depended-on symbols. Tests are excluded from both derived lists. Trail: hops record the direction they were walked (→ into a call, ← up to a caller), clicking one truncates back to it, Clear keeps the place instead of throwing it away, and the whole walk travels in the URL. A shared or reloaded trail arrives as ids, so hops learn their names back through a new batch endpoint and a session name cache — without it, walking back across a truncation redrew earlier hops as raw hashes. "Read as flow" stays hidden until there is a Flow view to send it to. New endpoints: /api/entrypoints and /api/nodes. New engine reads: getTopCallingFiles, getFileDependentCounts. Co-Authored-By: Claude Opus 5 --- __tests__/ui-search-model.test.ts | 324 ++++++++++++++++++++ __tests__/ui-server-api.test.ts | 136 ++++++++ docs/design/codegraph-ui-design-spec.md | 14 + src/db/queries.ts | 88 ++++++ src/index.ts | 23 ++ src/ui-server/api/entrypoints.ts | 184 +++++++++++ src/ui-server/api/index.ts | 18 +- src/ui-server/api/nodes.ts | 54 ++++ ui/src/App.svelte | 14 +- ui/src/components/PaletteRows.svelte | 150 +++++++++ ui/src/components/SearchPalette.svelte | 82 +++++ ui/src/components/TopBar.svelte | 88 +++++- ui/src/components/TrailBar.svelte | 31 +- ui/src/components/symbol/CallersRail.svelte | 1 + ui/src/lib/api.ts | 88 ++++++ ui/src/lib/palette.svelte.ts | 191 ++++++++++++ ui/src/lib/search-model.ts | 266 ++++++++++++++++ ui/src/lib/trail-codec.ts | 67 ++++ ui/src/lib/trail.svelte.ts | 112 ++++--- ui/src/views/HomeView.svelte | 62 ++++ 20 files changed, 1926 insertions(+), 67 deletions(-) create mode 100644 __tests__/ui-search-model.test.ts create mode 100644 src/ui-server/api/entrypoints.ts create mode 100644 src/ui-server/api/nodes.ts create mode 100644 ui/src/components/PaletteRows.svelte create mode 100644 ui/src/components/SearchPalette.svelte create mode 100644 ui/src/lib/palette.svelte.ts create mode 100644 ui/src/lib/search-model.ts create mode 100644 ui/src/lib/trail-codec.ts diff --git a/__tests__/ui-search-model.test.ts b/__tests__/ui-search-model.test.ts new file mode 100644 index 0000000..1d7759b --- /dev/null +++ b/__tests__/ui-search-model.test.ts @@ -0,0 +1,324 @@ +/** + * The search palette and the trail, without a browser (CG-45). + * + * Two things here can be silently wrong rather than merely ugly. The palette's + * flat item list must be exactly the concatenation of the sections it draws, or + * ↑/↓/Enter follows a different row than the one under the highlight. And the + * trail's wire format must round-trip, because it is the whole reason a walk + * survives a reload or travels in a shared link. + * + * The geometry-free half of the same split as `ui-symbol-model.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildEntryPalette, + buildSearchPalette, + groupByKind, + interleaveResults, + kindGroupTitle, + locationOf, + moveSelection, + parseFlowQuery, +} from '../ui/src/lib/search-model'; +import { decodeTrail, encodeTrail, hopLabel, type TrailHop } from '../ui/src/lib/trail-codec'; +import type { WireEntryPoints, WireSearch, WireSearchResult } from '../ui/src/lib/api'; + +/* ------------------------------------------------------------- fixtures -- */ + +function result(over: Partial = {}): WireSearchResult { + return { + id: over.id ?? `method:${over.name ?? 'load'}`, + kind: 'method', + name: 'load', + qualifiedName: 'Service::load', + file: 'src/service.ts', + line: 42, + endLine: 60, + language: 'typescript', + test: false, + matchKind: 'exact', + ...over, + } as WireSearchResult; +} + +function answer(items: WireSearchResult[]): WireSearch { + return { + query: 'q', + text: 'q', + filters: { kinds: [], languages: [], paths: [], names: [] }, + results: { total: items.length, shown: items.length, truncated: false, items }, + groups: [], + }; +} + +/* ----------------------------------------------------------- flow query -- */ + +describe('the flow grammar', () => { + it('recognises the three shapes the placeholder advertises', () => { + expect(parseFlowQuery('how does execute reach getFile')).toEqual({ + from: 'execute', + to: 'getFile', + }); + expect(parseFlowQuery('execute -> getFile')).toEqual({ from: 'execute', to: 'getFile' }); + expect(parseFlowQuery('execute → getFile')).toEqual({ from: 'execute', to: 'getFile' }); + expect(parseFlowQuery(' sync reaches indexFile? ')).toEqual({ + from: 'sync', + to: 'indexFile', + }); + }); + + it('asks about the last segment of a qualified name', () => { + // `Class.method` names the method; the class is how you say WHICH one, and + // the search ranks that out on its own. + expect(parseFlowQuery('how does CodeGraph.sync reach Cache.read')).toEqual({ + from: 'sync', + to: 'read', + }); + }); + + it('leaves an ordinary search alone', () => { + expect(parseFlowQuery('getImpactRadius')).toBeNull(); + expect(parseFlowQuery('kind:class Cache')).toBeNull(); + expect(parseFlowQuery('how does this work')).toBeNull(); + // A symbol reaching itself is not a path worth asking about. + expect(parseFlowQuery('sync -> sync')).toBeNull(); + }); +}); + +/* -------------------------------------------------------------- palette -- */ + +describe('the palette', () => { + it('flattens exactly what it draws, in draw order', () => { + const palette = buildSearchPalette( + [ + answer([ + result({ id: 'm1', name: 'load', kind: 'method' }), + result({ id: 'f1', name: 'loader', kind: 'function' }), + result({ id: 'm2', name: 'reload', kind: 'method' }), + ]), + ], + null + ); + + // Groups appear where their best result did, so flattening reproduces the + // ranking the keyboard walks. + expect(palette.sections.map((s) => s.title)).toEqual(['Methods', 'Function']); + expect(palette.items.map((i) => i.id)).toEqual(['m1', 'm2', 'f1']); + expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items)); + expect(palette.empty).toBeNull(); + }); + + it('says nothing matched instead of drawing an empty box', () => { + const palette = buildSearchPalette([answer([])], null); + expect(palette.items).toEqual([]); + expect(palette.empty).toContain('No symbol or file'); + }); + + it('interleaves a flow question so neither endpoint outranks the other', () => { + const a = [result({ id: 'a1' }), result({ id: 'a2' })]; + const b = [result({ id: 'b1' }), result({ id: 'b2' })]; + expect(interleaveResults(a, b).map((r) => r.id)).toEqual(['a1', 'b1', 'a2', 'b2']); + + // A symbol that matched both halves keeps its earliest position. + expect(interleaveResults(a, [result({ id: 'a2' })]).map((r) => r.id)).toEqual(['a1', 'a2']); + }); + + it('explains that a flow question is answered by both endpoints for now', () => { + const palette = buildSearchPalette( + [answer([result({ id: 'a', name: 'sync' })]), answer([result({ id: 'b', name: 'read' })])], + { from: 'sync', to: 'read' } + ); + expect(palette.items.map((i) => i.id)).toEqual(['a', 'b']); + expect(palette.hint).toContain('sync'); + expect(palette.hint).toContain('read'); + }); + + it('names a kind bucket in sentence case, singular when there is one', () => { + expect(kindGroupTitle('method', 3)).toBe('Methods'); + expect(kindGroupTitle('method', 1)).toBe('Method'); + expect(kindGroupTitle('type_alias', 2)).toBe('Type aliases'); + expect(kindGroupTitle('class', 2)).toBe('Classes'); + }); + + it('locates a symbol by file and line, and a file by its directory', () => { + expect(locationOf(result({ file: 'src/mcp/tools.ts', line: 412 }))).toBe('tools.ts:412'); + // The name column is already the basename; repeating the path says nothing. + expect( + locationOf(result({ kind: 'file', file: 'src/bin/codegraph.ts', name: 'codegraph.ts' })) + ).toBe('src/bin'); + expect(locationOf(result({ kind: 'file', file: 'README.md', name: 'README.md' }))).toBe( + 'project root' + ); + }); + + it('groups by kind without losing a row', () => { + const results = [ + result({ id: '1', kind: 'class' }), + result({ id: '2', kind: 'method' }), + result({ id: '3', kind: 'class' }), + ]; + const sections = groupByKind(results); + expect(sections.map((s) => s.title)).toEqual(['Classes', 'Method']); + expect(sections.flatMap((s) => s.items).map((i) => i.id)).toEqual(['1', '3', '2']); + }); + + it('wraps the selection at both ends', () => { + expect(moveSelection(0, -1, 3)).toBe(2); + expect(moveSelection(2, 1, 3)).toBe(0); + expect(moveSelection(0, 1, 3)).toBe(1); + // An empty list has one legal selection, and it is not -1. + expect(moveSelection(0, 1, 0)).toBe(0); + }); +}); + +/* --------------------------------------------------------- entry points -- */ + +function entryPoints(over: Partial = {}): WireEntryPoints { + return { + routes: { routed: false, routeCount: 0, items: [] }, + files: { + total: 2, + shown: 2, + truncated: false, + items: [ + { + ...result({ id: 'file:src/bin/codegraph.ts', kind: 'file', name: 'codegraph.ts' }), + file: 'src/bin/codegraph.ts', + calls: 9, + reaches: 37, + dependents: 3, + }, + ] as any, + }, + hubs: { + total: 1, + shown: 1, + truncated: false, + items: [{ ...result({ id: 'method:get', name: 'get' }), dependents: 264 }] as any, + }, + ...over, + } as WireEntryPoints; +} + +describe('the entry points', () => { + it('says what each row is derived from, not that it IS the entry point', () => { + const palette = buildEntryPalette(entryPoints()); + + expect(palette.sections.map((s) => s.title)).toEqual([ + 'Files that run something', + 'Most depended on', + ]); + expect(palette.sections[0]?.items[0]?.meta).toBe( + '9 calls at module level · reaches 37 files' + ); + expect(palette.sections[1]?.items[0]?.meta).toBe('264 dependents'); + expect(palette.items).toHaveLength(2); + }); + + it('puts routes first, and carries the id that makes a row clickable', () => { + const palette = buildEntryPalette( + entryPoints({ + routes: { + routed: true, + routeCount: 4, + items: [ + { + url: 'GET /users', + handler: 'listUsers', + file: 'src/routes.ts', + line: 11, + handlerId: 'function:listUsers', + }, + ], + }, + }) + ); + + expect(palette.sections[0]?.title).toBe('Routes'); + const row = palette.items[0]; + expect(row?.type).toBe('route'); + if (row?.type === 'route') { + expect(row.url).toBe('GET /users'); + expect(row.nodeId).toBe('function:listUsers'); + expect(row.location).toBe('routes.ts:11'); + } + }); + + it('shortens each section for the panel under the box', () => { + const many = entryPoints(); + (many.hubs.items as any) = Array.from({ length: 10 }, (_, i) => ({ + ...result({ id: `m${i}`, name: `hub${i}` }), + dependents: 100 - i, + })); + expect(buildEntryPalette(many, { perSection: 3 }).items).toHaveLength(4); + expect(buildEntryPalette(many).items).toHaveLength(11); + }); + + it('draws nothing at all before the answer arrives', () => { + const palette = buildEntryPalette(null); + expect(palette.sections).toEqual([]); + // Not an "empty" message: nothing is known yet, and saying "this index has + // nothing" while the request is in flight would be a claim, not a state. + expect(palette.empty).toBeNull(); + }); +}); + +/* ----------------------------------------------------------------- trail -- */ + +function hop(id: string, dir: TrailHop['dir']): TrailHop { + return { id, name: null, kind: null, dir }; +} + +describe('the trail in the URL', () => { + it('round-trips six hops with their directions intact', () => { + const walked: TrailHop[] = [ + hop('method:a', 'start'), + hop('method:b', 'down'), + hop('method:c', 'down'), + hop('method:d', 'up'), + hop('method:e', 'down'), + hop('file:src/bin/codegraph.ts', 'up'), + ]; + + const encoded = encodeTrail(walked); + const decoded = decodeTrail(encoded); + + expect(decoded).toHaveLength(6); + expect(decoded.map((h) => h.id)).toEqual(walked.map((h) => h.id)); + expect(decoded.map((h) => h.dir)).toEqual(['start', 'down', 'down', 'up', 'down', 'up']); + // Re-encoding is byte-identical, which is what makes a shared link stable. + expect(encodeTrail(decoded)).toBe(encoded); + }); + + it('keeps an id that begins with a direction letter', () => { + // `union:…` and `default:…` start with 'u' and 'd'; an optional direction + // prefix would swallow the first character of the id. + const hops = [hop('union:Shape', 'start'), hop('declaration:x', 'down')]; + expect(decodeTrail(encodeTrail(hops)).map((h) => h.id)).toEqual([ + 'union:Shape', + 'declaration:x', + ]); + }); + + it('survives an id carrying the separator, and a hand-mangled param', () => { + const hops = [hop('file:src/a,b.ts', 'start')]; + expect(decodeTrail(encodeTrail(hops))[0]?.id).toBe('file:src/a,b.ts'); + + expect(decodeTrail(null)).toEqual([]); + expect(decodeTrail('')).toEqual([]); + // A token with no direction letter is dropped; a lone '%' would throw in + // decodeURIComponent, so the raw text is kept instead — a hop that names + // nothing is better than a trail that silently loses a position. + expect(decodeTrail('x,,smethod%3Aa,d%')).toEqual([ + { id: 'method:a', name: null, kind: null, dir: 'start' }, + { id: '%', name: null, kind: null, dir: 'down' }, + ]); + }); + + it('labels an unresolved hop with something readable, never a raw hash', () => { + expect(hopLabel({ ...hop('method:x', 'down'), name: 'load' })).toBe('load'); + expect(hopLabel(hop('file:src/bin/codegraph.ts', 'start'))).toBe('codegraph.ts'); + expect(hopLabel(hop('method:ada8ef1603fc03e3566eec72dc91138f', 'down'))).toBe('ada8ef16…'); + }); +}); diff --git a/__tests__/ui-server-api.test.ts b/__tests__/ui-server-api.test.ts index 225af35..3877265 100644 --- a/__tests__/ui-server-api.test.ts +++ b/__tests__/ui-server-api.test.ts @@ -175,6 +175,22 @@ export function handleRequest(service: Service, key: string): string { ` ); + // Module-level statements: the engine records them as edges out of the FILE + // node, which is the only reason `/api/entrypoints` can see an executable + // root at all. Nothing else in the fixture runs anything on the way down. + fs.writeFileSync( + path.join(srcDir, 'main.ts'), + `import { Service } from './service'; +import { handleRequest } from './handler'; + +const service = new Service({ ttlMs: 5, label: 'main' }); +const first = handleRequest(service, 'boot'); +const second = service.load('warm'); + +export const started = [first, second]; +` + ); + // 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. @@ -209,6 +225,10 @@ export function testLoadsThroughCache(): void { const service = new Service({ ttlMs: 1, label: 'x' }); service.load('k'); } + +// Module level, on purpose: a test file that RUNS something must still be +// excluded from the entry points. +testLoadsThroughCache(); ` ); @@ -910,6 +930,20 @@ export default app; expect(handler.node.name).toBe('listUsers'); }); + it('offers its routes as entry points, ahead of anything derived', async () => { + const res = await requestOn(routedServer.port, '/api/entrypoints'); + const body = JSON.parse(res.body); + + expect(body.routes.routed).toBe(true); + expect(body.routes.routeCount).toBe(4); + const urls = body.routes.items.map((e: any) => e.url); + expect(urls).toEqual( + expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id']) + ); + // A route row has to be navigable, or it is a label. + expect(body.routes.items.every((e: any) => e.handlerId)).toBe(true); + }); + 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); @@ -998,6 +1032,108 @@ describe.runIf(CodeGraph.isInitialized(path.resolve(__dirname, '..')))( } ); +describe('GET /api/entrypoints', () => { + it('finds the file that runs something, and reports what it reaches', async () => { + const body = await getJson('/api/entrypoints'); + + const files = body.files.items.map((f: any) => f.file); + expect(files).toContain('src/main.ts'); + + const main = body.files.items.find((f: any) => f.file === 'src/main.ts'); + expect(main.kind).toBe('file'); + expect(main.id).toMatch(/^file:/); + // `new Service(...)`, `handleRequest(...)` and `service.load(...)` all sit + // at module level. + expect(main.calls).toBeGreaterThanOrEqual(2); + // It imports from service.ts and handler.ts, so it wires files together. + expect(main.reaches).toBeGreaterThanOrEqual(2); + expect(typeof main.dependents).toBe('number'); + }); + + it('leaves test files out — "where do I start" never means a test', async () => { + const body = await getJson('/api/entrypoints'); + + for (const file of body.files.items) expect(file.test).toBe(false); + // The fixture's test file calls its own helper at module level, so it IS a + // candidate by the raw graph signal and is excluded deliberately. + expect(body.files.items.map((f: any) => f.file)).not.toContain( + '__tests__/service.test.ts' + ); + for (const hub of body.hubs.items) expect(hub.test).toBe(false); + }); + + it('ranks the most depended-on symbols as hubs, with their dependent counts', async () => { + const body = await getJson('/api/entrypoints'); + + const hot = body.hubs.items.find((h: any) => h.name === 'hot'); + expect(hot, 'the 500-caller function should top the hubs').toBeTruthy(); + expect(hot.dependents).toBe(500); + expect(body.hubs.items[0].name).toBe('hot'); + + const counts = body.hubs.items.map((h: any) => h.dependents); + expect(counts).toEqual([...counts].sort((a: number, b: number) => b - a)); + // A file or a bare import is structure, not somewhere to start reading. + for (const hub of body.hubs.items) { + expect(['file', 'import', 'export', 'parameter']).not.toContain(hub.kind); + } + }); + + it('says a project without routes is not routed rather than failing', async () => { + const body = await getJson('/api/entrypoints'); + expect(body.routes.routed).toBe(false); + expect(body.routes.items).toEqual([]); + expect(body.routes.routeCount).toBe(0); + }); + + it('honours limit, and keeps every list within it', async () => { + const body = await getJson('/api/entrypoints?limit=1'); + expect(body.files.items.length).toBeLessThanOrEqual(1); + expect(body.hubs.items.length).toBe(1); + expect(body.hubs.total).toBeGreaterThanOrEqual(body.hubs.items.length); + + const bad = await getStatusAndJson('/api/entrypoints?limit=0'); + expect(bad.status).toBe(400); + expect(bad.body.code).toBe('bad-request'); + }); +}); + +describe('GET /api/nodes', () => { + it('answers a batch of ids in the order asked, and says which are missing', async () => { + const cacheId = await idOf('Cache', 'class'); + const loadId = await idOf('load', 'method'); + const body = await getJson( + `/api/nodes?id=${encodeURIComponent(loadId)}&id=${encodeURIComponent(cacheId)}&id=method%3Anot-a-real-id` + ); + + expect(body.items.map((n: any) => n.id)).toEqual([loadId, cacheId]); + expect(body.items[0].name).toBe('load'); + expect(body.items[1].name).toBe('Cache'); + expect(body.missing).toEqual(['method:not-a-real-id']); + // The REF shape, not the Symbol view payload: a trail redraws six names, + // not six rail sets. + expect(body.items[0].incoming).toBeUndefined(); + expect(body.items[0].file).toBe('src/service.ts'); + }); + + it('de-duplicates ids rather than answering twice', async () => { + const cacheId = await idOf('Cache', 'class'); + const encoded = encodeURIComponent(cacheId); + const body = await getJson(`/api/nodes?id=${encoded}&id=${encoded}`); + expect(body.items).toHaveLength(1); + }); + + it('refuses an empty or oversized request with guidance', async () => { + const none = await getStatusAndJson('/api/nodes'); + expect(none.status).toBe(400); + expect(none.body.hint).toContain('id='); + + const ids = Array.from({ length: 61 }, (_, i) => `id=method%3A${i}`).join('&'); + const many = await getStatusAndJson(`/api/nodes?${ids}`); + expect(many.status).toBe(400); + expect(many.body.error).toContain('Too many ids'); + }); +}); + 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-')); diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index 8cb84b6..ae7010d 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -168,6 +168,20 @@ Results panel under the input: 1px `--ink` border, max-height 420px; group heade rows grid `18px | 1fr | auto`, 6px 10px, `--rule-faint` separators, selected/hover `--press`; name 12.5px mono + signature 11.5px mono `--ink-3` + location 11px mono. Flow grammar: "how does X reach Y", "X -> Y", "X → Y". +**As built (phase 1, CG-45).** Group headers are the result's KIND — `Methods`, `Functions`, +`Classes`, `Files` — a group appearing where its best result did, so flattening the groups +reproduces the ranking ↑/↓ walks. The prototype's two-group split (`Flow` / `Symbols & files`) +waits for the Flow view: a flow question is recognised now, but until there is a path to draw it +searches both endpoints and says so in one line above the results rather than offering a row that +lands on a placeholder. A file's row shows its basename with its DIRECTORY in the location column — +its name column already carries the path, and printing it twice reads as an error. + +At rest — an empty box, or the empty screen — the panel shows **entry points** from +`/api/entrypoints`: routes (URL → handler), files that run something at module level (a CLI, a +worker entry, a script — ranked by calls × the number of other files they reach), and the most +depended-on symbols. Each section says what it is derived from, never that a file IS the entry +point. + ## 4. Libraries and versions - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges, hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a diff --git a/src/db/queries.ts b/src/db/queries.ts index afe017c..ff58424 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1987,6 +1987,94 @@ export class QueryBuilder { return rows; } + /** + * The graph's executable roots — files that RUN something at module level, + * ranked by how much of the project they set in motion. + * + * The engine records a statement at the top level of a file as an edge from + * the *file* node, so `src/bin/codegraph.ts` calling `program.parse()` at + * module scope is a `calls` edge out of a `file`. That set is what makes the + * roots of a dependency graph visible: a library module holds definitions and + * runs nothing until someone imports it, while a CLI, a worker entry or a + * build script does its work on the way down the file. `instantiates` counts + * the same way — `new Server(...)` at module scope is the same act. + * + * Ranking multiplies the two things an entry point does: it runs (calls), and + * it wires the project together (distinct other files its symbols reach). One + * alone is misleading — a registration table makes hundreds of module-level + * calls into itself, and a barrel file imports everything and runs nothing. + * The product puts the file that does both at the top. + */ + getTopCallingFiles( + limit: number + ): Array<{ nodeId: string; filePath: string; calls: number; reaches: number; score: number }> { + if (limit <= 0) return []; + return this.db + .prepare( + `WITH runs AS ( + SELECT e.source AS id, COUNT(*) AS calls + FROM edges e + JOIN nodes n ON n.id = e.source + WHERE n.kind = 'file' AND e.kind IN ('calls', 'instantiates') + GROUP BY e.source + ), + cand AS ( + SELECT r.id AS id, n.file_path AS fp, r.calls AS calls + FROM runs r JOIN nodes n ON n.id = r.id + ), + wires AS ( + SELECT sn.file_path AS fp, COUNT(DISTINCT tn.file_path) AS reaches + FROM edges e + JOIN nodes sn ON sn.id = e.source + JOIN nodes tn ON tn.id = e.target + WHERE e.kind != 'contains' + AND sn.file_path <> tn.file_path + AND sn.file_path IN (SELECT fp FROM cand) + GROUP BY sn.file_path + ) + SELECT c.id AS nodeId, + c.fp AS filePath, + c.calls AS calls, + COALESCE(w.reaches, 0) AS reaches, + c.calls * (1 + COALESCE(w.reaches, 0)) AS score + FROM cand c LEFT JOIN wires w ON w.fp = c.fp + ORDER BY score DESC, calls DESC, filePath + LIMIT ?` + ) + .all(limit) as Array<{ + nodeId: string; + filePath: string; + calls: number; + reaches: number; + score: number; + }>; + } + + /** + * How many OTHER files depend on each of the given files. + * + * Counted through the symbols, not the file nodes: an `imports` edge points + * at the imported symbol, so a file node almost never receives one and + * counting edges into it would report every file as depended on by nobody. + * Same-file edges are excluded, which is what makes zero mean "nothing else + * in the index reaches into this file" — the honest reading of a root. + */ + getFileDependentCounts(filePaths: string[]): Array<{ filePath: string; dependents: number }> { + if (filePaths.length === 0) return []; + return this.db + .prepare( + `SELECT tn.file_path AS filePath, COUNT(DISTINCT sn.file_path) AS dependents + FROM edges e + JOIN nodes tn ON tn.id = e.target + JOIN nodes sn ON sn.id = e.source + WHERE e.kind != 'contains' + AND tn.file_path IN (SELECT value FROM json_each(?)) + AND sn.file_path <> tn.file_path + GROUP BY tn.file_path` + ) + .all(JSON.stringify(filePaths)) as Array<{ filePath: string; dependents: number }>; + } + /** * 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 diff --git a/src/index.ts b/src/index.ts index 941ada9..bfed247 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1377,6 +1377,29 @@ export class CodeGraph { return this.queries.getTopDependedOn(limit); } + /** + * The graph's executable roots — files that run something at module level (a + * CLI, a worker entry, a script), ranked by calls x the number of other files + * they reach. A statement at the top level of a file is recorded as an edge + * out of the *file* node, which is what makes these visible at all. + */ + getTopCallingFiles( + limit: number + ): Array<{ nodeId: string; filePath: string; calls: number; reaches: number; score: number }> { + return this.queries.getTopCallingFiles(limit); + } + + /** + * How many other files depend on each of the given files, counted through + * their symbols (an `imports` edge points at the symbol, not the file). + * A zero means nothing else in the index reaches into that file. + */ + getFileDependentCounts(filePaths: string[]): Map { + return new Map( + this.queries.getFileDependentCounts(filePaths).map((row) => [row.filePath, row.dependents]) + ); + } + /** * 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 diff --git a/src/ui-server/api/entrypoints.ts b/src/ui-server/api/entrypoints.ts new file mode 100644 index 0000000..fe9277e --- /dev/null +++ b/src/ui-server/api/entrypoints.ts @@ -0,0 +1,184 @@ +/** + * `GET /api/entrypoints` — where to start reading a project you have never + * opened. + * + * The empty state and the resting search palette both have the same problem: + * a graph of thirteen thousand symbols and no obvious door. Three answers, + * every one of them derived from the graph rather than from a filename + * convention: + * + * - **Routes** — a request arriving from outside is the most literal entry a + * codebase has. Straight from the routing manifest (`/api/routes`), and + * absent for a project that is not a routed app. + * - **Files that run something** — the engine records a statement at the top + * level of a file as an edge out of the *file* node, so a CLI, a worker + * entry or a build script has `calls` where a library module has none. That + * is what makes `src/bin/codegraph.ts` the root of this repo's CLI flow. + * Ranked by calls x how many other files they reach, so the file that both + * runs and wires the project together outranks a registration table that + * makes a hundred module-level calls into itself. + * - **Hubs** — the most depended-on symbols. Not an entry in the "runs first" + * sense; an entry in the sense that reading one tells you the most about + * what the project is made of, and a change to one radiates furthest. + * + * Tests and fixtures are excluded from both derived lists. They are real code + * with real callers, but "where do I start reading" never means a test. + */ + +import type { CodeGraph } from '../../index'; +import type { Node, NodeKind } from '../../types'; +import { intParam } from './respond'; +import { buildRoutes } from './routes'; +import { isTestFile } from '../../search/query-utils'; +import { toNodeRef, wireList, type WireList, type WireNodeRef } from './wire'; + +/** Rows per derived list, and the default for `limit`. */ +const DEFAULT_LIMIT = 12; + +/** + * Ranked rows examined before the test filter and the per-directory cap run. + * + * Fixed rather than a multiple of `limit` so the same project answers with the + * same rows whatever the caller asks for. It also means the `total` on the two + * derived lists is a FLOOR — "at least this many" — because the tests it skips + * are only recognisable in JavaScript (`isTestFile` reads directory shapes and + * CamelCase suffixes that do not survive translation into SQL). That is the + * honest reading, and the viewer prints the rows rather than the count. + */ +const SCAN_ROWS = 400; + +/** + * At most this many executable files from any one directory. + * + * Without it a repo with twenty one-off scripts in `scripts/` answers "where do + * I start" with twenty scripts, and the CLI everybody actually wants falls off + * the end. Two keeps a directory represented without letting it own the list. + */ +const MAX_FILES_PER_DIR = 2; + +/** Kinds that are never a useful hub row: a mention, a container, or a name. */ +const NON_HUB_KINDS: ReadonlySet = new Set([ + 'file', + 'import', + 'export', + 'parameter', +]); + +export interface WireEntryFile extends WireNodeRef { + /** Calls and instantiations made at the top level of the file. */ + calls: number; + /** Distinct other files this one's symbols reach. */ + reaches: number; + /** Other files reaching into this one. Zero means nothing imports it. */ + dependents: number; +} + +export interface WireEntryHub extends WireNodeRef { + /** Distinct symbols that depend on this one. */ + dependents: number; +} + +export interface WireEntryPoints { + routes: { + routed: boolean; + routeCount: number; + items: Array<{ url: string; handler: string; file: string; line: number; handlerId: string | null }>; + }; + files: WireList; + hubs: WireList; +} + +export function buildEntryPoints(cg: CodeGraph, query: URLSearchParams): WireEntryPoints { + const limit = intParam(query, 'limit', { min: 1, max: 50, default: DEFAULT_LIMIT }); + + return { + routes: routeEntries(cg, limit), + files: executableFiles(cg, limit), + hubs: hubs(cg, limit), + }; +} + +/** + * The routing manifest, trimmed to a starting-points list. + * + * `buildRoutes` is reused rather than re-derived so a route row means exactly + * the same thing here as on the routes endpoint — including its handler id, + * which is what makes the row navigable. + */ +function routeEntries(cg: CodeGraph, limit: number): WireEntryPoints['routes'] { + const manifest = buildRoutes(cg, new URLSearchParams()) as { + routed: boolean; + routeCount: number; + entries: WireEntryPoints['routes']['items']; + }; + return { + routed: manifest.routed, + routeCount: manifest.routeCount, + items: manifest.entries.slice(0, limit), + }; +} + +/** + * Files that do something on the way down, most first. + * + * Over-fetched before filtering, because the two things that shrink the list — + * tests and the per-directory cap — are only knowable after the rows come back, + * and a project whose noisiest module-level callers are all test files would + * otherwise answer with an empty list. + */ +function executableFiles(cg: CodeGraph, limit: number): WireList { + const ranked = cg.getTopCallingFiles(SCAN_ROWS); + + const kept: Array<{ node: Node; calls: number; reaches: number }> = []; + const perDir = new Map(); + let eligible = 0; + + for (const row of ranked) { + if (isTestFile(row.filePath)) continue; + eligible += 1; + if (kept.length >= limit) continue; + const dir = directoryOf(row.filePath); + const taken = perDir.get(dir) ?? 0; + if (taken >= MAX_FILES_PER_DIR) continue; + const node = cg.getNode(row.nodeId); + if (!node) continue; + perDir.set(dir, taken + 1); + kept.push({ node, calls: row.calls, reaches: row.reaches }); + } + + const dependents = cg.getFileDependentCounts(kept.map((k) => k.node.filePath)); + const items: WireEntryFile[] = kept.map(({ node, calls, reaches }) => ({ + ...toNodeRef(node), + calls, + reaches, + dependents: dependents.get(node.filePath) ?? 0, + })); + + // `eligible` counts every non-test file the scan saw: a floor, never an + // overstatement. + return wireList(items, Math.max(eligible, items.length)); +} + +/** The most depended-on symbols, tests and non-navigable kinds removed. */ +function hubs(cg: CodeGraph, limit: number): WireList { + const ranked = cg.getTopDependedOn(SCAN_ROWS); + + const items: WireEntryHub[] = []; + let eligible = 0; + for (const row of ranked) { + const node = cg.getNode(row.nodeId); + if (!node || NON_HUB_KINDS.has(node.kind) || isTestFile(node.filePath)) continue; + eligible += 1; + if (items.length >= limit) continue; + items.push({ ...toNodeRef(node), dependents: row.dependents }); + } + + return wireList(items, Math.max(eligible, items.length)); +} + +/** `src/bin/codegraph.ts` -> `src/bin`; a root file -> `.`. */ +function directoryOf(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const cut = normalized.lastIndexOf('/'); + return cut < 0 ? '.' : normalized.slice(0, cut); +} diff --git a/src/ui-server/api/index.ts b/src/ui-server/api/index.ts index 2ae574d..cef8be1 100644 --- a/src/ui-server/api/index.ts +++ b/src/ui-server/api/index.ts @@ -1,7 +1,7 @@ /** * The read-only JSON API the viewer reads its screens from. * - * Six endpoints, one per screen, each answering in a single round-trip — the + * Eight 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. @@ -10,9 +10,11 @@ * 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/nodes?id=&id= names for ids you already have (the trail) * 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 + * GET /api/entrypoints where to start reading: routes, roots, hubs * ``` * * It mounts on the `api` seam of `startUiServer`, which means it sits *behind* @@ -33,10 +35,14 @@ import { buildNode } from './node'; import { buildSource } from './source'; import { buildFile } from './file'; import { buildRoutes } from './routes'; +import { buildEntryPoints } from './entrypoints'; +import { buildNodeRefs } from './nodes'; export { GraphSession } from './session'; export { ApiError } from './respond'; export * from './wire'; +export type { WireEntryPoints, WireEntryFile, WireEntryHub } from './entrypoints'; +export type { WireNodeRefs } from './nodes'; /** * A mounted API, plus the handle it holds open. @@ -62,6 +68,7 @@ const API_INDEX = { { 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/nodes', description: 'Names and locations for ids you already have.', params: ['id'] }, { path: '/api/source', description: 'Verbatim source for an indexed file, omitted when it has drifted on disk.', @@ -69,6 +76,11 @@ const API_INDEX = { }, { 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'] }, + { + path: '/api/entrypoints', + description: 'Where to start reading: routes, files that run something, and hubs.', + params: ['limit'], + }, ], }; @@ -87,6 +99,10 @@ export function createGraphApi(options: GraphApiOptions): GraphApi { 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/entrypoints': + return ok(res, buildEntryPoints(session.acquire(), ctx.query), ctx.method); + case '/api/nodes': + return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method); case '/api/source': return ok(res, buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method); default: diff --git a/src/ui-server/api/nodes.ts b/src/ui-server/api/nodes.ts new file mode 100644 index 0000000..42aa09d --- /dev/null +++ b/src/ui-server/api/nodes.ts @@ -0,0 +1,54 @@ +/** + * `GET /api/nodes?id=…&id=…` — names for ids you already have. + * + * The trail is the reason this exists. It travels in the URL, and a URL can + * only carry ids, so a shared or reloaded six-hop trail arrives as six opaque + * `method:` strings with nothing to draw. Every other screen learns a + * symbol's name as a side effect of asking for the symbol; the trail never + * asks, because it draws hops it is not looking at. + * + * Deliberately the ref shape (`WireNodeRef`) and not the Symbol view payload: + * six of those would ship six rail sets and six blast radiuses to render six + * words. Ids arrive as repeated `id` parameters rather than one comma-joined + * list — a node id can be a file path, and a file path can contain a comma. + */ + +import type { CodeGraph } from '../../index'; +import { badRequest } from './respond'; +import { toNodeRef, type WireNodeRef } from './wire'; + +/** Ids per request. A trail long enough to exceed this is not a trail. */ +export const MAX_NODE_REFS = 60; + +export interface WireNodeRefs { + items: WireNodeRef[]; + /** Ids that name nothing in this index — a stale link, not an error. */ + missing: string[]; +} + +export function buildNodeRefs(cg: CodeGraph, query: URLSearchParams): WireNodeRefs { + const ids = query.getAll('id').filter((id) => id !== ''); + if (ids.length === 0) { + throw badRequest( + 'No ids were given.', + 'Use /api/nodes?id=&id= — one `id` parameter per symbol.' + ); + } + if (ids.length > MAX_NODE_REFS) { + throw badRequest(`Too many ids: ${ids.length}. At most ${MAX_NODE_REFS} per request.`); + } + + const unique = [...new Set(ids)]; + const byId = cg.getNodesByIds(unique); + + const items: WireNodeRef[] = []; + const missing: string[] = []; + // Answer in the order asked, so the caller never has to re-sort. + for (const id of unique) { + const node = byId.get(id); + if (node) items.push(toNodeRef(node)); + else missing.push(id); + } + + return { items, missing }; +} diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 54f4322..540bd48 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -9,11 +9,9 @@ import FlowView from './views/FlowView.svelte'; import NotFoundView from './views/NotFoundView.svelte'; import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte'; - import { trail } from './lib/trail.svelte'; + import { trail, resolveTrailNames } from './lib/trail.svelte'; import { project } from './lib/project.svelte'; - let query = $state(''); - // One `/api/stats` for the whole app: the top bar's counts and the Symbol // view's blast-radius denominator come out of the same payload. $effect(() => { @@ -37,6 +35,14 @@ }); }); + // Hops restored from a URL carry ids and nothing else; one batched request + // turns the bar back into names. Runs after every trail change, and does + // nothing when every hop already has one. + $effect(() => { + void trail.hops.length; + void resolveTrailNames(); + }); + function isTypingTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; return ( @@ -84,7 +90,7 @@ - +
{#if route.view === 'symbol'} diff --git a/ui/src/components/PaletteRows.svelte b/ui/src/components/PaletteRows.svelte new file mode 100644 index 0000000..5c2b8a4 --- /dev/null +++ b/ui/src/components/PaletteRows.svelte @@ -0,0 +1,150 @@ + + +{#each palette.sections as section, s (section.title)} +
+ {section.title} + {#if section.note}{section.note}{/if} +
+ {#each section.items as item, r (item.id)} + {@const index = flatIndex(s, r)} + + {/each} +{/each} + + diff --git a/ui/src/components/SearchPalette.svelte b/ui/src/components/SearchPalette.svelte new file mode 100644 index 0000000..2d23aab --- /dev/null +++ b/ui/src/components/SearchPalette.svelte @@ -0,0 +1,82 @@ + + +
+ {#if view.hint} +

{view.hint}

+ {/if} + + palette.select(index)} + /> + + {#if palette.failure} +

{palette.failure}

+ {:else if palette.pending && view.items.length === 0} +

Searching…

+ {:else if view.empty} +

{view.empty}

+ {/if} +
+ + diff --git a/ui/src/components/TopBar.svelte b/ui/src/components/TopBar.svelte index 8927fbb..0b035c1 100644 --- a/ui/src/components/TopBar.svelte +++ b/ui/src/components/TopBar.svelte @@ -1,19 +1,19 @@ + +
@@ -51,19 +113,27 @@ Flow -