Просмотр исходного кода

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.
Colby McHenry 1 неделя назад
Родитель
Сommit
2a0c6dc58f

+ 6 - 0
CHANGELOG.md

@@ -66,6 +66,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
   Nothing changes for `codegraph ui` itself — it is the same viewer, now the library's first user. The package is prepared, not yet on npm.
 
+- **See what a type is built on, and what is built on it, in `codegraph ui`.** Open a class, interface, struct, trait or enum and a small tree now sits above its members: what it extends and implements, going all the way up rather than stopping at the direct parent, and everything that extends or implements it, going down. Inheritance is drawn with a solid line and implementation with a dashed one, so the two never read as the same relationship, and every row opens the type it names.
+
+  For an interface, the list below it is the answer to a question source code cannot give you: a call through that interface can land on any of them, and where there are enough of them to make a static answer meaningless the block says so in a sentence. Go's implicit interface satisfaction is included — a struct that satisfies an interface without either file mentioning the other appears in the fan, marked as matched by CodeGraph rather than written down, along with the line it was matched at. Long fans fold behind a "+N more implementations" button rather than being cut off, and if there is more below than was walked the tree says that too.
+
+  Members that redeclare something from a type above are marked in the outline ("overrides Base", or "satisfies Clock" for an interface), so a 40-member class shows at a glance which parts are its own and which are a contract it is filling. The number of implementations shown here is the same number `codegraph_explore` reports to your agent when it announces an interface dispatch.
+
 ### Fixes
 
 - Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.

+ 1 - 1
CLAUDE.md

@@ -77,7 +77,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the
 - `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`, `sqlite-adapter.ts`. Backed by Node's built-in **`node:sqlite`** (`DatabaseSync`) — real SQLite with WAL + FTS5, exposed through a thin better-sqlite3-shaped adapter. The bundled runtime always ships Node ≥22.5, so `node:sqlite` is always available: **no native build step and no wasm fallback**. (Running from source needs Node ≥22.5.) `codegraph status` reports the live backend (`node-sqlite`, the sole backend).
 - `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread.
 - `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges.
-- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries).
+- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
 - `src/context/` — `ContextBuilder` + formatter for markdown/JSON output.
 - `src/search/` — full-text query parser and helpers for FTS5.
 - `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers.

+ 622 - 0
__tests__/type-hierarchy.test.ts

@@ -0,0 +1,622 @@
+/**
+ * The type hierarchy (CG-58) — the walk, the fan, and the tree the viewer draws.
+ *
+ * The walk half runs against a real indexed fixture rather than a stubbed
+ * `CodeGraph`: the properties worth pinning are ones only a real index has —
+ * that a Go struct satisfies an interface through a SYNTHESIZED `implements`
+ * edge with no textual link between the two files, that a self-referential
+ * `extends` in generated code does not loop, that the breadth-first order puts
+ * every direct subtype ahead of any indirect one.
+ *
+ * The layout half is pure arithmetic over a payload, so it is asserted
+ * directly. Everything the block does that could be WRONG rather than merely
+ * ugly lives there: which row a connector attaches to, what folds, and which
+ * noun the fold uses.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import type { Node } from '../src/types';
+import {
+  buildTypeHierarchy,
+  canHaveHierarchy,
+  countImplementers,
+  DISPATCH_MIN_IMPLEMENTERS,
+  MAX_DESCENDANTS,
+} from '../src/graph/type-hierarchy';
+import { buildHierarchy } from '../src/ui-server/api/hierarchy';
+import {
+  buildHierarchyModel,
+  connectorPath,
+  visibleHierarchy,
+  HIER_FOLD_AT,
+  HIER_GLYPH_X,
+  HIER_INDENT,
+  HIER_PORT_X,
+  HIER_ROW_H,
+} from '../ui/src/lib/hierarchy-model';
+import type {
+  WireHierarchy,
+  WireHierarchyNode,
+  WireNodeDetail,
+} from '../ui/src/lib/wire';
+
+// =============================================================================
+// A real index
+// =============================================================================
+
+let tempDir: string;
+let projectRoot: string;
+let cg: CodeGraph;
+
+/** The one node with this name and kind, or a failure that says which was missing. */
+function nodeNamed(name: string, kind?: string): Node {
+  const hits = cg
+    .searchNodes(name, { limit: 40 })
+    .map((r: any) => (r.node ?? r) as Node)
+    .filter((n) => n.name === name && (!kind || n.kind === kind));
+  expect(hits.length, `no ${kind ?? 'node'} named ${name}`).toBeGreaterThan(0);
+  return hits[0]!;
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-hierarchy-'));
+  projectRoot = path.join(tempDir, 'project');
+  const src = path.join(projectRoot, 'src');
+  fs.mkdirSync(src, { recursive: true });
+
+  // A three-level TypeScript chain with a real override, plus an interface with
+  // enough implementations to be a dispatch fan.
+  fs.writeFileSync(
+    path.join(src, 'shapes.ts'),
+    `export interface Drawable {
+  draw(): string;
+}
+
+export abstract class Shape implements Drawable {
+  draw(): string {
+    return 'shape';
+  }
+  area(): number {
+    return 0;
+  }
+}
+
+export class Square extends Shape {
+  draw(): string {
+    return 'square';
+  }
+}
+
+export class Tile extends Square {
+  label = 'tile';
+}
+`
+  );
+
+  // Nine implementations, so the fan clears DISPATCH_MIN_IMPLEMENTERS.
+  const targets = [
+    'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel', 'India',
+  ];
+  fs.writeFileSync(
+    path.join(src, 'plugins.ts'),
+    `export interface Plugin {
+  run(): void;
+}
+
+${targets
+  .map((name) => `export class ${name}Plugin implements Plugin {\n  run(): void {}\n}`)
+  .join('\n\n')}
+`
+  );
+
+  // Go: `System` satisfies `Clock` without either file naming the other. The
+  // `implements` edge here is synthesized, which is the case the viewer draws
+  // differently — the fixture mirrors `__tests__/fixtures/payroll-go`.
+  fs.writeFileSync(path.join(projectRoot, 'go.mod'), 'module fixture\n\ngo 1.22\n');
+  fs.writeFileSync(
+    path.join(src, 'clock.go'),
+    `package clock
+
+import "time"
+
+// Clock is the time seam.
+type Clock interface {
+	Now() time.Time
+}
+
+// System is the production clock.
+type System struct{}
+
+func (System) Now() time.Time { return time.Now().UTC() }
+
+// Fixed is a frozen clock.
+type Fixed struct{ At time.Time }
+
+func (f Fixed) Now() time.Time { return f.At }
+`
+  );
+
+  cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts', 'src/**/*.go'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+}, 120_000);
+
+afterAll(() => {
+  cg?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('canHaveHierarchy', () => {
+  it('is false for a function, so the walk never runs for one', () => {
+    expect(canHaveHierarchy({ kind: 'function' } as Node)).toBe(false);
+    expect(canHaveHierarchy({ kind: 'method' } as Node)).toBe(false);
+    expect(canHaveHierarchy({ kind: 'class' } as Node)).toBe(true);
+    expect(canHaveHierarchy({ kind: 'interface' } as Node)).toBe(true);
+    expect(canHaveHierarchy({ kind: 'struct' } as Node)).toBe(true);
+    expect(canHaveHierarchy({ kind: 'trait' } as Node)).toBe(true);
+  });
+});
+
+describe('buildTypeHierarchy — upward', () => {
+  it('walks past the direct parent to the whole chain', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'));
+    expect(hierarchy).not.toBeNull();
+    const byName = new Map(hierarchy!.ancestors.map((a) => [a.node.name, a]));
+    expect(byName.get('Square')?.depth).toBe(1);
+    expect(byName.get('Shape')?.depth).toBe(2);
+    // `Shape implements Drawable`, so the interface is three steps up from Tile.
+    expect(byName.get('Drawable')?.depth).toBe(3);
+    expect(byName.get('Square')?.relation).toBe('extends');
+    expect(byName.get('Drawable')?.relation).toBe('implements');
+  });
+
+  it('nearest ancestors come first', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'))!;
+    const depths = hierarchy.ancestors.map((a) => a.depth);
+    expect(depths).toEqual([...depths].sort((a, b) => a - b));
+  });
+});
+
+describe('buildTypeHierarchy — the fan', () => {
+  it('returns every direct subtype before any indirect one', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Shape', 'class'))!;
+    const depths = hierarchy.descendants.map((d) => d.depth);
+    expect(depths).toEqual([...depths].sort((a, b) => a - b));
+    expect(hierarchy.descendants.map((d) => d.node.name)).toContain('Square');
+    expect(hierarchy.descendants.map((d) => d.node.name)).toContain('Tile');
+    expect(hierarchy.directSubtypes).toBe(1);
+  });
+
+  it('hangs an indirect subtype off its own parent, not off the focus', () => {
+    const focus = nodeNamed('Shape', 'class');
+    const hierarchy = buildTypeHierarchy(cg, focus)!;
+    const square = hierarchy.descendants.find((d) => d.node.name === 'Square')!;
+    const tile = hierarchy.descendants.find((d) => d.node.name === 'Tile')!;
+    expect(square.parentId).toBe(focus.id);
+    expect(tile.parentId).toBe(square.node.id);
+  });
+
+  it('calls a nine-implementation interface polymorphic', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Plugin', 'interface'))!;
+    expect(hierarchy.directImplementers).toBeGreaterThanOrEqual(DISPATCH_MIN_IMPLEMENTERS);
+    expect(hierarchy.polymorphic).toBe(true);
+    expect(hierarchy.directSubtypes).toBe(hierarchy.descendants.filter((d) => d.depth === 1).length);
+  });
+
+  it('does not call a two-implementation interface polymorphic', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
+    expect(hierarchy.directSubtypes).toBe(2);
+    expect(hierarchy.polymorphic).toBe(false);
+  });
+});
+
+describe('buildTypeHierarchy — Go implicit satisfaction', () => {
+  it('finds the implementations of an interface no file names', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
+    const names = hierarchy.descendants.map((d) => d.node.name).sort();
+    expect(names).toEqual(['Fixed', 'System']);
+    expect(hierarchy.descendants.every((d) => d.relation === 'implements')).toBe(true);
+  });
+
+  it('marks the synthesized edge, and keeps where it was wired', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
+    const system = hierarchy.descendants.find((d) => d.node.name === 'System')!;
+    expect(system.synthesized).toBe(true);
+    const meta = (system.edge.metadata ?? {}) as Record<string, unknown>;
+    expect(meta.synthesizedBy).toBe('go-implements');
+    expect(String(meta.registeredAt)).toContain('clock.go');
+  });
+});
+
+describe('buildTypeHierarchy — overrides', () => {
+  it('marks a member that redeclares an ancestor s, and names the ancestor', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Square', 'class'))!;
+    const matches = [...hierarchy.overrides.values()];
+    const draw = matches.find((m) => m.baseTypeName === 'Shape');
+    expect(draw, 'Square.draw should be matched against Shape.draw').toBeTruthy();
+    expect(draw!.relation).toBe('extends');
+  });
+
+  it('leaves a member that declares something new unmarked', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'))!;
+    // `label` exists on nothing above Tile.
+    const named = [...hierarchy.overrides.values()].map((m) => m.memberId);
+    const label = cg
+      .getOutgoingEdges(nodeNamed('Tile', 'class').id)
+      .filter((e) => e.kind === 'contains')
+      .map((e) => cg.getNode(e.target))
+      .find((n) => n?.name === 'label');
+    if (label) expect(named).not.toContain(label.id);
+  });
+
+  it('can be switched off without changing the tree', () => {
+    const focus = nodeNamed('Square', 'class');
+    const withOverrides = buildTypeHierarchy(cg, focus)!;
+    const without = buildTypeHierarchy(cg, focus, { overrides: false })!;
+    expect(without.overrides.size).toBe(0);
+    expect(without.descendants.length).toBe(withOverrides.descendants.length);
+    expect(without.ancestors.length).toBe(withOverrides.ancestors.length);
+  });
+});
+
+describe('countImplementers', () => {
+  it('counts distinct types, and agrees with the fan it sits beside', () => {
+    const plugin = nodeNamed('Plugin', 'interface');
+    const hierarchy = buildTypeHierarchy(cg, plugin)!;
+    expect(countImplementers(cg, plugin.id)).toBe(hierarchy.directSubtypes);
+  });
+
+  it('is zero for a type nothing extends', () => {
+    expect(countImplementers(cg, nodeNamed('Tile', 'class').id)).toBe(0);
+  });
+});
+
+describe('the /api/node block', () => {
+  it('is null for a function', () => {
+    const fn = cg
+      .searchNodes('run', { limit: 40 })
+      .map((r: any) => (r.node ?? r) as Node)
+      .find((n) => n.kind === 'method');
+    if (fn) expect(buildHierarchy(cg, fn)).toBeNull();
+  });
+
+  it('is null for a type with no hierarchy at all', () => {
+    const orphan = { id: 'x', kind: 'class', name: 'Nope' } as Node;
+    expect(buildHierarchy(cg, orphan)).toBeNull();
+  });
+
+  it('carries a total that equals the list beneath it', () => {
+    const built = buildHierarchy(cg, nodeNamed('Plugin', 'interface'))!;
+    expect(built.wire.descendants.items.length).toBe(built.wire.descendants.shown);
+    expect(built.wire.descendants.total).toBe(built.wire.descendants.items.length);
+    expect(built.wire.descendants.truncated).toBe(false);
+    expect(built.wire.direct).toBe(built.wire.descendants.total);
+  });
+
+  it('lifts the synthesized edge s wiring onto the row', () => {
+    const built = buildHierarchy(cg, nodeNamed('Clock', 'interface'))!;
+    const system = built.wire.descendants.items.find((d) => d.name === 'System')!;
+    expect(system.synthesized).toBe(true);
+    expect(system.via).toBe('go-implements');
+    expect(system.registeredAt).toContain('clock.go');
+  });
+
+  it('hands the outline its override marks', () => {
+    const built = buildHierarchy(cg, nodeNamed('Square', 'class'))!;
+    expect([...built.overrides.values()].some((o) => o.baseTypeName === 'Shape')).toBe(true);
+  });
+});
+
+// =============================================================================
+// The bounds, against a synthetic graph
+// =============================================================================
+
+/**
+ * A `CodeGraph` stub holding only what the walk reads.
+ *
+ * A fan wide enough to hit {@link MAX_DESCENDANTS} would be thousands of files
+ * to index for one assertion, and the property being pinned is arithmetic
+ * rather than extraction: that the cap stops materialising rows, keeps counting
+ * the direct ones, and says it was bounded.
+ */
+function stubGraph(childCount: number): any {
+  const type = (id: string, name: string): Node =>
+    ({
+      id,
+      kind: 'class',
+      name,
+      qualifiedName: name,
+      filePath: `src/${name}.ts`,
+      startLine: 1,
+      endLine: 2,
+      startColumn: 0,
+      endColumn: 0,
+      language: 'typescript',
+    }) as Node;
+
+  const root = type('root', 'Root');
+  const children = Array.from({ length: childCount }, (_, i) => type(`c${i}`, `Child${i}`));
+  const all = new Map<string, Node>([[root.id, root], ...children.map((c) => [c.id, c] as const)]);
+
+  return {
+    getIncomingEdgesTo: (ids: string[]) =>
+      ids.includes('root')
+        ? children.map((c) => ({ source: c.id, target: 'root', kind: 'extends' }))
+        : [],
+    getOutgoingEdgesFrom: () => [],
+    getNodesByIds: (ids: string[]) =>
+      new Map(ids.map((id) => [id, all.get(id)!]).filter(([, n]) => !!n) as Array<[string, Node]>),
+    root,
+  };
+}
+
+describe('the descendant bound', () => {
+  it('stays unbounded under the cap', () => {
+    const cgStub = stubGraph(10);
+    const hierarchy = buildTypeHierarchy(cgStub, cgStub.root)!;
+    expect(hierarchy.descendants.length).toBe(10);
+    expect(hierarchy.directSubtypes).toBe(10);
+    expect(hierarchy.bounded).toBe(false);
+  });
+
+  it('stops materialising rows past the cap but keeps the direct count true', () => {
+    const cgStub = stubGraph(MAX_DESCENDANTS + 37);
+    const hierarchy = buildTypeHierarchy(cgStub, cgStub.root)!;
+    expect(hierarchy.descendants.length).toBe(MAX_DESCENDANTS);
+    // The number of subtypes is not the number of rows, and says so.
+    expect(hierarchy.directSubtypes).toBe(MAX_DESCENDANTS + 37);
+    expect(hierarchy.bounded).toBe(true);
+  });
+
+  it('reports the cap through the wire block as a truncated list', () => {
+    const cgStub = stubGraph(MAX_DESCENDANTS + 37);
+    const built = buildHierarchy(cgStub, cgStub.root)!;
+    expect(built.wire.descendants.truncated).toBe(true);
+    expect(built.wire.descendants.items.length).toBeLessThan(built.wire.descendants.total);
+    expect(built.wire.bounded).toBe(true);
+    expect(built.wire.direct).toBe(MAX_DESCENDANTS + 37);
+  });
+});
+
+// =============================================================================
+// The tree the viewer draws
+// =============================================================================
+
+const FOCUS: WireNodeDetail = {
+  id: 'focus',
+  kind: 'interface',
+  name: 'Clock',
+  qualifiedName: 'Clock',
+  file: 'src/clock.ts',
+  line: 1,
+  endLine: 3,
+  language: 'typescript' as WireNodeDetail['language'],
+  test: false,
+  startColumn: 0,
+  endColumn: 0,
+  lines: 3,
+};
+
+function entry(
+  name: string,
+  depth: number,
+  parentId: string,
+  relation: 'extends' | 'implements' = 'implements'
+): WireHierarchyNode {
+  return {
+    id: name,
+    kind: 'class',
+    name,
+    qualifiedName: name,
+    file: `src/${name}.ts`,
+    line: 1,
+    endLine: 2,
+    language: 'typescript' as WireNodeDetail['language'],
+    test: false,
+    depth,
+    parentId,
+    relation,
+    synthesized: false,
+    hiddenSubtypes: 0,
+  };
+}
+
+function hierarchyOf(
+  ancestors: WireHierarchyNode[],
+  descendants: WireHierarchyNode[],
+  extra: Partial<WireHierarchy> = {}
+): WireHierarchy {
+  return {
+    ancestors: {
+      total: ancestors.length,
+      shown: ancestors.length,
+      truncated: false,
+      items: ancestors,
+    },
+    descendants: {
+      total: descendants.length,
+      shown: descendants.length,
+      truncated: false,
+      items: descendants,
+    },
+    direct: descendants.filter((d) => d.depth === 1).length,
+    implementers: descendants.filter((d) => d.depth === 1 && d.relation === 'implements').length,
+    bounded: false,
+    polymorphic: false,
+    ...extra,
+  };
+}
+
+describe('buildHierarchyModel', () => {
+  it('puts the focus between the two halves, farthest ancestor at the top', () => {
+    const model = buildHierarchyModel(
+      hierarchyOf(
+        [entry('Base', 2, 'Mid', 'extends'), entry('Mid', 1, 'focus', 'extends')],
+        [entry('Sub', 1, 'focus', 'extends')]
+      ),
+      FOCUS
+    );
+    expect(model.rows.map((r) => r.node.name)).toEqual(['Base', 'Mid', 'Clock', 'Sub']);
+    expect(model.focusIndex).toBe(2);
+    expect(model.rows[2]!.side).toBe('focus');
+  });
+
+  it('indents each descendant level and leaves ancestors at zero', () => {
+    const model = buildHierarchyModel(
+      hierarchyOf([entry('Base', 1, 'focus', 'extends')], [
+        entry('Sub', 1, 'focus', 'extends'),
+        entry('SubSub', 2, 'Sub', 'extends'),
+      ]),
+      FOCUS
+    );
+    const indents = Object.fromEntries(model.rows.map((r) => [r.node.name, r.indent]));
+    expect(indents.Base).toBe(0);
+    expect(indents.Clock).toBe(0);
+    expect(indents.Sub).toBe(HIER_INDENT);
+    expect(indents.SubSub).toBe(HIER_INDENT * 2);
+  });
+
+  it('draws a descendant connector from its own parent row, not from the focus', () => {
+    const model = buildHierarchyModel(
+      hierarchyOf([], [entry('Sub', 1, 'focus', 'extends'), entry('SubSub', 2, 'Sub', 'extends')]),
+      FOCUS
+    );
+    const rowOf = (name: string) => model.rows.findIndex((r) => r.node.name === name);
+    const deep = model.connectors.find((c) => c.toIndex === rowOf('SubSub'))!;
+    expect(deep.fromIndex).toBe(rowOf('Sub'));
+    // Leaves the parent's glyph centre, meets the child's glyph.
+    expect(deep.x).toBe(HIER_INDENT + HIER_PORT_X);
+    expect(deep.toX).toBe(HIER_INDENT * 2 + HIER_GLYPH_X - 2);
+  });
+
+  it('never hangs a descendant off an ancestor row that shares its name', () => {
+    // A cycle in generated code: `Loop` is both above and below the focus.
+    const model = buildHierarchyModel(
+      hierarchyOf([entry('Loop', 1, 'focus', 'extends')], [entry('Loop', 1, 'focus', 'extends')]),
+      FOCUS
+    );
+    const descendantRow = model.rows.findIndex((r) => r.side === 'descendant');
+    const connector = model.connectors.find((c) => c.toIndex === descendantRow)!;
+    expect(connector.fromIndex).toBe(model.focusIndex);
+  });
+
+  it('carries the relation into the connector so implements can be dashed', () => {
+    const model = buildHierarchyModel(
+      hierarchyOf([], [entry('Impl', 1, 'focus', 'implements')]),
+      FOCUS
+    );
+    expect(model.connectors[0]!.relation).toBe('implements');
+  });
+
+  it('claims a dispatch only when the payload says the type is polymorphic', () => {
+    const plain = buildHierarchyModel(hierarchyOf([], [entry('A', 1, 'focus')]), FOCUS);
+    expect(plain.headline).toBe('');
+
+    const fan = buildHierarchyModel(
+      hierarchyOf([], [entry('A', 1, 'focus')], { polymorphic: true, implementers: 9 }),
+      FOCUS
+    );
+    expect(fan.headline).toContain('9 implementations');
+    expect(fan.headline).toContain('Clock');
+  });
+});
+
+describe('the fold', () => {
+  const fan = (n: number, relation: 'extends' | 'implements' = 'implements') =>
+    hierarchyOf(
+      [],
+      Array.from({ length: n }, (_, i) => entry(`Impl${i}`, 1, 'focus', relation))
+    );
+
+  it('does not fold a fan of exactly the threshold — a "+0 more" is not a fold', () => {
+    const model = buildHierarchyModel(fan(HIER_FOLD_AT), FOCUS);
+    expect(model.foldFrom).toBeNull();
+    expect(model.foldCount).toBe(0);
+  });
+
+  it('folds the tail past the threshold and counts what it hid', () => {
+    const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
+    expect(model.foldCount).toBe(5);
+    expect(model.foldNoun).toBe('implementations');
+    const folded = visibleHierarchy(model, false);
+    expect(folded.rows.length).toBe(model.focusIndex + 1 + HIER_FOLD_AT);
+    expect(visibleHierarchy(model, true).rows.length).toBe(model.rows.length);
+  });
+
+  it('never leaves a connector running into the fold', () => {
+    const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
+    const folded = visibleHierarchy(model, false);
+    for (const connector of folded.connectors) {
+      expect(connector.toIndex).toBeLessThan(folded.rows.length);
+      expect(connector.fromIndex).toBeLessThan(folded.rows.length);
+    }
+  });
+
+  it('calls a family of subclasses subclasses, not implementations', () => {
+    const model = buildHierarchyModel(fan(HIER_FOLD_AT + 2, 'extends'), FOCUS);
+    expect(model.foldNoun).toBe('subclasses');
+  });
+
+  it('heights are the row count times the row height, with nothing measured', () => {
+    const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
+    expect(visibleHierarchy(model, false).height).toBe(
+      (model.focusIndex + 1 + HIER_FOLD_AT) * HIER_ROW_H
+    );
+    expect(visibleHierarchy(model, true).height).toBe(model.rows.length * HIER_ROW_H);
+  });
+});
+
+describe('connectorPath', () => {
+  it('is two straight runs and a corner, never a curve', () => {
+    const path = connectorPath({
+      fromIndex: 0,
+      toIndex: 1,
+      x: 26,
+      toX: 38,
+      relation: 'extends',
+      synthesized: false,
+    });
+    expect(path).toBe(`M 26 ${HIER_ROW_H / 2} L 26 ${HIER_ROW_H + HIER_ROW_H / 2} L 38 ${HIER_ROW_H + HIER_ROW_H / 2}`);
+    expect(path).not.toContain('C');
+  });
+
+  it('drops the horizontal run when the two rows share an indent', () => {
+    const path = connectorPath({
+      fromIndex: 0,
+      toIndex: 1,
+      x: 26,
+      toX: 26,
+      relation: 'implements',
+      synthesized: false,
+    });
+    expect(path.match(/L/g)).toHaveLength(1);
+  });
+});
+
+describe('the note under the tree', () => {
+  it('says how much of the fan is on screen when it was capped', () => {
+    const payload = hierarchyOf([], [entry('A', 1, 'focus')]);
+    payload.descendants.total = 900;
+    payload.descendants.truncated = true;
+    const model = buildHierarchyModel(payload, FOCUS);
+    expect(model.note).toContain('900');
+  });
+
+  it('says deeper subtypes exist when the walk stopped rather than the list', () => {
+    const model = buildHierarchyModel(
+      hierarchyOf([], [entry('A', 1, 'focus')], { bounded: true }),
+      FOCUS
+    );
+    expect(model.note).toContain('Deeper subtypes');
+  });
+
+  it('is empty when the payload is the whole truth', () => {
+    expect(buildHierarchyModel(hierarchyOf([], [entry('A', 1, 'focus')]), FOCUS).note).toBe('');
+  });
+});

+ 52 - 0
__tests__/ui-package.test.ts

@@ -29,6 +29,7 @@ import {
   SearchPalette,
   SymbolView,
   TrailBar,
+  TypeHierarchy,
   createHttpAdapter,
   fileHref,
   flowHref,
@@ -47,6 +48,7 @@ import {
   type WireNodeRef,
   type WireSource,
   type WireStats,
+  type WireHierarchy,
   type WireSymbolPayload,
 } from '../ui/src/index';
 
@@ -132,6 +134,7 @@ const SYMBOL: WireSymbolPayload = {
     ],
   },
   typesUsed: [],
+  hierarchy: null,
   counts: { callers: 1, callees: 1, typesUsed: 0, fanIn: 1, fanOut: 1, members: 0, hub: false },
   tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 },
   outsideIndex: { total: 0, byKind: {}, samples: [] },
@@ -489,6 +492,55 @@ describe('@colbymchenry/codegraph-ui — a host renders the package', () => {
     expect(text.toLowerCase()).toContain('test');
   });
 
+  it('TypeHierarchy draws the fan, its wiring and its fold from a payload alone', async () => {
+    const implementers = Array.from({ length: 14 }, (_, i) => ({
+      id: `impl-${i}`,
+      kind: 'class' as const,
+      name: `Target${i}`,
+      qualifiedName: `Target${i}`,
+      file: `src/targets/target-${i}.ts`,
+      line: 1,
+      endLine: 9,
+      language: 'typescript' as const,
+      test: false,
+      depth: 1,
+      parentId: SYMBOL.node.id,
+      relation: 'implements' as const,
+      // The first one arrived through a resolver rather than a parse, which is
+      // the case the block has to draw differently.
+      synthesized: i === 0,
+      ...(i === 0 ? { via: 'go-implements', registeredAt: 'src/clock.go:11' } : {}),
+      hiddenSubtypes: 0,
+    }));
+    const hierarchy: WireHierarchy = {
+      ancestors: { total: 0, shown: 0, truncated: false, items: [] },
+      descendants: {
+        total: implementers.length,
+        shown: implementers.length,
+        truncated: false,
+        items: implementers,
+      },
+      direct: implementers.length,
+      implementers: implementers.length,
+      bounded: false,
+      polymorphic: true,
+    };
+
+    await render(TypeHierarchy, { hierarchy, focus: SYMBOL.node, onopen: () => {} });
+
+    const text = host.textContent ?? '';
+    // The claim a reader cannot get by counting rows.
+    expect(text).toContain('14 implementations');
+    // The wiring site of the synthesized edge.
+    expect(text).toContain('go-implements');
+    // Twelve rows, then the fold — never a silent truncation.
+    expect(text).toContain('+2 more implementations');
+    expect(text).toContain('Target0');
+    expect(text).not.toContain('Target13');
+    // It draws no network of its own: this component was handed a payload.
+    expect(host.querySelectorAll('path').length).toBe(12);
+  });
+
   it('FlowStrip draws one card per hop from a mock adapter', async () => {
     const { adapter, calls } = mockAdapter();
     setGraphAdapter(adapter);

+ 12 - 0
__tests__/ui-server-api.test.ts

@@ -603,6 +603,18 @@ describe('GET /api/node/<id>', () => {
   });
 });
 
+describe('GET /api/node/<id> — the type hierarchy block', () => {
+  it('is null for a function, so the block costs a plain symbol nothing', async () => {
+    const body = await getJson(`/api/node/${await idOf('hot', 'function')}`);
+    expect(body.hierarchy).toBeNull();
+  });
+
+  it('is null for a class with nothing above or below it', async () => {
+    const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
+    expect(body.hierarchy).toBeNull();
+  });
+});
+
 describe('GET /api/node/<id> — the busiest symbol', () => {
   it('caps the caller list, keeps the true total, and stays fast', async () => {
     const hotId = await idOf('hot', 'function');

+ 21 - 1
docs/design/codegraph-ui-design-spec.md

@@ -317,6 +317,26 @@ Measured on this repository: `execute -> rowToFileRecord` (8 hops) exports 3690x
 16-module map exports 566x1077 and reproduces the on-screen picture exactly (16 boxes, 52 links, 9 layer rules, both band labels;
 with `src/index.ts` selected, 15 links and 4 dimmed boxes, matching the canvas).
 
+### 3.10 Type hierarchy (CG-58)
+Sits in the Symbol view between the header and the source block, above the members outline, for classes, interfaces, structs,
+traits, protocols, enums, unions and type aliases — and only when the type has an `extends`/`implements` edge in some direction.
+A vertical tree: **row height 24px**, names 12.5px mono with kind glyphs, ancestors above at **indent 0** (farthest first, so the
+focus's own parents sit adjacent to it), the focus in `--accent` (600), descendants below indented **22px per level**,
+breadth-first so every direct subtype precedes any indirect one. Connectors are orthogonal 1px `--ink-4` paths — down, then out —
+leaving the parent's glyph centre (indent + 26) and meeting the child's glyph (indent + 16): `extends` solid, `implements` dashed
+`4 3`, a synthesized edge dashed `6 3` in `--ink-3` with a `via <mechanism>` pill carrying its `registeredAt` as the tooltip.
+Rows are buttons, like outline rows; meta is the relation word (11px `--ink-3`) and the file (11px mono, "same file" when it
+matches the focus). Header hint reads "supertypes above · subtypes below". Fold: **more than 12** descendants shows the first 12
+and a `+N more implementations` button (`subclasses` when the folded rows are `extends`, `subtypes` when mixed); truncation or a
+bounded walk adds a note under the tree. A `polymorphic` type (≥ 8 direct implementers) leads with one line — *"A call through X
+dispatches to N implementations — no single static target."* — the only claim in the block a reader cannot get by counting rows.
+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.
+**Overrides** are marked on the members outline (`overrides Base` / `satisfies Base`, 10.5px mono pill before the signature).
+Nothing in the engine emits an `overrides` edge, so this is a NAME match inside a chain the graph already links, and the tooltip
+says so. It is deliberately blind to signatures — an overload set would need type resolution the graph does not have.
+Layout is arithmetic (row height × index): no `ResizeObserver`, no measurement, same payload → same picture.
+
 ## 4. Libraries and versions
 - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
   hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a
@@ -352,7 +372,7 @@ with `src/index.ts` selected, 15 links and 4 dimmed boxes, matching the canvas).
 
 ### 4.1 The component library (`@colbymchenry/codegraph-ui`, CG-61)
 The same `ui/src` tree builds a second way — `svelte-package` into `ui/dist` — so CodeGraph Pro renders the Symbol view, the Flow
-strip and the Map over its own in-process engine reads without forking a component. One tree, because a fork is a second answer to
+strip, the Map and the type-hierarchy tree over its own in-process engine reads without forking a component. One tree, because a fork is a second answer to
 the same question about the same graph.
 - **One seam: `GraphAdapter`** (`ui/src/lib/adapter.ts`) — eleven methods answering the `Wire*` shapes verbatim. `createHttpAdapter()`
   is the loopback JSON API and is what the CLI's viewer runs on; a host implements the same methods and never makes a request.

+ 2 - 1
scripts/check-ui-package.mjs

@@ -130,7 +130,7 @@ for (const [name, entry] of Object.entries(manifest.exports ?? {})) {
   }
 }
 
-// The five components the task names, plus the two seams they are useless
+// The exported screens, plus the two seams they are useless
 // without. Checked in the emitted JS, so a rename in index.ts that misses a
 // component fails here rather than in the Pro app.
 const entry = existsSync(join(DIST, 'index.js'))
@@ -138,6 +138,7 @@ const entry = existsSync(join(DIST, 'index.js'))
   : '';
 for (const name of [
   'SymbolView',
+  'TypeHierarchy',
   'FlowStrip',
   'ArchitectureMap',
   'TrailBar',

+ 15 - 0
src/graph/index.ts

@@ -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 - 0
src/graph/type-hierarchy.ts

@@ -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
src/mcp/tools.ts

@@ -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 - 0
src/ui-server/api/hierarchy.ts

@@ -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
src/ui-server/api/index.ts

@@ -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
src/ui-server/api/node.ts

@@ -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,
   };
 }

+ 39 - 5
ui/README.md

@@ -63,11 +63,17 @@ build so that mistake cannot land twice.
 ```
 
 Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`,
-`FileSourceView`, `EntryPointsView`, `TrailBar`, `SearchPalette`,
-`PalettePanel`, `PaletteRows`, `DriftBanner`, `KindGlyph`, `ExportButtons`,
-`CodegraphUi` — plus every pure model function the screens are built from
-(`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`, `tokensByLine`, …) and
-the `Wire*` types an adapter answers in.
+`FileSourceView`, `EntryPointsView`, `TypeHierarchy`, `TrailBar`,
+`SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`, `KindGlyph`,
+`ExportButtons`, `CodegraphUi` — plus every pure model function the screens are
+built from (`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`,
+`buildHierarchyModel`, `tokensByLine`, …) and the `Wire*` types an adapter
+answers in.
+
+`TypeHierarchy` is the one screen that takes its data as a prop rather than
+asking the adapter: it is part of `SymbolView`'s payload (`/api/node`'s
+`hierarchy`), so a host that already holds a `WireSymbolPayload` can render the
+tree on its own without a second read.
 
 ### The adapter is the only way data arrives
 
@@ -254,6 +260,34 @@ The verdict itself is not computed here or in the server: it is
 `findDynamicBoundaries` in `src/graph/dynamic-boundary-report.ts`, the same
 detector `codegraph_explore` announces boundaries with.
 
+## The type hierarchy
+
+A class, interface, struct, trait or enum carries a `hierarchy` on its
+`/api/node` payload: ancestors up, subtypes down, and the fan an interface call
+dispatches into. `buildHierarchyModel` turns it into a tree whose geometry is
+arithmetic — 24px rows, 22px of indent per descendant level, orthogonal 1px
+connectors computed from those two numbers. Nothing is measured; the same
+payload always draws the same picture.
+
+The details worth knowing before changing it:
+
+- **`extends` is solid, `implements` dashed `4 3`, a synthesized edge dashed
+  `6 3`** with a `via <mechanism>` pill. In Go, `System` satisfies `Clock`
+  without either file naming the other and the edge exists only because the
+  resolver made it — the block says so rather than drawing it like a parse.
+- **Overrides on the members outline are a NAME match**, not an `overrides`
+  edge (nothing in the engine emits one). They are matched against the nearest
+  ancestor that declares the name and are blind to signatures, and the tooltip
+  says which claim was actually checked.
+- **The fold trims the deepest end**, because the walk is breadth-first: a
+  reader looking at an interface gets every direct implementation before any
+  subclass of one appears at all.
+
+The walk is not computed here or in the server: it is `buildTypeHierarchy` in
+`src/graph/type-hierarchy.ts`, whose `countImplementers` is also the number
+`codegraph_explore` prints when it announces an interface dispatch — so "N types
+implement X" is the same N wherever you read it.
+
 ## Live updates
 
 The viewer never polls. `lib/live.svelte.ts` holds one `EventSource` on

+ 29 - 2
ui/src/components/symbol/MembersOutline.svelte

@@ -10,7 +10,7 @@
 -->
 <script lang="ts">
   import KindGlyph from '../KindGlyph.svelte';
-  import type { WireNodeRef } from '../../lib/api';
+  import type { WireNodeRef, WireOverride } from '../../lib/api';
   import type { OutlineRow } from '../../lib/symbol-model';
 
   interface Props {
@@ -21,6 +21,18 @@
   }
 
   let { rows, total, truncated, onopen }: Props = $props();
+
+  /**
+   * The override mark is a NAME match inside a chain the graph links, not an
+   * `overrides` edge — nothing in the engine emits one. The tooltip says so,
+   * because "overrides Base" and "declares the same name as Base" are different
+   * claims and only the second one was checked.
+   */
+  function overrideTitle(o: WireOverride): string {
+    return o.relation === 'implements'
+      ? `Declares a member ${o.baseTypeName} requires — matched by name.`
+      : `Redeclares a member of ${o.baseTypeName} — matched by name.`;
+  }
 </script>
 
 <div class="subh">
@@ -40,7 +52,13 @@
     >
       <KindGlyph kind={row.member.kind} />
       <span class="nm">{row.member.name}</span>
-      <span class="sig">{row.member.signature ?? ''}</span>
+      <span class="sig">
+        {#if row.member.overrides}
+          <span class="ovr" title={overrideTitle(row.member.overrides)}>
+            {row.member.overrides.relation === 'implements' ? 'satisfies' : 'overrides'}
+            {row.member.overrides.baseTypeName}
+          </span>
+        {/if}{row.member.signature ?? ''}</span>
       <span class="cnt">
         {#if row.member.fanIn}← {row.member.fanIn}{/if}{#if row.member.fanIn && row.member.fanOut}&nbsp;
         {/if}{#if row.member.fanOut}→ {row.member.fanOut}{/if}
@@ -101,6 +119,15 @@
     color: var(--ink-3);
   }
 
+  .ovr {
+    margin-right: 6px;
+    padding: 0 4px;
+    border: 1px solid var(--rule-soft);
+    color: var(--ink-2);
+    font: 10.5px var(--mono);
+    white-space: nowrap;
+  }
+
   .sig {
     overflow: hidden;
     color: var(--ink-3);

+ 20 - 3
ui/src/components/symbol/SymbolHeader.svelte

@@ -22,19 +22,36 @@
   interface Props {
     payload: WireSymbolPayload;
     onopen: (node: WireNodeRef) => void;
+    /**
+     * Draw the `extends X` / `implemented by …` chips.
+     *
+     * Off when the type-hierarchy tree is on screen: the tree answers the same
+     * question with more of the truth in it (depth, synthesized edges, the
+     * subtypes that are not direct), and two renderings of one relation in one
+     * column is how a reader ends up trusting neither.
+     */
+    relationChips?: boolean;
   }
 
-  let { payload, onopen }: Props = $props();
+  let { payload, onopen, relationChips = true }: Props = $props();
 
   let node = $derived<WireNodeDetail>(payload.node);
   let tests = $derived(payload.tests);
 
   /** `extends`/`implements` this symbol declares, and the ones declared on it. */
   let supertypes = $derived(
-    payload.outgoing.items.filter((r) => r.edgeKinds.some((k) => k === 'extends' || k === 'implements'))
+    relationChips
+      ? payload.outgoing.items.filter((r) =>
+          r.edgeKinds.some((k) => k === 'extends' || k === 'implements')
+        )
+      : []
   );
   let subtypes = $derived(
-    payload.incoming.items.filter((r) => r.edgeKinds.some((k) => k === 'extends' || k === 'implements'))
+    relationChips
+      ? payload.incoming.items.filter((r) =>
+          r.edgeKinds.some((k) => k === 'extends' || k === 'implements')
+        )
+      : []
   );
 
   const TYPE_CHIP_LIMIT = 12;

+ 271 - 0
ui/src/components/symbol/TypeHierarchy.svelte

@@ -0,0 +1,271 @@
+<!--
+  The type hierarchy: what this type is built on, and what is built on it
+  (design spec §3.10).
+
+  It sits above the members outline because it changes how the outline reads. A
+  method on a class that implements a twelve-member interface is not the same
+  object as a method on a class nothing extends: one is a contract you can break
+  for eleven other files, the other is a private detail. The tree says which
+  before the member list is on screen, and the outline's "overrides X" marks
+  come from the same walk.
+
+  Layout is arithmetic — fixed row height, fixed indent step — so the connectors
+  are drawn from two numbers rather than measured. `implements` is dashed and
+  `extends` solid; a synthesized edge (Go's implicit interface satisfaction) is
+  dashed wider and says where it was wired, exactly as the Flow strip draws a
+  synthesized hop.
+-->
+<script lang="ts">
+  import KindGlyph from '../KindGlyph.svelte';
+  import type { WireHierarchy, WireNodeDetail, WireNodeRef } from '../../lib/api';
+  import {
+    buildHierarchyModel,
+    connectorPath,
+    visibleHierarchy,
+    HIER_ROW_H,
+  } from '../../lib/hierarchy-model';
+
+  interface Props {
+    hierarchy: WireHierarchy;
+    focus: WireNodeDetail;
+    onopen: (node: WireNodeRef) => void;
+  }
+
+  let { hierarchy, focus, onopen }: Props = $props();
+
+  let expanded = $state(false);
+  let model = $derived(buildHierarchyModel(hierarchy, focus));
+  let view = $derived(visibleHierarchy(model, expanded));
+
+  // Reset the fold when the reader navigates to another type — an expanded fan
+  // left open across a navigation would silently apply to a different symbol.
+  $effect(() => {
+    focus.id;
+    expanded = false;
+  });
+
+  let counts = $derived(
+    [
+      hierarchy.ancestors.total > 0
+        ? `${hierarchy.ancestors.total} above`
+        : '',
+      hierarchy.direct > 0 ? `${hierarchy.descendants.total} below` : '',
+    ]
+      .filter(Boolean)
+      .join(' · ')
+  );
+
+  function title(row: (typeof view.rows)[number]): string {
+    const where = `${row.node.file}:${row.node.line}`;
+    if (!row.entry) return `${row.node.qualifiedName} — ${where}`;
+    const wiring = row.entry.synthesized
+      ? ` — matched by ${row.entry.via ?? 'the resolver'}${row.entry.registeredAt ? ` at ${row.entry.registeredAt}` : ''}`
+      : '';
+    return `${row.node.qualifiedName} — ${where}${wiring}`;
+  }
+</script>
+
+<div class="subh">
+  <span>Type hierarchy</span>
+  <span class="n">{counts}</span>
+  <span class="hint">supertypes above · subtypes below</span>
+</div>
+
+{#if model.headline}
+  <p class="headline">{model.headline}</p>
+{/if}
+
+<div class="tree">
+ <div class="canvas" style:height={`${view.height}px`}>
+  <svg class="wires" width="100%" height={view.height} aria-hidden="true">
+    {#each view.connectors as c, i (i)}
+      <path
+        d={connectorPath(c)}
+        class:dashed={c.relation === 'implements'}
+        class:synth={c.synthesized}
+      />
+    {/each}
+  </svg>
+
+  {#each view.rows as row (row.node.id + row.side)}
+    {#if row.side === 'focus'}
+      <div
+        class="row focus"
+        style:top={`${row.index * HIER_ROW_H}px`}
+        style:padding-left={`${row.indent + 18}px`}
+      >
+        <KindGlyph kind={row.node.kind} />
+        <span class="nm">{row.node.name}</span>
+      </div>
+    {:else}
+      <button
+        type="button"
+        class="row"
+        style:top={`${row.index * HIER_ROW_H}px`}
+        style:padding-left={`${row.indent + 18}px`}
+        onclick={() => onopen(row.node)}
+        title={title(row)}
+      >
+        <KindGlyph kind={row.node.kind} />
+        <span class="nm">{row.node.name}</span>
+        <span class="word">{row.word}</span>
+        {#if row.entry?.synthesized}
+          <span class="pill" title={row.entry.registeredAt ?? ''}>
+            via {row.entry.via ?? 'resolver'}
+          </span>
+        {/if}
+        {#if row.entry && row.entry.hiddenSubtypes > 0}
+          <span class="pill">+{row.entry.hiddenSubtypes} below</span>
+        {/if}
+        <span class="file">{row.node.file === focus.file ? 'same file' : row.node.file}</span>
+      </button>
+    {/if}
+  {/each}
+ </div>
+</div>
+
+{#if model.foldFrom !== null}
+  <button type="button" class="fold" onclick={() => (expanded = !expanded)}>
+    {expanded ? 'Fold' : `+${model.foldCount} more ${model.foldNoun}`}
+  </button>
+{/if}
+
+{#if model.note}
+  <div class="note">{model.note}</div>
+{/if}
+
+<style>
+  .subh {
+    display: flex;
+    align-items: baseline;
+    gap: 8px;
+    margin: 18px 0 4px;
+    font-weight: 600;
+    font-size: 13px;
+  }
+
+  .subh .n {
+    color: var(--ink-3);
+    font-weight: 400;
+  }
+
+  .subh .hint {
+    margin-left: auto;
+    color: var(--ink-3);
+    font-size: 11.5px;
+    font-weight: 400;
+  }
+
+  .headline {
+    margin: 0 0 6px;
+    color: var(--ink-2);
+    font-size: 12px;
+  }
+
+  .tree {
+    border-top: 1px solid var(--rule);
+    padding-top: 6px;
+  }
+
+  /* The one positioned box: rows and wires share its origin, so a row's y and
+     the y its connector lands on are the same arithmetic. */
+  .canvas {
+    position: relative;
+  }
+
+  .wires {
+    position: absolute;
+    top: 0;
+    left: 0;
+    overflow: visible;
+    pointer-events: none;
+  }
+
+  .wires path {
+    fill: none;
+    stroke: var(--ink-4);
+    stroke-width: 1;
+  }
+
+  .wires path.dashed {
+    stroke-dasharray: 4 3;
+  }
+
+  .wires path.synth {
+    stroke: var(--ink-3);
+    stroke-dasharray: 6 3;
+  }
+
+  .row {
+    position: absolute;
+    top: 0;
+    right: 0;
+    left: 0;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    height: 24px;
+    padding-right: 4px;
+    border: 1px solid transparent;
+    text-align: left;
+  }
+
+  button.row:hover {
+    background: var(--press);
+  }
+
+  .nm {
+    font: 12.5px var(--mono);
+    white-space: nowrap;
+  }
+
+  .row.focus {
+    color: var(--accent);
+  }
+
+  .row.focus .nm {
+    font-weight: 600;
+  }
+
+  .word {
+    color: var(--ink-3);
+    font-size: 11px;
+    white-space: nowrap;
+  }
+
+  .pill {
+    padding: 0 4px;
+    border: 1px solid var(--rule-soft);
+    color: var(--ink-3);
+    font: 10.5px var(--mono);
+    white-space: nowrap;
+  }
+
+  .file {
+    overflow: hidden;
+    margin-left: auto;
+    padding-left: 10px;
+    color: var(--ink-3);
+    font: 11px var(--mono);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .fold {
+    margin-top: 6px;
+    padding: 3px 8px;
+    border: 1px solid var(--rule-soft);
+    color: var(--ink-2);
+    font-size: 11.5px;
+  }
+
+  .fold:hover {
+    background: var(--press);
+  }
+
+  .note {
+    padding: 8px 0;
+    color: var(--ink-3);
+    font-size: 11.5px;
+  }
+</style>

+ 17 - 0
ui/src/index.ts

@@ -111,6 +111,8 @@ export { default as DriftBanner } from './components/DriftBanner.svelte';
 export { default as KindGlyph } from './components/KindGlyph.svelte';
 /** Copy image / download SVG for a Flow strip or a Map layout. */
 export { default as ExportButtons } from './components/ExportButtons.svelte';
+/** Ancestors up, subtypes down, and the fan an interface call dispatches into. */
+export { default as TypeHierarchy } from './components/symbol/TypeHierarchy.svelte';
 
 /* ------------------------------------------------------------- the state -- */
 
@@ -174,6 +176,21 @@ export type {
   FlowLinkLayout,
 } from './lib/flow-model';
 
+export {
+  buildHierarchyModel,
+  connectorPath,
+  visibleHierarchy,
+  HIER_FOLD_AT,
+  HIER_INDENT,
+  HIER_PORT_X,
+  HIER_ROW_H,
+} from './lib/hierarchy-model';
+export type {
+  HierarchyConnector,
+  HierarchyModel,
+  HierarchyRow,
+} from './lib/hierarchy-model';
+
 export { buildMapLayout, isEdgeVisible, moduleMetaLabel } from './lib/map-model';
 export type {
   MapEdgeLayout,

+ 271 - 0
ui/src/lib/hierarchy-model.ts

@@ -0,0 +1,271 @@
+/**
+ * The type-hierarchy tree, laid out arithmetically (design spec §3.10).
+ *
+ * A tree of types has no natural coordinate that the code supplies — unlike the
+ * callee rail, which is anchored to the line that calls it, and unlike the Map,
+ * which is layered by dependency. So the one rule that keeps it honest is
+ * determinism: rows are a fixed height, indents are a fixed step, and the
+ * connectors are computed from those two numbers. Nothing is measured, nothing
+ * is simulated, and the same payload always draws the same picture.
+ *
+ * Direction is spatial, as everywhere else in the viewer: what this type is
+ * built ON sits above it, what is built on THIS sits below and to the right.
+ * The focus is the one accent row in between.
+ */
+
+import type { WireHierarchy, WireHierarchyNode, WireNodeDetail, WireNodeRef } from './wire';
+
+/** Row height, in px. Fixed, so connector geometry is arithmetic. */
+export const HIER_ROW_H = 24;
+
+/** Indent per descendant level, in px (design spec §3.10). */
+export const HIER_INDENT = 22;
+
+/** Left edge of a row's kind glyph, measured from the row's own indent. */
+export const HIER_GLYPH_X = 18;
+
+/**
+ * Where a row's connector leaves it: the centre of its 16px kind glyph. Lines
+ * hang off the glyph rather than off the row, so the trunk of a fan reads as
+ * coming out of the type rather than out of the margin.
+ */
+export const HIER_PORT_X = HIER_GLYPH_X + 8;
+
+/**
+ * Subtypes drawn before the rest fold away.
+ *
+ * The spec's rule is "≥ 12 descendants fold": twelve rows is about the point
+ * where a fan stops being a list you read and starts being a wall you scroll
+ * past, and the count in the fold's label is the part that actually matters
+ * once you are past it. Folding starts at the row AFTER this one — a fan of
+ * exactly twelve draws twelve rows rather than eleven and a "+0 more".
+ */
+export const HIER_FOLD_AT = 12;
+
+/** One drawn row: a type, or the focus itself. */
+export interface HierarchyRow {
+  /** `null` for the focus row, which is not a hierarchy entry. */
+  entry: WireHierarchyNode | null;
+  node: WireNodeRef;
+  /** Which half of the tree this row belongs to. */
+  side: 'ancestor' | 'focus' | 'descendant';
+  /** Horizontal offset in px. Ancestors and the focus sit at 0. */
+  indent: number;
+  /** Row index from the top of the block, before any fold is applied. */
+  index: number;
+  /** The relation word shown beside the row: "extends", "implements", "". */
+  word: string;
+}
+
+/** One orthogonal connector: down the vertical, then out along the horizontal. */
+export interface HierarchyConnector {
+  /** Row index the segment starts at (the row nearer the top). */
+  fromIndex: number;
+  /** Row index it ends at. */
+  toIndex: number;
+  x: number;
+  /** Where the horizontal run ends. Equal to `x` when the two rows share an indent. */
+  toX: number;
+  relation: 'extends' | 'implements';
+  synthesized: boolean;
+}
+
+/** Everything the block draws, in one pass over the payload. */
+export interface HierarchyModel {
+  rows: HierarchyRow[];
+  connectors: HierarchyConnector[];
+  /** Index of the focus row — the accent one. */
+  focusIndex: number;
+  /** Rows from this index on are behind the fold. `null` when nothing folds. */
+  foldFrom: number | null;
+  /** How many rows the fold hides. */
+  foldCount: number;
+  /** "implementations" / "subclasses" / "subtypes", chosen from what is folded. */
+  foldNoun: string;
+  /** The one-line claim above the tree, or `''` when there is nothing worth claiming. */
+  headline: string;
+  /** A note under the tree when the payload is not the whole truth. */
+  note: string;
+}
+
+/**
+ * Lay the tree out.
+ *
+ * Ancestors are emitted FARTHEST first so the focus's own parents end up
+ * adjacent to it — read top to bottom, the block goes from the most general
+ * type to the most specific. Descendants come out of the payload breadth-first
+ * already, and stay in that order: a fold that trims the tail then trims the
+ * deepest, least relevant end.
+ */
+export function buildHierarchyModel(
+  hierarchy: WireHierarchy,
+  focus: WireNodeDetail
+): HierarchyModel {
+  const rows: HierarchyRow[] = [];
+
+  const ancestors = [...hierarchy.ancestors.items].sort(
+    (a, b) => b.depth - a.depth || a.name.localeCompare(b.name)
+  );
+  for (const entry of ancestors) {
+    rows.push({
+      entry,
+      node: entry,
+      side: 'ancestor',
+      indent: 0,
+      index: rows.length,
+      // The plain relation word, on both halves of the tree. It reads off the
+      // connector: this row is what the row below it extends or implements.
+      // The block's header says which half is which.
+      word: entry.relation,
+    });
+  }
+
+  const focusIndex = rows.length;
+  rows.push({ entry: null, node: focus, side: 'focus', indent: 0, index: focusIndex, word: '' });
+
+  for (const entry of hierarchy.descendants.items) {
+    rows.push({
+      entry,
+      node: entry,
+      side: 'descendant',
+      indent: entry.depth * HIER_INDENT,
+      index: rows.length,
+      word: entry.relation,
+    });
+  }
+
+  // Descendant elbows look their parent up here. Ancestors are deliberately
+  // excluded: a type that is somehow both above and below the focus (a cycle in
+  // generated code) must not make a subtype hang off a supertype row.
+  const byId = new Map(
+    rows.filter((r) => r.side !== 'ancestor').map((row) => [row.node.id, row] as const)
+  );
+  const connectors: HierarchyConnector[] = [];
+
+  // Ancestors: every row at indent 0, so each segment is a plain vertical to
+  // the row below it. The relation is carried by the row's own word as well —
+  // with two direct parents the line alone could not say which is which, and a
+  // connector is structure, not the claim.
+  for (let i = 0; i < focusIndex; i++) {
+    const row = rows[i];
+    const next = rows[i + 1];
+    if (!row?.entry || !next) continue;
+    connectors.push({
+      fromIndex: i,
+      toIndex: i + 1,
+      x: HIER_PORT_X,
+      toX: HIER_PORT_X,
+      relation: row.entry.relation,
+      synthesized: row.entry.synthesized,
+    });
+  }
+
+  // Descendants: an elbow from the parent row's vertical out to this row's glyph.
+  for (const row of rows) {
+    if (row.side !== 'descendant' || !row.entry) continue;
+    const parent = byId.get(row.entry.parentId) ?? rows[focusIndex];
+    if (!parent) continue;
+    connectors.push({
+      fromIndex: parent.index,
+      toIndex: row.index,
+      x: parent.indent + HIER_PORT_X,
+      // Stop two pixels short of the child's glyph, so the line meets the box
+      // instead of running under it.
+      toX: row.indent + HIER_GLYPH_X - 2,
+      relation: row.entry.relation,
+      synthesized: row.entry.synthesized,
+    });
+  }
+
+  const descendantCount = rows.length - focusIndex - 1;
+  const foldFrom = descendantCount > HIER_FOLD_AT ? focusIndex + 1 + HIER_FOLD_AT : null;
+  const folded = foldFrom === null ? [] : rows.slice(foldFrom);
+
+  return {
+    rows,
+    connectors,
+    focusIndex,
+    foldFrom,
+    foldCount: folded.length,
+    foldNoun: nounFor(folded.map((r) => r.entry).filter((e): e is WireHierarchyNode => !!e)),
+    headline: headlineFor(hierarchy, focus),
+    note: noteFor(hierarchy),
+  };
+}
+
+/**
+ * The word for a group of subtypes.
+ *
+ * "implementations" is what the spec asks for and what an interface's fan
+ * actually is; a fan of `extends` edges is a class family, and calling those
+ * implementations would be wrong in every language that has both.
+ */
+function nounFor(entries: readonly WireHierarchyNode[]): string {
+  if (entries.length === 0) return 'subtypes';
+  const implementsCount = entries.filter((e) => e.relation === 'implements').length;
+  if (implementsCount === entries.length) return 'implementations';
+  if (implementsCount === 0) return 'subclasses';
+  return 'subtypes';
+}
+
+/**
+ * The claim above the tree.
+ *
+ * The only claim worth making in a header is the one a reader cannot get by
+ * counting the rows: that a call through this type does not go anywhere in
+ * particular. Everything else the tree says for itself.
+ */
+function headlineFor(hierarchy: WireHierarchy, focus: WireNodeDetail): string {
+  if (hierarchy.polymorphic) {
+    return `A call through ${focus.name} dispatches to ${hierarchy.implementers} implementations — no single static target.`;
+  }
+  return '';
+}
+
+/** What the payload is NOT saying, when it is not saying all of it. */
+function noteFor(hierarchy: WireHierarchy): string {
+  const parts: string[] = [];
+  if (hierarchy.descendants.truncated) {
+    parts.push(
+      `Showing ${hierarchy.descendants.shown} of ${hierarchy.descendants.total} subtypes`
+    );
+  } else if (hierarchy.bounded) {
+    parts.push('Deeper subtypes exist below the levels walked');
+  }
+  if (hierarchy.ancestors.truncated) {
+    parts.push(`${hierarchy.ancestors.total - hierarchy.ancestors.shown} more supertypes above`);
+  }
+  return parts.join(' · ');
+}
+
+/**
+ * What is on screen for a given fold state.
+ *
+ * Connectors are filtered to the rows that are actually drawn, so a folded fan
+ * never leaves a line running off into the fold's own label. The height is
+ * arithmetic — {@link HIER_ROW_H} per row — which is the whole reason this
+ * block needs no `ResizeObserver`.
+ */
+export function visibleHierarchy(
+  model: HierarchyModel,
+  expanded: boolean
+): { rows: HierarchyRow[]; connectors: HierarchyConnector[]; height: number } {
+  const count = expanded || model.foldFrom === null ? model.rows.length : model.foldFrom;
+  return {
+    rows: model.rows.slice(0, count),
+    connectors: model.connectors.filter((c) => c.toIndex < count && c.fromIndex < count),
+    height: count * HIER_ROW_H,
+  };
+}
+
+/**
+ * The SVG path for one connector: down, then out. Two straight runs and a
+ * corner — never a curve, because a hierarchy is not a flow and a Bézier here
+ * would read as one.
+ */
+export function connectorPath(c: HierarchyConnector): string {
+  const y0 = c.fromIndex * HIER_ROW_H + HIER_ROW_H / 2;
+  const y1 = c.toIndex * HIER_ROW_H + HIER_ROW_H / 2;
+  if (c.toX <= c.x) return `M ${c.x} ${y0} L ${c.x} ${y1}`;
+  return `M ${c.x} ${y0} L ${c.x} ${y1} L ${c.toX} ${y1}`;
+}

+ 44 - 0
ui/src/lib/wire.ts

@@ -56,6 +56,48 @@ export interface WireMember extends WireNodeRef {
   depth: number;
   fanIn: number;
   fanOut: number;
+  /** This member redeclares one an ancestor type declares. */
+  overrides?: WireOverride;
+}
+
+/** How a subtype is tied to the type above it. */
+export type WireHierarchyRelation = 'extends' | 'implements';
+
+/** A member that redeclares an ancestor's — a name match inside a linked chain. */
+export interface WireOverride {
+  baseId: string;
+  baseTypeId: string;
+  baseTypeName: string;
+  relation: WireHierarchyRelation;
+}
+
+/** One type in the hierarchy tree, and the single edge that puts it 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: WireHierarchyRelation;
+  /** Synthesized rather than parsed (Go's implicit interface satisfaction). */
+  synthesized: boolean;
+  via?: string;
+  registeredAt?: string;
+  /** Direct subtypes of this row that are NOT in the payload. */
+  hiddenSubtypes: number;
+}
+
+/** Ancestors up, subtypes down, and the fan an interface call dispatches into. */
+export interface WireHierarchy {
+  ancestors: WireList<WireHierarchyNode>;
+  descendants: WireList<WireHierarchyNode>;
+  /** True number of DIRECT subtypes, whatever `descendants` was capped to. */
+  direct: number;
+  /** Of `direct`, the ones tied by `implements`. */
+  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;
 }
 
 export interface WireEdge {
@@ -124,6 +166,8 @@ export interface WireSymbolPayload {
   /** Outermost first: file, then module/class, then the symbol's own parent. */
   ancestors: WireNodeRef[];
   members: WireList<WireMember>;
+  /** The type-hierarchy block. `null` for anything that is not a type, and for a type with none. */
+  hierarchy: WireHierarchy | null;
   incoming: WireList<WireRelation>;
   outgoing: WireList<WireRelation>;
   typesUsed: WireRelation[];

+ 6 - 1
ui/src/views/SymbolView.svelte

@@ -20,6 +20,7 @@
   import Connectors from '../components/symbol/Connectors.svelte';
   import BlastStrip from '../components/symbol/BlastStrip.svelte';
   import MembersOutline from '../components/symbol/MembersOutline.svelte';
+  import TypeHierarchy from '../components/symbol/TypeHierarchy.svelte';
   import SourceBlock from '../components/symbol/SourceBlock.svelte';
   import SymbolHeader from '../components/symbol/SymbolHeader.svelte';
   import DriftBanner from '../components/DriftBanner.svelte';
@@ -560,7 +561,7 @@
         <Connectors {connectors} width={overlay.width} height={overlay.height} />
 
         <section class="center" bind:this={centerEl}>
-          <SymbolHeader {payload} onopen={open} />
+          <SymbolHeader {payload} onopen={open} relationChips={!payload.hierarchy} />
 
           {#if payload.drift}
             <div class="banner">
@@ -578,6 +579,10 @@
             </div>
           {/if}
 
+          {#if payload.hierarchy}
+            <TypeHierarchy hierarchy={payload.hierarchy} focus={payload.node} onopen={open} />
+          {/if}
+
           {#if codeBlock}
             <SourceBlock
               block={codeBlock}