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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
41a90c6ba4
commit
951ba3678a
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* `GET /api/file/<path>` — the File view in one round-trip.
|
||||
*
|
||||
* Three panes: what imports this file, the file's own outline in source order,
|
||||
* and what this file imports. All of it comes from four batched queries — the
|
||||
* file's nodes, their `contains` edges, their `imports` edges in each
|
||||
* direction — never a query per symbol.
|
||||
*
|
||||
* Two things worth knowing about `imports` edges before reading the mapping
|
||||
* below. First, they point at the *symbol* that was imported, not at the file
|
||||
* holding it, so file granularity means mapping each edge's endpoint through
|
||||
* `nodes.file_path`. Second, plenty of them stay inside one file (an import
|
||||
* declaration is a node in the importing file), so the same-file ones have to
|
||||
* be dropped or every file appears to import itself.
|
||||
*
|
||||
* The rails would still read as broken without the third piece: imports that
|
||||
* never resolved. A file importing `react`, `fs` and one local module would
|
||||
* otherwise show a single row, silently implying the other two do not exist.
|
||||
* They are listed separately, as what they are — outside the index.
|
||||
*/
|
||||
|
||||
import type { CodeGraph } from '../../index';
|
||||
import type { Edge, Node } from '../../types';
|
||||
import { isTestFile } from '../../search/query-utils';
|
||||
import { hasDriftedOnDisk, resolveRequestedFile } from './source';
|
||||
import {
|
||||
MAX_IMPORT_FILES,
|
||||
MAX_OUTLINE_NODES,
|
||||
toNodeRef,
|
||||
toPosixPath,
|
||||
wireList,
|
||||
type WireNodeRef,
|
||||
} from './wire';
|
||||
|
||||
/** Symbols named per import row before it just counts them. */
|
||||
const MAX_SYMBOLS_PER_IMPORT = 12;
|
||||
|
||||
/** Unresolved imports listed by name. */
|
||||
const MAX_UNRESOLVED_IMPORTS = 60;
|
||||
|
||||
/** A row in the file outline. */
|
||||
export interface WireOutlineEntry extends WireNodeRef {
|
||||
/** Containing symbol within this file, or null for a top-level one. */
|
||||
parentId: string | null;
|
||||
/** Nesting depth from the top level of the file, starting at 0. */
|
||||
depth: number;
|
||||
/** Incoming / outgoing edge counts — the `← in → out` column. */
|
||||
fanIn: number;
|
||||
fanOut: number;
|
||||
}
|
||||
|
||||
/** One end of the File view's import rails. */
|
||||
export interface WireImportRow {
|
||||
file: string;
|
||||
test: boolean;
|
||||
/** Which symbols the edges name, capped. */
|
||||
symbols: Array<{ id: string; name: string; kind: string; line: number }>;
|
||||
symbolCount: number;
|
||||
}
|
||||
|
||||
export function buildFile(cg: CodeGraph, projectRoot: string, requested: string): unknown {
|
||||
// Refusal first, index lookup second — a traversal out of the project is a
|
||||
// refusal, not "no such file". See `resolveRequestedFile`.
|
||||
const { record, storedPath } = resolveRequestedFile(cg, projectRoot, requested);
|
||||
|
||||
const nodes = cg.getNodesInFile(storedPath);
|
||||
const nodeIds = nodes.map((n) => n.id);
|
||||
const inThisFile = new Set(nodeIds);
|
||||
const fileNode = nodes.find((n) => n.kind === 'file') ?? null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outline
|
||||
// ---------------------------------------------------------------------------
|
||||
const containsEdges = cg.getOutgoingEdgesFrom(nodeIds, ['contains']);
|
||||
const parentOf = new Map<string, string>();
|
||||
for (const edge of containsEdges) {
|
||||
// Only nesting *within* this file: a `contains` edge reaching out of it is
|
||||
// not something a file outline can draw.
|
||||
if (inThisFile.has(edge.target) && !parentOf.has(edge.target)) {
|
||||
parentOf.set(edge.target, edge.source);
|
||||
}
|
||||
}
|
||||
|
||||
const fanIn = cg.getFanIn(nodeIds);
|
||||
const fanOut = cg.getFanOut(nodeIds);
|
||||
|
||||
const outlineNodes = nodes
|
||||
// The file node is the subject of the screen, not a row in its own outline;
|
||||
// import declarations get their own rail and would otherwise be most of it.
|
||||
.filter((n) => n.kind !== 'file' && n.kind !== 'import')
|
||||
.sort((a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name));
|
||||
|
||||
const outline: WireOutlineEntry[] = outlineNodes
|
||||
.slice(0, MAX_OUTLINE_NODES)
|
||||
.map((node) => ({
|
||||
...toNodeRef(node),
|
||||
parentId: resolveOutlineParent(node.id, parentOf, fileNode?.id),
|
||||
depth: depthOf(node.id, parentOf, fileNode?.id),
|
||||
fanIn: fanIn.get(node.id) ?? 0,
|
||||
fanOut: fanOut.get(node.id) ?? 0,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import rails
|
||||
// ---------------------------------------------------------------------------
|
||||
const importsOut = cg.getOutgoingEdgesFrom(nodeIds, ['imports']);
|
||||
const importsIn = cg.getIncomingEdgesTo(nodeIds, ['imports']);
|
||||
|
||||
const endpointIds = new Set<string>();
|
||||
for (const edge of importsOut) if (!inThisFile.has(edge.target)) endpointIds.add(edge.target);
|
||||
for (const edge of importsIn) if (!inThisFile.has(edge.source)) endpointIds.add(edge.source);
|
||||
const endpoints = cg.getNodesByIds([...endpointIds]);
|
||||
|
||||
const imports = groupByFile(
|
||||
importsOut.filter((e) => !inThisFile.has(e.target)),
|
||||
(e) => e.target,
|
||||
endpoints
|
||||
);
|
||||
const importedBy = groupByFile(
|
||||
importsIn.filter((e) => !inThisFile.has(e.source)),
|
||||
(e) => e.source,
|
||||
endpoints
|
||||
);
|
||||
|
||||
// Import statements that never resolved — the third-party packages and
|
||||
// runtime builtins. Attributed to the file node, which is where extraction
|
||||
// records a file-level import.
|
||||
const unresolvedImports = fileNode ? unresolvedImportsOf(cg, fileNode.id) : [];
|
||||
|
||||
return {
|
||||
file: {
|
||||
path: toPosixPath(storedPath),
|
||||
language: record.language,
|
||||
size: record.size,
|
||||
modifiedAt: record.modifiedAt,
|
||||
indexedAt: record.indexedAt,
|
||||
contentHash: record.contentHash,
|
||||
nodeCount: record.nodeCount,
|
||||
generated: record.generated === true,
|
||||
test: isTestFile(toPosixPath(storedPath)),
|
||||
errors: record.errors ?? [],
|
||||
/** The file node itself, so the viewer can navigate to it as a symbol. */
|
||||
id: fileNode?.id ?? null,
|
||||
},
|
||||
/** The file changed on disk since it was indexed — the outline's lines may be shifted. */
|
||||
drift: hasDriftedOnDisk(projectRoot, storedPath, record),
|
||||
outline: wireList(outline, outlineNodes.length),
|
||||
imports: wireList(imports.slice(0, MAX_IMPORT_FILES), imports.length),
|
||||
importedBy: wireList(importedBy.slice(0, MAX_IMPORT_FILES), importedBy.length),
|
||||
unresolvedImports,
|
||||
/**
|
||||
* The broader relationship: every file this one has a cross-file edge into,
|
||||
* and every file that has one into it — calls and type references, not just
|
||||
* import statements. `imports` alone understates both, badly in languages
|
||||
* where symbols resolve without an explicit import.
|
||||
*/
|
||||
dependencies: cg.getFileDependencies(storedPath).map(toPosixPath).sort(),
|
||||
dependents: cg.getFileDependents(storedPath).map(toPosixPath).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The outline parent of a symbol: its container within the file, or null when
|
||||
* that container is the file node itself (a top-level symbol has no parent row).
|
||||
*/
|
||||
function resolveOutlineParent(
|
||||
id: string,
|
||||
parentOf: Map<string, string>,
|
||||
fileNodeId: string | undefined
|
||||
): string | null {
|
||||
const parent = parentOf.get(id);
|
||||
if (!parent || parent === fileNodeId) return null;
|
||||
return parent;
|
||||
}
|
||||
|
||||
function depthOf(
|
||||
id: string,
|
||||
parentOf: Map<string, string>,
|
||||
fileNodeId: string | undefined
|
||||
): number {
|
||||
let depth = 0;
|
||||
let current = id;
|
||||
// Bounded by the number of links so a cyclic `contains` chain — which should
|
||||
// be impossible, but is one bad index away — cannot spin here.
|
||||
for (let guard = 0; guard < 32; guard++) {
|
||||
const parent = parentOf.get(current);
|
||||
if (!parent || parent === fileNodeId) return depth;
|
||||
depth++;
|
||||
current = parent;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/** Fold edges into one row per file at the far end, ordered by symbol count. */
|
||||
function groupByFile(
|
||||
edges: readonly Edge[],
|
||||
endpoint: (edge: Edge) => string,
|
||||
nodes: Map<string, Node>
|
||||
): WireImportRow[] {
|
||||
const byFile = new Map<string, Map<string, Node>>();
|
||||
for (const edge of edges) {
|
||||
const node = nodes.get(endpoint(edge));
|
||||
if (!node) continue;
|
||||
const file = toPosixPath(node.filePath);
|
||||
let bucket = byFile.get(file);
|
||||
if (!bucket) {
|
||||
bucket = new Map<string, Node>();
|
||||
byFile.set(file, bucket);
|
||||
}
|
||||
bucket.set(node.id, node);
|
||||
}
|
||||
|
||||
return [...byFile.entries()]
|
||||
.map(([file, symbols]) => {
|
||||
const ordered = [...symbols.values()].sort(
|
||||
(a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name)
|
||||
);
|
||||
return {
|
||||
file,
|
||||
test: isTestFile(file),
|
||||
symbols: ordered.slice(0, MAX_SYMBOLS_PER_IMPORT).map((n) => ({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
kind: n.kind,
|
||||
line: n.startLine,
|
||||
})),
|
||||
symbolCount: ordered.length,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.symbolCount - a.symbolCount || a.file.localeCompare(b.file));
|
||||
}
|
||||
|
||||
function unresolvedImportsOf(
|
||||
cg: CodeGraph,
|
||||
fileNodeId: string
|
||||
): Array<{ name: string; line: number }> {
|
||||
try {
|
||||
return cg
|
||||
.getUnresolvedReferencesFrom(fileNodeId)
|
||||
.filter((ref) => ref.referenceKind === 'imports')
|
||||
.sort((a, b) => a.line - b.line || a.referenceName.localeCompare(b.referenceName))
|
||||
.slice(0, MAX_UNRESOLVED_IMPORTS)
|
||||
.map((ref) => ({ name: ref.referenceName, line: ref.line }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* The read-only JSON API the viewer reads its screens from.
|
||||
*
|
||||
* Six 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.
|
||||
*
|
||||
* ```
|
||||
* 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/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
|
||||
* ```
|
||||
*
|
||||
* It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
|
||||
* the loopback boundary in `security.ts`: the `Host` allowlist, the absence of
|
||||
* CORS headers and the GET/HEAD restriction are already enforced by the time a
|
||||
* handler here runs. The one obligation that remains ours is the read
|
||||
* chokepoint — `resolveProjectFile` for anything that touches the repository —
|
||||
* and it lives in `source.ts`, the only module here that opens a file.
|
||||
*/
|
||||
|
||||
import type { UiApiHandler, UiRequestContext } from '../index';
|
||||
import { PathRefusalError } from '../security';
|
||||
import { GraphSession } from './session';
|
||||
import { ApiError, badRequest, fail, notFound, ok } from './respond';
|
||||
import { buildStats } from './stats';
|
||||
import { buildSearch } from './search';
|
||||
import { buildNode } from './node';
|
||||
import { buildSource } from './source';
|
||||
import { buildFile } from './file';
|
||||
import { buildRoutes } from './routes';
|
||||
|
||||
export { GraphSession } from './session';
|
||||
export { ApiError } from './respond';
|
||||
export * from './wire';
|
||||
|
||||
/**
|
||||
* A mounted API, plus the handle it holds open.
|
||||
*
|
||||
* `close()` releases the index; the CLI calls it on Ctrl-C so the process does
|
||||
* not exit with a live SQLite connection.
|
||||
*/
|
||||
export interface GraphApi {
|
||||
handler: UiApiHandler;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface GraphApiOptions {
|
||||
/** Absolute path of the indexed project to read. */
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
/** What `GET /api` answers: the endpoint list, for anyone poking at it by hand. */
|
||||
const API_INDEX = {
|
||||
name: 'codegraph ui',
|
||||
readOnly: true,
|
||||
endpoints: [
|
||||
{ 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/source',
|
||||
description: 'Verbatim source for an indexed file, omitted when it has drifted on disk.',
|
||||
params: ['file', 'from', 'to'],
|
||||
},
|
||||
{ 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'] },
|
||||
],
|
||||
};
|
||||
|
||||
export function createGraphApi(options: GraphApiOptions): GraphApi {
|
||||
const session = new GraphSession(options.projectRoot);
|
||||
|
||||
const handler: UiApiHandler = (_req, res, ctx) => {
|
||||
const route = normalize(ctx.pathname);
|
||||
try {
|
||||
switch (route) {
|
||||
case '/api':
|
||||
return ok(res, API_INDEX, ctx.method);
|
||||
case '/api/stats':
|
||||
return ok(res, buildStats(session.acquire(), ctx.projectRoot), ctx.method);
|
||||
case '/api/search':
|
||||
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/source':
|
||||
return ok(res, buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
|
||||
default:
|
||||
return dispatchPathRoutes(route, res, ctx, session);
|
||||
}
|
||||
} catch (err) {
|
||||
// A refusal from the read chokepoint is a 403 with the reason attached —
|
||||
// the request asked for something outside the project, and there is no
|
||||
// version of it we would serve.
|
||||
if (err instanceof PathRefusalError) {
|
||||
return fail(res, new ApiError('refused', err.message), ctx.method);
|
||||
}
|
||||
return fail(res, err, ctx.method);
|
||||
}
|
||||
};
|
||||
|
||||
return { handler, close: () => session.close() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The two endpoints that carry their argument in the path.
|
||||
*
|
||||
* `ctx.pathname` is already percent-decoded, so a node id or a file path
|
||||
* containing `/` (`file:src/a.ts`) arrives whole — the remainder after the
|
||||
* prefix IS the argument, slashes and all. Node ids are opaque: they go
|
||||
* straight to an exact lookup, and anything that names nothing is a 404. File
|
||||
* paths go through the read chokepoint before anything is opened.
|
||||
*/
|
||||
function dispatchPathRoutes(
|
||||
route: string,
|
||||
res: Parameters<UiApiHandler>[1],
|
||||
ctx: UiRequestContext,
|
||||
session: GraphSession
|
||||
): boolean {
|
||||
const nodeId = suffixAfter(route, '/api/node/');
|
||||
if (nodeId !== null) {
|
||||
if (nodeId === '') throw badRequest('No symbol id was given. Use /api/node/<id>.');
|
||||
return ok(res, buildNode(session.acquire(), ctx.projectRoot, nodeId), ctx.method);
|
||||
}
|
||||
|
||||
const filePath = suffixAfter(route, '/api/file/');
|
||||
if (filePath !== null) {
|
||||
if (filePath === '') throw badRequest('No file path was given. Use /api/file/<path>.');
|
||||
return ok(res, buildFile(session.acquire(), ctx.projectRoot, filePath), ctx.method);
|
||||
}
|
||||
|
||||
// `/api/node` and `/api/file` with no argument at all, so the message can say
|
||||
// what the endpoint wants instead of falling through to a bare 404.
|
||||
if (route === '/api/node' || route === '/api/file') {
|
||||
throw badRequest(`${route} needs an argument: ${route}/<${route.endsWith('node') ? 'id' : 'path'}>.`);
|
||||
}
|
||||
|
||||
throw notFound(
|
||||
`No such endpoint: ${route}`,
|
||||
'GET /api lists everything this server answers.'
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop a single trailing slash, so `/api/stats/` and `/api/stats` are one route. */
|
||||
function normalize(pathname: string): string {
|
||||
return pathname.length > 4 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
}
|
||||
|
||||
function suffixAfter(route: string, prefix: string): string | null {
|
||||
return route.startsWith(prefix) ? route.slice(prefix.length) : null;
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* `GET /api/node/<id>` — everything the Symbol view draws, in one round-trip.
|
||||
*
|
||||
* The Symbol view is three panes and a strip: callers on the left, the verbatim
|
||||
* body in the middle with a port per call site, callees on the right anchored
|
||||
* to those lines, and a blast-radius summary underneath. Splitting that across
|
||||
* five endpoints would mean five waterfalls before the screen settles, and the
|
||||
* screen is the product. So this endpoint answers all of it.
|
||||
*
|
||||
* Two properties it has to hold, and the reasons they are not obvious:
|
||||
*
|
||||
* **No N+1, anywhere.** The engine's own busiest symbol has 545 incoming edges.
|
||||
* Resolving those one `getNode` at a time is 545 queries and blows the budget on
|
||||
* its own; so every edge list is resolved with one batched `getNodesByIds`, and
|
||||
* fan-in for the rail pills comes from one batched `getFanIn`.
|
||||
*
|
||||
* **Capped lists that still tell the truth.** 545 callers cannot all be rows,
|
||||
* but the payload must never suggest there are fewer. Every list carries the
|
||||
* true `total` beside the `shown` slice, and the ordering is chosen so the
|
||||
* slice is the useful end: same file first, then production code, then tests.
|
||||
*/
|
||||
|
||||
import type { CodeGraph } from '../../index';
|
||||
import type { Edge, Node, NodeKind } from '../../types';
|
||||
import { isTestFile } from '../../search/query-utils';
|
||||
import { notFound } from './respond';
|
||||
import { findIndexedFile, hasDriftedOnDisk } from './source';
|
||||
import {
|
||||
CALLER_EDGE_KINDS,
|
||||
CONTAINER_KINDS,
|
||||
HUB_THRESHOLD,
|
||||
MAX_INCOMING_GROUPS,
|
||||
MAX_OUTGOING_GROUPS,
|
||||
MAX_OUTLINE_NODES,
|
||||
MAX_OUTSIDE_INDEX_SAMPLES,
|
||||
MAX_TEST_FILES,
|
||||
TEST_CALLER_BUDGET,
|
||||
TEST_CALLER_HOPS,
|
||||
TYPE_KINDS,
|
||||
firstLine,
|
||||
groupRelations,
|
||||
toNodeDetail,
|
||||
toNodeRef,
|
||||
toPosixPath,
|
||||
wireList,
|
||||
type WireNodeRef,
|
||||
} from './wire';
|
||||
|
||||
/** Depth the blast-radius summary walks. Matches `codegraph_explore`'s claim. */
|
||||
const BLAST_DEPTH = 3;
|
||||
|
||||
/** A member row in the focal symbol's outline, with its place in the tree. */
|
||||
export interface WireMember extends WireNodeRef {
|
||||
/** The container this member belongs to — the focal node, or one of its children. */
|
||||
parentId: string;
|
||||
/** 1 = direct member, 2 = a member of a member (a class's method inside a file). */
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): unknown {
|
||||
const node = cg.getNode(nodeId);
|
||||
if (!node) {
|
||||
throw notFound(
|
||||
'No symbol with that id is in this index.',
|
||||
'Symbol ids change whenever the file is re-indexed — search for the symbol by ' +
|
||||
'name instead of reusing an id from an older session.'
|
||||
);
|
||||
}
|
||||
|
||||
const incomingAll = cg.getIncomingEdges(nodeId);
|
||||
const outgoingAll = cg.getOutgoingEdges(nodeId);
|
||||
|
||||
// `contains` is structure, not dependency: upward it is the parent (already in
|
||||
// `ancestors`), downward it is the members outline. Leaving it in the rails
|
||||
// would put a symbol's own class in its caller list.
|
||||
const incoming = incomingAll.filter((e) => e.kind !== 'contains');
|
||||
const outgoingRest: Edge[] = [];
|
||||
const containsOut: Edge[] = [];
|
||||
for (const edge of outgoingAll) {
|
||||
if (edge.kind === 'contains') containsOut.push(edge);
|
||||
else outgoingRest.push(edge);
|
||||
}
|
||||
|
||||
const ancestors = cg.getAncestors(nodeId);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One batched resolve for every endpoint this payload names.
|
||||
// ---------------------------------------------------------------------------
|
||||
const endpointIds = new Set<string>();
|
||||
for (const edge of incoming) endpointIds.add(edge.source);
|
||||
for (const edge of outgoingRest) endpointIds.add(edge.target);
|
||||
for (const edge of containsOut) endpointIds.add(edge.target);
|
||||
const endpoints = cg.getNodesByIds([...endpointIds]);
|
||||
|
||||
// A `references` edge into a type is "uses type X", not "calls X" — the
|
||||
// header shows those as chips rather than as callee rows. Split at the EDGE
|
||||
// level so a class that is both instantiated and named as a type appears in
|
||||
// both places, which is what the source actually says.
|
||||
const calleeEdges: Edge[] = [];
|
||||
const typeRefs: Edge[] = [];
|
||||
for (const edge of outgoingRest) {
|
||||
const target = endpoints.get(edge.target);
|
||||
if (edge.kind === 'references' && target && TYPE_KINDS.has(target.kind)) typeRefs.push(edge);
|
||||
else calleeEdges.push(edge);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rails
|
||||
// ---------------------------------------------------------------------------
|
||||
const focalFile = toPosixPath(node.filePath);
|
||||
|
||||
const incomingGroups = groupRelations(incoming, (e) => e.source, endpoints);
|
||||
incomingGroups.sort((a, b) => {
|
||||
// The symbol's own file first ("same file" in the left rail), then
|
||||
// production code, then tests — so a cap trims the least useful end.
|
||||
const aSame = a.node.file === focalFile ? 0 : 1;
|
||||
const bSame = b.node.file === focalFile ? 0 : 1;
|
||||
if (aSame !== bSame) return aSame - bSame;
|
||||
if (a.node.test !== b.node.test) return a.node.test ? 1 : -1;
|
||||
return a.node.file.localeCompare(b.node.file) || firstLine(a) - firstLine(b);
|
||||
});
|
||||
|
||||
const outgoingGroups = groupRelations(calleeEdges, (e) => e.target, endpoints);
|
||||
// The right rail is line-anchored: rows sit beside the line that calls them.
|
||||
outgoingGroups.sort((a, b) => firstLine(a) - firstLine(b) || a.node.name.localeCompare(b.node.name));
|
||||
|
||||
const typeGroups = groupRelations(typeRefs, (e) => e.target, endpoints);
|
||||
typeGroups.sort((a, b) => firstLine(a) - firstLine(b) || a.node.name.localeCompare(b.node.name));
|
||||
|
||||
const shownIncoming = incomingGroups.slice(0, MAX_INCOMING_GROUPS);
|
||||
const shownOutgoing = outgoingGroups.slice(0, MAX_OUTGOING_GROUPS);
|
||||
|
||||
// Fan-in for the rail pills ("hub · N"), for the rows actually returned —
|
||||
// one query, not one per row.
|
||||
const fanInOf = cg.getFanIn([
|
||||
...shownIncoming.map((r) => r.node.id),
|
||||
...shownOutgoing.map((r) => r.node.id),
|
||||
...typeGroups.map((r) => r.node.id),
|
||||
]);
|
||||
for (const relation of [...shownIncoming, ...shownOutgoing, ...typeGroups]) {
|
||||
const count = fanInOf.get(relation.node.id) ?? 0;
|
||||
relation.fanIn = count;
|
||||
relation.hub = count >= HUB_THRESHOLD;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Members outline
|
||||
// ---------------------------------------------------------------------------
|
||||
const members = buildMembers(cg, node, containsOut, endpoints);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Counts, tests, what leaves the index, blast radius
|
||||
// ---------------------------------------------------------------------------
|
||||
const directCallers: Node[] = [];
|
||||
const seenCaller = new Set<string>();
|
||||
for (const edge of incoming) {
|
||||
if (!CALLER_EDGE_KINDS.has(edge.kind) || seenCaller.has(edge.source)) continue;
|
||||
seenCaller.add(edge.source);
|
||||
const source = endpoints.get(edge.source);
|
||||
if (source) directCallers.push(source);
|
||||
}
|
||||
|
||||
const drift = driftFor(cg, projectRoot, node.filePath);
|
||||
|
||||
return {
|
||||
node: toNodeDetail(node),
|
||||
/** Outermost first: file, then module/class, then the symbol's own parent. */
|
||||
ancestors: [...ancestors].reverse().map(toNodeRef),
|
||||
members: wireList(members.items, members.total),
|
||||
incoming: wireList(shownIncoming, incomingGroups.length),
|
||||
outgoing: wireList(shownOutgoing, outgoingGroups.length),
|
||||
/** `references` edges into a type — the header's "uses types …" chips. */
|
||||
typesUsed: typeGroups,
|
||||
counts: {
|
||||
// Every count below is the length of a list this payload also returns, so
|
||||
// a badge and the rail beneath it can never disagree.
|
||||
/** Distinct symbols that reach this one — `incoming.total`. Drives `hub`. */
|
||||
callers: incomingGroups.length,
|
||||
/** Distinct symbols this one calls — `outgoing.total`. Types are counted separately. */
|
||||
callees: outgoingGroups.length,
|
||||
/** Distinct types this symbol names — `typesUsed.length`. */
|
||||
typesUsed: typeGroups.length,
|
||||
/** EDGE counts, which run higher: one caller can call from many lines. */
|
||||
fanIn: incoming.length,
|
||||
fanOut: outgoingRest.length,
|
||||
members: members.total,
|
||||
hub: incomingGroups.length >= HUB_THRESHOLD,
|
||||
},
|
||||
tests: summarizeTestCallers(cg, directCallers),
|
||||
outsideIndex: summarizeOutsideIndex(cg, nodeId),
|
||||
blast: summarizeBlast(cg, node, incomingGroups.length),
|
||||
/** The symbol's file changed on disk since the index — line ranges may be shifted. */
|
||||
drift,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Members
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The focal symbol's members, in source order, one level of nesting deep.
|
||||
*
|
||||
* A file's outline is file → class → method, so direct children alone would
|
||||
* show a class and nothing inside it. The grandchildren come from ONE batched
|
||||
* `getOutgoingEdgesFrom` over the container children, never a query per child.
|
||||
*/
|
||||
function buildMembers(
|
||||
cg: CodeGraph,
|
||||
focal: Node,
|
||||
containsOut: readonly Edge[],
|
||||
endpoints: Map<string, Node>
|
||||
): { items: WireMember[]; total: number } {
|
||||
const direct: Array<{ node: Node; parentId: string; depth: number }> = [];
|
||||
for (const edge of containsOut) {
|
||||
const child = endpoints.get(edge.target);
|
||||
if (child) direct.push({ node: child, parentId: focal.id, depth: 1 });
|
||||
}
|
||||
|
||||
const containerIds = direct
|
||||
.filter((entry) => CONTAINER_KINDS.has(entry.node.kind))
|
||||
.map((entry) => entry.node.id);
|
||||
|
||||
const nested: Array<{ node: Node; parentId: string; depth: number }> = [];
|
||||
if (containerIds.length > 0) {
|
||||
const grandEdges = cg.getOutgoingEdgesFrom(containerIds, ['contains']);
|
||||
const grandNodes = cg.getNodesByIds(grandEdges.map((e) => e.target));
|
||||
for (const edge of grandEdges) {
|
||||
const child = grandNodes.get(edge.target);
|
||||
if (child) nested.push({ node: child, parentId: edge.source, depth: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
const all = [...direct, ...nested].sort(
|
||||
(a, b) => a.node.startLine - b.node.startLine || a.node.name.localeCompare(b.node.name)
|
||||
);
|
||||
return {
|
||||
items: all.slice(0, MAX_OUTLINE_NODES).map((entry) => ({
|
||||
...toNodeRef(entry.node),
|
||||
parentId: entry.parentId,
|
||||
depth: entry.depth,
|
||||
})),
|
||||
total: all.length,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test coverage
|
||||
// =============================================================================
|
||||
|
||||
export interface WireTestSummary {
|
||||
/** A test file reaches this symbol within {@link TEST_CALLER_HOPS} caller hops. */
|
||||
reached: boolean;
|
||||
/** How many hops away the nearest test was. 1 = a test calls it directly. */
|
||||
hops: number | null;
|
||||
fileCount: number;
|
||||
files: string[];
|
||||
/**
|
||||
* The search finished rather than running out of budget. `false` weakens the
|
||||
* claim from "no test reaches this within 3 hops" to "no test calls this
|
||||
* directly", which is all that was actually checked.
|
||||
*/
|
||||
exhaustive: boolean;
|
||||
hopsSearched: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which tests reach this symbol — the same question, and the same method,
|
||||
* behind `codegraph_explore`'s "tests:" line.
|
||||
*
|
||||
* Direct test callers first; failing that, walk up to two more caller hops,
|
||||
* because a helper called only by production code is still tested through
|
||||
* whatever calls it. The budget bounds a god-symbol, and running out of it is
|
||||
* reported rather than papered over: claiming "no test reaches this" after an
|
||||
* incomplete search would be exactly the kind of confident wrong answer the
|
||||
* viewer exists to avoid.
|
||||
*/
|
||||
function summarizeTestCallers(cg: CodeGraph, directCallers: readonly Node[]): WireTestSummary {
|
||||
const directFiles = [
|
||||
...new Set(directCallers.map((n) => toPosixPath(n.filePath)).filter(isTestFile)),
|
||||
];
|
||||
if (directFiles.length > 0) {
|
||||
return {
|
||||
reached: true,
|
||||
hops: 1,
|
||||
fileCount: directFiles.length,
|
||||
files: directFiles.slice(0, MAX_TEST_FILES),
|
||||
exhaustive: true,
|
||||
hopsSearched: 1,
|
||||
};
|
||||
}
|
||||
|
||||
let budget = TEST_CALLER_BUDGET;
|
||||
const visited = new Set(directCallers.map((n) => n.id));
|
||||
let frontier: Node[] = [...directCallers];
|
||||
let hopsSearched = 1;
|
||||
|
||||
for (let hop = 2; hop <= TEST_CALLER_HOPS && frontier.length > 0 && budget > 0; hop++) {
|
||||
hopsSearched = hop;
|
||||
const next: Node[] = [];
|
||||
const found = new Set<string>();
|
||||
for (const current of frontier) {
|
||||
if (budget-- <= 0) break;
|
||||
let callers: Array<{ node: Node }>;
|
||||
try {
|
||||
callers = cg.getCallers(current.id) as Array<{ node: Node }>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const caller of callers) {
|
||||
const source = caller?.node;
|
||||
if (!source || visited.has(source.id)) continue;
|
||||
visited.add(source.id);
|
||||
const file = toPosixPath(source.filePath);
|
||||
if (isTestFile(file)) found.add(file);
|
||||
else next.push(source);
|
||||
}
|
||||
}
|
||||
if (found.size > 0) {
|
||||
const files = [...found];
|
||||
return {
|
||||
reached: true,
|
||||
hops: hop,
|
||||
fileCount: files.length,
|
||||
files: files.slice(0, MAX_TEST_FILES),
|
||||
exhaustive: true,
|
||||
hopsSearched: hop,
|
||||
};
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
|
||||
return {
|
||||
reached: false,
|
||||
hops: null,
|
||||
fileCount: 0,
|
||||
files: [],
|
||||
exhaustive: budget > 0,
|
||||
hopsSearched,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// References that leave the index
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calls and type mentions from this symbol that never resolved to a node — a
|
||||
* third-party package, a runtime builtin, a construct extraction doesn't model.
|
||||
*
|
||||
* Without this the callee rail would silently be shorter than the body's call
|
||||
* sites, which reads as "nothing else happens here". Saying "+N calls into
|
||||
* symbols outside the index" is the honest version of the same screen.
|
||||
*/
|
||||
function summarizeOutsideIndex(
|
||||
cg: CodeGraph,
|
||||
nodeId: string
|
||||
): {
|
||||
total: number;
|
||||
byKind: Record<string, number>;
|
||||
samples: Array<{ name: string; kind: string; line: number; col: number }>;
|
||||
} {
|
||||
let refs;
|
||||
try {
|
||||
refs = cg.getUnresolvedReferencesFrom(nodeId);
|
||||
} catch {
|
||||
return { total: 0, byKind: {}, samples: [] };
|
||||
}
|
||||
|
||||
const byKind: Record<string, number> = {};
|
||||
for (const ref of refs) byKind[ref.referenceKind] = (byKind[ref.referenceKind] ?? 0) + 1;
|
||||
|
||||
const samples = [...refs]
|
||||
.sort((a, b) => a.line - b.line || a.column - b.column)
|
||||
.slice(0, MAX_OUTSIDE_INDEX_SAMPLES)
|
||||
.map((ref) => ({
|
||||
name: ref.referenceName,
|
||||
kind: ref.referenceKind,
|
||||
line: ref.line,
|
||||
col: ref.column,
|
||||
}));
|
||||
|
||||
return { total: refs.length, byKind, samples };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Blast radius
|
||||
// =============================================================================
|
||||
|
||||
export interface WireBlastSummary {
|
||||
/** Distinct symbols that depend on this one directly. */
|
||||
direct: number;
|
||||
/** Distinct symbols reached within {@link BLAST_DEPTH} dependency hops. */
|
||||
withinHops: number;
|
||||
hops: number;
|
||||
files: number;
|
||||
testFiles: number;
|
||||
routes: number;
|
||||
/** Up to 40 of the dependent files, most-affected first, for the "what would need re-checking" fold. */
|
||||
topFiles: Array<{ file: string; symbols: number; test: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What would need re-checking if this symbol changed.
|
||||
*
|
||||
* `getImpactRadius` at depth 3 is the engine's own answer to that question —
|
||||
* incoming dependencies only, `contains` excluded upward so a leaf symbol does
|
||||
* not explode into its whole class, container members expanded downward so
|
||||
* callers of a class's methods count against the class.
|
||||
*/
|
||||
function summarizeBlast(cg: CodeGraph, node: Node, direct: number): WireBlastSummary | null {
|
||||
let subgraph;
|
||||
try {
|
||||
subgraph = cg.getImpactRadius(node.id, BLAST_DEPTH);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const perFile = new Map<string, number>();
|
||||
let routes = 0;
|
||||
for (const [id, dependent] of subgraph.nodes) {
|
||||
if (id === node.id) continue;
|
||||
const file = toPosixPath(dependent.filePath);
|
||||
perFile.set(file, (perFile.get(file) ?? 0) + 1);
|
||||
if (dependent.kind === ('route' as NodeKind)) routes++;
|
||||
}
|
||||
|
||||
const testFiles = [...perFile.keys()].filter(isTestFile).length;
|
||||
const topFiles = [...perFile.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, 40)
|
||||
.map(([file, symbols]) => ({ file, symbols, test: isTestFile(file) }));
|
||||
|
||||
return {
|
||||
direct,
|
||||
withinHops: Math.max(0, subgraph.nodes.size - 1),
|
||||
hops: BLAST_DEPTH,
|
||||
files: perFile.size,
|
||||
testFiles,
|
||||
routes,
|
||||
topFiles,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Drift
|
||||
// =============================================================================
|
||||
|
||||
function driftFor(cg: CodeGraph, projectRoot: string, filePath: string): boolean {
|
||||
const found = findIndexedFile(cg, filePath);
|
||||
if (!found) return false;
|
||||
return hasDriftedOnDisk(projectRoot, found.storedPath, found.record);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* How the JSON API answers — success, refusal, and every failure in between.
|
||||
*
|
||||
* The viewer is the only client, and it runs on the same machine as the index,
|
||||
* so an error here is a message to a developer looking at their own project,
|
||||
* not information to withhold from a prober. Every failure therefore says what
|
||||
* went wrong and — where there is one — what to do about it, exactly the way
|
||||
* the CLI and the MCP tools do. What it never does is leak a stack trace.
|
||||
*/
|
||||
|
||||
import type { ServerResponse } from 'http';
|
||||
import { sendJson } from '../static';
|
||||
|
||||
/**
|
||||
* Machine-readable failure reasons. The viewer switches on these rather than
|
||||
* on prose, so renaming a message never breaks a screen.
|
||||
*/
|
||||
export type ApiErrorCode =
|
||||
| 'bad-request'
|
||||
| 'not-found'
|
||||
| 'refused'
|
||||
| 'no-index'
|
||||
| 'index-unusable'
|
||||
| 'internal';
|
||||
|
||||
const STATUS: Record<ApiErrorCode, number> = {
|
||||
'bad-request': 400,
|
||||
'not-found': 404,
|
||||
// A path refusal, not an authentication failure — the request asked for
|
||||
// something outside the project (traversal, an absolute path, a sensitive
|
||||
// directory) and there is no version of it we would serve.
|
||||
refused: 403,
|
||||
// The index is missing or unusable. 503 rather than 404: the endpoint is
|
||||
// real, the data behind it is not there *yet* — `codegraph init` fixes it.
|
||||
'no-index': 503,
|
||||
'index-unusable': 503,
|
||||
internal: 500,
|
||||
};
|
||||
|
||||
/** An error that already carries a user-facing message and a status. */
|
||||
export class ApiError extends Error {
|
||||
readonly code: ApiErrorCode;
|
||||
/** Optional second line: what the user can do about it. */
|
||||
readonly hint: string | undefined;
|
||||
|
||||
constructor(code: ApiErrorCode, message: string, hint?: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.hint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
export function badRequest(message: string, hint?: string): ApiError {
|
||||
return new ApiError('bad-request', message, hint);
|
||||
}
|
||||
|
||||
export function notFound(message: string, hint?: string): ApiError {
|
||||
return new ApiError('not-found', message, hint);
|
||||
}
|
||||
|
||||
/** Send a successful payload. */
|
||||
export function ok(res: ServerResponse, payload: unknown, method: string): true {
|
||||
sendJson(res, 200, payload, method);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Send a failure. Anything that is not an {@link ApiError} becomes a 500. */
|
||||
export function fail(res: ServerResponse, err: unknown, method: string): true {
|
||||
if (err instanceof ApiError) {
|
||||
const body: { error: string; code: ApiErrorCode; hint?: string } = {
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
};
|
||||
if (err.hint) body.hint = err.hint;
|
||||
sendJson(res, STATUS[err.code], body, method);
|
||||
return true;
|
||||
}
|
||||
sendJson(
|
||||
res,
|
||||
500,
|
||||
{
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
code: 'internal' satisfies ApiErrorCode,
|
||||
},
|
||||
method
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Query parameters
|
||||
// =============================================================================
|
||||
|
||||
/** A required, non-empty string parameter. */
|
||||
export function requiredParam(query: URLSearchParams, name: string): string {
|
||||
const raw = query.get(name);
|
||||
if (raw === null || raw.trim() === '') {
|
||||
throw badRequest(`Missing required parameter "${name}".`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bounded integer parameter.
|
||||
*
|
||||
* Out-of-range values are an error rather than silently clamped: a viewer
|
||||
* asking for line 10 000 000 of a 200-line file has a bug, and answering it
|
||||
* with line 200 would hide that.
|
||||
*/
|
||||
export function intParam(
|
||||
query: URLSearchParams,
|
||||
name: string,
|
||||
opts: { min: number; max: number; default?: number }
|
||||
): number {
|
||||
const raw = query.get(name);
|
||||
if (raw === null || raw.trim() === '') {
|
||||
if (opts.default !== undefined) return opts.default;
|
||||
throw badRequest(`Missing required parameter "${name}".`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value < opts.min || value > opts.max) {
|
||||
throw badRequest(
|
||||
`Parameter "${name}" must be a whole number between ${opts.min} and ${opts.max} (got "${raw}").`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-form text input, bounded.
|
||||
*
|
||||
* The same reasoning as the MCP tools' input ceiling: a huge string is never a
|
||||
* real query, and letting one through means a full-table LIKE scan or an FTS5
|
||||
* parse over megabytes.
|
||||
*/
|
||||
export const MAX_QUERY_LENGTH = 2_000;
|
||||
|
||||
export function textParam(query: URLSearchParams, name: string): string {
|
||||
const raw = requiredParam(query, name);
|
||||
return boundLength(raw, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text that must be PRESENT but may be empty — a search box the user has
|
||||
* cleared. Absent is still an error; empty is a legitimate state.
|
||||
*/
|
||||
export function optionalTextParam(query: URLSearchParams, name: string): string {
|
||||
const raw = query.get(name);
|
||||
if (raw === null) throw badRequest(`Missing required parameter "${name}".`);
|
||||
return boundLength(raw, name);
|
||||
}
|
||||
|
||||
function boundLength(raw: string, name: string): string {
|
||||
if (raw.length > MAX_QUERY_LENGTH) {
|
||||
throw badRequest(`Parameter "${name}" is too long (max ${MAX_QUERY_LENGTH} characters).`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* `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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* `GET /api/search?q=` — the search palette's one round-trip.
|
||||
*
|
||||
* Three lookups feed it, because no single one covers what a person types into
|
||||
* a palette:
|
||||
*
|
||||
* - `getNodesByNameSubstring` — case-insensitive, catches the exact, prefix and
|
||||
* mid-name matches (`profileInfo` inside `getProfileInfoV2`) that FTS tokens
|
||||
* cannot.
|
||||
* - `searchNodes` — FTS5, plus the engine's own LIKE and fuzzy fallbacks, and
|
||||
* the `kind:` / `lang:` / `path:` / `name:` filter grammar for free.
|
||||
* - `getNodesByName` — every symbol with exactly that name, uncapped, so a
|
||||
* heavily-overloaded name never loses its definitions below a search cut.
|
||||
*
|
||||
* They are then merged and ranked by HOW the name matched — exact, prefix,
|
||||
* substring, qualified name, file path — rather than by any single engine's
|
||||
* score, because those scores are not comparable with each other. Results are
|
||||
* grouped by kind: "did I mean the class or the method" is the question a
|
||||
* palette actually has to answer.
|
||||
*/
|
||||
|
||||
import type { CodeGraph } from '../../index';
|
||||
import type { Node, NodeKind } from '../../types';
|
||||
import { parseQuery, type ParsedQuery } from '../../search/query-parser';
|
||||
import { intParam, optionalTextParam } from './respond';
|
||||
import { toNodeRef, wireList, type WireNodeRef } from './wire';
|
||||
|
||||
/** How a result's text matched the query. Also the primary sort key. */
|
||||
export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
|
||||
|
||||
const MATCH_RANK: Record<MatchKind, number> = {
|
||||
exact: 0,
|
||||
prefix: 1,
|
||||
substring: 2,
|
||||
qualified: 3,
|
||||
file: 4,
|
||||
// Matched by FTS through a signature, docstring or fuzzy neighbour — real,
|
||||
// but never what someone typing a name is looking for first.
|
||||
related: 5,
|
||||
};
|
||||
|
||||
/** Candidates pulled from each source before ranking trims to `limit`. */
|
||||
const CANDIDATE_POOL = 400;
|
||||
|
||||
export interface WireSearchResult extends WireNodeRef {
|
||||
matchKind: MatchKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tie-break inside a match tier: the kinds someone navigates to, before the
|
||||
* kinds that merely mention a name.
|
||||
*/
|
||||
function kindRank(kind: NodeKind): number {
|
||||
switch (kind) {
|
||||
case 'function':
|
||||
case 'method':
|
||||
case 'class':
|
||||
case 'component':
|
||||
case 'interface':
|
||||
case 'struct':
|
||||
case 'trait':
|
||||
case 'protocol':
|
||||
case 'enum':
|
||||
case 'union':
|
||||
case 'type_alias':
|
||||
case 'route':
|
||||
return 0;
|
||||
case 'constant':
|
||||
case 'property':
|
||||
case 'field':
|
||||
case 'variable':
|
||||
case 'enum_member':
|
||||
return 1;
|
||||
case 'file':
|
||||
case 'module':
|
||||
case 'namespace':
|
||||
return 2;
|
||||
default:
|
||||
// import / export / parameter — a mention, not a definition.
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
function classify(node: Node, needle: string): MatchKind | null {
|
||||
const name = node.name.toLowerCase();
|
||||
if (name === needle) return 'exact';
|
||||
if (name.startsWith(needle)) return 'prefix';
|
||||
if (name.includes(needle)) return 'substring';
|
||||
if (node.qualifiedName.toLowerCase().includes(needle)) return 'qualified';
|
||||
if (node.filePath.toLowerCase().replace(/\\/g, '/').includes(needle)) return 'file';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildSearch(cg: CodeGraph, query: URLSearchParams): unknown {
|
||||
const raw = optionalTextParam(query, 'q');
|
||||
const limit = intParam(query, 'limit', { min: 1, max: 200, default: 60 });
|
||||
|
||||
// An empty search box is the palette's resting state, not a mistake — it
|
||||
// answers with nothing rather than with an error the viewer has to special-
|
||||
// case. A MISSING `q` is still a 400: that is a caller bug.
|
||||
if (raw.trim() === '') return emptySearch(raw);
|
||||
|
||||
// The filter grammar (`kind:function auth`) belongs to `searchNodes`; the
|
||||
// name lookups only ever want the free-text part of what was typed.
|
||||
const parsed = parseQuery(raw);
|
||||
const text = parsed.text.trim();
|
||||
const needle = text.toLowerCase();
|
||||
|
||||
const candidates = new Map<string, Node>();
|
||||
const remember = (node: Node): void => {
|
||||
if (!candidates.has(node.id)) candidates.set(node.id, node);
|
||||
};
|
||||
|
||||
if (text.length > 0) {
|
||||
for (const node of cg.getNodesByName(text)) remember(node);
|
||||
for (const node of cg.getNodesByNameSubstring(text, { limit: CANDIDATE_POOL })) remember(node);
|
||||
}
|
||||
for (const result of cg.searchNodes(raw, { limit: CANDIDATE_POOL })) remember(result.node);
|
||||
|
||||
const scored: Array<{ node: Node; match: MatchKind }> = [];
|
||||
for (const node of candidates.values()) {
|
||||
// `searchNodes` applies the filter grammar to its own results, but the two
|
||||
// direct name lookups above know nothing about it — so `kind:class Cache`
|
||||
// would otherwise pull in `CacheKey` and every `Cache` method through the
|
||||
// substring lookup. The gate belongs to the merged candidate set.
|
||||
if (!matchesFilters(node, parsed)) continue;
|
||||
// An empty text portion means the query was pure filters (`kind:route`);
|
||||
// everything `searchNodes` returned already satisfies them, so there is no
|
||||
// name match to grade and every row is equally "related".
|
||||
const match = needle.length === 0 ? 'related' : classify(node, needle) ?? 'related';
|
||||
scored.push({ node, match });
|
||||
}
|
||||
|
||||
scored.sort((a, b) => {
|
||||
const byMatch = MATCH_RANK[a.match] - MATCH_RANK[b.match];
|
||||
if (byMatch !== 0) return byMatch;
|
||||
const byKind = kindRank(a.node.kind) - kindRank(b.node.kind);
|
||||
if (byKind !== 0) return byKind;
|
||||
// Production code before tests and fixtures: both are real answers, but one
|
||||
// of them is the one someone searching for a symbol usually means.
|
||||
const aTest = isTestPath(a.node.filePath);
|
||||
const bTest = isTestPath(b.node.filePath);
|
||||
if (aTest !== bTest) return aTest ? 1 : -1;
|
||||
// Shorter names are closer to what was typed (`get` before `getOrCreate`).
|
||||
const byLength = a.node.name.length - b.node.name.length;
|
||||
if (byLength !== 0) return byLength;
|
||||
return (
|
||||
a.node.filePath.localeCompare(b.node.filePath) || a.node.startLine - b.node.startLine
|
||||
);
|
||||
});
|
||||
|
||||
const top = scored.slice(0, limit);
|
||||
const results: WireSearchResult[] = top.map(({ node, match }) => ({
|
||||
...toNodeRef(node),
|
||||
matchKind: match,
|
||||
}));
|
||||
|
||||
// Groups keep the ranked order: a group appears where its best result did, so
|
||||
// flattening the groups reproduces the flat ranking for keyboard navigation.
|
||||
const groups: Array<{ kind: NodeKind; count: number; items: WireSearchResult[] }> = [];
|
||||
const byKind = new Map<NodeKind, WireSearchResult[]>();
|
||||
for (const result of results) {
|
||||
const bucket = byKind.get(result.kind);
|
||||
if (bucket) {
|
||||
bucket.push(result);
|
||||
} else {
|
||||
const created = [result];
|
||||
byKind.set(result.kind, created);
|
||||
groups.push({ kind: result.kind, count: 0, items: created });
|
||||
}
|
||||
}
|
||||
for (const group of groups) group.count = group.items.length;
|
||||
|
||||
return {
|
||||
query: raw,
|
||||
text,
|
||||
filters: {
|
||||
kinds: parsed.kinds,
|
||||
languages: parsed.languages,
|
||||
paths: parsed.pathFilters,
|
||||
names: parsed.nameFilters,
|
||||
},
|
||||
results: wireList(results, scored.length),
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately a plain path check rather than the engine's `isTestFile`: this
|
||||
* is a ranking nudge inside one tier, and `isTestFile` also treats `examples/`,
|
||||
* `benchmarks/` and `fixtures/` as tests — pushing a legitimately-searched
|
||||
* example below an unrelated production symbol.
|
||||
*/
|
||||
function isTestPath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase().replace(/\\/g, '/');
|
||||
return (
|
||||
/(^|\/)(tests?|specs?|__tests__)\//.test(lower) ||
|
||||
/[._-](test|tests|spec|specs)\.[a-z0-9]+$/.test(lower)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hard gate the `kind:` / `lang:` / `path:` / `name:` grammar asks for.
|
||||
*
|
||||
* Deliberately the same predicates `searchNodes` uses internally — kinds and
|
||||
* languages exact, paths and names case-insensitive substrings, each list OR'd
|
||||
* within itself and AND'd across lists — so a filtered search means the same
|
||||
* thing whichever lookup a result came from.
|
||||
*/
|
||||
function matchesFilters(node: Node, parsed: ParsedQuery): boolean {
|
||||
if (parsed.kinds.length > 0 && !parsed.kinds.includes(node.kind)) return false;
|
||||
if (parsed.languages.length > 0 && !parsed.languages.includes(node.language)) return false;
|
||||
if (parsed.pathFilters.length > 0) {
|
||||
const file = node.filePath.toLowerCase();
|
||||
if (!parsed.pathFilters.some((p) => file.includes(p.toLowerCase()))) return false;
|
||||
}
|
||||
if (parsed.nameFilters.length > 0) {
|
||||
const name = node.name.toLowerCase();
|
||||
if (!parsed.nameFilters.some((n) => name.includes(n.toLowerCase()))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The resting state of the palette: the shape of a real answer, with nothing in it. */
|
||||
function emptySearch(raw: string): unknown {
|
||||
return {
|
||||
query: raw,
|
||||
text: '',
|
||||
filters: { kinds: [], languages: [], paths: [], names: [] },
|
||||
results: wireList<WireSearchResult>([], 0),
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The one open handle on the project's index.
|
||||
*
|
||||
* `CodeGraph.openSync` costs tens of milliseconds and runs pending migrations,
|
||||
* so it happens once for the life of the server rather than once per request.
|
||||
* That leaves two things this module has to get right:
|
||||
*
|
||||
* - **A missing index is guidance, not a stack trace.** `codegraph ui` refuses
|
||||
* to start without one, but a user can delete `.codegraph/` while the viewer
|
||||
* is open, so every endpoint has to be able to say so in the same words the
|
||||
* CLI does.
|
||||
* - **A re-index must not be served from a phantom database.** `codegraph init`
|
||||
* on an already-indexed project *replaces the database file* (see
|
||||
* `CodeGraph.recreate`). On POSIX our handle would keep reading the unlinked
|
||||
* inode and happily serve a graph that no longer exists on disk. So the file
|
||||
* identity is re-checked on acquisition — one `stat` — and a swapped file
|
||||
* reopens the connection.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { CodeGraph } from '../../index';
|
||||
import { getDatabasePath } from '../../db';
|
||||
import { isInitialized } from '../../directory';
|
||||
import { ApiError } from './respond';
|
||||
|
||||
/** Identity of the database file, so a swap underneath us is detectable. */
|
||||
interface FileIdentity {
|
||||
ino: number;
|
||||
birthtimeMs: number;
|
||||
}
|
||||
|
||||
function identify(dbPath: string): FileIdentity | null {
|
||||
try {
|
||||
const st = fs.statSync(dbPath);
|
||||
return { ino: st.ino, birthtimeMs: st.birthtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sameFile(a: FileIdentity | null, b: FileIdentity | null): boolean {
|
||||
if (a === null || b === null) return false;
|
||||
// `ino` is 0 on a few Windows filesystems; birthtime alone still catches a
|
||||
// recreate there, and a false "changed" only costs one reopen.
|
||||
return a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guidance shown when there is no index to read. Deliberately the same three
|
||||
* facts the CLI prints: the viewer never creates an index, `codegraph init`
|
||||
* does, and you can point the viewer somewhere already indexed.
|
||||
*/
|
||||
function noIndexError(projectRoot: string): ApiError {
|
||||
return new ApiError(
|
||||
'no-index',
|
||||
`No CodeGraph index found for ${projectRoot}.`,
|
||||
'The viewer reads an index that already exists — it never creates one. ' +
|
||||
'Run "codegraph init" in that project, or start the viewer against a project ' +
|
||||
'that has one: codegraph ui /path/to/indexed/project'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the project's `CodeGraph` open for the life of the server.
|
||||
*
|
||||
* Not thread-safe and does not need to be: `node:http` dispatches on one
|
||||
* thread, and every read below is synchronous.
|
||||
*/
|
||||
export class GraphSession {
|
||||
readonly projectRoot: string;
|
||||
private readonly dbPath: string;
|
||||
private cg: CodeGraph | null = null;
|
||||
private identity: FileIdentity | null = null;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.projectRoot = projectRoot;
|
||||
this.dbPath = getDatabasePath(projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* The open graph, opening (or reopening) it if needed.
|
||||
*
|
||||
* @throws {ApiError} `no-index` when the project has no index,
|
||||
* `index-unusable` when it has one that will not open.
|
||||
*/
|
||||
acquire(): CodeGraph {
|
||||
const current = identify(this.dbPath);
|
||||
|
||||
if (this.cg !== null) {
|
||||
if (sameFile(this.identity, current)) return this.cg;
|
||||
// The database was replaced (a re-index) or removed. Drop the stale
|
||||
// handle; falling through re-opens against whatever is there now.
|
||||
this.closeQuietly();
|
||||
}
|
||||
|
||||
if (!isInitialized(this.projectRoot)) throw noIndexError(this.projectRoot);
|
||||
|
||||
try {
|
||||
this.cg = CodeGraph.openSync(this.projectRoot);
|
||||
} catch (err) {
|
||||
this.cg = null;
|
||||
this.identity = null;
|
||||
throw new ApiError(
|
||||
'index-unusable',
|
||||
`The CodeGraph index for ${this.projectRoot} could not be opened: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
'If another CodeGraph process is rebuilding it, wait for that to finish. ' +
|
||||
'If the index is damaged, rebuild it with "codegraph init".'
|
||||
);
|
||||
}
|
||||
this.identity = current ?? identify(this.dbPath);
|
||||
return this.cg;
|
||||
}
|
||||
|
||||
/** Release the handle. Idempotent — the CLI calls it on Ctrl-C. */
|
||||
close(): void {
|
||||
this.closeQuietly();
|
||||
}
|
||||
|
||||
private closeQuietly(): void {
|
||||
const cg = this.cg;
|
||||
this.cg = null;
|
||||
this.identity = null;
|
||||
if (!cg) return;
|
||||
try {
|
||||
cg.close();
|
||||
} catch {
|
||||
// A close that fails has nothing left to release — the process is either
|
||||
// exiting or the file is already gone. Never let it fail a request.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* `GET /api/source?file=&from=&to=` — verbatim source, or an honest refusal.
|
||||
*
|
||||
* This is the one endpoint that reads the user's repository, so two rules
|
||||
* govern it and neither is negotiable.
|
||||
*
|
||||
* **Every read goes through `resolveProjectFile`.** That is the chokepoint from
|
||||
* `security.ts` — traversal, in-tree symlinks pointing out of the root,
|
||||
* absolute paths, sensitive system directories. Without it,
|
||||
* `?file=../../.ssh/id_rsa` is a credential leak over a port the user opened to
|
||||
* read their own code.
|
||||
*
|
||||
* **A file that changed on disk since it was indexed is never sliced.** The
|
||||
* viewer asks for line ranges the *index* recorded; if the file moved on since,
|
||||
* those ranges can point at a different symbol's body, which would be served
|
||||
* under the requested name and look perfectly plausible. So the bytes are
|
||||
* hashed and compared against `files.content_hash`, and on a mismatch the slice
|
||||
* is omitted with `drift: true` — the same call `codegraph_node` makes when it
|
||||
* says "changed on disk after the last index sync".
|
||||
*
|
||||
* Only files that are IN the index are served. That is a tighter boundary than
|
||||
* the MCP tools take, and it costs the viewer nothing (it only ever renders
|
||||
* indexed symbols) while making the drift verdict meaningful for every answer:
|
||||
* there is always a hash to compare against.
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { FileRecord } from '../../types';
|
||||
import type { CodeGraph } from '../../index';
|
||||
import { resolveProjectFile } from '../security';
|
||||
import { ApiError, badRequest, intParam, notFound, textParam } from './respond';
|
||||
|
||||
/**
|
||||
* Largest file we will read to answer a source request.
|
||||
*
|
||||
* The whole file has to be read to hash it, so this bounds the work one request
|
||||
* can cause. Well above the 1 MB ceiling extraction itself applies, so anything
|
||||
* actually in the index is comfortably inside it.
|
||||
*/
|
||||
export const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Lines returned in one response. The Symbol view asks for windows, not files. */
|
||||
export const MAX_SOURCE_LINES = 4000;
|
||||
|
||||
/**
|
||||
* Look up a file record by a viewer-supplied path, WITHOUT validating it.
|
||||
*
|
||||
* Indexed paths are normalized to forward slashes at extraction time, so that
|
||||
* is the form tried first; the platform-separator form is a fallback for an
|
||||
* index written before that normalization.
|
||||
*
|
||||
* Callers that go on to READ the file must use {@link resolveRequestedFile}
|
||||
* instead — it puts the path through the security chokepoint first. This one is
|
||||
* for endpoints that only need the record (a drift flag on a path the index
|
||||
* itself handed us).
|
||||
*/
|
||||
export function findIndexedFile(
|
||||
cg: CodeGraph,
|
||||
requested: string
|
||||
): { record: FileRecord; storedPath: string } | null {
|
||||
const posix = toRequestPath(requested);
|
||||
const record = cg.getFile(posix);
|
||||
if (record) return { record, storedPath: posix };
|
||||
|
||||
const native = posix.split('/').join(path.sep);
|
||||
if (native !== posix) {
|
||||
const legacy = cg.getFile(native);
|
||||
if (legacy) return { record: legacy, storedPath: native };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward slashes and no leading `./` — the form indexed paths are stored in.
|
||||
*
|
||||
* A LEADING SLASH IS LEFT ALONE on purpose. Stripping it would quietly turn
|
||||
* `/etc/passwd` into the project-relative `etc/passwd` and answer "not in this
|
||||
* index" — reinterpreting the request instead of refusing it, and leaving the
|
||||
* chokepoint's absolute-path rule with nothing to catch.
|
||||
*/
|
||||
export function toRequestPath(requested: string): string {
|
||||
return requested.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a viewer-supplied path, THEN look it up in the index.
|
||||
*
|
||||
* The order is the point. `resolveProjectFile` runs first, so a traversal, an
|
||||
* absolute path or a sensitive system directory is refused as what it is,
|
||||
* before the index is consulted — a 403 that says "outside the project", not a
|
||||
* 404 that says "not indexed" and quietly depends on the index lookup missing.
|
||||
* It also means the absolute path every reader uses has already been through
|
||||
* the chokepoint by construction, rather than by remembering to call it.
|
||||
*
|
||||
* @throws {PathRefusalError} the path is not one we would ever read.
|
||||
* @throws {ApiError} `not-found` when it is fine but not in the index.
|
||||
*/
|
||||
export function resolveRequestedFile(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
requested: string
|
||||
): { record: FileRecord; storedPath: string; absolute: string } {
|
||||
const posix = toRequestPath(requested);
|
||||
// Refusals happen here, ahead of everything.
|
||||
const absolute = resolveProjectFile(projectRoot, posix);
|
||||
|
||||
const found = findIndexedFile(cg, posix);
|
||||
if (!found) throw notIndexedError(posix);
|
||||
return { ...found, absolute };
|
||||
}
|
||||
|
||||
export function notIndexedError(file: string): ApiError {
|
||||
return notFound(
|
||||
`${file} is not in this CodeGraph index.`,
|
||||
'The viewer only reads files the index knows about. If the file is new, ' +
|
||||
'it appears after the next sync; if it is excluded (gitignored, generated, ' +
|
||||
'or too large to parse), it will not appear at all.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split source the way the index counted it.
|
||||
*
|
||||
* Rows are `\n`-delimited — that is how tree-sitter numbers them — so a CRLF
|
||||
* file has the same line numbers here as in the graph. The trailing `\r` is
|
||||
* dropped per line so it does not render as a stray glyph.
|
||||
*/
|
||||
export function splitLines(content: string): string[] {
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] as string;
|
||||
if (line.endsWith('\r')) lines[i] = line.slice(0, -1);
|
||||
}
|
||||
// A file ending in a newline splits to a final empty string that is not a
|
||||
// line of source. Every other trailing empty line IS one.
|
||||
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an indexed file has changed on disk since it was indexed — the same
|
||||
* verdict `/api/source` returns, for endpoints that must *flag* drift without
|
||||
* serving source (a symbol header, a file outline).
|
||||
*
|
||||
* Cheap first: size plus floored mtime is the identical freshness test the sync
|
||||
* fast path uses, so an untouched file costs one `stat`. Only a stat mismatch
|
||||
* pays for a hash, which is what keeps a `touch` or a checkout that rewrote
|
||||
* identical bytes from reading as drift.
|
||||
*
|
||||
* Any failure answers `false`. A wrong "stale" flag would put a warning banner
|
||||
* over correct source; the cases that would trip it (missing record, unreadable
|
||||
* file) have their own handling in the endpoints that actually read.
|
||||
*/
|
||||
export function hasDriftedOnDisk(
|
||||
projectRoot: string,
|
||||
storedPath: string,
|
||||
record: FileRecord
|
||||
): boolean {
|
||||
try {
|
||||
const absolute = resolveProjectFile(projectRoot, storedPath);
|
||||
const stats = fs.statSync(absolute);
|
||||
if (stats.size === record.size && Math.floor(stats.mtimeMs) === Math.floor(record.modifiedAt)) {
|
||||
return false;
|
||||
}
|
||||
if (stats.size > MAX_SOURCE_BYTES) return true;
|
||||
const content = fs.readFileSync(absolute, 'utf-8');
|
||||
return createHash('sha256').update(content).digest('hex') !== record.contentHash;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SourceResult {
|
||||
file: string;
|
||||
language: string;
|
||||
/** The file on disk differs from what was indexed — no slice is served. */
|
||||
drift: boolean;
|
||||
contentHash: string;
|
||||
indexedAt: number;
|
||||
generated: boolean;
|
||||
totalLines: number | null;
|
||||
from?: number;
|
||||
to?: number;
|
||||
lines?: string[];
|
||||
truncated?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function buildSource(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
query: URLSearchParams
|
||||
): SourceResult {
|
||||
const requested = textParam(query, 'file');
|
||||
// Refusal first, index lookup second — see `resolveRequestedFile`.
|
||||
const { record, storedPath, absolute } = resolveRequestedFile(cg, projectRoot, requested);
|
||||
|
||||
const from = intParam(query, 'from', { min: 1, max: 5_000_000, default: 1 });
|
||||
const to = intParam(query, 'to', { min: 1, max: 5_000_000, default: 0 });
|
||||
if (to !== 0 && to < from) {
|
||||
throw badRequest(`Parameter "to" (${to}) must not be before "from" (${from}).`);
|
||||
}
|
||||
|
||||
const base: SourceResult = {
|
||||
file: storedPath.replace(/\\/g, '/'),
|
||||
language: record.language,
|
||||
drift: false,
|
||||
contentHash: record.contentHash,
|
||||
indexedAt: record.indexedAt,
|
||||
generated: record.generated === true,
|
||||
totalLines: null,
|
||||
};
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(absolute);
|
||||
} catch {
|
||||
// Indexed but gone. That IS drift, and the strongest kind: nothing on disk
|
||||
// corresponds to the ranges the graph holds.
|
||||
return { ...base, drift: true, reason: 'The file is in the index but no longer on disk.' };
|
||||
}
|
||||
if (stats.size > MAX_SOURCE_BYTES) {
|
||||
throw badRequest(
|
||||
`${base.file} is ${Math.round(stats.size / 1024 / 1024)} MB — too large to serve as source.`
|
||||
);
|
||||
}
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(absolute, 'utf-8');
|
||||
} catch (err) {
|
||||
throw new ApiError(
|
||||
'internal',
|
||||
`Could not read ${base.file}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Byte-identical to extraction's `hashContent` (sha256 over the utf-8
|
||||
// string). A touch or a checkout that rewrote the same bytes must not count
|
||||
// as drift, which is exactly what hashing content rather than mtime buys.
|
||||
const hash = createHash('sha256').update(content).digest('hex');
|
||||
if (hash !== record.contentHash) {
|
||||
return {
|
||||
...base,
|
||||
drift: true,
|
||||
reason:
|
||||
'This file changed on disk after the last index sync, so the indexed line ' +
|
||||
'ranges no longer reliably match. Source is omitted rather than risk showing ' +
|
||||
"a different symbol's code; it returns after the next sync.",
|
||||
};
|
||||
}
|
||||
|
||||
const all = splitLines(content);
|
||||
// Past the end of the file `from` names nothing, which is a caller bug worth
|
||||
// surfacing rather than answering with the last line as if that were meant.
|
||||
// `to` past the end is different — "line 30 to the end, whatever that is" is
|
||||
// an ordinary way to ask, so it clamps.
|
||||
if (from > all.length) {
|
||||
throw badRequest(
|
||||
`Parameter "from" (${from}) is past the end of ${base.file}, which has ${all.length} lines.`
|
||||
);
|
||||
}
|
||||
const start = from;
|
||||
const requestedEnd = to === 0 ? all.length : Math.min(to, all.length);
|
||||
const end = Math.min(requestedEnd, start + MAX_SOURCE_LINES - 1);
|
||||
|
||||
return {
|
||||
...base,
|
||||
totalLines: all.length,
|
||||
from: start,
|
||||
to: end,
|
||||
lines: all.slice(start - 1, end),
|
||||
truncated: end < requestedEnd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* `GET /api/stats` — what this index is, and how much to trust it.
|
||||
*
|
||||
* The viewer's top bar shows a couple of numbers from here, but the reason the
|
||||
* endpoint carries more than that is honesty: an index can be truncated
|
||||
* (`state: "indexing"` after a killed run), built by an older extractor, or
|
||||
* simply old. A reader that draws confident graphs over a half-built index is
|
||||
* the failure mode worth designing against, so the state travels with the
|
||||
* counts rather than being something the UI has to ask for separately.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import type { CodeGraph } from '../../index';
|
||||
import { HUB_THRESHOLD, UNCERTAIN_BELOW } from './wire';
|
||||
|
||||
export function buildStats(cg: CodeGraph, projectRoot: string): unknown {
|
||||
const stats = cg.getStats();
|
||||
const build = cg.getIndexBuildInfo();
|
||||
|
||||
return {
|
||||
project: {
|
||||
root: projectRoot,
|
||||
name: path.basename(projectRoot) || projectRoot,
|
||||
},
|
||||
index: {
|
||||
/**
|
||||
* `complete` is the only good value. `indexing` means a run was killed
|
||||
* part-way and the graph on disk is a truncated one; `partial`/`failed`
|
||||
* mean the run finished but dropped files. `null` predates the marker.
|
||||
*/
|
||||
state: cg.getIndexState(),
|
||||
lastIndexedAt: cg.getLastIndexedAt(),
|
||||
/** Built by an older extractor — a re-index would add data no migration can. */
|
||||
stale: cg.isIndexStale(),
|
||||
version: build.version,
|
||||
extractionVersion: build.extractionVersion,
|
||||
backend: cg.getBackend(),
|
||||
journalMode: cg.getJournalMode(),
|
||||
/** References still waiting to resolve; > 0 means edges are still missing. */
|
||||
pendingReferences: cg.getPendingReferenceCount(),
|
||||
generatedFiles: cg.getGeneratedFileCount(),
|
||||
watching: cg.isWatching(),
|
||||
watcherDegraded: cg.isWatcherDegraded(),
|
||||
},
|
||||
graph: {
|
||||
nodes: stats.nodeCount,
|
||||
edges: stats.edgeCount,
|
||||
files: stats.fileCount,
|
||||
nodesByKind: stats.nodesByKind,
|
||||
edgesByKind: stats.edgesByKind,
|
||||
filesByLanguage: stats.filesByLanguage,
|
||||
dbSizeBytes: stats.dbSizeBytes,
|
||||
walSizeBytes: stats.walSizeBytes,
|
||||
},
|
||||
frameworks: cg.getDetectedFrameworks(),
|
||||
/**
|
||||
* The thresholds the API itself applied, so the viewer's copy ("hub · N",
|
||||
* "confidence < 0.6") stays in step with the data instead of hard-coding a
|
||||
* second copy of the same numbers.
|
||||
*/
|
||||
thresholds: { hub: HUB_THRESHOLD, uncertainBelow: UNCERTAIN_BELOW },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* The wire shapes the viewer reads, and the rules for producing them.
|
||||
*
|
||||
* Two ideas run through this file:
|
||||
*
|
||||
* 1. **One round-trip per screen.** Every endpoint returns everything a screen
|
||||
* draws, in the spirit of `codegraph_explore`: the Symbol view never has to
|
||||
* ask a follow-up question to render a rail, a badge or a count.
|
||||
* 2. **Capped lists, honest totals.** A symbol with 545 callers cannot ship 545
|
||||
* rows, but it must never claim it has fewer. Every capped list carries the
|
||||
* true `total` beside the `shown` slice, so the UI can say "+N more" rather
|
||||
* than quietly truncating.
|
||||
*
|
||||
* Nothing here reads the filesystem — that lives in `source.ts`, behind
|
||||
* `resolveProjectFile`.
|
||||
*/
|
||||
|
||||
import type { Edge, EdgeKind, Language, Node, NodeKind } from '../../types';
|
||||
import { isTestFile } from '../../search/query-utils';
|
||||
|
||||
// =============================================================================
|
||||
// Caps and thresholds
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fan-in at or above which a symbol is a "hub" — changing it is a
|
||||
* repo-wide event. Matches the threshold the Symbol view's `hub · N` badge
|
||||
* uses (design spec §3.2).
|
||||
*/
|
||||
export const HUB_THRESHOLD = 40;
|
||||
|
||||
/**
|
||||
* Below this resolution confidence an edge is a name-only guess. The viewer
|
||||
* folds these away behind "Uncertain · N name-only matches, confidence < 0.6"
|
||||
* rather than mixing them into the rails as if they were resolved.
|
||||
*/
|
||||
export const UNCERTAIN_BELOW = 0.6;
|
||||
|
||||
/** Caller groups (one per calling symbol) returned for a node. */
|
||||
export const MAX_INCOMING_GROUPS = 300;
|
||||
|
||||
/** Callee groups (one per called symbol) returned for a node. */
|
||||
export const MAX_OUTGOING_GROUPS = 200;
|
||||
|
||||
/** Edges kept inside a single group — one symbol calling another 400 times. */
|
||||
export const MAX_EDGES_PER_GROUP = 40;
|
||||
|
||||
/** Test files named in a node's test-caller summary (explore uses the same shape). */
|
||||
export const MAX_TEST_FILES = 6;
|
||||
|
||||
/** Caller hops walked looking for a test. Mirrors `codegraph_explore`'s "tests:" line. */
|
||||
export const TEST_CALLER_HOPS = 3;
|
||||
|
||||
/** `getCallers` lookups the test walk may spend, so a god-symbol can't stall a request. */
|
||||
export const TEST_CALLER_BUDGET = 64;
|
||||
|
||||
/** Unresolved references listed by name before the payload just counts them. */
|
||||
export const MAX_OUTSIDE_INDEX_SAMPLES = 40;
|
||||
|
||||
/** Symbols in a file outline. Beyond this the outline is truncated, not dropped. */
|
||||
export const MAX_OUTLINE_NODES = 3000;
|
||||
|
||||
/** Files listed in each direction of the File view's import rails. */
|
||||
export const MAX_IMPORT_FILES = 300;
|
||||
|
||||
// =============================================================================
|
||||
// Node shapes
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A symbol as it appears in a rail, an outline or a search result: enough to
|
||||
* draw a row and navigate to it, and nothing else. Deliberately excludes the
|
||||
* docstring — a 300-caller rail would otherwise ship 300 docstrings.
|
||||
*/
|
||||
export interface WireNodeRef {
|
||||
id: string;
|
||||
kind: NodeKind;
|
||||
name: string;
|
||||
qualifiedName: string;
|
||||
/** Project-relative, forward slashes on every platform. */
|
||||
file: string;
|
||||
line: number;
|
||||
endLine: number;
|
||||
language: Language;
|
||||
signature?: string;
|
||||
exported?: boolean;
|
||||
/** The file this symbol lives in looks like test/fixture code. */
|
||||
test: boolean;
|
||||
}
|
||||
|
||||
/** The focal symbol of a Symbol view — the ref, plus everything the header shows. */
|
||||
export interface WireNodeDetail extends WireNodeRef {
|
||||
startColumn: number;
|
||||
endColumn: number;
|
||||
docstring?: string;
|
||||
visibility?: string;
|
||||
async?: boolean;
|
||||
static?: boolean;
|
||||
abstract?: boolean;
|
||||
decorators?: string[];
|
||||
typeParameters?: string[];
|
||||
returnType?: string;
|
||||
/** `endLine - line + 1`, so the header can print "N lines" without the source. */
|
||||
lines: number;
|
||||
}
|
||||
|
||||
const rel = (p: string): string => p.replace(/\\/g, '/');
|
||||
|
||||
export function toNodeRef(node: Node): WireNodeRef {
|
||||
const file = rel(node.filePath);
|
||||
const ref: WireNodeRef = {
|
||||
id: node.id,
|
||||
kind: node.kind,
|
||||
name: node.name,
|
||||
qualifiedName: node.qualifiedName,
|
||||
file,
|
||||
line: node.startLine,
|
||||
endLine: node.endLine,
|
||||
language: node.language,
|
||||
test: isTestFile(file),
|
||||
};
|
||||
if (node.signature) ref.signature = node.signature;
|
||||
if (node.isExported) ref.exported = true;
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function toNodeDetail(node: Node): WireNodeDetail {
|
||||
const detail: WireNodeDetail = {
|
||||
...toNodeRef(node),
|
||||
startColumn: node.startColumn,
|
||||
endColumn: node.endColumn,
|
||||
lines: Math.max(1, node.endLine - node.startLine + 1),
|
||||
};
|
||||
if (node.docstring) detail.docstring = node.docstring;
|
||||
if (node.visibility) detail.visibility = node.visibility;
|
||||
if (node.isAsync) detail.async = true;
|
||||
if (node.isStatic) detail.static = true;
|
||||
if (node.isAbstract) detail.abstract = true;
|
||||
if (node.decorators?.length) detail.decorators = node.decorators;
|
||||
if (node.typeParameters?.length) detail.typeParameters = node.typeParameters;
|
||||
if (node.returnType) detail.returnType = node.returnType;
|
||||
return detail;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Edge shapes
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* One edge, flattened.
|
||||
*
|
||||
* `metadata` is a free-form JSON blob in the schema; the fields lifted out here
|
||||
* are the ones the viewer draws with — confidence decides the uncertain fold,
|
||||
* `provenance`/`synthesizedBy`/`via`/`registeredAt` decide how a connector is
|
||||
* dashed and what the "via <mechanism>" pill says, `valueRef` distinguishes
|
||||
* "passes as value" from "calls". Anything else in the blob stays out: it is
|
||||
* resolver bookkeeping, not something a reader can act on.
|
||||
*/
|
||||
export interface WireEdge {
|
||||
kind: EdgeKind;
|
||||
line?: number;
|
||||
col?: number;
|
||||
confidence?: number;
|
||||
resolvedBy?: string;
|
||||
provenance?: string;
|
||||
synthesizedBy?: string;
|
||||
via?: string;
|
||||
registeredAt?: string;
|
||||
valueRef?: boolean;
|
||||
}
|
||||
|
||||
export function toWireEdge(edge: Edge): WireEdge {
|
||||
const meta = (edge.metadata ?? {}) as Record<string, unknown>;
|
||||
const wire: WireEdge = { kind: edge.kind };
|
||||
if (typeof edge.line === 'number') wire.line = edge.line;
|
||||
if (typeof edge.column === 'number') wire.col = edge.column;
|
||||
if (typeof meta.confidence === 'number') wire.confidence = meta.confidence;
|
||||
if (typeof meta.resolvedBy === 'string') wire.resolvedBy = meta.resolvedBy;
|
||||
if (edge.provenance) wire.provenance = edge.provenance;
|
||||
if (typeof meta.synthesizedBy === 'string') wire.synthesizedBy = meta.synthesizedBy;
|
||||
if (typeof meta.via === 'string') wire.via = meta.via;
|
||||
if (typeof meta.registeredAt === 'string') wire.registeredAt = meta.registeredAt;
|
||||
if (meta.valueRef === true) wire.valueRef = true;
|
||||
return wire;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Relations — edges grouped by the symbol at the other end
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Every edge between the focal symbol and ONE other symbol, as a single row.
|
||||
*
|
||||
* Grouping is what makes the rails readable: a helper called from eleven lines
|
||||
* of the same function is one row with eleven call-site chips, not eleven rows.
|
||||
*/
|
||||
export interface WireRelation {
|
||||
node: WireNodeRef;
|
||||
/** Distinct edge kinds between the two, in first-seen order. */
|
||||
edgeKinds: EdgeKind[];
|
||||
/** Up to {@link MAX_EDGES_PER_GROUP} edges, ordered by line. */
|
||||
edges: WireEdge[];
|
||||
/** True number of edges, even when `edges` was capped. */
|
||||
edgeCount: number;
|
||||
/** Distinct call-site lines, ascending — what the gutter ports anchor to. */
|
||||
lines: number[];
|
||||
/** Highest confidence any edge in the group carries; null when none does. */
|
||||
confidence: number | null;
|
||||
/** The whole group is a name-only guess (see {@link UNCERTAIN_BELOW}). */
|
||||
uncertain: boolean;
|
||||
/** At least one edge was synthesized rather than parsed (dynamic dispatch). */
|
||||
synthesized: boolean;
|
||||
/** Fan-in of the other symbol — the `hub · N` pill. Only filled where the UI shows it. */
|
||||
fanIn?: number;
|
||||
hub?: boolean;
|
||||
}
|
||||
|
||||
/** A capped list that still knows how long it really is. */
|
||||
export interface WireList<T> {
|
||||
total: number;
|
||||
shown: number;
|
||||
truncated: boolean;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export function wireList<T>(items: T[], total: number): WireList<T> {
|
||||
return { total, shown: items.length, truncated: items.length < total, items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold edges into one relation per counterpart symbol.
|
||||
*
|
||||
* @param edges edges all sharing the focal node at one end
|
||||
* @param endpoint which end of each edge names the OTHER symbol
|
||||
* @param nodes batch-resolved endpoint nodes (never a lookup per edge)
|
||||
*/
|
||||
export function groupRelations(
|
||||
edges: readonly Edge[],
|
||||
endpoint: (edge: Edge) => string,
|
||||
nodes: Map<string, Node>
|
||||
): WireRelation[] {
|
||||
const byNode = new Map<string, Edge[]>();
|
||||
for (const edge of edges) {
|
||||
const id = endpoint(edge);
|
||||
const bucket = byNode.get(id);
|
||||
if (bucket) bucket.push(edge);
|
||||
else byNode.set(id, [edge]);
|
||||
}
|
||||
|
||||
const relations: WireRelation[] = [];
|
||||
for (const [id, group] of byNode) {
|
||||
const node = nodes.get(id);
|
||||
// An edge whose endpoint is missing from `nodes` means the graph and the
|
||||
// node table disagree — skip it rather than invent a row. Callers still see
|
||||
// it in the totals they computed from the raw edge list.
|
||||
if (!node) continue;
|
||||
const ordered = [...group].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
|
||||
const wireEdges = ordered.slice(0, MAX_EDGES_PER_GROUP).map(toWireEdge);
|
||||
|
||||
const edgeKinds: EdgeKind[] = [];
|
||||
for (const edge of ordered) if (!edgeKinds.includes(edge.kind)) edgeKinds.push(edge.kind);
|
||||
|
||||
const lines = [
|
||||
...new Set(ordered.map((e) => e.line).filter((l): l is number => typeof l === 'number' && l > 0)),
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
let confidence: number | null = null;
|
||||
let synthesized = false;
|
||||
for (const edge of ordered) {
|
||||
const value = (edge.metadata as Record<string, unknown> | undefined)?.confidence;
|
||||
if (typeof value === 'number' && (confidence === null || value > confidence)) confidence = value;
|
||||
if (edge.provenance === 'heuristic') synthesized = true;
|
||||
}
|
||||
|
||||
relations.push({
|
||||
node: toNodeRef(node),
|
||||
edgeKinds,
|
||||
edges: wireEdges,
|
||||
edgeCount: ordered.length,
|
||||
lines,
|
||||
confidence,
|
||||
// No confidence recorded is NOT uncertain: tree-sitter edges extracted
|
||||
// straight from the AST carry none precisely because they are certain.
|
||||
uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
|
||||
synthesized,
|
||||
});
|
||||
}
|
||||
return relations;
|
||||
}
|
||||
|
||||
/** First call-site line of a relation, for line-anchored ordering. Unlined rows sort last. */
|
||||
export function firstLine(relation: WireRelation): number {
|
||||
return relation.lines[0] ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/** Node kinds that count as "a type" for the Symbol view's "types used" chips. */
|
||||
export const TYPE_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
|
||||
'interface',
|
||||
'type_alias',
|
||||
'class',
|
||||
'struct',
|
||||
'enum',
|
||||
'union',
|
||||
'trait',
|
||||
'protocol',
|
||||
]);
|
||||
|
||||
/** Container kinds whose members the outline nests one level deeper. */
|
||||
export const CONTAINER_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
|
||||
'file',
|
||||
'module',
|
||||
'namespace',
|
||||
'class',
|
||||
'struct',
|
||||
'interface',
|
||||
'trait',
|
||||
'protocol',
|
||||
'enum',
|
||||
'union',
|
||||
]);
|
||||
|
||||
/** The four edge kinds `getCallers` treats as "reaches this symbol". */
|
||||
export const CALLER_EDGE_KINDS: ReadonlySet<EdgeKind> = new Set<EdgeKind>([
|
||||
'calls',
|
||||
'references',
|
||||
'imports',
|
||||
'instantiates',
|
||||
]);
|
||||
|
||||
export { rel as toPosixPath };
|
||||
Reference in New Issue
Block a user