Files
codegraph/src/ui-server/api/routes.ts
T
Colby McHenryandClaude Opus 5 951ba3678a feat(ui): read-only JSON API over the index for the viewer (CG-42)
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>
2026-08-26 23:33:30 -05:00

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,
};
}