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
@@ -1961,6 +1961,32 @@ export class QueryBuilder {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes with the most DISTINCT dependents, most first.
|
||||
*
|
||||
* "Distinct" is the difference that matters: a helper called forty times from
|
||||
* one function has a fan-in of 40 but exactly one dependent. This counts the
|
||||
* second thing — the number a reader means by "N callers" — so the top of
|
||||
* this list is the set of symbols a change actually radiates furthest from.
|
||||
*
|
||||
* `contains` is excluded because it is structure, not dependency: counting it
|
||||
* would rank every file and class above the code they hold.
|
||||
*/
|
||||
getTopDependedOn(limit: number): Array<{ nodeId: string; dependents: number }> {
|
||||
if (limit <= 0) return [];
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT target AS nodeId, COUNT(DISTINCT source) AS dependents
|
||||
FROM edges
|
||||
WHERE kind != 'contains' AND source != target
|
||||
GROUP BY target
|
||||
ORDER BY dependents DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(limit) as Array<{ nodeId: string; dependents: number }>;
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* References recorded against a symbol that never resolved to a node — the
|
||||
* calls and type mentions that leave the index (a third-party package, a
|
||||
|
||||
@@ -1368,6 +1368,15 @@ export class CodeGraph {
|
||||
return this.queries.countOutgoingEdges(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* The symbols with the most distinct dependents, most first — the index's
|
||||
* hubs. Distinct dependents, not edges: a helper called forty times from one
|
||||
* function has one dependent, and it is dependents a blast radius grows from.
|
||||
*/
|
||||
getTopDependedOn(limit: number): Array<{ nodeId: string; dependents: number }> {
|
||||
return this.queries.getTopDependedOn(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* References from a symbol that never resolved to an indexed node — the
|
||||
* calls and type mentions that leave the index. Lets a reader account for
|
||||
|
||||
@@ -26,6 +26,7 @@ import { isTestFile } from '../../search/query-utils';
|
||||
import { notFound } from './respond';
|
||||
import { findIndexedFile, hasDriftedOnDisk } from './source';
|
||||
import {
|
||||
BLAST_DEPTH,
|
||||
CALLER_EDGE_KINDS,
|
||||
CONTAINER_KINDS,
|
||||
HUB_THRESHOLD,
|
||||
@@ -46,15 +47,23 @@ import {
|
||||
type WireNodeRef,
|
||||
} from './wire';
|
||||
|
||||
/** Depth the blast-radius summary walks. Matches `codegraph_explore`'s claim. */
|
||||
const BLAST_DEPTH = 3;
|
||||
|
||||
/** A member row in the focal symbol's outline, with its place in the tree. */
|
||||
export interface WireMember extends WireNodeRef {
|
||||
/** The container this member belongs to — the focal node, or one of its children. */
|
||||
parentId: string;
|
||||
/** 1 = direct member, 2 = a member of a member (a class's method inside a file). */
|
||||
depth: number;
|
||||
/**
|
||||
* Edges in and out of this member — the outline's `← in → out` columns.
|
||||
*
|
||||
* A container's own fan-out is usually zero (a class calls nothing; its
|
||||
* methods do), so without these an outline of a 700-line class says nothing
|
||||
* about which member is load-bearing and which is a getter. Edge counts, not
|
||||
* distinct counterparts: the column is a weight, and it sits beside a
|
||||
* signature rather than beside a caller list it could contradict.
|
||||
*/
|
||||
fanIn: number;
|
||||
fanOut: number;
|
||||
}
|
||||
|
||||
export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): unknown {
|
||||
@@ -234,11 +243,21 @@ function buildMembers(
|
||||
const all = [...direct, ...nested].sort(
|
||||
(a, b) => a.node.startLine - b.node.startLine || a.node.name.localeCompare(b.node.name)
|
||||
);
|
||||
const shown = all.slice(0, MAX_OUTLINE_NODES);
|
||||
|
||||
// Two queries for the whole outline, not two per row: a file with 400
|
||||
// symbols would otherwise be 800 lookups behind one screen.
|
||||
const memberIds = shown.map((entry) => entry.node.id);
|
||||
const fanIn = cg.getFanIn(memberIds);
|
||||
const fanOut = cg.getFanOut(memberIds);
|
||||
|
||||
return {
|
||||
items: all.slice(0, MAX_OUTLINE_NODES).map((entry) => ({
|
||||
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,
|
||||
})),
|
||||
total: all.length,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,86 @@
|
||||
|
||||
import * as path from 'path';
|
||||
import type { CodeGraph } from '../../index';
|
||||
import { HUB_THRESHOLD, UNCERTAIN_BELOW } from './wire';
|
||||
import { BLAST_DEPTH, HUB_THRESHOLD, UNCERTAIN_BELOW } from './wire';
|
||||
|
||||
/**
|
||||
* How many of the index's most-depended-on symbols the blast scale measures.
|
||||
*
|
||||
* The Symbol view's blast bar is a comparison — "wide for this repo, or
|
||||
* narrow?" — so it needs a denominator, and the honest one is the widest
|
||||
* radius in the index. Measuring all of them means a depth-3 traversal per
|
||||
* symbol, which on a large repo is minutes. Measuring the most-depended-on
|
||||
* ones costs 24 traversals and finds the widest radius in practice: a radius
|
||||
* is grown by dependents, so the symbol with the widest one is very nearly
|
||||
* always near the top of that list.
|
||||
*
|
||||
* "Very nearly always" is not "always" — a symbol with three dependents that
|
||||
* each have three hundred can beat them — so the scale is a floor, not a
|
||||
* claim: {@link blastScaleFor} reports it as `sampled`, and the viewer raises
|
||||
* it whenever the symbol on screen exceeds it rather than drawing past 100%.
|
||||
*/
|
||||
const BLAST_SCALE_SAMPLE = 24;
|
||||
|
||||
export interface WireBlastScale {
|
||||
/** Most distinct dependents any symbol in the index has. Exact — one query. */
|
||||
maxDirect: number;
|
||||
/** Widest depth-{@link BLAST_DEPTH} radius found across the sampled symbols. */
|
||||
maxWithinHops: number;
|
||||
hops: number;
|
||||
/** How many symbols were measured for `maxWithinHops`. */
|
||||
sampled: number;
|
||||
/** True whenever `maxWithinHops` came from a sample rather than every symbol. */
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The denominator for the Symbol view's blast bar.
|
||||
*
|
||||
* Computed once per process and cached against the index's build stamp: it is
|
||||
* a property of the whole graph, every Symbol view needs it, and re-deriving it
|
||||
* per request would put 24 traversals in front of every screen.
|
||||
*/
|
||||
let cachedScale: { key: string; value: WireBlastScale } | null = null;
|
||||
|
||||
export function blastScaleFor(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
edgeCount: number
|
||||
): WireBlastScale {
|
||||
// Keyed on the project AND the index's stamp AND its edge count, so a
|
||||
// re-index (or a sync that only moved edges) invalidates it and two indexes
|
||||
// opened by one process cannot share a denominator. A stale one would
|
||||
// silently rescale every bar in the app.
|
||||
const key = `${projectRoot}\u0000${cg.getLastIndexedAt() ?? 0}:${edgeCount}`;
|
||||
if (cachedScale?.key === key) return cachedScale.value;
|
||||
|
||||
const top = cg.getTopDependedOn(BLAST_SCALE_SAMPLE);
|
||||
let maxWithinHops = 0;
|
||||
for (const candidate of top) {
|
||||
try {
|
||||
const subgraph = cg.getImpactRadius(candidate.nodeId, BLAST_DEPTH);
|
||||
maxWithinHops = Math.max(maxWithinHops, subgraph.nodes.size - 1);
|
||||
} catch {
|
||||
// A candidate that cannot be traversed (a node the edge table names but
|
||||
// the node table lost) narrows the sample; it must not fail the screen.
|
||||
}
|
||||
}
|
||||
|
||||
const value: WireBlastScale = {
|
||||
maxDirect: top[0]?.dependents ?? 0,
|
||||
maxWithinHops,
|
||||
hops: BLAST_DEPTH,
|
||||
sampled: top.length,
|
||||
estimated: true,
|
||||
};
|
||||
cachedScale = { key, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Drop the memoised scale — for tests, which build a fresh index per case. */
|
||||
export function resetBlastScaleCache(): void {
|
||||
cachedScale = null;
|
||||
}
|
||||
|
||||
export function buildStats(cg: CodeGraph, projectRoot: string): unknown {
|
||||
const stats = cg.getStats();
|
||||
@@ -59,5 +138,12 @@ export function buildStats(cg: CodeGraph, projectRoot: string): unknown {
|
||||
* second copy of the same numbers.
|
||||
*/
|
||||
thresholds: { hub: HUB_THRESHOLD, uncertainBelow: UNCERTAIN_BELOW },
|
||||
/**
|
||||
* The denominator the Symbol view's blast bar is drawn against, so one
|
||||
* symbol's radius reads as wide or narrow *for this repo* instead of as a
|
||||
* bare number. See {@link blastScaleFor} for what "sampled" costs and
|
||||
* concedes.
|
||||
*/
|
||||
blastScale: blastScaleFor(cg, projectRoot, stats.edgeCount),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@ export const MAX_EDGES_PER_GROUP = 40;
|
||||
/** Test files named in a node's test-caller summary (explore uses the same shape). */
|
||||
export const MAX_TEST_FILES = 6;
|
||||
|
||||
/**
|
||||
* Dependency hops the blast-radius summary walks. Matches the depth
|
||||
* `codegraph_explore` claims when it says "within 3 hops".
|
||||
*/
|
||||
export const BLAST_DEPTH = 3;
|
||||
|
||||
/** Caller hops walked looking for a test. Mirrors `codegraph_explore`'s "tests:" line. */
|
||||
export const TEST_CALLER_HOPS = 3;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user