feat(ui): the type hierarchy — what a type is built on, and what dispatches through it (CG-58)

A vertical tree above the members outline for classes, interfaces, structs,
traits, protocols, enums, unions and type aliases: ancestors above (the whole
chain, not just the direct parent), the focus in accent, subtypes below indented
per level. `extends` draws solid, `implements` dashed; a synthesized edge — Go's
implicit interface satisfaction — draws dashed wider and carries the site it was
wired at, so a relation the resolver inferred never reads like one the source
wrote down. For an interface the fan below IS the set of runtime targets a call
can land on, and a type with eight or more implementers leads with that in a
sentence. Members that redeclare an ancestor's are marked in the outline.

The walk lives in `src/graph/type-hierarchy.ts`, following CG-50/CG-51: shared
computation in `src/graph/`, presentation in the caller. Its `countImplementers`
is now also what `ToolHandler.buildPolymorphicBoundaries` counts with, so "N
types implement X" is the same N whether an agent reads it or a person does.
`/api/node` carries the block as `hierarchy` rather than a second endpoint —
it is part of the Symbol view's first paint, and gated to types, so a function
costs one kind test.

Layout is arithmetic (24px rows, 22px indent, orthogonal connectors computed
from the two): no ResizeObserver, same payload → same picture. The header's
`extends X` / `implemented by …` chips are suppressed while the tree is on
screen — two renderings of one relation in one column is how a reader ends up
trusting neither.

`TypeHierarchy` is exported from `@colbymchenry/codegraph-ui` and takes its data
as a prop, so a host holding a `WireSymbolPayload` renders it without a second
read.
This commit is contained in:
Colby McHenry
2026-08-27 07:14:55 -05:00
parent c15413f200
commit 2a0c6dc58f
21 changed files with 2100 additions and 28 deletions
+622
View File
@@ -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
View File
@@ -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
View File
@@ -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');