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;
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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