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:
@@ -6,3 +6,18 @@
|
||||
|
||||
export { GraphTraverser } from './traversal';
|
||||
export { GraphQueryManager } from './queries';
|
||||
export {
|
||||
buildTypeHierarchy,
|
||||
canHaveHierarchy,
|
||||
countImplementers,
|
||||
DISPATCH_MIN_IMPLEMENTERS,
|
||||
HIERARCHY_EDGE_KINDS,
|
||||
HIERARCHY_KINDS,
|
||||
MAX_DESCENDANTS,
|
||||
} from './type-hierarchy';
|
||||
export type {
|
||||
HierarchyEntry,
|
||||
HierarchyRelation,
|
||||
OverrideMatch,
|
||||
TypeHierarchy,
|
||||
} from './type-hierarchy';
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* The type hierarchy — one derivation of "what is above this type, what is
|
||||
* below it, and what a call through it can land on".
|
||||
*
|
||||
* Three surfaces ask that question. The viewer draws it as a tree above the
|
||||
* members outline (design spec §3.10). `codegraph_explore` announces it as an
|
||||
* interface-dispatch boundary ("`execute` → runtime dispatch to **611** types
|
||||
* implementing `INodeType`"). `codegraph_node` shows the same relations as
|
||||
* chips. Three derivations would eventually disagree about the ONE number that
|
||||
* matters — how many implementations a call can reach — and a reader holding
|
||||
* two of them has no way to tell which is lying. So the walk lives here once,
|
||||
* and each caller renders it: `src/ui-server/api/node.ts` turns it into
|
||||
* `WireHierarchy`, `ToolHandler.buildPolymorphicBoundaries` into prose.
|
||||
*
|
||||
* Everything here is query-time and read-only. No edge is invented: the tree is
|
||||
* exactly the `extends`/`implements` edges the graph holds, and the one thing
|
||||
* that is *derived* — which members override an ancestor's — is derived by name
|
||||
* within a chain the graph already links, and is labelled as a match rather
|
||||
* than as an `overrides` edge (nothing in the engine emits one).
|
||||
*
|
||||
* ## Why the fan is the interesting direction
|
||||
*
|
||||
* Ancestors are a fact about the code you are reading: `class X extends Y` is
|
||||
* written on line 1. Descendants are a fact you cannot get from the file at
|
||||
* all — the implementations of an interface live anywhere in the repo, and they
|
||||
* are precisely what a call through that interface dispatches to. Go makes this
|
||||
* sharpest: `System` and `Fixed` satisfy `Clock` without either file naming the
|
||||
* other, and the `implements` edge that links them is synthesized by the
|
||||
* resolver (`synthesizedBy: 'go-implements'`). So the fan carries its own
|
||||
* provenance and the caller draws a synthesized hop differently — the same
|
||||
* honesty rule the Flow strip's dashed connectors follow.
|
||||
*/
|
||||
|
||||
import type CodeGraph from '../index';
|
||||
import type { Edge, EdgeKind, Node, NodeKind } from '../types';
|
||||
|
||||
/** The two edge kinds that make a type hierarchy. Nothing else is a subtype. */
|
||||
export const HIERARCHY_EDGE_KINDS: readonly EdgeKind[] = ['extends', 'implements'];
|
||||
|
||||
/**
|
||||
* Kinds that can sit in a type hierarchy.
|
||||
*
|
||||
* `type_alias` is in deliberately — TypeScript's `interface A extends B` and
|
||||
* Rust's associated types both land here, and an alias with subtypes is a real
|
||||
* hierarchy however it was spelled. `enum` is in for Java/Kotlin/Swift, where an
|
||||
* enum implements interfaces.
|
||||
*/
|
||||
export const HIERARCHY_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
|
||||
'class',
|
||||
'interface',
|
||||
'struct',
|
||||
'trait',
|
||||
'protocol',
|
||||
'enum',
|
||||
'type_alias',
|
||||
'union',
|
||||
]);
|
||||
|
||||
/** Member kinds an override can be declared on. */
|
||||
const OVERRIDABLE_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
|
||||
'method',
|
||||
'function',
|
||||
'property',
|
||||
'field',
|
||||
]);
|
||||
|
||||
/** Levels walked upward. A chain deeper than this is a generated-code artefact. */
|
||||
export const MAX_ANCESTOR_DEPTH = 8;
|
||||
|
||||
/** Levels walked downward. Depth, not breadth — the fan itself is capped separately. */
|
||||
export const MAX_DESCENDANT_DEPTH = 6;
|
||||
|
||||
/**
|
||||
* Subtypes returned across the whole downward walk.
|
||||
*
|
||||
* A framework base class can have thousands, and the caller caps again for
|
||||
* display; this bound is what stops the *query* from walking them. When it
|
||||
* bites, {@link TypeHierarchy.bounded} says so — a fan that quietly stopped at
|
||||
* 400 would read as a complete answer.
|
||||
*/
|
||||
export const MAX_DESCENDANTS = 400;
|
||||
|
||||
/** Ancestors whose members are read when matching overrides. */
|
||||
const MAX_OVERRIDE_ANCESTORS = 12;
|
||||
|
||||
/**
|
||||
* Implementations at or above which a call through the type cannot be resolved
|
||||
* statically at all — the same threshold `codegraph_explore` uses before it
|
||||
* announces an interface-dispatch boundary.
|
||||
*/
|
||||
export const DISPATCH_MIN_IMPLEMENTERS = 8;
|
||||
|
||||
// =============================================================================
|
||||
// Shapes
|
||||
// =============================================================================
|
||||
|
||||
/** How a subtype is tied to the type above it. */
|
||||
export type HierarchyRelation = 'extends' | 'implements';
|
||||
|
||||
/** One type in the tree, and the single edge that puts it there. */
|
||||
export interface HierarchyEntry {
|
||||
node: Node;
|
||||
/** Steps from the focus. 1 = declared directly on the focus (either way). */
|
||||
depth: number;
|
||||
/**
|
||||
* The entry one step NEARER the focus — the row this one hangs off when the
|
||||
* tree is drawn. The focus's own id for a depth-1 entry.
|
||||
*/
|
||||
parentId: string;
|
||||
relation: HierarchyRelation;
|
||||
/** The edge itself, always oriented subtype → supertype as the code declares it. */
|
||||
edge: Edge;
|
||||
/**
|
||||
* The edge was synthesized rather than parsed — Go's implicit interface
|
||||
* satisfaction, a framework registry. Drawn dashed, with its wiring site.
|
||||
*/
|
||||
synthesized: boolean;
|
||||
/** Direct subtypes this entry has that are NOT in the returned set. */
|
||||
hiddenSubtypes: number;
|
||||
}
|
||||
|
||||
/** A member of the focus that redeclares a member of one of its ancestors. */
|
||||
export interface OverrideMatch {
|
||||
/** The member on the focus. */
|
||||
memberId: string;
|
||||
/** The member it redeclares. */
|
||||
baseId: string;
|
||||
/** The ancestor type that declares {@link baseId}. */
|
||||
baseTypeId: string;
|
||||
baseTypeName: string;
|
||||
/** How the focus reaches that ancestor — `implements` reads as "satisfies". */
|
||||
relation: HierarchyRelation;
|
||||
}
|
||||
|
||||
/** What is above a type, what is below it, and what a call through it reaches. */
|
||||
export interface TypeHierarchy {
|
||||
focus: Node;
|
||||
/** Supertypes, nearest first. Ordered so the focus's own parents lead. */
|
||||
ancestors: HierarchyEntry[];
|
||||
/** Subtypes, breadth-first, so depth 1 is complete before depth 2 begins. */
|
||||
descendants: HierarchyEntry[];
|
||||
/** True number of DIRECT subtypes, whatever `descendants` was capped to. */
|
||||
directSubtypes: number;
|
||||
/** Of {@link directSubtypes}, the ones tied by `implements`. */
|
||||
directImplementers: number;
|
||||
/**
|
||||
* The downward walk hit {@link MAX_DESCENDANTS} or {@link MAX_DESCENDANT_DEPTH}
|
||||
* — subtypes exist that are not in `descendants`.
|
||||
*/
|
||||
bounded: boolean;
|
||||
/**
|
||||
* A call through this type dispatches at runtime rather than to one target.
|
||||
* `directImplementers >= DISPATCH_MIN_IMPLEMENTERS`.
|
||||
*/
|
||||
polymorphic: boolean;
|
||||
/** Members of the focus that redeclare an ancestor's, keyed by member id. */
|
||||
overrides: Map<string, OverrideMatch>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The walk
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Whether a node could have a hierarchy at all.
|
||||
*
|
||||
* Cheap enough to gate on before doing any work: a function never has one, and
|
||||
* the overwhelming majority of symbols a reader opens are functions.
|
||||
*/
|
||||
export function canHaveHierarchy(node: Node): boolean {
|
||||
return HIERARCHY_KINDS.has(node.kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole hierarchy of one type.
|
||||
*
|
||||
* Cost is one query per level in each direction plus one batched member read,
|
||||
* never one per node — a base class with 400 subtypes is 2–3 queries, not 400.
|
||||
*
|
||||
* Returns `null` when the node cannot have a hierarchy or has no
|
||||
* `extends`/`implements` edge in either direction, so a caller can gate on the
|
||||
* return value rather than on the emptiness of three lists.
|
||||
*/
|
||||
export function buildTypeHierarchy(
|
||||
cg: CodeGraph,
|
||||
focus: Node,
|
||||
options: { overrides?: boolean } = {}
|
||||
): TypeHierarchy | null {
|
||||
if (!canHaveHierarchy(focus)) return null;
|
||||
|
||||
const ancestors = walkAncestors(cg, focus);
|
||||
const down = walkDescendants(cg, focus);
|
||||
if (ancestors.length === 0 && down.entries.length === 0) return null;
|
||||
|
||||
return {
|
||||
focus,
|
||||
ancestors,
|
||||
descendants: down.entries,
|
||||
directSubtypes: down.directTotal,
|
||||
directImplementers: down.directImplementers,
|
||||
bounded: down.bounded,
|
||||
polymorphic: down.directImplementers >= DISPATCH_MIN_IMPLEMENTERS,
|
||||
overrides: options.overrides === false ? new Map() : matchOverrides(cg, focus, ancestors),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up. Multiple direct parents are normal (a class extends one and
|
||||
* implements three), so this is a BFS rather than a chain, ordered nearest
|
||||
* first and — within a level — `extends` before `implements`, because the one
|
||||
* that carries the implementation is the one a reader wants adjacent.
|
||||
*/
|
||||
function walkAncestors(cg: CodeGraph, focus: Node): HierarchyEntry[] {
|
||||
const out: HierarchyEntry[] = [];
|
||||
const seen = new Set<string>([focus.id]);
|
||||
let frontier = [focus.id];
|
||||
|
||||
for (let depth = 1; depth <= MAX_ANCESTOR_DEPTH && frontier.length > 0; depth++) {
|
||||
const edges = hierarchyEdges(cg, frontier, 'up');
|
||||
if (edges.length === 0) break;
|
||||
const nodes = cg.getNodesByIds(edges.map((e) => e.target));
|
||||
|
||||
const level: HierarchyEntry[] = [];
|
||||
for (const edge of edges) {
|
||||
const node = nodes.get(edge.target);
|
||||
if (!node || seen.has(node.id)) continue;
|
||||
seen.add(node.id);
|
||||
level.push(toEntry(node, depth, edge.source, edge));
|
||||
}
|
||||
sortLevel(level);
|
||||
out.push(...level);
|
||||
frontier = level.map((e) => e.node.id);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk down — the fan. Breadth-first so the cap always trims the deepest,
|
||||
* least-relevant end: a reader looking at an interface wants its direct
|
||||
* implementations complete before a subclass of a subclass appears at all.
|
||||
*/
|
||||
function walkDescendants(cg: CodeGraph, focus: Node): {
|
||||
entries: HierarchyEntry[];
|
||||
directTotal: number;
|
||||
directImplementers: number;
|
||||
bounded: boolean;
|
||||
} {
|
||||
const entries: HierarchyEntry[] = [];
|
||||
const byId = new Map<string, HierarchyEntry>();
|
||||
const seen = new Set<string>([focus.id]);
|
||||
let frontier = [focus.id];
|
||||
let directTotal = 0;
|
||||
let directImplementers = 0;
|
||||
let bounded = false;
|
||||
|
||||
for (let depth = 1; depth <= MAX_DESCENDANT_DEPTH && frontier.length > 0; depth++) {
|
||||
const edges = hierarchyEdges(cg, frontier, 'down');
|
||||
if (edges.length === 0) break;
|
||||
const nodes = cg.getNodesByIds(edges.map((e) => e.source));
|
||||
|
||||
// One row per subtype, not per edge: a class tied to its supertype by both
|
||||
// a parsed `extends` and a synthesized `implements` is ONE implementation.
|
||||
// `extends` wins the relation because it is the one written in the file.
|
||||
const level: HierarchyEntry[] = [];
|
||||
const overflow = new Map<string, number>();
|
||||
const levelSeen = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
const node = nodes.get(edge.source);
|
||||
if (!node || seen.has(node.id)) continue;
|
||||
const existing = levelSeen.has(node.id)
|
||||
? level.find((e) => e.node.id === node.id)
|
||||
: undefined;
|
||||
if (existing) {
|
||||
if (existing.relation === 'implements' && edge.kind === 'extends') {
|
||||
existing.relation = 'extends';
|
||||
existing.edge = edge;
|
||||
existing.synthesized = edge.provenance === 'heuristic';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (depth === 1) {
|
||||
directTotal++;
|
||||
if (edge.kind === 'implements') directImplementers++;
|
||||
}
|
||||
if (entries.length + level.length >= MAX_DESCENDANTS) {
|
||||
// Stop materialising rows, but keep counting depth 1 so
|
||||
// `directSubtypes` stays the true number.
|
||||
bounded = true;
|
||||
overflow.set(edge.target, (overflow.get(edge.target) ?? 0) + 1);
|
||||
levelSeen.add(node.id);
|
||||
continue;
|
||||
}
|
||||
levelSeen.add(node.id);
|
||||
level.push(toEntry(node, depth, edge.target, edge));
|
||||
}
|
||||
for (const entry of level) seen.add(entry.node.id);
|
||||
sortLevel(level);
|
||||
for (const entry of level) {
|
||||
entries.push(entry);
|
||||
byId.set(entry.node.id, entry);
|
||||
}
|
||||
for (const [parentId, count] of overflow) {
|
||||
const parent = byId.get(parentId);
|
||||
if (parent) parent.hiddenSubtypes += count;
|
||||
}
|
||||
if (bounded) break;
|
||||
|
||||
frontier = level.map((e) => e.node.id);
|
||||
if (depth === MAX_DESCENDANT_DEPTH && frontier.length > 0) {
|
||||
// A level exists below the one we are about to stop at. Say so rather
|
||||
// than letting the deepest row read as a leaf.
|
||||
for (const edge of hierarchyEdges(cg, frontier, 'down')) {
|
||||
if (seen.has(edge.source)) continue;
|
||||
bounded = true;
|
||||
const parent = byId.get(edge.target);
|
||||
if (parent) parent.hiddenSubtypes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, directTotal, directImplementers, bounded };
|
||||
}
|
||||
|
||||
/** One batched edge read per level, filtered to the two hierarchy kinds. */
|
||||
function hierarchyEdges(cg: CodeGraph, ids: readonly string[], direction: 'up' | 'down'): Edge[] {
|
||||
const kinds = [...HIERARCHY_EDGE_KINDS];
|
||||
try {
|
||||
const edges =
|
||||
direction === 'up'
|
||||
? cg.getOutgoingEdgesFrom(ids, kinds)
|
||||
: cg.getIncomingEdgesTo(ids, kinds);
|
||||
// Belt and braces: the kind filter is applied in SQL, but a caller reading
|
||||
// `entry.relation` must never see a third value.
|
||||
return edges.filter((e) => e.kind === 'extends' || e.kind === 'implements');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toEntry(node: Node, depth: number, parentId: string, edge: Edge): HierarchyEntry {
|
||||
return {
|
||||
node,
|
||||
depth,
|
||||
parentId,
|
||||
relation: edge.kind === 'implements' ? 'implements' : 'extends',
|
||||
edge,
|
||||
synthesized: edge.provenance === 'heuristic',
|
||||
hiddenSubtypes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic order within one level: `extends` first, then by name, then by
|
||||
* file. Never by insertion — two runs against the same index must draw the same
|
||||
* tree, and SQLite's row order is not a promise.
|
||||
*/
|
||||
function sortLevel(level: HierarchyEntry[]): void {
|
||||
level.sort(
|
||||
(a, b) =>
|
||||
(a.relation === b.relation ? 0 : a.relation === 'extends' ? -1 : 1) ||
|
||||
a.node.name.localeCompare(b.node.name) ||
|
||||
a.node.filePath.localeCompare(b.node.filePath) ||
|
||||
a.node.startLine - b.node.startLine
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Overrides
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Which of the focus's members redeclare an ancestor's.
|
||||
*
|
||||
* Nothing in the engine emits an `overrides` edge (the kind exists in the
|
||||
* schema and no extractor writes one), so this is a NAME match — but a name
|
||||
* match inside a chain the graph already established, which is exactly what
|
||||
* every language's dispatch rule is. It is reported as a match against a named
|
||||
* base member the reader can open, never as an edge, and it is deliberately
|
||||
* blind to signatures: an overload set would need type resolution the graph
|
||||
* does not have, and claiming "overrides" for the wrong overload is worse than
|
||||
* saying which type also declares this name.
|
||||
*
|
||||
* Two batched queries total, whatever the ancestor count.
|
||||
*/
|
||||
function matchOverrides(
|
||||
cg: CodeGraph,
|
||||
focus: Node,
|
||||
ancestors: readonly HierarchyEntry[]
|
||||
): Map<string, OverrideMatch> {
|
||||
const result = new Map<string, OverrideMatch>();
|
||||
if (ancestors.length === 0) return result;
|
||||
|
||||
const ownMembers = membersOf(cg, [focus.id]);
|
||||
if (ownMembers.length === 0) return result;
|
||||
|
||||
// Nearest ancestors win: a method redeclared two levels up is still reported
|
||||
// against the type the reader would actually look in.
|
||||
const chain = ancestors.slice(0, MAX_OVERRIDE_ANCESTORS);
|
||||
const baseMembers = membersOf(
|
||||
cg,
|
||||
chain.map((a) => a.node.id)
|
||||
);
|
||||
if (baseMembers.length === 0) return result;
|
||||
|
||||
const ancestorById = new Map(chain.map((a) => [a.node.id, a] as const));
|
||||
const byName = new Map<string, { member: Node; ownerId: string }>();
|
||||
// `chain` is nearest-first and `membersOf` preserves the order of the ids it
|
||||
// was given, so the first entry for a name is the nearest declaration.
|
||||
for (const { member, ownerId } of baseMembers) {
|
||||
if (!byName.has(member.name)) byName.set(member.name, { member, ownerId });
|
||||
}
|
||||
|
||||
for (const { member } of ownMembers) {
|
||||
if (!OVERRIDABLE_KINDS.has(member.kind)) continue;
|
||||
const base = byName.get(member.name);
|
||||
if (!base || base.member.id === member.id) continue;
|
||||
const owner = ancestorById.get(base.ownerId);
|
||||
if (!owner) continue;
|
||||
result.set(member.id, {
|
||||
memberId: member.id,
|
||||
baseId: base.member.id,
|
||||
baseTypeId: owner.node.id,
|
||||
baseTypeName: owner.node.name,
|
||||
relation: owner.relation,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Direct `contains` children of the given containers, in the containers' order. */
|
||||
function membersOf(
|
||||
cg: CodeGraph,
|
||||
containerIds: readonly string[]
|
||||
): Array<{ member: Node; ownerId: string }> {
|
||||
if (containerIds.length === 0) return [];
|
||||
let edges: Edge[];
|
||||
try {
|
||||
edges = cg.getOutgoingEdgesFrom(containerIds, ['contains']);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (edges.length === 0) return [];
|
||||
const nodes = cg.getNodesByIds(edges.map((e) => e.target));
|
||||
|
||||
const rank = new Map(containerIds.map((id, i) => [id, i] as const));
|
||||
const out: Array<{ member: Node; ownerId: string }> = [];
|
||||
for (const edge of edges) {
|
||||
const member = nodes.get(edge.target);
|
||||
if (member) out.push({ member, ownerId: edge.source });
|
||||
}
|
||||
out.sort(
|
||||
(a, b) =>
|
||||
(rank.get(a.ownerId) ?? 0) - (rank.get(b.ownerId) ?? 0) ||
|
||||
a.member.startLine - b.member.startLine
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The fan, on its own
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* How many distinct types extend or implement this one — the number
|
||||
* `codegraph_explore` prints when it announces an interface dispatch and the
|
||||
* number the viewer's fan draws.
|
||||
*
|
||||
* DISTINCT types, not edges: a class tied to a supertype by both an `extends`
|
||||
* and a synthesized `implements` edge is one implementation, and a count that
|
||||
* disagrees with the length of the list beside it is the bug this function
|
||||
* exists to prevent.
|
||||
*/
|
||||
export function countImplementers(cg: CodeGraph, typeId: string): number {
|
||||
try {
|
||||
const edges = cg.getIncomingEdgesTo([typeId], [...HIERARCHY_EDGE_KINDS]);
|
||||
return new Set(edges.map((e) => e.source)).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user