feat(ui): the type hierarchy — what a type is built on, and what dispatches through it (CG-58)

A vertical tree above the members outline for classes, interfaces, structs,
traits, protocols, enums, unions and type aliases: ancestors above (the whole
chain, not just the direct parent), the focus in accent, subtypes below indented
per level. `extends` draws solid, `implements` dashed; a synthesized edge — Go's
implicit interface satisfaction — draws dashed wider and carries the site it was
wired at, so a relation the resolver inferred never reads like one the source
wrote down. For an interface the fan below IS the set of runtime targets a call
can land on, and a type with eight or more implementers leads with that in a
sentence. Members that redeclare an ancestor's are marked in the outline.

The walk lives in `src/graph/type-hierarchy.ts`, following CG-50/CG-51: shared
computation in `src/graph/`, presentation in the caller. Its `countImplementers`
is now also what `ToolHandler.buildPolymorphicBoundaries` counts with, so "N
types implement X" is the same N whether an agent reads it or a person does.
`/api/node` carries the block as `hierarchy` rather than a second endpoint —
it is part of the Symbol view's first paint, and gated to types, so a function
costs one kind test.

Layout is arithmetic (24px rows, 22px indent, orthogonal connectors computed
from the two): no ResizeObserver, same payload → same picture. The header's
`extends X` / `implemented by …` chips are suppressed while the tree is on
screen — two renderings of one relation in one column is how a reader ends up
trusting neither.

`TypeHierarchy` is exported from `@colbymchenry/codegraph-ui` and takes its data
as a prop, so a host holding a `WireSymbolPayload` renders it without a second
read.
This commit is contained in:
Colby McHenry
2026-08-27 07:14:55 -05:00
parent c15413f200
commit 2a0c6dc58f
21 changed files with 2100 additions and 28 deletions
+145
View File
@@ -0,0 +1,145 @@
/**
* The type hierarchy block on `/api/node` — ancestors up, subtypes down, and
* the fan an interface call dispatches into (design spec §3.10).
*
* The walk itself is `src/graph/type-hierarchy.ts`, shared with
* `codegraph_explore`'s interface-dispatch announcement so the two can never
* print different implementation counts for the same interface. This module is
* the renderer: it flattens the tree into rows the viewer can draw without
* measuring anything, and caps the fan while keeping the true totals.
*
* It rides on `/api/node` rather than sitting behind its own endpoint for the
* same reason `highlight` rides on `/api/source`: the block is part of the
* Symbol view's first paint, and a second round-trip would let the screen
* settle and then grow a tree above the code the reader had already started
* reading. The cost of carrying it is gated to types — `canHaveHierarchy` is a
* kind test, and the overwhelming majority of symbols a reader opens are
* functions.
*/
import type { CodeGraph } from '../../index';
import type { Node } from '../../types';
import {
buildTypeHierarchy,
canHaveHierarchy,
type HierarchyEntry,
type HierarchyRelation,
type TypeHierarchy,
} from '../../graph/type-hierarchy';
import { toNodeRef, wireList, type WireList, type WireNodeRef } from './wire';
/** Subtype rows carried on the payload. The viewer folds long fans again at 12. */
export const MAX_HIERARCHY_DESCENDANTS = 240;
/** Supertype rows carried on the payload. A chain longer than this is generated code. */
export const MAX_HIERARCHY_ANCESTORS = 24;
/** One type in the tree: the ref, its place, and how it got there. */
export interface WireHierarchyNode extends WireNodeRef {
/** Steps from the focus, in whichever direction the row sits. 1 = direct. */
depth: number;
/** The row this one hangs off — the focus's id at depth 1. */
parentId: string;
relation: HierarchyRelation;
/**
* The edge was synthesized rather than parsed — Go's implicit interface
* satisfaction is the common case. Drawn dashed, with `registeredAt` naming
* the wiring site, exactly as the Flow strip draws a synthesized hop.
*/
synthesized: boolean;
via?: string;
registeredAt?: string;
/** Direct subtypes of this row that are NOT in the payload. */
hiddenSubtypes: number;
}
/** Everything the type-hierarchy block draws. */
export interface WireHierarchy {
/** Supertypes, nearest first. */
ancestors: WireList<WireHierarchyNode>;
/** Subtypes, breadth-first: depth 1 is complete before depth 2 starts. */
descendants: WireList<WireHierarchyNode>;
/** True number of DIRECT subtypes, whatever `descendants` was capped to. */
direct: number;
/** Of `direct`, the ones tied by `implements` — what a call through the type reaches. */
implementers: number;
/** Subtypes exist below what the walk returned. */
bounded: boolean;
/** A call through this type dispatches at runtime rather than to one target. */
polymorphic: boolean;
}
/** A member of the focus that redeclares an ancestor's member. */
export interface WireOverride {
/** The member it redeclares — open it to read what is being replaced. */
baseId: string;
baseTypeId: string;
baseTypeName: string;
/** `implements` reads as "satisfies", `extends` as "overrides". */
relation: HierarchyRelation;
}
/**
* Build the block, or `null` when there is nothing to draw.
*
* `null` is the answer for every function, and for a class that neither
* extends nor is extended — the viewer draws no empty tree and no "no
* hierarchy" note, because a class with no subtypes is the normal case and
* saying so on every screen is noise.
*/
export function buildHierarchy(
cg: CodeGraph,
node: Node
): { wire: WireHierarchy; overrides: Map<string, WireOverride> } | null {
if (!canHaveHierarchy(node)) return null;
let hierarchy: TypeHierarchy | null;
try {
hierarchy = buildTypeHierarchy(cg, node);
} catch {
return null;
}
if (!hierarchy) return null;
const ancestors = hierarchy.ancestors.slice(0, MAX_HIERARCHY_ANCESTORS).map(toWireHierarchyNode);
const descendants = hierarchy.descendants
.slice(0, MAX_HIERARCHY_DESCENDANTS)
.map(toWireHierarchyNode);
const overrides = new Map<string, WireOverride>();
for (const [memberId, match] of hierarchy.overrides) {
overrides.set(memberId, {
baseId: match.baseId,
baseTypeId: match.baseTypeId,
baseTypeName: match.baseTypeName,
relation: match.relation,
});
}
return {
wire: {
ancestors: wireList(ancestors, hierarchy.ancestors.length),
descendants: wireList(descendants, hierarchy.descendants.length),
direct: hierarchy.directSubtypes,
implementers: hierarchy.directImplementers,
bounded: hierarchy.bounded,
polymorphic: hierarchy.polymorphic,
},
overrides,
};
}
function toWireHierarchyNode(entry: HierarchyEntry): WireHierarchyNode {
const meta = (entry.edge.metadata ?? {}) as Record<string, unknown>;
const wire: WireHierarchyNode = {
...toNodeRef(entry.node),
depth: entry.depth,
parentId: entry.parentId,
relation: entry.relation,
synthesized: entry.synthesized,
hiddenSubtypes: entry.hiddenSubtypes,
};
if (typeof meta.synthesizedBy === 'string') wire.via = meta.synthesizedBy;
else if (typeof meta.via === 'string') wire.via = meta.via;
if (typeof meta.registeredAt === 'string') wire.registeredAt = meta.registeredAt;
return wire;
}
+8 -2
View File
@@ -11,7 +11,7 @@
* ```
* GET /api/stats what this index is and how much to trust it
* GET /api/search?q= the search palette
* GET /api/node/<id> the Symbol view: rails, members, tests, blast radius
* GET /api/node/<id> the Symbol view: rails, members, hierarchy, tests, blast
* GET /api/nodes?id=&id= names for ids you already have (the trail)
* GET /api/source?file=&from=&to= verbatim source, with a drift verdict
* GET /api/file/<path> the File view: outline and import rails
@@ -58,6 +58,8 @@ export type {
WireEntryHub,
} from './entrypoints';
export type { WireRoute, WireRoutes } from './routes';
export type { WireHierarchy, WireHierarchyNode, WireOverride } from './hierarchy';
export { MAX_HIERARCHY_ANCESTORS, MAX_HIERARCHY_DESCENDANTS } from './hierarchy';
export type { WireNodeRefs } from './nodes';
export type {
WireFlowPayload,
@@ -112,7 +114,11 @@ const API_INDEX = {
endpoints: [
{ path: '/api/stats', description: 'Index state, graph counts, detected frameworks.' },
{ path: '/api/search', description: 'Ranked symbol search.', params: ['q', 'limit'] },
{ path: '/api/node/<id>', description: 'One symbol: callers, callees, members, tests, blast radius.' },
{
path: '/api/node/<id>',
description:
'One symbol: callers, callees, members, type hierarchy, tests, blast radius.',
},
{ path: '/api/nodes', description: 'Names and locations for ids you already have.', params: ['id'] },
{
path: '/api/source',
+30 -9
View File
@@ -23,6 +23,7 @@
import type { CodeGraph } from '../../index';
import type { Edge, Node, NodeKind } from '../../types';
import { isTestFile } from '../../search/query-utils';
import { buildHierarchy, type WireOverride } from './hierarchy';
import { notFound } from './respond';
import { findIndexedFile, hasDriftedOnDisk } from './source';
import {
@@ -64,6 +65,12 @@ export interface WireMember extends WireNodeRef {
*/
fanIn: number;
fanOut: number;
/**
* This member redeclares one an ancestor type declares — a name match inside
* a chain the graph already links, not an `overrides` edge (nothing emits
* one). Absent for every member that declares something new.
*/
overrides?: WireOverride;
}
export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): unknown {
@@ -155,7 +162,10 @@ export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): u
// ---------------------------------------------------------------------------
// Members outline
// ---------------------------------------------------------------------------
const members = buildMembers(cg, node, containsOut, endpoints);
// The type hierarchy, and the override marks it puts on the outline. Gated
// to types inside `buildHierarchy`, so a function costs one kind test.
const hierarchy = buildHierarchy(cg, node);
const members = buildMembers(cg, node, containsOut, endpoints, hierarchy?.overrides);
// ---------------------------------------------------------------------------
// Counts, tests, what leaves the index, blast radius
@@ -176,6 +186,11 @@ export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): u
/** Outermost first: file, then module/class, then the symbol's own parent. */
ancestors: [...ancestors].reverse().map(toNodeRef),
members: wireList(members.items, members.total),
/**
* Ancestors, subtypes and the dispatch fan — `null` for anything that is
* not a type, and for a type with no hierarchy at all.
*/
hierarchy: hierarchy?.wire ?? null,
incoming: wireList(shownIncoming, incomingGroups.length),
outgoing: wireList(shownOutgoing, outgoingGroups.length),
/** `references` edges into a type — the header's "uses types …" chips. */
@@ -218,7 +233,8 @@ function buildMembers(
cg: CodeGraph,
focal: Node,
containsOut: readonly Edge[],
endpoints: Map<string, Node>
endpoints: Map<string, Node>,
overrides?: Map<string, WireOverride>
): { items: WireMember[]; total: number } {
const direct: Array<{ node: Node; parentId: string; depth: number }> = [];
for (const edge of containsOut) {
@@ -252,13 +268,18 @@ function buildMembers(
const fanOut = cg.getFanOut(memberIds);
return {
items: shown.map((entry) => ({
...toNodeRef(entry.node),
parentId: entry.parentId,
depth: entry.depth,
fanIn: fanIn.get(entry.node.id) ?? 0,
fanOut: fanOut.get(entry.node.id) ?? 0,
})),
items: shown.map((entry) => {
const member: WireMember = {
...toNodeRef(entry.node),
parentId: entry.parentId,
depth: entry.depth,
fanIn: fanIn.get(entry.node.id) ?? 0,
fanOut: fanOut.get(entry.node.id) ?? 0,
};
const override = overrides?.get(entry.node.id);
if (override) member.overrides = override;
return member;
}),
total: all.length,
};
}