diff --git a/__tests__/ui-server-api.test.ts b/__tests__/ui-server-api.test.ts index 207e9ea..225af35 100644 --- a/__tests__/ui-server-api.test.ts +++ b/__tests__/ui-server-api.test.ts @@ -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/', () => { 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(); diff --git a/__tests__/ui-symbol-model.test.ts b/__tests__/ui-symbol-model.test.ts new file mode 100644 index 0000000..10f8060 --- /dev/null +++ b/__tests__/ui-symbol-model.test.ts @@ -0,0 +1,545 @@ +/** + * The Symbol view's decisions, without a browser (CG-44). + * + * Everything the screen does that could be wrong rather than merely ugly lives + * in `ui/src/lib/` as plain functions over the `/api/node` payload: which lines + * survive into a windowed body, which identifier a call-site link lands on, + * which callers fold away, which reference is a guess. Those are the parts + * worth pinning — the geometry that needs a real layout (row placement, + * connector paths) is verified against a running viewer instead. + */ + +import { describe, it, expect } from 'vitest'; +import { + assignRefs, + buildCalleeRail, + buildCallerRail, + buildCodeBlock, + buildOutline, + edgeWord, + graphCallLines, + kindPhrase, + refsByLine, + showsBody, + synthesizedBy, + FULL_BODY_LINES, + HEAD_LINES, + type LineRef, +} from '../ui/src/lib/symbol-model'; +import { newLexState, tokenize } from '../ui/src/lib/highlight'; +import type { WireRelation, WireSymbolPayload } from '../ui/src/lib/api'; + +/* ------------------------------------------------------------- fixtures -- */ + +function nodeRef(over: Partial = {}): any { + return { + id: 'method:a', + kind: 'method', + name: 'load', + qualifiedName: 'Service::load', + file: 'src/service.ts', + line: 10, + endLine: 20, + language: 'typescript', + test: false, + ...over, + }; +} + +function relation(over: Partial & { node?: any } = {}): WireRelation { + const { node, ...rest } = over; + const lines = rest.lines ?? [12]; + return { + edgeKinds: ['calls'], + edges: lines.map((line) => ({ kind: 'calls' as const, line, col: 4 })), + edgeCount: lines.length, + lines, + confidence: 0.9, + uncertain: false, + synthesized: false, + ...rest, + node: nodeRef(node), + } as WireRelation; +} + +function payload(over: Partial = {}): WireSymbolPayload { + return { + node: { ...nodeRef(), startColumn: 2, endColumn: 3, lines: 11 }, + ancestors: [], + members: { total: 0, shown: 0, truncated: false, items: [] }, + incoming: { total: 0, shown: 0, truncated: false, items: [] }, + outgoing: { total: 0, shown: 0, truncated: false, items: [] }, + typesUsed: [], + counts: { callers: 0, callees: 0, typesUsed: 0, fanIn: 0, fanOut: 0, members: 0, hub: false }, + tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 }, + outsideIndex: { total: 0, byKind: {}, samples: [] }, + blast: null, + drift: false, + ...over, + } as WireSymbolPayload; +} + +const body = (count: number, from = 1): string[] => + Array.from({ length: count }, (_, i) => `line ${from + i}`); + +/* ---------------------------------------------------------------- words -- */ + +describe('edge wording', () => { + it('names the relationships that are not a plain call, and leaves calls unlabelled', () => { + // Labelling every row "calls" is noise that hides the rows where the + // relationship is something else. + expect(edgeWord({ kind: 'calls' })).toBe(''); + expect(edgeWord({ kind: 'instantiates' })).toBe('creates'); + expect(edgeWord({ kind: 'references' })).toBe('uses type'); + expect(edgeWord({ kind: 'references', valueRef: true })).toBe('passes as value'); + expect(edgeWord({ kind: 'implements' })).toBe('implements'); + }); + + it('names the synthesizer behind a heuristic edge, and nothing for a parsed one', () => { + const parsed = relation(); + expect(synthesizedBy(parsed)).toBeNull(); + + const synthesized = { + ...parsed, + synthesized: true, + edges: [{ kind: 'calls', line: 12, provenance: 'heuristic', synthesizedBy: 'react-render' }], + } as WireRelation; + expect(synthesizedBy(synthesized)).toBe('react-render'); + }); + + it('falls back to a truthful placeholder when the synthesizer did not name itself', () => { + const synthesized = { + ...relation(), + synthesized: true, + edges: [{ kind: 'calls', line: 12, provenance: 'heuristic' }], + } as WireRelation; + expect(synthesizedBy(synthesized)).toBe('synthesized'); + }); +}); + +describe('kindPhrase', () => { + it('reads the modifiers a reader acts on, and stays silent about the default ones', () => { + expect(kindPhrase({ kind: 'method', async: true })).toBe('method · async'); + expect(kindPhrase({ kind: 'type_alias' })).toBe('type'); + expect(kindPhrase({ kind: 'method', visibility: 'public' })).toBe('method'); + expect(kindPhrase({ kind: 'method', static: true, visibility: 'private' })).toBe( + 'method · static · private' + ); + }); +}); + +/* -------------------------------------------------------------- windows -- */ + +describe('buildCodeBlock', () => { + it('shows a body of 260 lines or fewer whole, with no gaps', () => { + const block = buildCodeBlock(1, body(FULL_BODY_LINES), [5, 200]); + expect(block.whole).toBe(true); + expect(block.windows).toHaveLength(1); + expect(block.windows[0]?.start).toBe(1); + expect(block.windows[0]?.lines).toHaveLength(FULL_BODY_LINES); + expect(block.gapsAfter).toEqual([]); + expect(block.tailGap).toBe(0); + }); + + it('keeps the head plus a window round every call site once the body is longer', () => { + // One call, far past the head: head + one ±4 window, one gap between them. + const block = buildCodeBlock(1, body(400), [300]); + expect(block.whole).toBe(false); + expect(block.windows).toHaveLength(2); + expect(block.windows[0]).toMatchObject({ start: 1 }); + expect(block.windows[0]?.lines).toHaveLength(HEAD_LINES); + expect(block.windows[1]?.start).toBe(296); + expect(block.windows[1]?.lines).toHaveLength(9); + expect(block.gapsAfter).toEqual([215]); + // 400 − 304 lines never reached the screen, and the block says how many. + expect(block.tailGap).toBe(96); + }); + + it('merges windows that all but touch, rather than drawing a one-line gap', () => { + const block = buildCodeBlock(1, body(400), [300, 310]); + // 296–304 and 306–314 are two apart: one window, no gap row between them. + expect(block.windows).toHaveLength(2); + expect(block.windows[1]).toMatchObject({ start: 296 }); + expect(block.windows[1]?.lines).toHaveLength(19); + expect(block.gapsAfter).toEqual([215]); + }); + + it('ignores call sites already inside the head', () => { + const block = buildCodeBlock(1, body(400), [3, 40]); + expect(block.windows).toHaveLength(1); + expect(block.windows[0]?.lines).toHaveLength(HEAD_LINES); + expect(block.tailGap).toBe(320); + }); + + it("numbers windows from the symbol's real first line, not from one", () => { + const block = buildCodeBlock(778, body(400, 778), [1000]); + expect(block.windows[0]?.start).toBe(778); + expect(block.windows[1]?.start).toBe(996); + expect(block.windows[1]?.lines[0]).toBe('line 996'); + }); + + it('never runs a window past the end of the body', () => { + const block = buildCodeBlock(1, body(400), [399]); + const last = block.windows[block.windows.length - 1]; + expect((last?.start ?? 0) + (last?.lines.length ?? 0) - 1).toBe(400); + expect(block.tailGap).toBe(0); + }); + + it('windows only on edges that reach the graph, not on unresolved references', () => { + // A function calling `console.log` 200 times would otherwise window around + // nearly every line, and the head-plus-windows rule would buy nothing. + const view = payload({ + outgoing: { total: 1, shown: 1, truncated: false, items: [relation({ lines: [300] })] }, + outsideIndex: { + total: 1, + byKind: { calls: 1 }, + samples: [{ name: 'console.log', kind: 'calls', line: 350, col: 4 }], + }, + }); + expect(graphCallLines(view)).toEqual([300]); + expect(refsByLine(view).has(350)).toBe(true); + }); +}); + +/* ----------------------------------------------------------------- refs -- */ + +describe('assignRefs', () => { + const toks = (line: string) => tokenize(line, newLexState(), 'typescript'); + const ref = (over: Partial): LineRef => ({ + ident: 'withLock', + col: null, + targetId: 'method:x', + uncertain: false, + outside: false, + title: '', + ...over, + }); + + it('marks the callee, not the receiver the column actually points at', () => { + // The recorded column is the start of the calling EXPRESSION, so an exact + // hit is the exception: `this` sits at column 11, `withLock` at 27. + const line = ' return this.indexMutex.withLock(async () => {'; + const tokens = toks(line); + const claimed = assignRefs(tokens, [ref({ col: 11 })]); + const [index] = [...claimed.keys()]; + expect(tokens[index as number]?.text).toBe('withLock'); + }); + + it('prefers the token the column lands inside when there is one', () => { + const line = 'render(); render();'; + const tokens = toks(line); + const second = line.lastIndexOf('render'); + const claimed = assignRefs(tokens, [ref({ ident: 'render', col: second })]); + const [index] = [...claimed.keys()]; + expect(tokens[index as number]?.col).toBe(second); + }); + + it('gives two refs to the same name two different tokens', () => { + const tokens = toks('render(); render();'); + const claimed = assignRefs(tokens, [ + ref({ ident: 'render', col: null, targetId: 'a' }), + ref({ ident: 'render', col: null, targetId: 'b' }), + ]); + expect(claimed.size).toBe(2); + expect(new Set([...claimed.values()].map((r) => r.targetId))).toEqual(new Set(['a', 'b'])); + }); + + it('claims nothing when the identifier is not on the line', () => { + // Better a missing link than an accent underline on the wrong word. + expect(assignRefs(toks('return 1;'), [ref({ ident: 'nowhere' })]).size).toBe(0); + }); + + it('never marks a keyword, a string or a comment as a call site', () => { + const tokens = toks('// call render here'); + expect(assignRefs(tokens, [ref({ ident: 'render' })]).size).toBe(0); + expect(assignRefs(toks('const s = "render";'), [ref({ ident: 'render' })]).size).toBe(0); + }); +}); + +describe('refsByLine', () => { + it('carries type references too, so a line that only names a type gets its port', () => { + const view = payload({ + typesUsed: [relation({ node: { id: 'interface:c', kind: 'interface', name: 'Config' }, lines: [11] })], + }); + const refs = refsByLine(view); + expect(refs.get(11)?.[0]).toMatchObject({ ident: 'Config', outside: false }); + }); + + it('uses the last segment of a qualified name — that is what is in the source', () => { + const view = payload({ + outgoing: { + total: 1, + shown: 1, + truncated: false, + items: [relation({ node: { id: 'm:1', name: 'Cache.read' }, lines: [12] })], + }, + }); + expect(refsByLine(view).get(12)?.[0]?.ident).toBe('read'); + }); + + it('drops an unresolved "name" that is not an identifier at all', () => { + // The resolver's samples are raw bookkeeping; a captured arrow function + // cannot be found in the line, and searching for it would claim the wrong + // token. + const view = payload({ + outsideIndex: { + total: 2, + byKind: { calls: 2 }, + samples: [ + { name: '(() => {\n return t', kind: 'calls', line: 12, col: 0 }, + { name: 'this.db', kind: 'function_ref', line: 13, col: 4 }, + ], + }, + }); + const refs = refsByLine(view); + expect(refs.has(12)).toBe(false); + // `this.db` reduces to `db`, which IS in the line — kept, and marked as + // outside the index so it renders as text rather than a link. + expect(refs.get(13)?.[0]).toMatchObject({ ident: 'db', outside: true, targetId: null }); + }); +}); + +/* ---------------------------------------------------------------- rails -- */ + +describe('buildCallerRail', () => { + const caller = (over: { id: string; file: string; test?: boolean; uncertain?: boolean; edges?: number }) => + ({ + ...relation({ lines: [4657] }), + node: { + ...nodeRef({ id: over.id, file: over.file, name: over.id }), + test: over.test ?? false, + }, + edgeCount: over.edges ?? 1, + uncertain: over.uncertain ?? false, + }) as WireRelation; + + it("puts the symbol's own file first and groups the rest by path", () => { + const view = payload({ + node: { ...nodeRef({ file: 'src/service.ts' }), startColumn: 0, endColumn: 0, lines: 11 }, + incoming: { + total: 3, + shown: 3, + truncated: false, + items: [ + caller({ id: 'z', file: 'src/z.ts' }), + caller({ id: 'a', file: 'src/a.ts' }), + caller({ id: 'own', file: 'src/service.ts' }), + ], + }, + }); + const rail = buildCallerRail(view); + expect(rail.groups.map((g) => g.file)).toEqual(['src/service.ts', 'src/a.ts', 'src/z.ts']); + expect(rail.groups[0]?.same).toBe(true); + expect(rail.groups[1]?.same).toBe(false); + }); + + it('folds test callers away with their call and file counts intact', () => { + const view = payload({ + incoming: { + total: 3, + shown: 3, + truncated: false, + items: [ + caller({ id: 'prod', file: 'src/a.ts' }), + caller({ id: 't1', file: '__tests__/a.test.ts', test: true, edges: 4 }), + caller({ id: 't2', file: '__tests__/b.test.ts', test: true, edges: 2 }), + ], + }, + }); + const rail = buildCallerRail(view); + expect(rail.groups).toHaveLength(1); + expect(rail.tests.rows).toHaveLength(2); + expect(rail.tests.calls).toBe(6); + expect(rail.tests.files).toEqual(['__tests__/a.test.ts', '__tests__/b.test.ts']); + // The header count stays the real one — nothing is silently dropped. + expect(rail.total).toBe(3); + }); + + it('folds an uncertain test caller as uncertain, not as a test', () => { + // Uncertainty is a claim about the EDGE. Filing it under "tests" would + // present a name-only guess as an established call. + const view = payload({ + incoming: { + total: 1, + shown: 1, + truncated: false, + items: [caller({ id: 'g', file: '__tests__/a.test.ts', test: true, uncertain: true })], + }, + }); + const rail = buildCallerRail(view); + expect(rail.uncertain).toHaveLength(1); + expect(rail.tests.rows).toHaveLength(0); + expect(rail.groups).toHaveLength(0); + }); + + it('reports the callers the API had to cap away', () => { + const view = payload({ + incoming: { total: 545, shown: 1, truncated: true, items: [caller({ id: 'a', file: 'src/a.ts' })] }, + }); + expect(buildCallerRail(view).hiddenGroups).toBe(544); + }); +}); + +describe('buildCalleeRail', () => { + it('anchors each row to its first call site and folds the guesses to the bottom', () => { + const view = payload({ + outgoing: { + total: 2, + shown: 2, + truncated: false, + items: [ + relation({ node: { id: 'sure' }, lines: [12, 18] }), + { ...relation({ node: { id: 'guess' }, lines: [15] }), uncertain: true, confidence: 0.4 }, + ], + }, + }); + const rail = buildCalleeRail(view); + expect(rail.rows).toHaveLength(1); + expect(rail.rows[0]?.anchor).toBe(12); + expect(rail.rows[0]?.lines).toEqual([12, 18]); + expect(rail.uncertain).toHaveLength(1); + }); + + it('separates calls that leave the index from type references that do', () => { + const view = payload({ + outsideIndex: { total: 24, byKind: { calls: 21, references: 2, function_ref: 1 }, samples: [] }, + }); + const rail = buildCalleeRail(view); + expect(rail.outsideCalls).toBe(22); + expect(rail.outsideTypeRefs).toBe(2); + }); + + it('leaves a row with no recorded line unanchored rather than guessing a height', () => { + const view = payload({ + outgoing: { + total: 1, + shown: 1, + truncated: false, + items: [{ ...relation({ lines: [] }), lines: [], edges: [] } as WireRelation], + }, + }); + expect(buildCalleeRail(view).rows[0]?.anchor).toBeNull(); + }); +}); + +/* -------------------------------------------------------------- outline -- */ + +describe('members outline', () => { + it('dims data members and indents the ones nested a level deeper', () => { + const view = payload({ + members: { + total: 2, + shown: 2, + truncated: false, + items: [ + { ...nodeRef({ kind: 'property', name: 'store' }), parentId: 'x', depth: 1, fanIn: 1, fanOut: 0 }, + { ...nodeRef({ kind: 'method', name: 'read' }), parentId: 'y', depth: 2, fanIn: 3, fanOut: 5 }, + ] as any, + }, + }); + const rows = buildOutline(view); + expect(rows[0]).toMatchObject({ dimmed: true, nested: false }); + expect(rows[1]).toMatchObject({ dimmed: false, nested: true }); + }); +}); + +describe('showsBody', () => { + it("swaps a large container's body for its outline, and keeps a large function's", () => { + expect(showsBody('class', 700)).toBe(false); + expect(showsBody('file', 2000)).toBe(false); + expect(showsBody('class', 40)).toBe(true); + // A 700-line function IS its body — there is no outline to show instead. + expect(showsBody('function', 700)).toBe(true); + expect(showsBody('method', 259)).toBe(true); + }); +}); + +/* ---------------------------------------------------------------- lexer -- */ + +describe('tokenize', () => { + const kinds = (line: string, state = newLexState(), language = 'typescript') => + tokenize(line, state, language).map((t) => `${t.cls}:${t.text}`); + + it('separates the four things the near-monochrome theme colours', () => { + expect(kinds('const x = 1; // note')).toEqual([ + 'keyword:const', + 'space: ', + 'ident:x', + 'space: ', + 'punct:=', + 'space: ', + 'number:1', + 'punct:;', + 'space: ', + 'comment:// note', + ]); + }); + + it('carries a block comment across lines so the next line is not read as code', () => { + const state = newLexState(); + expect(kinds('/* open', state)).toEqual(['comment:/* open']); + expect(state.block).toBe(true); + expect(kinds('still comment', state)).toEqual(['comment:still comment']); + expect(kinds('done */ const x = 1;', state)).toEqual([ + 'comment:done */', + 'space: ', + 'keyword:const', + 'space: ', + 'ident:x', + 'space: ', + 'punct:=', + 'space: ', + 'number:1', + 'punct:;', + ]); + expect(state.block).toBe(false); + }); + + it('carries a template literal across lines, and closes it on the right backtick', () => { + const state = newLexState(); + expect(kinds('const s = `open', state)).toContain('string:`open'); + expect(state.stringEnd).toBe('`'); + expect(kinds('closed` + x', state)).toEqual([ + 'string:closed`', + 'space: ', + 'punct:+', + 'space: ', + 'ident:x', + ]); + }); + + it('does not eat the rest of a window on an apostrophe in prose', () => { + // An unterminated single-line quote is punctuation in English far more + // often than a real string, so it stops at the line. + const state = newLexState(); + kinds("// it's fine", state); + expect(state.stringEnd).toBeNull(); + const after = kinds('const x = 1;', state); + expect(after[0]).toBe('keyword:const'); + }); + + it('reads a # comment as a comment in Python and as code in TypeScript', () => { + expect(kinds('# note', newLexState(), 'python')).toEqual(['comment:# note']); + expect(kinds('x = 1 # note', newLexState(), 'python').at(-1)).toBe('comment:# note'); + expect(kinds('# note', newLexState(), 'typescript')[0]).not.toBe('comment:# note'); + }); + + it('closes a Python triple-quoted string on the triple, not on the first quote', () => { + const state = newLexState(); + expect(kinds('"""docstring', state, 'python')).toEqual(['string:"""docstring']); + expect(state.stringEnd).toBe('"""'); + expect(kinds('more"""', state, 'python')).toEqual(['string:more"""']); + }); + + it("reports each token's column, which is how a ref finds its identifier", () => { + const tokens = tokenize(' return render();', newLexState(), 'typescript'); + const render = tokens.find((t) => t.text === 'render'); + expect(render?.col).toBe(' return '.length); + }); + + it('falls back to a C-family reading for a language it has no table for', () => { + // Silence beats a wrong claim, but a plain `//` comment is not a claim + // worth getting wrong in a language we have not enumerated. + expect(kinds('// note', newLexState(), 'some-new-language')).toEqual(['comment:// note']); + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index ba4140a..afe017c 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index 2ff4ce7..941ada9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/ui-server/api/node.ts b/src/ui-server/api/node.ts index 381f0fd..ec02553 100644 --- a/src/ui-server/api/node.ts +++ b/src/ui-server/api/node.ts @@ -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, }; diff --git a/src/ui-server/api/stats.ts b/src/ui-server/api/stats.ts index bf30315..a21cd2b 100644 --- a/src/ui-server/api/stats.ts +++ b/src/ui-server/api/stats.ts @@ -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), }; } diff --git a/src/ui-server/api/wire.ts b/src/ui-server/api/wire.ts index 8e4184e..9fb4cf6 100644 --- a/src/ui-server/api/wire.ts +++ b/src/ui-server/api/wire.ts @@ -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; diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 6b5b6e7..54f4322 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -10,13 +10,16 @@ import NotFoundView from './views/NotFoundView.svelte'; import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte'; import { trail } from './lib/trail.svelte'; + import { project } from './lib/project.svelte'; - // Filled by the project stats call once the JSON API exists (CG-42); the - // top bar renders nothing rather than a placeholder until then. - let project = $state(null); - let stats = $state(null); let query = $state(''); + // One `/api/stats` for the whole app: the top bar's counts and the Symbol + // view's blast-radius denominator come out of the same payload. + $effect(() => { + void project.ensure(); + }); + let topbar: TopBar | null = $state(null); let route = $derived(router.route); @@ -81,7 +84,7 @@ - +
{#if route.view === 'symbol'} @@ -95,7 +98,7 @@ {:else if route.view === 'unknown'} {:else} - + {/if}
diff --git a/ui/src/components/TrailBar.svelte b/ui/src/components/TrailBar.svelte index 66b1091..f531731 100644 --- a/ui/src/components/TrailBar.svelte +++ b/ui/src/components/TrailBar.svelte @@ -31,8 +31,17 @@ {:else} {#each hops as hop, i (hop.id)} {#if i > 0} -