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/<id> rails, members, tests, blast radius
/api/source?file=&from=&to= verbatim source + a drift verdict
/api/file/<path> 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 <noreply@anthropic.com>
95 lines
3.7 KiB
TypeScript
95 lines
3.7 KiB
TypeScript
/**
|
|
* `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<string, string>();
|
|
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,
|
|
};
|
|
}
|