feat(ui): entry points — routes, executable files and tests as flow starting points (CG-54)

`#/entry` answers "where does anything start" at full length, and turns any row
that names a symbol into a flow.

Server. `/api/entrypoints` gains `frameworks` (from `getDetectedFrameworks`), a
`tests` list, a `routes` limit of its own, and a cache keyed on the index build
— nothing here is read from disk, so unlike `/api/source` a cached answer cannot
be stale about drift. `routes.items` is now a `WireList` like every other list on
the payload.

Routes carry where the URL is REGISTERED as well as where it is served:
`getRoutingManifest` selects the route node's id, file and line, and
`buildRoutes` splits the verb off the name against a fixed list (never "the
first word", which would take the head off a file-routed `/blog/[slug]`). All
four payroll-go routes register in one router file and three are served from
another — group by the handler file and one router becomes two groups plus an
orphan.

`isTestFile` is split into `isTestPath` (test filename and directory
conventions) + the non-production catch-all, byte-identical at every existing
call site. The Tests list uses the narrow half: an example, a benchmark or a
fixture is off-target for ranking but is not a test, and a heading that says
"Tests" must not quietly count them. Tests rank by REACH — distinct other files
touched — because Go, Rust and Java put test work inside functions where a
module-level-calls ranking sees nothing. Two read-only engine queries make that
affordable: `getFileReachCounts` (the mirror of `getFileDependentCounts`, driven
from `nodes` by path so the cost follows the files asked about rather than the
edge table) and `getFileNodes`.

Viewer. `ui/src/lib/entry-model.ts` folds the four lists into file groups —
pure, and `panel.rows` stays exactly the sections it draws. `EntryView` +
`EntrySection` render them with the caller rail's `.filegroup` / `.row` shapes
rather than a second visual language for the same idea. A row that names a
callable symbol carries a `Flow ›` chip; the other end is typed or picked with
`→ here` on another row. File and test rows carry none: `/api/flow` searches by
name, and a file has none the path finder can look up.

A project with fewer than three resolvable routes gets no Routes heading at all,
not an empty one. Typing into the search box now also returns matching entry
points under their own heading below the symbol matches, so a URL comes back
with its handler attached; rows already in the results are dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-27 05:20:15 -05:00
co-authored by Claude Opus 5
parent dc7f1e590e
commit 94f4e287e6
28 changed files with 2400 additions and 81 deletions
+71 -2
View File
@@ -1024,7 +1024,17 @@ export class QueryBuilder {
* mapping AND the handler implementations.
*/
getRoutingManifest(limit: number = 40): {
entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>;
entries: Array<{
url: string;
handler: string;
handlerFile: string;
handlerLine: number;
handlerKind: string;
/** The route node itself: where the URL is REGISTERED, not where it is served. */
routeId: string;
routeFile: string;
routeLine: number;
}>;
topHandlerFile: string | null;
topHandlerFileCount: number;
totalRoutes: number;
@@ -1036,6 +1046,9 @@ export class QueryBuilder {
this.stmts.getRoutingManifest = this.db.prepare(`
SELECT
r.name AS url,
r.id AS route_id,
r.file_path AS route_file,
r.start_line AS route_line,
h.name AS handler,
h.file_path AS handler_file,
h.start_line AS handler_line,
@@ -1051,7 +1064,8 @@ export class QueryBuilder {
`);
}
const rows = this.stmts.getRoutingManifest.all(limit) as Array<{
url: string; handler: string; handler_file: string; handler_line: number; handler_kind: string;
url: string; route_id: string; route_file: string; route_line: number;
handler: string; handler_file: string; handler_line: number; handler_kind: string;
}>;
// Drop test/generated handlers — same hygiene as elsewhere.
const generated = this.getGeneratedPathsAmong(rows.map(r => r.handler_file));
@@ -1077,6 +1091,9 @@ export class QueryBuilder {
handlerFile: r.handler_file,
handlerLine: r.handler_line,
handlerKind: r.handler_kind,
routeId: r.route_id,
routeFile: r.route_file,
routeLine: r.route_line,
})),
topHandlerFile,
topHandlerFileCount,
@@ -2076,6 +2093,58 @@ export class QueryBuilder {
.all(JSON.stringify(filePaths)) as Array<{ filePath: string; dependents: number }>;
}
/**
* How far each of the given files reaches OUT: distinct other files its
* symbols touch, and how many references that is.
*
* The mirror of {@link getFileDependentCounts}, and the same reasoning about
* `contains` and same-file edges applies. It is driven from `nodes` rather
* than from `edges` so the work is proportional to the files asked about —
* the entry-points endpoint asks it about every test file in the index, and
* an edge-first plan would scan the whole table to answer a question about a
* tenth of it.
*/
getFileReachCounts(filePaths: string[]): Array<{ filePath: string; reaches: number; refs: number }> {
if (filePaths.length === 0) return [];
return this.db
.prepare(
`SELECT sn.file_path AS filePath,
COUNT(DISTINCT tn.file_path) AS reaches,
COUNT(*) AS refs
FROM nodes sn
JOIN edges e ON e.source = sn.id
JOIN nodes tn ON tn.id = e.target
WHERE sn.file_path IN (SELECT value FROM json_each(?))
AND e.kind != 'contains'
AND tn.file_path <> sn.file_path
GROUP BY sn.file_path`
)
.all(JSON.stringify(filePaths)) as Array<{
filePath: string;
reaches: number;
refs: number;
}>;
}
/**
* The `file` nodes for the given paths, in one query.
*
* A file's own node is what makes a file row navigable, and looking it up
* with {@link getNodesInFile} means materialising every symbol in the file to
* throw all but one away.
*/
getFileNodes(filePaths: string[]): Node[] {
if (filePaths.length === 0) return [];
const rows = this.db
.prepare(
`SELECT * FROM nodes
WHERE kind = 'file'
AND file_path IN (SELECT value FROM json_each(?))`
)
.all(JSON.stringify(filePaths)) as NodeRow[];
return rows.map(rowToNode);
}
/**
* Roll the whole edge table up to module granularity in one pass.
*