feat(ui): the search palette, entry points and a trail that survives the URL (CG-45)
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e9596af1cf
commit
87afc50e76
@@ -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
|
||||
|
||||
@@ -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<string, number> {
|
||||
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
|
||||
|
||||
@@ -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<NodeKind> = new Set<NodeKind>([
|
||||
'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<WireEntryFile>;
|
||||
hubs: WireList<WireEntryHub>;
|
||||
}
|
||||
|
||||
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<WireEntryFile> {
|
||||
const ranked = cg.getTopCallingFiles(SCAN_ROWS);
|
||||
|
||||
const kept: Array<{ node: Node; calls: number; reaches: number }> = [];
|
||||
const perDir = new Map<string, number>();
|
||||
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<WireEntryHub> {
|
||||
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);
|
||||
}
|
||||
@@ -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/<id> 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/<path> 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/<id>', 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/<path>', description: 'One file: outline and import rails.' },
|
||||
{ path: '/api/routes', description: 'URL to handler map, when the project is a routed app.', params: ['limit'] },
|
||||
{
|
||||
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:
|
||||
|
||||
@@ -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:<hash>` 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>&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 };
|
||||
}
|
||||
Reference in New Issue
Block a user