Files
codegraph/src/ui-server/api/wire.ts
T
Colby McHenryandClaude Opus 5 56dfdb0655 feat(ui): dead code and islands — what nothing reaches, and everything that could still reach it (CG-59)
A Dead code screen and a mark on the Map, both drawn from one derivation in
src/graph/dead-code.ts so a second surface can never disagree with the first.

The SQL half is four lines — no incoming edge but `contains`. It returns ~2 500
candidates on this repository and the shipped list is 20; everything in between
is the feature. A candidate is dropped the moment there is any reason to believe
something outside the graph reaches it: exported symbols and header
declarations, test and generated files, abstract and interface members, anything
carrying a `decorates` edge, overrides of an ancestor's member, names the
language calls by itself, vendored directories, files nothing in the index
reaches (those are islands, and the Map says so instead), names the resolver
failed to resolve somewhere, and names shared with a symbol that IS referenced —
the mis-resolution that leaves a used method with a self-edge and its twin with
nothing. The last rule is the only one that is not a graph query: before a claim
is made, the declaring file and every file that reaches it are read and the
identifier counted, which is what catches the references the extractor never
recorded (`this.handleMessage.bind(this)`, a call inside an object literal, a
shorthand property).

Every subtraction is counted and printed under the list with the scale it came
from, and the caveat line above it never collapses: the claim is "no static
reference in the index", not "unused".

On the Map a module nothing depends on keeps its stroke and says so in its count
line, and tool-generated files and modules recede to ink-4 there, in the map's
file list, in search results and on the file screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 07:57:00 -05:00

345 lines
12 KiB
TypeScript

/**
* 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;
/**
* Dependency hops the blast-radius summary walks. Matches the depth
* `codegraph_explore` claims when it says "within 3 hops".
*/
export const BLAST_DEPTH = 3;
/** 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 file this symbol lives in is tool-generated, so the row draws in ink-4.
*
* OPTIONAL and absent by default: the verdict is a bounded lookup
* (`generatedFilePredicate`), affordable over a screen's worth of rows and
* not over a 545-caller rail. An endpoint fills it where it shows.
*/
generated?: 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 };