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
+15
View File
@@ -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';
+482
View File
@@ -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 23 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;
}
}
+7 -3
View File
@@ -41,6 +41,7 @@ import {
import { createHash } from 'crypto';
import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report';
import { countImplementers } from '../graph/type-hierarchy';
import {
lastQualifierPart,
matchesSymbol,
@@ -2830,9 +2831,12 @@ export class ToolHandler {
let best: { node: Node; impl: number; targets: Node[] } | null = null;
for (const { node, count, targets } of supers.values()) {
if (count < MIN_SUPPORT) continue;
let impl = 0;
try { impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length; }
catch { /* leave 0 — gated out below */ }
// The implementer count is `countImplementers` — the same function the
// viewer's type-hierarchy fan counts with, so "dispatch to N types
// implementing X" is the same N on both surfaces (CG-58). Distinct
// types, not edges: a class tied to its supertype by both a parsed
// `extends` and a synthesized `implements` is one implementation.
const impl = countImplementers(cg, node.id);
if (impl < MIN_IMPL) continue;
if (!best || impl > best.impl) best = { node, impl, targets };
}
+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,
};
}