feat(ui): the Symbol view — callers, gutter-ported source, line-anchored callee rail (CG-44)
The core screen of `codegraph ui`: who calls a symbol on the left, its verbatim body in the middle with a port on every line that has an outgoing edge, and what it calls on the right — each callee row placed beside the line that makes the call, with a hairline connector between them. The callee rail is the part that is not a list. A row wants to sit at the centre of its first call-site line and is pushed down only when that would collide with the row above, so the rail keeps source order; the connector still runs to the real line, so the displacement is visible rather than silent. Positions come from measuring the laid-out DOM, so they are recomputed on resize, on font load and whenever a fold opens. Honesty is carried in the drawing, not in a footnote: a filled port means the resolver matched something on that line and a hollow one means it only guessed; uncertain connectors are dashed and their targets fold away behind their count; synthesized edges are dashed differently and tagged with the mechanism that made them; references that leave the index are text with a soft underline rather than links to nowhere, and they are counted. Long bodies keep their head plus a window round every call site — windowed on graph edges only, since a function calling `console.log` two hundred times would otherwise window round every line and buy nothing. Containers over 80 lines show a members outline with per-member fan-in/fan-out instead of 700 lines of braces. Two small additions to the read-only API this needed: * `/api/node` gives every outline member its own fanIn/fanOut (two batched queries for the whole outline). A class's own fan-out is nearly always zero because its methods do the calling, so without these the outline cannot say which member carries weight. * `/api/stats` gains `blastScale` — the denominator the blast bar is drawn against, so one symbol's radius reads as wide or narrow *for this repo*. It is measured across the index's 24 most-depended-on symbols (found with a new `getTopDependedOn`, distinct dependents rather than edges), memoised against the index stamp, and reported as sampled; a symbol wider than the sample becomes the scale instead of overflowing the track. Verified against a real index in a real browser: parity with the prototype on `CodeGraph.sync` (259 lines, 27 callee rows, no overlaps), `GraphTraverser` (20-member outline), a 773-line function (26 windows, 78 connectors), light and dark, hover linking in both directions, keyboard-only navigation, and reflow on resize and on fold toggles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e7288ffa36
commit
5cecaabfc2
@@ -289,6 +289,33 @@ describe('GET /api/stats', () => {
|
||||
// The thresholds travel with the data so the viewer's copy cannot drift.
|
||||
expect(body.thresholds).toEqual({ hub: 40, uncertainBelow: 0.6 });
|
||||
});
|
||||
|
||||
it('reports a blast-radius scale the widest symbol in the index reaches', async () => {
|
||||
const body = await getJson('/api/stats');
|
||||
const scale = body.blastScale;
|
||||
|
||||
// `hot` is called by 500 distinct functions and nothing else in the fixture
|
||||
// comes close, so the exact maximum is knowable here.
|
||||
expect(scale.maxDirect).toBe(500);
|
||||
// Its radius is at least its own callers; the sample is capped, so the
|
||||
// count is a floor and the flag says so rather than claiming exhaustive.
|
||||
expect(scale.maxWithinHops).toBeGreaterThanOrEqual(500);
|
||||
expect(scale.hops).toBe(3);
|
||||
expect(scale.sampled).toBeGreaterThan(0);
|
||||
expect(scale.sampled).toBeLessThanOrEqual(24);
|
||||
expect(scale.estimated).toBe(true);
|
||||
});
|
||||
|
||||
it('serves the scale from cache — the second call does not re-traverse', async () => {
|
||||
const first = await getJson('/api/stats');
|
||||
const started = Date.now();
|
||||
const second = await getJson('/api/stats');
|
||||
expect(second.blastScale).toEqual(first.blastScale);
|
||||
// 24 depth-3 traversals over a 500-caller graph are not free; a cached
|
||||
// answer is. The margin is wide because this is a smoke test for the
|
||||
// memo existing at all, not a benchmark.
|
||||
expect(Date.now() - started).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/search', () => {
|
||||
@@ -385,6 +412,28 @@ describe('GET /api/node/<id>', () => {
|
||||
expect(body.members.total).toBe(body.members.shown);
|
||||
});
|
||||
|
||||
it('gives every member its own fan-in and fan-out — the outline is the body', async () => {
|
||||
const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
|
||||
const byName = new Map(body.members.items.map((m: any) => [m.name, m]));
|
||||
|
||||
for (const member of body.members.items) {
|
||||
expect(typeof member.fanIn).toBe('number');
|
||||
expect(typeof member.fanOut).toBe('number');
|
||||
expect(member.fanIn).toBeGreaterThanOrEqual(0);
|
||||
expect(member.fanOut).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
|
||||
// `Service.load` calls both, and `Cache` contains them: at least the
|
||||
// containment edge plus one call each. Without these numbers a 700-line
|
||||
// class's outline cannot say which member carries weight.
|
||||
expect((byName.get('read') as any).fanIn).toBeGreaterThanOrEqual(2);
|
||||
expect((byName.get('write') as any).fanIn).toBeGreaterThanOrEqual(2);
|
||||
// The class itself calls nothing — its methods do, which is exactly why
|
||||
// the per-member counts have to come from the members.
|
||||
expect(body.counts.callees).toBe(0);
|
||||
expect(body.members.items.some((m: any) => m.fanOut > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('nests a file outline one level deeper, so a class shows its methods', async () => {
|
||||
const body = await getJson(`/api/node/${await idOf('cache.ts', 'file')}`);
|
||||
const byDepth = new Map<number, string[]>();
|
||||
|
||||
Reference in New Issue
Block a user