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:
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user