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:
co-authored by
Claude Opus 5
parent
dc7f1e590e
commit
94f4e287e6
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* `GET /api/entrypoints` — where to start reading a project you have never
|
||||
* opened.
|
||||
* opened, and where a flow starts.
|
||||
*
|
||||
* 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:
|
||||
* The empty state, the resting search palette and the entry-points panel all
|
||||
* have the same problem: a graph of thirteen thousand symbols and no obvious
|
||||
* door. Four 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.
|
||||
* absent for a project that is not a routed app. Carried with the file the
|
||||
* URL is REGISTERED in as well as the one that serves it, because a router
|
||||
* file is how a reader groups routes and the two are rarely the same file.
|
||||
* - **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
|
||||
@@ -17,24 +19,41 @@
|
||||
* 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.
|
||||
* - **Tests** — the other direction: not where the project starts, but what
|
||||
* already exercises it. Ranked by how many other files a test reaches, so
|
||||
* the suites that cross the most of the codebase come first.
|
||||
* - **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.
|
||||
* Tests and fixtures are excluded from the two *reading* lists — "where do I
|
||||
* start reading" never means a test — and the Tests list is built from the
|
||||
* narrow {@link isTestPath}, not from {@link isTestFile}: an example, a
|
||||
* benchmark or a fixture is not a test, and a heading that says "Tests" must
|
||||
* not be quietly counting them.
|
||||
*/
|
||||
|
||||
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';
|
||||
import { buildRoutes, type WireRoute } from './routes';
|
||||
import { isTestFile, isTestPath } from '../../search/query-utils';
|
||||
import { toNodeRef, toPosixPath, wireList, type WireList, type WireNodeRef } from './wire';
|
||||
|
||||
/** Rows per derived list, and the default for `limit`. */
|
||||
const DEFAULT_LIMIT = 12;
|
||||
|
||||
/**
|
||||
* Route rows, and the default for `routes`.
|
||||
*
|
||||
* Separate from `limit` because routes are the one list whose useful length is
|
||||
* set by the project rather than by the reader: a panel that groups 60 routes
|
||||
* under four router files is legible, while 60 rows of "most depended on" is
|
||||
* a wall. Both are honest — every list carries the real total.
|
||||
*/
|
||||
const DEFAULT_ROUTE_LIMIT = 60;
|
||||
const MAX_ROUTE_LIMIT = 300;
|
||||
|
||||
/**
|
||||
* Ranked rows examined before the test filter and the per-directory cap run.
|
||||
*
|
||||
@@ -56,6 +75,17 @@ const SCAN_ROWS = 400;
|
||||
*/
|
||||
const MAX_FILES_PER_DIR = 2;
|
||||
|
||||
/**
|
||||
* Test files asked about per reach query.
|
||||
*
|
||||
* The reach query is driven from `nodes` by file path, so its cost is
|
||||
* proportional to the files in the chunk rather than to the edge table — but a
|
||||
* repo with ten thousand test files would still put ten thousand paths into
|
||||
* one `json_each`. Chunking keeps every statement bounded WITHOUT capping the
|
||||
* candidate list, which would silently drop test files from the ranking.
|
||||
*/
|
||||
const TEST_CHUNK = 500;
|
||||
|
||||
/** 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',
|
||||
@@ -73,48 +103,131 @@ export interface WireEntryFile extends WireNodeRef {
|
||||
dependents: number;
|
||||
}
|
||||
|
||||
export interface WireEntryTest extends WireNodeRef {
|
||||
/** Distinct other files this test reaches — what it exercises. */
|
||||
reaches: number;
|
||||
/** References behind that reach. */
|
||||
refs: number;
|
||||
}
|
||||
|
||||
export interface WireEntryHub extends WireNodeRef {
|
||||
/** Distinct symbols that depend on this one. */
|
||||
dependents: number;
|
||||
}
|
||||
|
||||
export interface WireEntryPoints {
|
||||
/**
|
||||
* Frameworks the resolver detected, e.g. `["go"]`, `["express"]`.
|
||||
*
|
||||
* The Routes section's header names them: a route list is a claim about a
|
||||
* framework's conventions, and saying which one produced it is the
|
||||
* difference between a fact and an assertion.
|
||||
*/
|
||||
frameworks: string[];
|
||||
routes: {
|
||||
routed: boolean;
|
||||
/** Every `route` node in the graph, resolved handler or not. */
|
||||
routeCount: number;
|
||||
items: Array<{ url: string; handler: string; file: string; line: number; handlerId: string | null }>;
|
||||
items: WireList<WireRoute>;
|
||||
};
|
||||
/** `total` is a floor on these three — the server counts what its scan saw. */
|
||||
files: WireList<WireEntryFile>;
|
||||
tests: WireList<WireEntryTest>;
|
||||
hubs: WireList<WireEntryHub>;
|
||||
index: { lastIndexedAt: number | null; files: number };
|
||||
timing: { elapsedMs: number; cached: boolean };
|
||||
}
|
||||
|
||||
export function buildEntryPoints(cg: CodeGraph, query: URLSearchParams): WireEntryPoints {
|
||||
const limit = intParam(query, 'limit', { min: 1, max: 50, default: DEFAULT_LIMIT });
|
||||
// =============================================================================
|
||||
// Cache
|
||||
// =============================================================================
|
||||
|
||||
return {
|
||||
routes: routeEntries(cg, limit),
|
||||
/**
|
||||
* One answer per (project, index build, limits).
|
||||
*
|
||||
* Unlike `/api/source` and everything downstream of it, nothing here is read
|
||||
* from disk: every field comes out of the index, so an answer is exactly as
|
||||
* fresh as the index build it was keyed on. The Tests list is the reason it is
|
||||
* worth caching at all — it asks a reach query per chunk of test files, and
|
||||
* every screen in the viewer refetches this payload when the index moves.
|
||||
*/
|
||||
const CACHE_LIMIT = 8;
|
||||
const cache = new Map<string, WireEntryPoints>();
|
||||
|
||||
export function resetEntryPointsCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Build
|
||||
// =============================================================================
|
||||
|
||||
export function buildEntryPoints(cg: CodeGraph, query: URLSearchParams): WireEntryPoints {
|
||||
const started = Date.now();
|
||||
const limit = intParam(query, 'limit', { min: 1, max: 50, default: DEFAULT_LIMIT });
|
||||
const routeLimit = intParam(query, 'routes', {
|
||||
min: 3,
|
||||
max: MAX_ROUTE_LIMIT,
|
||||
default: DEFAULT_ROUTE_LIMIT,
|
||||
});
|
||||
|
||||
const stats = cg.getStats();
|
||||
// JSON rather than a joined string: a project root can contain any character
|
||||
// a separator might have picked, and this key is compared for equality only.
|
||||
const key = JSON.stringify([
|
||||
cg.getProjectRoot(),
|
||||
cg.getLastIndexedAt() ?? 0,
|
||||
stats.edgeCount,
|
||||
stats.fileCount,
|
||||
limit,
|
||||
routeLimit,
|
||||
]);
|
||||
const hit = cache.get(key);
|
||||
if (hit) {
|
||||
// Re-stamp rather than mutate: the body is shared with the next caller.
|
||||
return { ...hit, timing: { elapsedMs: Date.now() - started, cached: true } };
|
||||
}
|
||||
|
||||
const payload: WireEntryPoints = {
|
||||
frameworks: cg.getDetectedFrameworks(),
|
||||
routes: routeEntries(cg, routeLimit),
|
||||
files: executableFiles(cg, limit),
|
||||
tests: testFiles(cg, limit),
|
||||
hubs: hubs(cg, limit),
|
||||
index: { lastIndexedAt: cg.getLastIndexedAt() ?? null, files: stats.fileCount },
|
||||
timing: { elapsedMs: Date.now() - started, cached: false },
|
||||
};
|
||||
|
||||
if (cache.size >= CACHE_LIMIT) {
|
||||
const oldest = cache.keys().next();
|
||||
if (!oldest.done) cache.delete(oldest.value);
|
||||
}
|
||||
cache.set(key, payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* the same thing here as on the routes endpoint — including its handler id and
|
||||
* its registration site, which are what make the row navigable and groupable.
|
||||
*/
|
||||
function routeEntries(cg: CodeGraph, limit: number): WireEntryPoints['routes'] {
|
||||
const manifest = buildRoutes(cg, new URLSearchParams()) as {
|
||||
routed: boolean;
|
||||
routeCount: number;
|
||||
entries: WireEntryPoints['routes']['items'];
|
||||
};
|
||||
const manifest = buildRoutes(cg, new URLSearchParams([['limit', String(limit)]]));
|
||||
return {
|
||||
routed: manifest.routed,
|
||||
routeCount: manifest.routeCount,
|
||||
items: manifest.entries.slice(0, limit),
|
||||
// `shown` counts the rows; `truncated` is the manifest's own verdict on
|
||||
// whether the window cut anything, and it is more trustworthy than
|
||||
// comparing against `routeCount` (which counts URLs whose handler never
|
||||
// resolved as well).
|
||||
items: {
|
||||
total: manifest.truncated ? Math.max(manifest.shown + 1, manifest.routeCount) : manifest.shown,
|
||||
shown: manifest.shown,
|
||||
truncated: manifest.truncated,
|
||||
items: manifest.entries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,6 +272,51 @@ function executableFiles(cg: CodeGraph, limit: number): WireList<WireEntryFile>
|
||||
return wireList(items, Math.max(eligible, items.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* The suites that exercise the most of the project, widest first.
|
||||
*
|
||||
* Ranked by reach rather than by size or by module-level calls: a test's
|
||||
* useful property is how much of the codebase runs when it does, and only Go,
|
||||
* Rust and Java put that work inside functions where a "runs something at
|
||||
* module level" ranking cannot see it at all.
|
||||
*
|
||||
* `total` is exact here — the candidate list is every test file in the index,
|
||||
* decided in JavaScript before any query runs — which is why it is the one
|
||||
* derived list whose count is not a floor. A test file that reaches nothing
|
||||
* outside itself is left out on purpose: it exercises nothing this graph can
|
||||
* name.
|
||||
*/
|
||||
function testFiles(cg: CodeGraph, limit: number): WireList<WireEntryTest> {
|
||||
const candidates = cg
|
||||
.getFiles()
|
||||
.map((file) => toPosixPath(file.path))
|
||||
.filter((path) => isTestPath(path));
|
||||
if (candidates.length === 0) return wireList([], 0);
|
||||
|
||||
const reach = new Map<string, { reaches: number; refs: number }>();
|
||||
for (let i = 0; i < candidates.length; i += TEST_CHUNK) {
|
||||
for (const [path, counts] of cg.getFileReachCounts(candidates.slice(i, i + TEST_CHUNK))) {
|
||||
reach.set(toPosixPath(path), counts);
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = [...reach.entries()]
|
||||
.map(([path, counts]) => ({ path, ...counts }))
|
||||
.sort((a, b) => b.reaches - a.reaches || b.refs - a.refs || a.path.localeCompare(b.path));
|
||||
|
||||
const top = ranked.slice(0, limit);
|
||||
const nodes = new Map(cg.getFileNodes(top.map((row) => row.path)).map((n) => [toPosixPath(n.filePath), n]));
|
||||
|
||||
const items: WireEntryTest[] = [];
|
||||
for (const row of top) {
|
||||
const node = nodes.get(row.path);
|
||||
if (!node) continue;
|
||||
items.push({ ...toNodeRef(node), reaches: row.reaches, refs: row.refs });
|
||||
}
|
||||
|
||||
return wireList(items, Math.max(ranked.length, 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);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* GET /api/file/<path> the File view: outline and import rails
|
||||
* GET /api/filecode/<path> the whole-file view: ports, arcs, callee rail
|
||||
* GET /api/routes the URL to handler map, when there is one
|
||||
* GET /api/entrypoints where to start reading: routes, roots, hubs
|
||||
* GET /api/entrypoints where to start reading: routes, roots, tests, hubs
|
||||
* GET /api/map?root=&depth= the module map: modules, links, cycles
|
||||
* GET /api/flow?from=&to= the flow strip: one card per hop
|
||||
* GET /api/events the live channel (SSE): drift and refresh
|
||||
@@ -51,7 +51,13 @@ import { EventHub } from './events';
|
||||
export { GraphSession } from './session';
|
||||
export { ApiError } from './respond';
|
||||
export * from './wire';
|
||||
export type { WireEntryPoints, WireEntryFile, WireEntryHub } from './entrypoints';
|
||||
export type {
|
||||
WireEntryPoints,
|
||||
WireEntryFile,
|
||||
WireEntryTest,
|
||||
WireEntryHub,
|
||||
} from './entrypoints';
|
||||
export type { WireRoute, WireRoutes } from './routes';
|
||||
export type { WireNodeRefs } from './nodes';
|
||||
export type {
|
||||
WireFlowPayload,
|
||||
|
||||
@@ -24,6 +24,61 @@ import type { CodeGraph } from '../../index';
|
||||
import { intParam } from './respond';
|
||||
import { toPosixPath } from './wire';
|
||||
|
||||
/**
|
||||
* HTTP verbs a route name may lead with, plus the two stand-ins the resolvers
|
||||
* emit when the registration names no verb (`mux.Handle`, `app.use`).
|
||||
*
|
||||
* The split is done against this list rather than against "the first word" so
|
||||
* a file-routed page (`/blog/[slug]`) or a message-bus subscription keeps its
|
||||
* whole name in the URL column instead of losing its first segment to a
|
||||
* method column that was never there.
|
||||
*/
|
||||
const HTTP_METHODS: ReadonlySet<string> = new Set([
|
||||
'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT',
|
||||
'ANY', 'ALL', 'USE',
|
||||
]);
|
||||
|
||||
/** One row of the URL → handler map. */
|
||||
export interface WireRoute {
|
||||
/** The route node's name, verbatim: "POST /v1/users/{id}". */
|
||||
url: string;
|
||||
/** The verb, when the name leads with one. Null for file-routed pages. */
|
||||
method: string | null;
|
||||
/** The URL without the verb — the same string as `url` when there is none. */
|
||||
path: string;
|
||||
handler: string;
|
||||
handlerKind: string;
|
||||
/** Where the request is SERVED. */
|
||||
file: string;
|
||||
line: number;
|
||||
handlerId: string | null;
|
||||
/** Where the URL is REGISTERED — the router file, which is how routes group. */
|
||||
routeFile: string;
|
||||
routeLine: number;
|
||||
routeId: string;
|
||||
}
|
||||
|
||||
export interface WireRoutes {
|
||||
routed: boolean;
|
||||
/** Every URL the index holds, whether or not its handler resolved. */
|
||||
routeCount: number;
|
||||
/** Rows in `entries` — the ones whose handler the manifest could name. */
|
||||
shown: number;
|
||||
truncated: boolean;
|
||||
topHandlerFile: string | null;
|
||||
topHandlerFileCount: number;
|
||||
entries: WireRoute[];
|
||||
}
|
||||
|
||||
/** "POST /v1/users" -> { method: 'POST', path: '/v1/users' }. */
|
||||
export function splitRouteName(url: string): { method: string | null; path: string } {
|
||||
const space = url.indexOf(' ');
|
||||
if (space <= 0) return { method: null, path: url };
|
||||
const head = url.slice(0, space);
|
||||
if (!HTTP_METHODS.has(head.toUpperCase())) return { method: null, path: url };
|
||||
return { method: head.toUpperCase(), path: url.slice(space + 1).trimStart() };
|
||||
}
|
||||
|
||||
/** Distinct handler files we will resolve node ids for. */
|
||||
const MAX_HANDLER_FILES = 60;
|
||||
|
||||
@@ -34,7 +89,7 @@ const MAX_HANDLER_FILES = 60;
|
||||
*/
|
||||
const MIN_LIMIT = 3;
|
||||
|
||||
export function buildRoutes(cg: CodeGraph, query: URLSearchParams): unknown {
|
||||
export function buildRoutes(cg: CodeGraph, query: URLSearchParams): WireRoutes {
|
||||
const limit = intParam(query, 'limit', { min: MIN_LIMIT, max: 500, default: 200 });
|
||||
|
||||
// One row over the limit, purely to learn whether there were more.
|
||||
@@ -70,21 +125,23 @@ export function buildRoutes(cg: CodeGraph, query: URLSearchParams): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
const entries = rows.map((entry) => ({
|
||||
const entries: WireRoute[] = rows.map((entry) => ({
|
||||
url: entry.url,
|
||||
...splitRouteName(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,
|
||||
routeFile: toPosixPath(entry.routeFile),
|
||||
routeLine: entry.routeLine,
|
||||
routeId: entry.routeId,
|
||||
}));
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user