feat(ui): the viewer's screens as @colbymchenry/codegraph-ui, behind one adapter (CG-61)

`ui/src` now builds two ways from one tree: the static app `codegraph ui`
serves, and — via `svelte-package` — a Svelte library the Pro app imports.
A forked component would be a second answer to the same question about the
same graph, so there is no fork.

Everything a screen knows arrives through a `GraphAdapter`: eleven methods
answering the wire shapes verbatim, with `createHttpAdapter()` (the loopback
JSON API) as the default and a host's in-process engine reads as the point.
`lib/api.ts` became a one-line-per-call facade over it, which is why no call
site in the views changed. The payload types moved to `lib/wire.ts` — no
imports, no runtime — so a host can depend on the vocabulary alone.

Two more seams and one guard:

- `lib/navigation.ts` holds the href builders behind a `NavigationDriver`, so
  a host addresses its own URL space. The app's half — the hash parser and the
  live route, which attach window listeners at module scope — stays in
  `router.svelte.ts` and is pruned out of the package: rendering a Symbol view
  must not install a hash router in somebody else's application.
- `lib/theme.css` carries the design tokens and maps Svelte Flow's `--xy-*`
  variables onto them, so a host never sees library defaults. Dark now also
  answers to a bare `[data-theme]`, which is how `<CodegraphUi theme>` themes
  a container rather than the document.
- `scripts/check-ui-package.mjs` prunes the app's shell, resolves the
  extensionless specifiers svelte-package leaves behind, and asserts that
  nothing but `lib/adapter.js` reaches the network.

The search box, its keyboard and its panel are one component now
(`SearchPalette`), because splitting them is what breaks a palette.

`__tests__/ui-package.test.ts` mounts the three screens from the package entry
against a mock adapter in jsdom; it runs as a second vitest project so the
`browser` resolve condition it needs cannot reach the engine's suites.

Versioned with the engine. Prepared, not published: `private: true` is the
guard and `pack-npm.sh` only packs a tarball under CODEGRAPH_PACK_UI=1.
This commit is contained in:
Colby McHenry
2026-08-27 06:52:57 -05:00
parent ad91c8fdd8
commit c15413f200
42 changed files with 4245 additions and 1157 deletions
+360
View File
@@ -0,0 +1,360 @@
/**
* The data seam: everything these components know about a project arrives
* through one {@link GraphAdapter} (task CG-61).
*
* The viewer shipped by `codegraph ui` uses {@link createHttpAdapter}, which is
* the read-only JSON API over loopback. A host that already holds the graph —
* CodeGraph Pro, which opens the index in-process — implements the same eleven
* methods against its own reads and never makes an HTTP request. The components
* cannot tell the difference, which is the whole point: one implementation of
* the Symbol view, the Flow strip and the Map, drawn from whichever side of the
* wire the caller happens to be on.
*
* ## The shapes are the contract, not the transport
*
* Every method answers a `Wire*` type from `./wire`, verbatim — the same object
* `src/ui-server/api/` serialises. An adapter is therefore allowed to be a
* `fetch`, a function call, a cache, or a fixture in a test; what it is not
* allowed to do is invent a shape. `./wire` has no imports and no runtime, so a
* host can depend on the vocabulary without depending on the viewer.
*
* ## One adapter per page
*
* The current adapter is module-level state, not Svelte context. Two reasons:
* the pure model modules (`symbol-model`, `flow-model`, the palette store) are
* plain TypeScript and cannot read a component's context, and a reader is
* looking at one project at a time — the screens are a reading of *a* graph.
* A host calls {@link setGraphAdapter} once before it renders, or wraps its
* tree in `<CodegraphUi>`, which does it during initialisation.
*/
import type {
WireEntryPoints,
WireFilePayload,
WireFileCodePayload,
WireFlowPayload,
WireMapPayload,
WireNodeRefs,
WireRoutes,
WireSearch,
WireSource,
WireStats,
WireSymbolPayload,
} from './wire';
/* ---------------------------------------------------------------- errors -- */
/**
* An error the answering side described.
*
* The JSON API answers JSON for *every* outcome, including refusals, so a
* non-2xx still carries a sentence worth showing — this is what puts the
* server's own words on the screen instead of "Failed to fetch". An in-process
* adapter should throw the same thing for the same reason: the screens read
* `code` (`'not-found'` has its own empty state) and print `guidance`.
*/
export class ApiFailure extends Error {
readonly status: number;
readonly code: string;
readonly guidance: string | null;
constructor(status: number, code: string, message: string, guidance: string | null) {
super(message);
this.name = 'ApiFailure';
this.status = status;
this.code = code;
this.guidance = guidance;
}
}
/** What `fail()` in `src/ui-server/api/respond.ts` sends. */
interface ApiErrorBody {
error?: string;
code?: string;
hint?: string;
}
/* -------------------------------------------------------------- requests -- */
export interface SourceRequest {
file: string;
/** 1-based, inclusive. */
from: number;
/** 1-based, inclusive. Omitted means "to the end of the file". */
to?: number;
/**
* What to answer when the file has changed since it was indexed. The default
* omits the slice — an indexed range over rewritten bytes can show a
* different symbol's code under the right name. `'current'` asks for the
* file's current lines instead, and the answer says `showing: 'current'` so
* the caller can switch every line-anchored marking off over it.
*/
ondrift?: 'current';
}
/**
* A flow question. Exactly one of the three shapes is asked at a time:
* `{ from, to }` ("how does X reach Y"), `{ symbols }` (`codegraph_explore`'s
* own question, verbatim) or `{ trail }` (the hops the reader walked, as
* `<dir><id>` strings).
*/
export interface FlowRequest {
from?: string;
to?: string;
symbols?: string;
trail?: readonly string[];
}
export interface MapRequest {
/** The subtree to aggregate — a monorepo's package. Null lets the graph pick. */
root?: string | null;
/** How many path segments under the root name a module. */
depth?: number;
}
export interface SearchRequest {
limit?: number;
}
export interface EntryPointsRequest {
limit?: number;
routes?: number;
}
export interface RoutesRequest {
limit?: number;
}
/* ----------------------------------------------------------------- live -- */
/**
* The live channel's events, as the viewer's `live.svelte.ts` consumes them.
*
* An adapter that has no way to know the graph moved simply omits
* {@link GraphAdapter.events}; the screens then render once and stay put, which
* is the correct behaviour for a host that re-mounts them itself.
*/
export interface LiveHandlers {
hello(event: unknown): void;
changed(event: unknown): void;
index(event: unknown): void;
degraded(event: unknown): void;
/** The connection dropped. The caller owns the backoff — never retry here. */
error(): void;
}
/* -------------------------------------------------------------- adapter -- */
/**
* Everything the components ask of a project.
*
* Seven of these are the reading surface named in the task — `search`, `node`,
* `source`, `file`, `flow`, `map`, `routes` — and the other four are what the
* screens around them need: `stats` (the blast bar's denominator and the top
* bar's counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode`
* (the whole-file view) and `entryPoints` (where a reader starts).
*/
export interface GraphAdapter {
/** The index's own facts: counts, thresholds, the blast scale. */
stats(signal?: AbortSignal): Promise<WireStats>;
search(query: string, opts?: SearchRequest, signal?: AbortSignal): Promise<WireSearch>;
/** One symbol with its rails, outline, tests, blast radius and drift verdict. */
node(id: string, signal?: AbortSignal): Promise<WireSymbolPayload>;
/** Names and locations for ids the caller already holds (the trail). */
nodes(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs>;
/** A slice of an indexed file, classified for highlighting. */
source(request: SourceRequest, signal?: AbortSignal): Promise<WireSource>;
/** One file: outline, import rails, dependencies, drift. */
file(path: string, signal?: AbortSignal): Promise<WireFilePayload>;
/** Everything the graph says about the LINES of one file (ports and arcs). */
fileCode(path: string, signal?: AbortSignal): Promise<WireFileCodePayload>;
/** The call path between symbols — `resolveNamedSymbolFlow`'s own answer. */
flow(request: FlowRequest, signal?: AbortSignal): Promise<WireFlowPayload>;
/** The repository at module granularity, layered. */
map(request?: MapRequest, signal?: AbortSignal): Promise<WireMapPayload>;
/** The URL → handler map. */
routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
/** Where a reader starts: routes, files that run something, tests, hubs. */
entryPoints(request?: EntryPointsRequest, signal?: AbortSignal): Promise<WireEntryPoints>;
/**
* Subscribe to index/disk changes. Optional — a host without a live channel
* omits it and nothing polls. Returns a function that closes the stream.
*/
events?(handlers: LiveHandlers): () => void;
}
/* ------------------------------------------------------------ http impl -- */
export interface HttpAdapterOptions {
/**
* Where the API lives, ending in a slash. The default is relative (`''`),
* which is what the CLI serves: the viewer is mounted at `/` and asks for
* `api/stats`, so it survives being mounted under a sub-path.
*/
baseUrl?: string;
/** Injectable for tests and for a host that wraps `fetch` with auth. */
fetch?: typeof globalThis.fetch;
}
/** Ids and paths carry ':' and '/', so encode per segment and rejoin. */
function encodePath(value: string): string {
return value.split('/').map(encodeURIComponent).join('/');
}
function query(params: URLSearchParams): string {
const text = params.toString();
return text ? `?${text}` : '';
}
/**
* The default adapter: the read-only JSON API `codegraph ui` serves.
*
* Every failure it can describe comes back as an {@link ApiFailure} carrying
* the server's own sentence. The one it cannot describe — the server was
* stopped while the tab stayed open — is given a sentence here, because a
* network-level `TypeError` says nothing a reader can act on.
*/
export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapter {
const base = options.baseUrl ?? '';
const doFetch = options.fetch ?? ((...args: Parameters<typeof globalThis.fetch>) =>
globalThis.fetch(...args));
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
let response: Response;
try {
response = await doFetch(`${base}${path}`, {
signal,
headers: { accept: 'application/json' },
});
} catch (cause) {
if (signal?.aborted) throw cause;
throw new ApiFailure(
0,
'unreachable',
'The codegraph ui server is not answering.',
'It may have been stopped — restart it with `codegraph ui` and reload this page.'
);
}
const body = (await response.json().catch(() => null)) as unknown;
if (!response.ok) {
const failure = (body as ApiErrorBody | null) ?? {};
throw new ApiFailure(
response.status,
failure.code ?? 'error',
failure.error ?? `The server answered ${response.status}.`,
failure.hint ?? null
);
}
return body as T;
}
return {
stats: (signal) => getJson<WireStats>('api/stats', signal),
search(text, opts = {}, signal) {
const params = new URLSearchParams({ q: text });
if (opts.limit) params.set('limit', String(opts.limit));
return getJson<WireSearch>(`api/search${query(params)}`, signal);
},
node: (id, signal) => getJson<WireSymbolPayload>(`api/node/${encodePath(id)}`, signal),
nodes(ids, signal) {
const params = new URLSearchParams();
// Repeated `id` params, never one comma-joined list: a node id can be a
// file path and a file path can contain a comma.
for (const id of ids) params.append('id', id);
return getJson<WireNodeRefs>(`api/nodes${query(params)}`, signal);
},
source(request, signal) {
const params = new URLSearchParams({
file: request.file,
from: String(request.from),
});
// Absent `to` means "to the end of the file"; sending 0 for that would be
// out of range, not a synonym.
if (request.to !== undefined && request.to > 0) params.set('to', String(request.to));
if (request.ondrift) params.set('ondrift', request.ondrift);
return getJson<WireSource>(`api/source${query(params)}`, signal);
},
file: (path, signal) => getJson<WireFilePayload>(`api/file/${encodePath(path)}`, signal),
fileCode: (path, signal) =>
getJson<WireFileCodePayload>(`api/filecode/${encodePath(path)}`, signal),
flow(request, signal) {
const params = new URLSearchParams();
if (request.from) params.set('from', request.from);
if (request.to) params.set('to', request.to);
if (request.symbols) params.set('symbols', request.symbols);
for (const hop of request.trail ?? []) params.append('hop', hop);
return getJson<WireFlowPayload>(`api/flow${query(params)}`, signal);
},
map(request = {}, signal) {
const params = new URLSearchParams();
if (request.root !== undefined && request.root !== null) params.set('root', request.root);
if (request.depth) params.set('depth', String(request.depth));
return getJson<WireMapPayload>(`api/map${query(params)}`, signal);
},
routes(request = {}, signal) {
const params = new URLSearchParams();
if (request.limit) params.set('limit', String(request.limit));
return getJson<WireRoutes>(`api/routes${query(params)}`, signal);
},
entryPoints(request = {}, signal) {
const params = new URLSearchParams();
if (request.limit) params.set('limit', String(request.limit));
if (request.routes) params.set('routes', String(request.routes));
return getJson<WireEntryPoints>(`api/entrypoints${query(params)}`, signal);
},
events(handlers) {
if (typeof EventSource === 'undefined') return () => {};
const stream = new EventSource(`${base}api/events`);
stream.addEventListener('hello', (event) => handlers.hello(parse(event)));
stream.addEventListener('changed', (event) => handlers.changed(parse(event)));
stream.addEventListener('index', (event) => handlers.index(parse(event)));
stream.addEventListener('degraded', (event) => handlers.degraded(parse(event)));
// The backoff belongs to the caller, not here: an adapter that retried on
// its own would race the one that already does and double the requests.
stream.addEventListener('error', () => handlers.error());
return () => stream.close();
},
};
}
function parse(event: Event): unknown {
const data = (event as MessageEvent<string>).data;
if (typeof data !== 'string') return null;
try {
return JSON.parse(data) as unknown;
} catch {
return null;
}
}
/* ------------------------------------------------------------- registry -- */
let current: GraphAdapter | null = null;
/**
* Install the adapter every screen reads through.
*
* Call once, before anything renders. Passing `null` restores the default HTTP
* adapter, which is what the standalone viewer runs on.
*/
export function setGraphAdapter(adapter: GraphAdapter | null): void {
current = adapter;
}
/** The installed adapter, defaulting to the HTTP one on first use. */
export function getGraphAdapter(): GraphAdapter {
if (current === null) current = createHttpAdapter();
return current;
}
+55 -644
View File
@@ -1,619 +1,51 @@
/**
* The viewer's side of the read-only JSON API (`src/ui-server/api/`, CG-42).
* The screens' side of the graph API.
*
* The types below mirror the server's wire shapes rather than re-deriving
* them: the API is versioned with the binary that serves it, so a field the
* server stopped sending should break the type-check here, not surface as
* `undefined` in a rail three screens later.
* Every function here is one call on the installed {@link GraphAdapter}
* (`adapter.ts`). Nothing in this file knows about HTTP: the standalone viewer
* runs on `createHttpAdapter`, and a host that already holds the index — the
* Pro app — installs its own and these same functions read from it.
*
* One rule for every call: the API answers JSON for *every* outcome, including
* refusals. So a non-2xx still has a body worth reading, and `ApiFailure`
* carries the server's own sentence instead of "Failed to fetch".
* The wire types live in `./wire` (types only, no runtime) and are re-exported
* here so that a screen can keep asking one module for both the call and the
* shape it answers with.
*/
import type { WireHighlight } from './highlight';
import { getGraphAdapter } from './adapter';
import type {
WireEntryPoints,
WireFilePayload,
WireFileCodePayload,
WireFlowPayload,
WireMapPayload,
WireNodeRefs,
WireRoutes,
WireSearch,
WireSource,
WireStats,
WireSymbolPayload,
} from './wire';
/* ---------------------------------------------------------------- shapes -- */
export type NodeKind = string;
export type EdgeKind = string;
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: string;
signature?: string;
exported?: boolean;
/** Lives in a file that looks like test or fixture code. */
test: boolean;
}
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;
lines: number;
}
export interface WireMember extends WireNodeRef {
parentId: string;
/** 1 = a direct member; 2 = a member of a member (a method inside a file's class). */
depth: number;
fanIn: number;
fanOut: number;
}
export interface WireEdge {
kind: EdgeKind;
line?: number;
col?: number;
confidence?: number;
resolvedBy?: string;
provenance?: string;
synthesizedBy?: string;
via?: string;
registeredAt?: string;
valueRef?: boolean;
}
/** Every edge between the focal symbol and ONE other symbol, as a single row. */
export interface WireRelation {
node: WireNodeRef;
edgeKinds: EdgeKind[];
edges: WireEdge[];
edgeCount: number;
/** Distinct call-site lines, ascending — what the gutter ports anchor to. */
lines: number[];
confidence: number | null;
uncertain: boolean;
synthesized: boolean;
fanIn?: number;
hub?: boolean;
}
export interface WireList<T> {
total: number;
shown: number;
truncated: boolean;
items: T[];
}
export interface WireTestSummary {
reached: boolean;
hops: number | null;
fileCount: number;
files: string[];
/** False weakens the claim to "no test calls this directly" — see the server. */
exhaustive: boolean;
hopsSearched: number;
}
export interface WireOutsideIndex {
total: number;
byKind: Record<string, number>;
samples: Array<{ name: string; kind: string; line?: number; col?: number }>;
}
export interface WireBlastSummary {
direct: number;
withinHops: number;
hops: number;
files: number;
testFiles: number;
routes: number;
topFiles: Array<{ file: string; symbols: number; test: boolean }>;
}
export interface WireSymbolPayload {
node: WireNodeDetail;
/** Outermost first: file, then module/class, then the symbol's own parent. */
ancestors: WireNodeRef[];
members: WireList<WireMember>;
incoming: WireList<WireRelation>;
outgoing: WireList<WireRelation>;
typesUsed: WireRelation[];
counts: {
callers: number;
callees: number;
typesUsed: number;
fanIn: number;
fanOut: number;
members: number;
hub: boolean;
};
tests: WireTestSummary;
outsideIndex: WireOutsideIndex;
blast: WireBlastSummary | null;
/** The file changed on disk since the index — line ranges may be shifted. */
drift: boolean;
}
export interface WireSource {
file: string;
language: string;
drift: boolean;
/**
* Which numbering `lines` belong to. `'indexed'` — the file matches the
* index. `'current'` — it drifted and we asked for the bytes anyway
* (`ondrift: 'current'`), so nothing the graph holds about this file lines up
* with them. `'none'` — it drifted and no slice came back.
*/
showing: 'indexed' | 'current' | 'none';
contentHash: string;
indexedAt: number;
generated: boolean;
totalLines: number | null;
from?: number;
to?: number;
/** Absent when the file drifted and `ondrift` was left at its default. */
lines?: string[];
truncated?: boolean;
reason?: string;
/**
* The same lines, classified by the server's tree-sitter parse — one entry
* per line, each a list of `[classId, text]` pairs indexed into `classes`.
* Absent whenever `lines` is, and `engine: 'plain'` whenever no grammar
* covers the file. See `lib/highlight.ts`.
*/
highlight?: WireHighlight;
}
/* ------------------------------------------------------------- file view -- */
/** A row in the file outline — a symbol, its nesting and its edge counts. */
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;
fanIn: number;
fanOut: number;
}
/** One file at the far end of an import rail, with the symbols the edges name. */
export interface WireImportRow {
file: string;
test: boolean;
symbols: Array<{ id: string; name: string; kind: string; line: number }>;
symbolCount: number;
}
export interface WireFilePayload {
file: {
path: string;
language: string;
size: number;
modifiedAt: number;
indexedAt: number;
contentHash: string;
nodeCount: number;
generated: boolean;
test: boolean;
errors: string[];
/** The file node's own id, so the viewer can open the file AS a symbol. */
id: string | null;
};
/** Calls made outside every definition — module-level code. */
topLevel: { calls: number };
/** The file changed on disk since it was indexed; the outline's lines shifted. */
drift: boolean;
outline: WireList<WireOutlineEntry>;
/** `imports` edges only — a subset of `dependencies`, with symbol names. */
imports: WireList<WireImportRow>;
importedBy: WireList<WireImportRow>;
/** Import statements that resolved to nothing indexed: packages, builtins. */
unresolvedImports: Array<{ name: string; line: number }>;
/** Every file this one reaches by any cross-file edge — `getFileDependencies`. */
dependencies: string[];
/** Every file that reaches into this one — `getFileDependents`. */
dependents: string[];
}
/* ------------------------------------------------ whole-file source view -- */
/** A reference the resolver never landed: a gutter port with no destination. */
export interface WireFileOutsideRef {
line: number;
col: number;
name: string;
kind: string;
}
/** Every edge from ONE symbol in a file to ONE symbol anywhere. */
export interface WireFileCall {
/** The symbol making the calls — the file node itself for top-level code. */
ownerId: string;
ownerLine: number;
relation: WireRelation;
}
export interface WireFileCodePayload {
file: {
path: string;
language: string;
size: number;
indexedAt: number;
contentHash: string;
generated: boolean;
test: boolean;
errors: string[];
id: string | null;
/** Lines on disk now — the height of the scrolling document. */
totalLines: number | null;
};
drift: boolean;
reason?: string;
outline: WireList<WireOutlineEntry>;
calls: WireList<WireFileCall>;
outside: WireList<WireFileOutsideRef>;
/** Calls landing on a definition in this same file — the arc diagram's total. */
intraFileCalls: number;
timing: { elapsedMs: number };
}
export interface WireBlastScale {
maxDirect: number;
maxWithinHops: number;
hops: number;
sampled: number;
estimated: boolean;
}
/* ------------------------------------------------------- search palette -- */
/** How a result's text matched the query — the server's primary sort key. */
export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
export interface WireSearchResult extends WireNodeRef {
matchKind: MatchKind;
}
export interface WireSearchGroup {
kind: NodeKind;
count: number;
items: WireSearchResult[];
}
export interface WireSearch {
query: string;
/** The free-text part, with any `kind:` / `lang:` / `path:` filters removed. */
text: string;
filters: { kinds: string[]; languages: string[]; paths: string[]; names: string[] };
results: WireList<WireSearchResult>;
/** Kind buckets in ranked order — flattening them reproduces the ranking. */
groups: WireSearchGroup[];
}
export interface WireNodeRefs {
items: WireNodeRef[];
/** Ids that name nothing in this index — a stale link, not an error. */
missing: string[];
}
/* ---------------------------------------------------------- entry points -- */
export interface WireEntryRoute {
/** The route node's name, verbatim: "POST /v1/users/{id}". */
url: string;
/** The verb, when the name leads with one. Null for a file-routed page. */
method: string | null;
/** The URL without the verb — the same string as `url` when there is none. */
path: string;
handler: string;
handlerKind: string;
/** Where the request is SERVED. */
file: string;
line: number;
handlerId: string | null;
/** Where the URL is REGISTERED — the router file, which is how routes group. */
routeFile: string;
routeLine: number;
routeId: string;
}
export interface WireEntryFile extends WireNodeRef {
/** Calls and instantiations made at the top level of the file. */
calls: number;
/** Distinct other files this one's symbols reach. */
reaches: number;
/** Other files reaching into this one. Zero means nothing imports it. */
dependents: number;
}
export interface WireEntryHub extends WireNodeRef {
dependents: number;
}
export interface WireEntryTest extends WireNodeRef {
/** Distinct other files this test reaches — what it exercises. */
reaches: number;
/** References behind that reach. */
refs: number;
}
export interface WireEntryPoints {
/** Frameworks the resolver detected — named in the Routes header. */
frameworks: string[];
routes: {
routed: boolean;
/** Every `route` node in the graph, resolved handler or not. */
routeCount: number;
items: WireList<WireEntryRoute>;
};
/** `total` is a floor on `files` and `hubs`; on `tests` it is exact. */
files: WireList<WireEntryFile>;
tests: WireList<WireEntryTest>;
hubs: WireList<WireEntryHub>;
index: { lastIndexedAt: number | null; files: number };
timing: { elapsedMs: number; cached: boolean };
}
export interface WireStats {
project: { root: string; name: string };
index: {
state: string | null;
lastIndexedAt: number | null;
stale: boolean;
version: string | null;
extractionVersion: number | null;
backend: string;
journalMode: string;
pendingReferences: number;
generatedFiles: number;
watching: boolean;
watcherDegraded: boolean;
};
graph: {
nodes: number;
edges: number;
files: number;
nodesByKind: Record<string, number>;
edgesByKind: Record<string, number>;
filesByLanguage: Record<string, number>;
dbSizeBytes: number;
walSizeBytes: number;
};
frameworks: string[];
thresholds: { hub: number; uncertainBelow: number };
blastScale: WireBlastScale;
}
/* ----------------------------------------------------------------- fetch -- */
/** An error the server described. `guidance` is its "what to do instead" line. */
export class ApiFailure extends Error {
readonly status: number;
readonly code: string;
readonly guidance: string | null;
constructor(status: number, code: string, message: string, guidance: string | null) {
super(message);
this.name = 'ApiFailure';
this.status = status;
this.code = code;
this.guidance = guidance;
}
}
/** What `fail()` in `src/ui-server/api/respond.ts` sends. */
interface ApiErrorBody {
error?: string;
code?: string;
hint?: string;
}
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
let response: Response;
try {
response = await fetch(path, { signal, headers: { accept: 'application/json' } });
} catch (cause) {
if (signal?.aborted) throw cause;
// The one failure the server cannot describe, because it never heard the
// request: `codegraph ui` was stopped while the tab stayed open.
throw new ApiFailure(
0,
'unreachable',
'The codegraph ui server is not answering.',
'It may have been stopped — restart it with `codegraph ui` and reload this page.'
);
}
const body = (await response.json().catch(() => null)) as unknown;
if (!response.ok) {
const failure = (body as ApiErrorBody | null) ?? {};
throw new ApiFailure(
response.status,
failure.code ?? 'error',
failure.error ?? `The server answered ${response.status}.`,
failure.hint ?? null
);
}
return body as T;
}
/* ------------------------------------------------------------- flow strip -- */
export interface WireFlowEdge extends WireEdge {
/** The link's label: "calls", "via callback · registered at file:line". */
label: string;
/** This hop reads callee → caller — the reader stepped UP into it. */
upward: boolean;
/** Confidence below 0.6: the link is dashed `2 3`. */
uncertain: boolean;
/** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
synthesized: boolean;
}
export interface WireFlowSource {
file: string;
language: string;
from: number;
to: number;
/** Absent when `drift` — a mis-sliced window is worse than an empty card. */
lines?: string[];
highlight?: WireHighlight;
drift: boolean;
reason?: string;
}
/** The call site a card is opened at — the identifier drawn as an accent link. */
export interface WireFlowCallRef {
line: number;
col: number | null;
name: string;
targetId: string;
/** The link points back at the previous card, not on to the next one. */
backwards: boolean;
}
export interface WireFlowHop {
node: WireNodeRef;
/** The edge from the PREVIOUS hop into this one; null on the first. */
edge: WireFlowEdge | null;
callRef: WireFlowCallRef | null;
source: WireFlowSource | null;
}
/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
export interface WireBoundaryCandidate {
node: WireNodeRef;
display: string;
named: boolean;
}
/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
export interface WireBoundarySite {
form: string;
label: string;
snippet: string;
line: number;
key: string | null;
keyIsType: boolean;
moreSites: number;
candidates: WireBoundaryCandidate[];
candidateNote: string | null;
}
export interface WireFlowContinuation {
node: WireNodeRef;
line: number | null;
confidence: number | null;
}
/** Where the graph stops — the strip's end cap (design spec §3.5). */
export interface WireFlowBoundary {
node: WireNodeRef;
sites: WireBoundarySite[];
uncertain: WireList<WireFlowContinuation>;
further: WireList<WireFlowContinuation>;
missed: WireNodeRef[];
}
export interface WireFlow {
id: string;
/** "execute → rowToFileRecord", for the header's flow picker. */
label: string;
hops: WireFlowHop[];
/** Null on a flow that reaches everything it was asked about. */
boundary: WireFlowBoundary | null;
/** One card at the dispatch site, not a path: the answer ran out here. */
partial: boolean;
}
export interface WireFlowAmbiguity {
token: string;
chosen: WireNodeRef | null;
others: WireNodeRef[];
}
export interface WireFlowPayload {
query: {
kind: 'directed' | 'symbols' | 'trail';
from: string | null;
to: string | null;
symbols: string[];
};
flows: WireFlow[];
ambiguous: WireFlowAmbiguity[];
/** Tokens that named nothing in this index. */
unresolved: string[];
/** Why there is no flow, when there is none. */
reason: string | null;
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number };
}
/* -------------------------------------------------------------- the map -- */
export interface WireMapModule {
/** Directory path, the `(root files)` bucket, or a façade file's own path. */
id: string;
label: string;
files: number;
symbols: number;
languages: Array<{ language: string; files: number }>;
/** More than half its files are tests. */
test: boolean;
/** A single file kept out of the root bucket because it is the façade. */
facade: boolean;
/** Its files, capped — the side panel's list when the module is selected. */
fileList: { total: number; shown: number; truncated: boolean; items: string[] };
}
export interface WireMapLink {
source: string;
target: string;
/** Every confident cross-module edge behind this link. */
count: number;
/**
* The subset resolved through an import, a qualified name, an inheritance
* clause or a typed receiver — what the layering trusts.
*/
declared: number;
byKind: Array<{ kind: EdgeKind; count: number }>;
topPairs: Array<{ from: string; to: string; count: number; declared: number }>;
}
export interface WireMapCycle {
size: number;
files: string[];
modules: string[];
}
export interface WireMapPayload {
root: string;
depth: number;
roots: Array<{ root: string; label: string; files: number }>;
modules: WireMapModule[];
links: WireMapLink[];
cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] };
excluded: { uncertainEdges: number; confidenceBelow: number };
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number; cached: boolean };
}
export * from './wire';
export { ApiFailure } from './adapter';
export type {
GraphAdapter,
EntryPointsRequest,
FlowRequest,
HttpAdapterOptions,
LiveHandlers,
MapRequest,
RoutesRequest,
SearchRequest,
SourceRequest,
} from './adapter';
export function fetchStats(signal?: AbortSignal): Promise<WireStats> {
return getJson<WireStats>('api/stats', signal);
return getGraphAdapter().stats(signal);
}
export function fetchSymbol(id: string, signal?: AbortSignal): Promise<WireSymbolPayload> {
// Ids carry ':' and '/' (`method:<hash>`, `file:src/mcp/tools.ts`); encode
// per segment so the path stays readable and still round-trips.
const encoded = id.split('/').map(encodeURIComponent).join('/');
return getJson<WireSymbolPayload>(`api/node/${encoded}`, signal);
return getGraphAdapter().node(id, signal);
}
export function fetchSearch(
@@ -621,34 +53,31 @@ export function fetchSearch(
opts: { limit?: number } = {},
signal?: AbortSignal
): Promise<WireSearch> {
const params = new URLSearchParams({ q: query });
if (opts.limit) params.set('limit', String(opts.limit));
return getJson<WireSearch>(`api/search?${params}`, signal);
return getGraphAdapter().search(query, opts, signal);
}
/** Names and locations for ids you already have — what the trail redraws with. */
export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
const params = new URLSearchParams();
for (const id of ids) params.append('id', id);
return getJson<WireNodeRefs>(`api/nodes?${params}`, signal);
return getGraphAdapter().nodes(ids, signal);
}
export function fetchEntryPoints(
opts: { limit?: number; routes?: number } = {},
signal?: AbortSignal
): Promise<WireEntryPoints> {
const params = new URLSearchParams();
if (opts.limit) params.set('limit', String(opts.limit));
if (opts.routes) params.set('routes', String(opts.routes));
const query = params.toString();
return getJson<WireEntryPoints>(`api/entrypoints${query ? `?${query}` : ''}`, signal);
return getGraphAdapter().entryPoints(opts, signal);
}
/** The URL → handler map. The palette reads routes through `fetchEntryPoints`. */
export function fetchRoutes(
opts: { limit?: number } = {},
signal?: AbortSignal
): Promise<WireRoutes> {
return getGraphAdapter().routes(opts, signal);
}
export function fetchFile(path: string, signal?: AbortSignal): Promise<WireFilePayload> {
// Paths carry '/'; encode per segment so `api/file/src/mcp/tools.ts` stays
// readable and a segment with a reserved character still round-trips.
const encoded = path.split('/').map(encodeURIComponent).join('/');
return getJson<WireFilePayload>(`api/file/${encoded}`, signal);
return getGraphAdapter().file(path, signal);
}
/**
@@ -661,8 +90,7 @@ export function fetchFileCode(
path: string,
signal?: AbortSignal
): Promise<WireFileCodePayload> {
const encoded = path.split('/').map(encodeURIComponent).join('/');
return getJson<WireFileCodePayload>(`api/filecode/${encoded}`, signal);
return getGraphAdapter().fileCode(path, signal);
}
/**
@@ -683,29 +111,19 @@ export function fetchSource(
signal?: AbortSignal,
ondrift?: 'current'
): Promise<WireSource> {
const params = new URLSearchParams({ file, from: String(from) });
// `to` is 1-based on the wire and absent means "to the end of the file" —
// sending 0 for that would be out of range, not a synonym.
if (to > 0) params.set('to', String(to));
if (ondrift) params.set('ondrift', ondrift);
return getJson<WireSource>(`api/source?${params}`, signal);
return getGraphAdapter().source({ file, from, to, ondrift }, signal);
}
/**
* The module map. `root` selects the subtree (a monorepo's package); `depth`
* is how many path segments under it name a module. Omitting `root` lets the
* server pick the repository's source directory.
* adapter pick the repository's source directory.
*/
export function fetchMap(
opts: { root?: string | null; depth?: number } = {},
signal?: AbortSignal
): Promise<WireMapPayload> {
const params = new URLSearchParams();
if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
if (opts.depth) params.set('depth', String(opts.depth));
const query = params.toString();
return getJson<WireMapPayload>(`api/map${query ? `?${query}` : ''}`, signal);
return getGraphAdapter().map(opts, signal);
}
/**
@@ -713,18 +131,11 @@ export function fetchMap(
*
* - `{ from, to }` — "how does X reach Y", from the search box.
* - `{ symbols }` — `codegraph_explore`'s own question, verbatim.
* - `{ trail }` — the hops the reader walked, as `<dir><id>` strings. Each one
* is its own parameter, because a node id can be a file path and a file path
* can contain a comma.
* - `{ trail }` — the hops the reader walked, as `<dir><id>` strings.
*/
export function fetchFlow(
spec: { from?: string; to?: string; symbols?: string; trail?: readonly string[] },
signal?: AbortSignal
): Promise<WireFlowPayload> {
const params = new URLSearchParams();
if (spec.from) params.set('from', spec.from);
if (spec.to) params.set('to', spec.to);
if (spec.symbols) params.set('symbols', spec.symbols);
for (const hop of spec.trail ?? []) params.append('hop', hop);
return getJson<WireFlowPayload>(`api/flow?${params}`, signal);
return getGraphAdapter().flow(spec, signal);
}
+119 -64
View File
@@ -16,13 +16,17 @@
*
* ## Nothing polls, and nothing loops
*
* `EventSource` is the transport, but its own reconnect is not: left alone it
* retries forever at a fixed interval, so a viewer left open against a stopped
* The transport is `GraphAdapter.events` — an `EventSource` on `/api/events`
* under `codegraph ui`, whatever a host already has under a host — but the
* reconnect is NOT the transport's. Left to itself an `EventSource` retries
* forever at a fixed interval, so a viewer left open against a stopped
* `codegraph ui` becomes a request every three seconds until the tab is closed.
* So each `error` closes the stream and schedules ONE reconnect on a backoff
* that ends: after {@link MAX_ATTEMPTS} consecutive failures the connection
* gives up and says so, and only a deliberate signal — the tab coming back to
* the foreground, or the window regaining focus — starts it again.
* the foreground, or the window regaining focus — starts it again. An adapter
* with no `events` at all leaves every counter at zero and nothing connects;
* a host can still move them by hand with `live.signal`.
*
* The same rule covers the server's own bad day: a `degraded` event means live
* watching has stopped for good on that side. The client records it and shows
@@ -31,6 +35,7 @@
*/
import { untrack } from 'svelte';
import { getGraphAdapter } from './adapter';
/* ----------------------------------------------------------- wire shapes -- */
@@ -58,6 +63,21 @@ export interface LiveChanged {
at: number;
}
/**
* What a host passes to `live.signal` — every field optional, because the
* counters are what the screens read and the detail is only there for the
* disk case, where "which files" decides whether a drift banner appears.
*/
export interface LiveSignalDetail {
files?: string[];
total?: number;
truncated?: boolean;
/** The change could not be described file by file — assume any file is hit. */
scan?: boolean;
index?: LiveIndexRevision;
at?: number;
}
export interface LiveIndexEvent {
type: 'index';
index: LiveIndexRevision;
@@ -86,10 +106,13 @@ let diskTick = $state(0);
let lastIndex = $state<LiveIndexEvent | null>(null);
let lastChanged = $state<LiveChanged | null>(null);
let source: EventSource | null = null;
/** Closes the current subscription, or null when there is none open. */
let close: (() => void) | null = null;
let retry: ReturnType<typeof setTimeout> | null = null;
let attempts = 0;
let started = false;
/** The installed adapter has no live channel. Nothing to connect, ever. */
let unsupported = false;
/**
* Ticks that arrived while the tab was in the background.
@@ -138,71 +161,66 @@ function flushDeferred(): void {
/* ------------------------------------------------------------ connection -- */
function open(): void {
if (source || typeof EventSource === 'undefined') return;
if (close !== null) return;
if (retry !== null) {
clearTimeout(retry);
retry = null;
}
stopped = false;
const es = new EventSource('api/events');
source = es;
es.addEventListener('open', () => {
connected = true;
});
es.addEventListener('hello', (event) => {
const hello = parse<LiveHello>(event);
if (!hello) return;
// A hello is the only proof the stream is really working: `open` fires on
// the response headers, and a server that answered and then died would
// otherwise reset the backoff it should have been paying.
attempts = 0;
connected = true;
watching = hello.watching;
degraded = hello.degraded;
});
es.addEventListener('changed', (event) => {
const changed = parse<LiveChanged>(event);
if (changed) bumpDisk(changed);
});
es.addEventListener('index', (event) => {
const moved = parse<LiveIndexEvent>(event);
if (moved) bumpIndex(moved);
});
es.addEventListener('degraded', (event) => {
const note = parse<{ reason: string }>(event);
if (note) degraded = note.reason;
});
es.addEventListener('error', () => {
connected = false;
es.close();
if (source === es) source = null;
attempts += 1;
if (attempts >= MAX_ATTEMPTS) {
// Out of attempts. Nothing on a timer from here — the tab coming back to
// the foreground is the only thing that tries again.
stopped = true;
return;
}
const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000;
retry = setTimeout(open, delay);
});
}
function parse<T>(event: Event): T | null {
const data = (event as MessageEvent<string>).data;
if (typeof data !== 'string') return null;
try {
return JSON.parse(data) as T;
} catch {
return null;
// The transport belongs to the adapter, not to this module: `codegraph ui`
// answers it with an EventSource on `/api/events`, and a host that already
// knows when its index moved answers it with whatever it already has. An
// adapter with no live channel simply omits `events` — and then nothing here
// ever runs, which is the correct behaviour and not a degraded one.
const subscribe = getGraphAdapter().events;
if (!subscribe) {
unsupported = true;
return;
}
close = subscribe({
hello(event) {
const hello = event as LiveHello | null;
if (!hello) return;
// A hello is the only proof the stream is really working: a connection
// opens on the response headers, and a server that answered and then
// died would otherwise reset the backoff it should have been paying.
attempts = 0;
connected = true;
watching = hello.watching;
degraded = hello.degraded;
},
changed(event) {
const changed = event as LiveChanged | null;
if (changed) bumpDisk(changed);
},
index(event) {
const moved = event as LiveIndexEvent | null;
if (moved) bumpIndex(moved);
},
degraded(event) {
const note = event as { reason: string } | null;
if (note) degraded = note.reason;
},
error() {
connected = false;
// Take the closer before calling it: a transport that calls `error`
// again from inside its own teardown must not re-enter this.
const closer = close;
close = null;
closer?.();
attempts += 1;
if (attempts >= MAX_ATTEMPTS) {
// Out of attempts. Nothing on a timer from here — the tab coming back
// to the foreground is the only thing that tries again.
stopped = true;
return;
}
const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000;
retry = setTimeout(open, delay);
},
});
}
/** Connect, once, for the life of the page. */
@@ -227,8 +245,8 @@ function start(): void {
open();
});
window.addEventListener('pagehide', () => {
source?.close();
source = null;
close?.();
close = null;
});
open();
@@ -263,7 +281,44 @@ export const live = {
get lastChanged(): LiveChanged | null {
return lastChanged;
},
/** The installed adapter has no live channel — this page never was live. */
get unsupported(): boolean {
return unsupported;
},
start,
/**
* Move a counter from outside.
*
* A host that learns about a sync through its own machinery — a websocket, a
* webhook, a store it already owns — calls this instead of implementing
* `GraphAdapter.events`, and every mounted screen refetches exactly as it
* does under `codegraph ui`. It is the same code path the stream uses, so
* there is no second way for a screen to go stale.
*/
signal(kind: 'index' | 'disk', detail: LiveSignalDetail = {}): void {
if (kind === 'index') {
bumpIndex({
type: 'index',
index: detail.index ?? { lastIndexedAt: null, files: 0 },
files: detail.files ?? [],
total: detail.total ?? detail.files?.length ?? 0,
truncated: detail.truncated ?? false,
at: detail.at ?? 0,
});
return;
}
bumpDisk({
type: 'changed',
files: detail.files ?? [],
total: detail.total ?? detail.files?.length ?? 0,
truncated: detail.truncated ?? false,
// No named files and no scan flag would mean "nothing changed", which is
// not what a caller asking for a disk tick means.
scan: detail.scan ?? (detail.files === undefined || detail.files.length === 0),
at: detail.at ?? 0,
});
},
};
/**
+209
View File
@@ -0,0 +1,209 @@
/**
* The navigation seam: where a click on a symbol, a file, a flow or the map
* takes the reader (task CG-61).
*
* The standalone viewer is a hash app — `#/s/<id>`, `#/file/<path>`, `#/map` —
* and that is the default driver below. A host embedding these components has
* its own router and its own URL space (a review page, a PR, a workspace), so
* it installs a {@link NavigationDriver} and every rail row, breadcrumb, chip
* and card in the package addresses *its* app instead.
*
* Two reasons the components go through href builders rather than through a
* single `onNavigate` callback:
*
* - **A row is a link.** Middle-click, cmd-click and "copy link address" are
* how people read code, and they only work if the `<a>` really carries an
* href. A callback-only design turns every row into a `<div>` with an
* onclick, which is a worse screen.
* - **The trail travels in the address.** The walk is part of the URL, so
* building one is a thing the components must be able to do, not just ask
* for.
*
* The live route (`router.svelte.ts`) is the *app's* half and is deliberately
* not imported here: it attaches `hashchange`/`popstate` listeners at module
* scope, which a host must never inherit just by rendering a Symbol view.
*/
export interface SymbolHrefOptions {
/** A line to highlight and scroll to in the destination. */
line?: number;
/** The encoded trail, so a reload or a shared link reproduces the walk. */
trail?: string;
}
export interface FileHrefOptions {
line?: number;
/** The whole-file source view rather than the outline. */
source?: boolean;
}
export interface MapHrefOptions {
root?: string | null;
depth?: number;
tests?: boolean;
}
export interface FlowHrefOptions {
from?: string;
to?: string;
symbols?: string;
trail?: string;
}
/**
* Where the components send the reader.
*
* Implement all of it: a half-implemented driver produces a screen where some
* rows navigate the host and others silently jump to a hash the host does not
* serve.
*/
export interface NavigationDriver {
symbolHref(id: string, opts?: SymbolHrefOptions): string;
fileHref(path: string, opts?: FileHrefOptions): string;
mapHref(opts?: MapHrefOptions): string;
flowHref(opts?: FlowHrefOptions): string;
entryHref(): string;
/** Go to an href this driver built. */
navigate(href: string, opts?: { replace?: boolean }): void;
/** Back one entry in the host's history. */
back(): void;
}
/* ------------------------------------------------------- the hash driver -- */
/**
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
* `<kind>:<relative/path>`, so they can contain both ':' and '/'. Encoding per
* slash-separated segment keeps the URL readable (`#/file/src/mcp/tools.ts`)
* and still round-trips a segment that itself contains a reserved character.
*/
function encodePath(value: string): string {
return value.split('/').map(encodeURIComponent).join('/');
}
function query(params: URLSearchParams): string {
const text = params.toString();
return text ? `?${text}` : '';
}
/** The `codegraph ui` address space: the hash is the route. */
export const hashNavigation: NavigationDriver = {
symbolHref(id, opts = {}) {
const params = new URLSearchParams();
if (opts.trail) params.set('t', opts.trail);
if (opts.line) params.set('hl', String(opts.line));
return `#/s/${encodePath(id)}${query(params)}`;
},
fileHref(path, opts = {}) {
const params = new URLSearchParams();
// `src` before `hl` so the two file URLs a reader shares differ in their
// first character after the path, not somewhere in the middle.
if (opts.source) params.set('src', '1');
if (opts.line) params.set('hl', String(opts.line));
return `#/file/${encodePath(path)}${query(params)}`;
},
mapHref(opts = {}) {
const params = new URLSearchParams();
if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth));
if (opts.tests) params.set('tests', '1');
return `#/map${query(params)}`;
},
flowHref(opts = {}) {
const params = new URLSearchParams();
if (opts.from) params.set('from', opts.from);
if (opts.to) params.set('to', opts.to);
if (opts.symbols) params.set('symbols', opts.symbols);
// `t`, not `trail`: the trail already travels under that name everywhere
// else, and a flow read from one is the same walk under a different lens.
if (opts.trail) params.set('t', opts.trail);
return `#/flow${query(params)}`;
},
entryHref() {
return '#/entry';
},
navigate(href, opts = {}) {
const target = href.startsWith('#') ? href : `#${href}`;
if (opts.replace) {
history.replaceState(history.state, '', target);
onHashWritten();
return;
}
if (location.hash === target) return;
location.hash = target;
// hashchange fires asynchronously; the sync is idempotent, so calling it
// now keeps a navigate() immediately followed by a read consistent.
onHashWritten();
},
back() {
history.back();
},
};
/**
* The live route's re-read hook, registered by `router.svelte.ts`.
*
* The driver has to tell the route store that the hash moved, and the store
* has to attach window listeners — but a component importing the driver must
* not drag those listeners in. So the dependency runs this way round: the store
* registers itself with the driver, and a page that never loads the store gets
* a driver that simply writes the hash.
*/
let onHashWritten: () => void = () => {};
export function registerHashSync(sync: () => void): void {
onHashWritten = sync;
}
/* ------------------------------------------------------------- registry -- */
let driver: NavigationDriver = hashNavigation;
/**
* Install the driver every link in the package is built with.
*
* Call once, before anything renders. Passing `null` restores the hash driver.
*/
export function setNavigationDriver(next: NavigationDriver | null): void {
driver = next ?? hashNavigation;
}
export function getNavigationDriver(): NavigationDriver {
return driver;
}
/* --------------------------- what the components actually call ----------- */
export function symbolHref(id: string, opts: SymbolHrefOptions = {}): string {
return driver.symbolHref(id, opts);
}
export function fileHref(path: string, opts: FileHrefOptions = {}): string {
return driver.fileHref(path, opts);
}
export function mapHref(opts: MapHrefOptions = {}): string {
return driver.mapHref(opts);
}
export function flowHref(opts: FlowHrefOptions = {}): string {
return driver.flowHref(opts);
}
export function entryHref(): string {
return driver.entryHref();
}
export function navigate(href: string, opts: { replace?: boolean } = {}): void {
driver.navigate(href, opts);
}
export function back(): void {
driver.back();
}
+33 -74
View File
@@ -18,8 +18,37 @@
* encoded *per slash-separated segment* and rejoined on the way out: the URL
* stays readable (`#/file/src/mcp/tools.ts`) and still round-trips a segment
* that itself contains a reserved character.
*
* This module is the APP's half — it parses the hash and holds the live route,
* and it attaches window listeners to do it. The href builders and `navigate`
* live in `./navigation`, behind a driver a host can replace, and the shared
* components import them from there: rendering a Symbol view inside somebody
* else's app must not install a hash router in it. They are re-exported below
* so this file stays the app's one-stop import.
*/
import { registerHashSync } from './navigation';
export {
back,
entryHref,
fileHref,
flowHref,
getNavigationDriver,
hashNavigation,
mapHref,
navigate,
setNavigationDriver,
symbolHref,
} from './navigation';
export type {
FileHrefOptions,
FlowHrefOptions,
MapHrefOptions,
NavigationDriver,
SymbolHrefOptions,
} from './navigation';
export type Route =
| { view: 'home' }
| { view: 'symbol'; id: string; line: number | null }
@@ -63,10 +92,6 @@ function decodeSegment(segment: string): string {
}
}
function encodePath(value: string): string {
return value.split('/').map(encodeURIComponent).join('/');
}
function parseLine(params: URLSearchParams): number | null {
const raw = params.get('hl');
if (raw === null) return null;
@@ -120,58 +145,6 @@ export function parseHash(hash: string): RouterLocation {
return { route, params, raw };
}
/* ---------- href builders (the only place hashes are assembled) ---------- */
export function symbolHref(id: string, opts: { line?: number; trail?: string } = {}): string {
const params = new URLSearchParams();
if (opts.trail) params.set('t', opts.trail);
if (opts.line) params.set('hl', String(opts.line));
const query = params.toString();
return `#/s/${encodePath(id)}${query ? `?${query}` : ''}`;
}
export function fileHref(
path: string,
opts: { line?: number; source?: boolean } = {}
): string {
const params = new URLSearchParams();
// `src` before `hl` so the two file URLs a reader shares differ in their
// first character after the path, not somewhere in the middle.
if (opts.source) params.set('src', '1');
if (opts.line) params.set('hl', String(opts.line));
const query = params.toString();
return `#/file/${encodePath(path)}${query ? `?${query}` : ''}`;
}
export function mapHref(
opts: { root?: string | null; depth?: number; tests?: boolean } = {}
): string {
const params = new URLSearchParams();
if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth));
if (opts.tests) params.set('tests', '1');
const query = params.toString();
return `#/map${query ? `?${query}` : ''}`;
}
export function entryHref(): string {
return '#/entry';
}
export function flowHref(
opts: { from?: string; to?: string; symbols?: string; trail?: string } = {}
): string {
const params = new URLSearchParams();
if (opts.from) params.set('from', opts.from);
if (opts.to) params.set('to', opts.to);
if (opts.symbols) params.set('symbols', opts.symbols);
// `t`, not `trail`: the trail already travels under that name everywhere
// else, and a flow read from one is the same walk under a different lens.
if (opts.trail) params.set('t', opts.trail);
const query = params.toString();
return `#/flow${query ? `?${query}` : ''}`;
}
/* ---------- the live route ---------- */
const initial = parseHash(typeof location === 'undefined' ? '' : location.hash);
@@ -187,6 +160,10 @@ if (typeof window !== 'undefined') {
// popstate too: `navigate(…, { replace: true })` and history.back() across
// a replaced entry both move the hash without firing hashchange.
window.addEventListener('popstate', sync);
// The hash driver writes `location.hash` directly; this is how it tells the
// route store to re-read. Registered here rather than imported there, so a
// host that never loads this module gets no window listeners at all.
registerHashSync(sync);
}
export const router = {
@@ -200,21 +177,3 @@ export const router = {
return current.params;
},
};
export function navigate(href: string, opts: { replace?: boolean } = {}): void {
const target = href.startsWith('#') ? href : `#${href}`;
if (opts.replace) {
history.replaceState(history.state, '', target);
sync();
return;
}
if (location.hash === target) return;
location.hash = target;
// hashchange fires asynchronously; sync() is idempotent, so calling it now
// keeps a navigate() immediately followed by a read consistent.
sync();
}
export function back(): void {
history.back();
}
+192
View File
@@ -0,0 +1,192 @@
/* =====================================================================
@colbymchenry/codegraph-ui — design tokens
The engine's paper/ink editorial system, as specified in
docs/design/codegraph-ui-design-spec.md §2.2: flat, hairline rules,
square corners everywhere, no shadows, no gradients, sentence case,
one oxblood accent, one amber (the "untested" badge).
This file is the package's whole theming surface. Import it once and
override any variable on a narrower selector — the components read
nothing else. What is NOT themable is geometry: 34px rail rows, the
300/320px rails, the 20px code line. Those are measured against each
other by the Symbol view's layout pass, and a host that moves one of
them moves a callee row away from the line it points at.
Colour and type only. Every token is defined once on the bare :root
below and only REDEFINED in the two dark blocks, so a host that sets
`--accent` on its own container gets it in both schemes.
===================================================================== */
/* ---------- tokens: light / paper (the bare :root set) ---------- */
:root {
--paper: #f7f6f2;
--paper-2: #f1efe8;
--press: #e8e6dd;
--press-2: #dedbd0;
--ink: #16150f;
--ink-2: #56544a;
--ink-3: #87847a;
--ink-4: #b4b1a5;
--rule: #16150f;
--rule-soft: #d6d3c8;
--rule-faint: #e6e3d9;
--accent: #7a2230;
--accent-ink: #5e1a25;
--accent-soft: #f0e3e5;
--accent-line: #d9b3b9;
--amber: #8a5a0b;
--amber-soft: #f3e9d2;
/* The one code colour that is not a plain re-use of the ink ramp.
The spec asks for comments at --ink-3; measured against --paper that
is 3.46:1 and against the hot-line tint --accent-soft it is 3.00:1,
both under the 4.5:1 an AA reading of 12.5px body text needs. This is
the smallest step DOWN the same warm-grey ramp that clears 4.5:1 on
all three backgrounds a code line can have (paper 5.23, paper-2 4.92,
accent-soft 4.53) while staying quieter than --ink-2, which strings
and numbers use — so the recession order the spec describes is
unchanged, only legible. Dark needed the mirror step UP (4.51 on
accent-soft, where --ink-3 was 4.10). */
--code-comment: #6a675d;
--sans: 'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
--mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--code-size: 12.5px;
--code-lh: 20px;
/* App-shell geometry, shared by the grid and by anything that has to
offset itself under the bars (sticky rail headers, SVG overlays). */
--topbar-h: 48px;
--trailbar-h: 34px;
color-scheme: light dark;
}
/* ---------- tokens: dark / ink ----------
Every colour is defined on the bare :root above; these blocks only
redefine. `:not([data-theme="light"])` lets an explicit light choice
win over the OS preference. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--paper: #16150f;
--paper-2: #1c1a14;
--press: #23211a;
--press-2: #2c2a22;
--ink: #f3f1ea;
--ink-2: #b8b5a8;
--ink-3: #87847a;
--ink-4: #5d5b52;
--rule: #f3f1ea;
--rule-soft: #34322a;
--rule-faint: #26241d;
--accent: #d48b96;
--accent-ink: #e5a5ae;
--accent-soft: #33201f;
--accent-line: #6b3a42;
--amber: #d9a94a;
--amber-soft: #2e2716;
--code-comment: #8e8b81;
}
}
/* An explicit choice, wherever it is made. `:root[data-theme]` is the viewer's
own switch; the bare attribute selector is what `<CodegraphUi theme="dark">`
sets on its wrapper — custom properties inherit, so redefining them on a
container re-themes that subtree without touching :root. A host can therefore
put a light reader inside a dark application, and the reverse. */
:root[data-theme='dark'],
[data-theme='dark'] {
--paper: #16150f;
--paper-2: #1c1a14;
--press: #23211a;
--press-2: #2c2a22;
--ink: #f3f1ea;
--ink-2: #b8b5a8;
--ink-3: #87847a;
--ink-4: #5d5b52;
--rule: #f3f1ea;
--rule-soft: #34322a;
--rule-faint: #26241d;
--accent: #d48b96;
--accent-ink: #e5a5ae;
--accent-soft: #33201f;
--accent-line: #6b3a42;
--amber: #d9a94a;
--amber-soft: #2e2716;
--code-comment: #8e8b81;
color-scheme: dark;
}
/* The light values again, for a container that asks for light inside a page
the OS is painting dark. `:root[data-theme='light']` needs no block — the
media query above already excludes it. */
[data-theme='light'] {
--paper: #f7f6f2;
--paper-2: #f1efe8;
--press: #e8e6dd;
--press-2: #dedbd0;
--ink: #16150f;
--ink-2: #56544a;
--ink-3: #87847a;
--ink-4: #b4b1a5;
--rule: #16150f;
--rule-soft: #d6d3c8;
--rule-faint: #e6e3d9;
--accent: #7a2230;
--accent-ink: #5e1a25;
--accent-soft: #f0e3e5;
--accent-line: #d9b3b9;
--amber: #8a5a0b;
--amber-soft: #f3e9d2;
--code-comment: #6a675d;
color-scheme: light;
}
/* ---------- Svelte Flow ----------
The Map and the Flow strip draw on @xyflow/svelte, which ships its own
blue-on-white palette in `--xy-*` variables. Mapping them onto the ink
ramp here — rather than in each canvas — is what stops a host from
seeing library defaults in the gaps our custom node and edge
components do not paint: the pane behind the cards, the controls, the
minimap, the selection ring, the attribution.
Set on :root so it reaches the portalled panes too. A host that wants
the library's own look overrides these after importing this file. */
:root {
--xy-background-color: var(--paper);
--xy-background-pattern-color: var(--rule-faint);
--xy-edge-stroke: var(--ink-3);
--xy-edge-stroke-selected: var(--accent);
--xy-edge-stroke-width: 1;
--xy-connectionline-stroke: var(--ink-3);
--xy-node-color: var(--ink);
--xy-node-background-color: var(--paper);
--xy-node-border: 1px solid var(--rule-soft);
--xy-node-boxshadow-hover: none;
--xy-node-boxshadow-selected: none;
--xy-selection-background-color: var(--accent-soft);
--xy-selection-border: 1px solid var(--accent-line);
--xy-handle-background-color: transparent;
--xy-handle-border-color: transparent;
--xy-controls-button-background-color: var(--paper);
--xy-controls-button-background-color-hover: var(--press);
--xy-controls-button-color: var(--ink-2);
--xy-controls-button-color-hover: var(--ink);
--xy-controls-button-border-color: var(--rule-soft);
--xy-controls-box-shadow: none;
--xy-minimap-background-color: var(--paper-2);
--xy-minimap-mask-background-color: var(--paper);
--xy-minimap-node-background-color: var(--rule-soft);
--xy-minimap-node-stroke-color: var(--ink-3);
--xy-attribution-background-color: transparent;
--xy-resize-background-color: var(--accent);
--xy-error-color: var(--accent);
}
+1 -1
View File
@@ -13,7 +13,7 @@
* link reproduces the walk rather than starting a fresh one at the same symbol.
*/
import { fileHref, navigate, symbolHref } from './router.svelte';
import { fileHref, navigate, symbolHref } from './navigation';
import { encodeTrail, trail, type HopDirection } from './trail.svelte';
import type { EntryTarget } from './entry-model';
+589
View File
@@ -0,0 +1,589 @@
/**
* The wire shapes of the graph API — types only, no runtime.
*
* These mirror the server's payloads (`src/ui-server/api/`, CG-42) rather than
* re-deriving them: the API is versioned with the binary that serves it, so a
* field the server stopped sending should break the type-check here, not
* surface as `undefined` in a rail three screens later.
*
* They are also the vocabulary of {@link GraphAdapter} (`adapter.ts`): a host
* embedding these components answers in exactly these shapes, whether it is
* reading them over HTTP from `codegraph ui` or building them in-process from
* its own engine. Keeping them in a file with no imports and no side effects is
* what lets a host depend on the vocabulary without pulling in the transport.
*/
import type { WireHighlight } from './highlight';
/* ---------------------------------------------------------------- shapes -- */
export type NodeKind = string;
export type EdgeKind = string;
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: string;
signature?: string;
exported?: boolean;
/** Lives in a file that looks like test or fixture code. */
test: boolean;
}
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;
lines: number;
}
export interface WireMember extends WireNodeRef {
parentId: string;
/** 1 = a direct member; 2 = a member of a member (a method inside a file's class). */
depth: number;
fanIn: number;
fanOut: number;
}
export interface WireEdge {
kind: EdgeKind;
line?: number;
col?: number;
confidence?: number;
resolvedBy?: string;
provenance?: string;
synthesizedBy?: string;
via?: string;
registeredAt?: string;
valueRef?: boolean;
}
/** Every edge between the focal symbol and ONE other symbol, as a single row. */
export interface WireRelation {
node: WireNodeRef;
edgeKinds: EdgeKind[];
edges: WireEdge[];
edgeCount: number;
/** Distinct call-site lines, ascending — what the gutter ports anchor to. */
lines: number[];
confidence: number | null;
uncertain: boolean;
synthesized: boolean;
fanIn?: number;
hub?: boolean;
}
export interface WireList<T> {
total: number;
shown: number;
truncated: boolean;
items: T[];
}
export interface WireTestSummary {
reached: boolean;
hops: number | null;
fileCount: number;
files: string[];
/** False weakens the claim to "no test calls this directly" — see the server. */
exhaustive: boolean;
hopsSearched: number;
}
export interface WireOutsideIndex {
total: number;
byKind: Record<string, number>;
samples: Array<{ name: string; kind: string; line?: number; col?: number }>;
}
export interface WireBlastSummary {
direct: number;
withinHops: number;
hops: number;
files: number;
testFiles: number;
routes: number;
topFiles: Array<{ file: string; symbols: number; test: boolean }>;
}
export interface WireSymbolPayload {
node: WireNodeDetail;
/** Outermost first: file, then module/class, then the symbol's own parent. */
ancestors: WireNodeRef[];
members: WireList<WireMember>;
incoming: WireList<WireRelation>;
outgoing: WireList<WireRelation>;
typesUsed: WireRelation[];
counts: {
callers: number;
callees: number;
typesUsed: number;
fanIn: number;
fanOut: number;
members: number;
hub: boolean;
};
tests: WireTestSummary;
outsideIndex: WireOutsideIndex;
blast: WireBlastSummary | null;
/** The file changed on disk since the index — line ranges may be shifted. */
drift: boolean;
}
export interface WireSource {
file: string;
language: string;
drift: boolean;
/**
* Which numbering `lines` belong to. `'indexed'` — the file matches the
* index. `'current'` — it drifted and we asked for the bytes anyway
* (`ondrift: 'current'`), so nothing the graph holds about this file lines up
* with them. `'none'` — it drifted and no slice came back.
*/
showing: 'indexed' | 'current' | 'none';
contentHash: string;
indexedAt: number;
generated: boolean;
totalLines: number | null;
from?: number;
to?: number;
/** Absent when the file drifted and `ondrift` was left at its default. */
lines?: string[];
truncated?: boolean;
reason?: string;
/**
* The same lines, classified by the server's tree-sitter parse — one entry
* per line, each a list of `[classId, text]` pairs indexed into `classes`.
* Absent whenever `lines` is, and `engine: 'plain'` whenever no grammar
* covers the file. See `lib/highlight.ts`.
*/
highlight?: WireHighlight;
}
/* ------------------------------------------------------------- file view -- */
/** A row in the file outline — a symbol, its nesting and its edge counts. */
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;
fanIn: number;
fanOut: number;
}
/** One file at the far end of an import rail, with the symbols the edges name. */
export interface WireImportRow {
file: string;
test: boolean;
symbols: Array<{ id: string; name: string; kind: string; line: number }>;
symbolCount: number;
}
export interface WireFilePayload {
file: {
path: string;
language: string;
size: number;
modifiedAt: number;
indexedAt: number;
contentHash: string;
nodeCount: number;
generated: boolean;
test: boolean;
errors: string[];
/** The file node's own id, so the viewer can open the file AS a symbol. */
id: string | null;
};
/** Calls made outside every definition — module-level code. */
topLevel: { calls: number };
/** The file changed on disk since it was indexed; the outline's lines shifted. */
drift: boolean;
outline: WireList<WireOutlineEntry>;
/** `imports` edges only — a subset of `dependencies`, with symbol names. */
imports: WireList<WireImportRow>;
importedBy: WireList<WireImportRow>;
/** Import statements that resolved to nothing indexed: packages, builtins. */
unresolvedImports: Array<{ name: string; line: number }>;
/** Every file this one reaches by any cross-file edge — `getFileDependencies`. */
dependencies: string[];
/** Every file that reaches into this one — `getFileDependents`. */
dependents: string[];
}
/* ------------------------------------------------ whole-file source view -- */
/** A reference the resolver never landed: a gutter port with no destination. */
export interface WireFileOutsideRef {
line: number;
col: number;
name: string;
kind: string;
}
/** Every edge from ONE symbol in a file to ONE symbol anywhere. */
export interface WireFileCall {
/** The symbol making the calls — the file node itself for top-level code. */
ownerId: string;
ownerLine: number;
relation: WireRelation;
}
export interface WireFileCodePayload {
file: {
path: string;
language: string;
size: number;
indexedAt: number;
contentHash: string;
generated: boolean;
test: boolean;
errors: string[];
id: string | null;
/** Lines on disk now — the height of the scrolling document. */
totalLines: number | null;
};
drift: boolean;
reason?: string;
outline: WireList<WireOutlineEntry>;
calls: WireList<WireFileCall>;
outside: WireList<WireFileOutsideRef>;
/** Calls landing on a definition in this same file — the arc diagram's total. */
intraFileCalls: number;
timing: { elapsedMs: number };
}
export interface WireBlastScale {
maxDirect: number;
maxWithinHops: number;
hops: number;
sampled: number;
estimated: boolean;
}
/* ------------------------------------------------------- search palette -- */
/** How a result's text matched the query — the server's primary sort key. */
export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
export interface WireSearchResult extends WireNodeRef {
matchKind: MatchKind;
}
export interface WireSearchGroup {
kind: NodeKind;
count: number;
items: WireSearchResult[];
}
export interface WireSearch {
query: string;
/** The free-text part, with any `kind:` / `lang:` / `path:` filters removed. */
text: string;
filters: { kinds: string[]; languages: string[]; paths: string[]; names: string[] };
results: WireList<WireSearchResult>;
/** Kind buckets in ranked order — flattening them reproduces the ranking. */
groups: WireSearchGroup[];
}
export interface WireNodeRefs {
items: WireNodeRef[];
/** Ids that name nothing in this index — a stale link, not an error. */
missing: string[];
}
/* --------------------------------------------------------------- routes -- */
/** One row of the URL -> handler map (`/api/routes`). */
export interface WireRoute {
/** The route node's name, verbatim: "POST /v1/users/{id}". */
url: string;
/** The verb, when the name leads with one. Null for a file-routed page. */
method: string | null;
/** The URL without the verb — the same string as `url` when there is none. */
path: string;
handler: string;
handlerKind: string;
/** Where the request is SERVED. */
file: string;
line: number;
handlerId: string | null;
/** Where the URL is REGISTERED — the router file, which is how routes group. */
routeFile: string;
routeLine: number;
routeId: string;
}
export interface WireRoutes {
routed: boolean;
/** Every URL the index holds, whether or not its handler resolved. */
routeCount: number;
/** Rows in `entries` — the ones whose handler the manifest could name. */
shown: number;
truncated: boolean;
topHandlerFile: string | null;
topHandlerFileCount: number;
entries: WireRoute[];
}
/* ---------------------------------------------------------- entry points -- */
export interface WireEntryRoute {
/** The route node's name, verbatim: "POST /v1/users/{id}". */
url: string;
/** The verb, when the name leads with one. Null for a file-routed page. */
method: string | null;
/** The URL without the verb — the same string as `url` when there is none. */
path: string;
handler: string;
handlerKind: string;
/** Where the request is SERVED. */
file: string;
line: number;
handlerId: string | null;
/** Where the URL is REGISTERED — the router file, which is how routes group. */
routeFile: string;
routeLine: number;
routeId: string;
}
export interface WireEntryFile extends WireNodeRef {
/** Calls and instantiations made at the top level of the file. */
calls: number;
/** Distinct other files this one's symbols reach. */
reaches: number;
/** Other files reaching into this one. Zero means nothing imports it. */
dependents: number;
}
export interface WireEntryHub extends WireNodeRef {
dependents: number;
}
export interface WireEntryTest extends WireNodeRef {
/** Distinct other files this test reaches — what it exercises. */
reaches: number;
/** References behind that reach. */
refs: number;
}
export interface WireEntryPoints {
/** Frameworks the resolver detected — named in the Routes header. */
frameworks: string[];
routes: {
routed: boolean;
/** Every `route` node in the graph, resolved handler or not. */
routeCount: number;
items: WireList<WireEntryRoute>;
};
/** `total` is a floor on `files` and `hubs`; on `tests` it is exact. */
files: WireList<WireEntryFile>;
tests: WireList<WireEntryTest>;
hubs: WireList<WireEntryHub>;
index: { lastIndexedAt: number | null; files: number };
timing: { elapsedMs: number; cached: boolean };
}
export interface WireStats {
project: { root: string; name: string };
index: {
state: string | null;
lastIndexedAt: number | null;
stale: boolean;
version: string | null;
extractionVersion: number | null;
backend: string;
journalMode: string;
pendingReferences: number;
generatedFiles: number;
watching: boolean;
watcherDegraded: boolean;
};
graph: {
nodes: number;
edges: number;
files: number;
nodesByKind: Record<string, number>;
edgesByKind: Record<string, number>;
filesByLanguage: Record<string, number>;
dbSizeBytes: number;
walSizeBytes: number;
};
frameworks: string[];
thresholds: { hub: number; uncertainBelow: number };
blastScale: WireBlastScale;
}
/* ------------------------------------------------------------- flow strip -- */
export interface WireFlowEdge extends WireEdge {
/** The link's label: "calls", "via callback · registered at file:line". */
label: string;
/** This hop reads callee → caller — the reader stepped UP into it. */
upward: boolean;
/** Confidence below 0.6: the link is dashed `2 3`. */
uncertain: boolean;
/** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
synthesized: boolean;
}
export interface WireFlowSource {
file: string;
language: string;
from: number;
to: number;
/** Absent when `drift` — a mis-sliced window is worse than an empty card. */
lines?: string[];
highlight?: WireHighlight;
drift: boolean;
reason?: string;
}
/** The call site a card is opened at — the identifier drawn as an accent link. */
export interface WireFlowCallRef {
line: number;
col: number | null;
name: string;
targetId: string;
/** The link points back at the previous card, not on to the next one. */
backwards: boolean;
}
export interface WireFlowHop {
node: WireNodeRef;
/** The edge from the PREVIOUS hop into this one; null on the first. */
edge: WireFlowEdge | null;
callRef: WireFlowCallRef | null;
source: WireFlowSource | null;
}
/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
export interface WireBoundaryCandidate {
node: WireNodeRef;
display: string;
named: boolean;
}
/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
export interface WireBoundarySite {
form: string;
label: string;
snippet: string;
line: number;
key: string | null;
keyIsType: boolean;
moreSites: number;
candidates: WireBoundaryCandidate[];
candidateNote: string | null;
}
export interface WireFlowContinuation {
node: WireNodeRef;
line: number | null;
confidence: number | null;
}
/** Where the graph stops — the strip's end cap (design spec §3.5). */
export interface WireFlowBoundary {
node: WireNodeRef;
sites: WireBoundarySite[];
uncertain: WireList<WireFlowContinuation>;
further: WireList<WireFlowContinuation>;
missed: WireNodeRef[];
}
export interface WireFlow {
id: string;
/** "execute → rowToFileRecord", for the header's flow picker. */
label: string;
hops: WireFlowHop[];
/** Null on a flow that reaches everything it was asked about. */
boundary: WireFlowBoundary | null;
/** One card at the dispatch site, not a path: the answer ran out here. */
partial: boolean;
}
export interface WireFlowAmbiguity {
token: string;
chosen: WireNodeRef | null;
others: WireNodeRef[];
}
export interface WireFlowPayload {
query: {
kind: 'directed' | 'symbols' | 'trail';
from: string | null;
to: string | null;
symbols: string[];
};
flows: WireFlow[];
ambiguous: WireFlowAmbiguity[];
/** Tokens that named nothing in this index. */
unresolved: string[];
/** Why there is no flow, when there is none. */
reason: string | null;
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number };
}
/* -------------------------------------------------------------- the map -- */
export interface WireMapModule {
/** Directory path, the `(root files)` bucket, or a façade file's own path. */
id: string;
label: string;
files: number;
symbols: number;
languages: Array<{ language: string; files: number }>;
/** More than half its files are tests. */
test: boolean;
/** A single file kept out of the root bucket because it is the façade. */
facade: boolean;
/** Its files, capped — the side panel's list when the module is selected. */
fileList: { total: number; shown: number; truncated: boolean; items: string[] };
}
export interface WireMapLink {
source: string;
target: string;
/** Every confident cross-module edge behind this link. */
count: number;
/**
* The subset resolved through an import, a qualified name, an inheritance
* clause or a typed receiver — what the layering trusts.
*/
declared: number;
byKind: Array<{ kind: EdgeKind; count: number }>;
topPairs: Array<{ from: string; to: string; count: number; declared: number }>;
}
export interface WireMapCycle {
size: number;
files: string[];
modules: string[];
}
export interface WireMapPayload {
root: string;
depth: number;
roots: Array<{ root: string; label: string; files: number }>;
modules: WireMapModule[];
links: WireMapLink[];
cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] };
excluded: { uncertainEdges: number; confidenceBelow: number };
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number; cached: boolean };
}