Files
codegraph/__tests__/ui-flow-model.test.ts
T
Colby McHenry dc7f1e590e feat(ui): where the graph stops — the Flow strip's dynamic-dispatch end cap (CG-51)
A flow that does not reach what it was asked about now ends in a cap instead
of in silence: the dispatch form that ended it, the line, the static key when
the source spells one out, the candidate runtime targets as clickable rows,
and the name-only matches under 0.6 the search refused to follow. A flow that
does reach its destination never shows one.

The verdict is lifted out of `ToolHandler` into
`src/graph/dynamic-boundary-report.ts` and both callers render it —
`codegraph_explore`'s prose and `/api/flow`'s `WireFlowBoundary` — the same
move `named-symbol-flow.ts` made for the path finder, and for the same reason:
a reader holding the strip and the MCP answer must not be told two different
things. The explore prose is unchanged, byte for byte.

When nothing connects at all and a dispatch site explains why, the strip is
that site: one card opened at the line where the static path ends, plus the
cap. When nothing explains it, no stopping point is invented.
2026-08-27 04:54:37 -05:00

490 lines
17 KiB
TypeScript

/**
* The Flow strip's geometry (CG-50) — `ui/src/lib/flow-model.ts`.
*
* Pure functions, no browser: this is where the strip's two load-bearing claims
* are checked. That a card's height is ARITHMETIC (the CSS pins the same
* number, so an arrow lands where the layout said it would), and that a column
* is a card's LONGEST distance from a start (so two routes that rejoin do so in
* the same column, and nothing is ever drawn left of something that calls it).
*
* The endpoint that feeds it is tested against a real index in
* `ui-flow-api.test.ts`.
*/
import { describe, it, expect } from 'vitest';
import {
buildFlowLayout,
cardHeight,
dashFor,
labelLinesFor,
lineLabelFor,
CARD_WIDTH,
CODE_LINE_HEIGHT,
CODE_PADDING,
COLUMN_PITCH,
HEADER_HEIGHT,
LABEL_MAX_CHARS,
LINK_WIDTH,
NO_SOURCE_HEIGHT,
PADDING,
ROW_GAP,
capId,
endCapHeight,
endCapText,
END_CAP_DASH,
END_CAP_WIDTH,
} from '../ui/src/lib/flow-model';
import type {
WireFlow,
WireFlowBoundary,
WireFlowEdge,
WireFlowHop,
WireNodeRef,
} from '../ui/src/lib/api';
/* ------------------------------------------------------------- builders -- */
function edge(over: Partial<WireFlowEdge> = {}): WireFlowEdge {
return {
kind: 'calls',
label: 'calls',
upward: false,
uncertain: false,
synthesized: false,
...over,
};
}
function hop(name: string, opts: { lines?: number; edge?: WireFlowEdge | null } = {}): WireFlowHop {
const lines = opts.lines ?? 7;
return {
node: {
id: `method:${name}`,
kind: 'method',
name,
qualifiedName: name,
file: `src/${name}.ts`,
line: 10,
endLine: 40,
language: 'typescript',
test: false,
},
edge: opts.edge === undefined ? edge() : opts.edge,
callRef: null,
source:
lines === 0
? null
: {
file: `src/${name}.ts`,
language: 'typescript',
from: 7,
to: 6 + lines,
lines: Array.from({ length: lines }, (_, i) => `line ${i}`),
drift: false,
},
};
}
function flow(
id: string,
names: string[],
extra: { boundary?: WireFlowBoundary | null; partial?: boolean } = {}
): WireFlow {
return {
id,
label: `${names[0]}${names[names.length - 1]}`,
hops: names.map((name, i) => hop(name, { edge: i === 0 ? null : edge() })),
boundary: extra.boundary ?? null,
partial: extra.partial === true,
};
}
function ref(name: string): WireNodeRef {
return {
id: `method:${name}`,
kind: 'method',
name,
qualifiedName: name,
file: `src/${name}.ts`,
line: 10,
endLine: 40,
language: 'typescript',
test: false,
};
}
function boundary(over: Partial<WireFlowBoundary> = {}): WireFlowBoundary {
return {
node: ref('routeAny'),
sites: [
{
form: 'computed-call',
label: 'computed member call',
snippet: "return table[name](payload);",
line: 61,
key: 'save',
keyIsType: false,
moreSites: 0,
candidates: [{ node: ref('onSave'), display: 'onSave', named: true }],
candidateNote: null,
},
],
uncertain: { total: 0, shown: 0, truncated: false, items: [] },
further: { total: 0, shown: 0, truncated: false, items: [] },
missed: [ref('onSave')],
...over,
};
}
/* ---------------------------------------------------------------- tests -- */
describe('cardHeight', () => {
it('is the header plus one row per source line', () => {
expect(cardHeight(hop('a', { lines: 7 }))).toBe(HEADER_HEIGHT + 7 * CODE_LINE_HEIGHT + CODE_PADDING);
expect(cardHeight(hop('a', { lines: 1 }))).toBe(HEADER_HEIGHT + CODE_LINE_HEIGHT + CODE_PADDING);
});
it('gives a card with no source the height of the sentence that replaces it', () => {
expect(cardHeight(hop('a', { lines: 0 }))).toBe(HEADER_HEIGHT + NO_SOURCE_HEIGHT);
});
});
describe('dashFor', () => {
it('marks a synthesized hop `5 3` and an uncertain one `2 3`', () => {
expect(dashFor(edge({ synthesized: true }))).toBe('5 3');
expect(dashFor(edge({ uncertain: true }))).toBe('2 3');
expect(dashFor(edge())).toBeNull();
});
it('lets the synthesized pattern win, because it is the stronger claim', () => {
// A dynamic-dispatch bridge that also scored low confidence is still first
// and foremost a bridge: "we inferred this hop" is what a reader has to see.
expect(dashFor(edge({ synthesized: true, uncertain: true }))).toBe('5 3');
});
});
describe('labelLinesFor', () => {
it('leaves an ordinary call as one word', () => {
expect(labelLinesFor(edge())).toEqual(['calls']);
});
it('stacks a synthesized label and shortens the wiring site to a basename', () => {
expect(
labelLinesFor(
edge({ synthesized: true, label: 'via callback · registered at src/deep/nested/wire.ts:88' })
)
).toEqual(['via callback', 'registered at wire.ts:88']);
});
it('cuts anything still too wide for an 86px connector', () => {
const lines = labelLinesFor(edge({ label: 'via an extraordinarily long mechanism name' }));
expect(lines).toHaveLength(1);
expect(lines[0]!.length).toBe(LABEL_MAX_CHARS);
expect(lines[0]!.endsWith('…')).toBe(true);
});
});
describe('lineLabelFor', () => {
it('prints the recorded line, and nothing when there is none', () => {
expect(lineLabelFor(edge({ line: 2029 }))).toBe('line 2029');
expect(lineLabelFor(edge())).toBeNull();
expect(lineLabelFor(edge({ line: 0 }))).toBeNull();
});
});
describe('buildFlowLayout — one path', () => {
const single = flow('f1', ['a', 'b', 'c']);
it('puts one card per column, left to right, at the spec pitch', () => {
const layout = buildFlowLayout([single], 'f1');
expect(layout.cards.map((c) => c.hop.node.name)).toEqual(['a', 'b', 'c']);
expect(layout.cards.map((c) => c.column)).toEqual([0, 1, 2]);
expect(layout.cards.map((c) => c.x)).toEqual([PADDING, PADDING + COLUMN_PITCH, PADDING + 2 * COLUMN_PITCH]);
expect(COLUMN_PITCH).toBe(CARD_WIDTH + LINK_WIDTH);
});
it('places every card on one row and numbers its step on the active flow', () => {
const layout = buildFlowLayout([single], 'f1');
expect(new Set(layout.cards.map((c) => c.y)).size).toBe(1);
expect(layout.cards.map((c) => c.step)).toEqual([0, 1, 2]);
});
it('links consecutive cards and nothing else', () => {
const layout = buildFlowLayout([single], 'f1');
expect(layout.links.map((l) => [l.source, l.target])).toEqual([
['method:a', 'method:b'],
['method:b', 'method:c'],
]);
});
it('sizes the canvas to the cards it drew', () => {
const layout = buildFlowLayout([single], 'f1');
expect(layout.columns).toBe(3);
expect(layout.gaps).toEqual([LINK_WIDTH, LINK_WIDTH]);
expect(layout.width).toBe(PADDING * 2 + 3 * CARD_WIDTH + 2 * LINK_WIDTH);
expect(layout.height).toBe(PADDING * 2 + cardHeight(single.hops[0] as WireFlowHop));
});
it('widens the gap a long synthesized label has to fit into', () => {
// 86px holds `calls`; it does not hold `registered at App.tsx:3764`, which
// at a fixed pitch ran under the source of the card it was explaining.
const wired: WireFlow = {
id: 'f1',
label: 'a → b',
hops: [
hop('a', { edge: null }),
hop('b', {
edge: edge({
synthesized: true,
line: 5337,
label: 'via callback · onUpdate · registered at src/app/App.tsx:3764',
}),
}),
],
};
const layout = buildFlowLayout([wired], 'f1');
expect(layout.gaps[0]).toBeGreaterThan(LINK_WIDTH);
// Wide enough for the widest line it has to hold.
const widest = Math.max(...(layout.links[0]?.labelLines ?? []).map((l) => l.length));
expect(layout.gaps[0]).toBeGreaterThanOrEqual(widest * 6.65);
// …and the second card starts past it, so nothing is drawn over the label.
expect(layout.cards[1]?.x).toBe(PADDING + CARD_WIDTH + (layout.gaps[0] as number));
});
it('answers an empty picture for no flows at all', () => {
expect(buildFlowLayout([], null)).toEqual({
cards: [],
endCaps: [],
links: [],
width: 0,
height: 0,
columns: 0,
gaps: [],
});
});
});
describe('buildFlowLayout — two paths that merge', () => {
// a → b → d and a → c → d: the same start, the same end, different middles.
const left = flow('f1', ['a', 'b', 'd']);
const right = flow('f2', ['a', 'c', 'd']);
it('draws one DAG, not two strips', () => {
const layout = buildFlowLayout([left, right], 'f1');
expect(layout.cards).toHaveLength(4);
expect(layout.links).toHaveLength(4);
expect(layout.columns).toBe(3);
});
it('rejoins the shared cards in one column and stacks the branch', () => {
const layout = buildFlowLayout([left, right], 'f1');
const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
expect(at('a').column).toBe(0);
expect(at('d').column).toBe(2);
expect(at('b').column).toBe(1);
expect(at('c').column).toBe(1);
// Same column, different rows, exactly one gap apart.
expect(at('c').y - at('b').y).toBe(at('b').height + ROW_GAP);
});
it('records which paths a shared card and a branch link belong to', () => {
const layout = buildFlowLayout([left, right], 'f1');
const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
expect(at('a').flows).toEqual(['f1', 'f2']);
expect(at('b').flows).toEqual(['f1']);
expect(at('c').flows).toEqual(['f2']);
expect(layout.links.find((l) => l.target === 'method:c')!.flows).toEqual(['f2']);
});
it('marks the picked path, and only the picked path, with a step', () => {
const picked = buildFlowLayout([left, right], 'f2');
const at = (name: string) => picked.cards.find((c) => c.hop.node.name === name)!;
expect(at('c').step).toBe(1);
expect(at('b').step).toBe(-1);
// …and the picked path is the one drawn along the top of its columns.
expect(at('c').y).toBeLessThan(at('b').y);
});
});
describe('endCapText', () => {
it('names the form, keeps the key and counts the candidates', () => {
const text = endCapText(boundary());
expect(text.intro).toContain('routeAny');
expect(text.sites[0].headline).toBe('computed member call at line 61');
expect(text.sites[0].key).toBe('save');
expect(text.sites[0].candidateHeading).toBe('1 candidate target \u203a');
expect(text.quiet).toBeNull();
expect(text.missed).toContain('onSave');
});
it('says the key is a runtime value rather than leaving the line blank', () => {
const b = boundary();
b.sites[0]!.key = null;
b.sites[0]!.candidates = [];
const text = endCapText(b);
expect(text.sites[0].key).toBeNull();
expect(text.sites[0].notes).toContain('the key is a runtime value');
expect(text.sites[0].candidateHeading).toBeNull();
});
it('admits when the detector found nothing rather than implying a cause', () => {
const text = endCapText(boundary({ sites: [] }));
expect(text.quiet).toMatch(/No dynamic-dispatch site/);
expect(text.sites).toEqual([]);
});
it('leads with the unfollowed name-only matches and their confidence', () => {
const text = endCapText(
boundary({
uncertain: {
total: 3,
shown: 2,
truncated: true,
items: [
{ node: ref('save'), line: 61, confidence: 0.4 },
{ node: ref('store'), line: 62, confidence: 0.35 },
],
},
})
);
// The count is the TRUE total, not the length of the visible list.
expect(text.uncertainHeading).toBe('3 name-only matches not followed (confidence < 0.6)');
expect(text.uncertain).toHaveLength(2);
});
it('counts further resolved calls in the plural the number actually needs', () => {
const one = endCapText(
boundary({ further: { total: 1, shown: 1, truncated: false, items: [] } })
);
expect(one.further).toContain('1 further resolved call ');
const many = endCapText(
boundary({ further: { total: 4, shown: 0, truncated: true, items: [] } })
);
expect(many.further).toContain('4 further resolved calls ');
});
});
describe('endCapHeight', () => {
it('grows with what the cap has to say', () => {
const bare = endCapHeight(boundary({ sites: [], missed: [] }));
const full = endCapHeight(
boundary({
uncertain: {
total: 2,
shown: 2,
truncated: false,
items: [
{ node: ref('save'), line: 61, confidence: 0.4 },
{ node: ref('store'), line: 62, confidence: 0.3 },
],
},
further: { total: 5, shown: 0, truncated: true, items: [] },
})
);
expect(full).toBeGreaterThan(bare);
});
it('is a whole number, because it is a pixel', () => {
expect(Number.isInteger(endCapHeight(boundary()))).toBe(true);
});
});
describe('buildFlowLayout — the end cap', () => {
it('places the cap one column past the symbol the path stopped at', () => {
const f = flow('f1', ['alpha', 'routeAny'], { boundary: boundary() });
const layout = buildFlowLayout([f], 'f1');
expect(layout.endCaps).toHaveLength(1);
const cap = layout.endCaps[0]!;
expect(cap.id).toBe(capId('method:routeAny'));
expect(cap.anchorId).toBe('method:routeAny');
expect(cap.column).toBe(1 + 1);
expect(cap.width).toBe(END_CAP_WIDTH);
expect(layout.columns).toBe(3);
// The card the cap hangs off is tinted at the dispatch line.
expect(layout.cards.find((c) => c.id === 'method:routeAny')!.stopLine).toBe(61);
expect(layout.cards.find((c) => c.id === 'method:alpha')!.stopLine).toBeNull();
});
it('joins it with a dotted link that carries no arrow and no edge', () => {
const layout = buildFlowLayout([flow('f1', ['alpha', 'routeAny'], { boundary: boundary() })], 'f1');
const link = layout.links.find((l) => l.cap);
expect(link).toBeDefined();
expect(link!.edge).toBeNull();
expect(link!.dash).toBe(END_CAP_DASH);
expect(link!.label).toBe('end of static path');
expect(link!.labelLines.join(' ')).toBe('end of static path');
expect(link!.lineLabel).toBeNull();
});
it('draws no cap for a flow that reached what it was asked for', () => {
const layout = buildFlowLayout([flow('f1', ['alpha', 'beta'])], 'f1');
expect(layout.endCaps).toEqual([]);
expect(layout.links.every((l) => !l.cap)).toBe(true);
});
it('draws ONE cap when two paths run out at the same symbol', () => {
const a = flow('a', ['alpha', 'routeAny'], { boundary: boundary() });
const b = flow('b', ['gamma', 'routeAny'], { boundary: boundary() });
const layout = buildFlowLayout([a, b], 'a');
expect(layout.endCaps).toHaveLength(1);
expect(layout.endCaps[0]!.flows.sort()).toEqual(['a', 'b']);
});
it('leaves room for a cap wider or narrower than a card', () => {
const layout = buildFlowLayout([flow('f1', ['alpha', 'routeAny'], { boundary: boundary() })], 'f1');
const cap = layout.endCaps[0]!;
// The canvas is wide enough to hold the cap, not just the cards.
expect(layout.width).toBe(cap.x + cap.width + PADDING);
// And the cap starts one gap past the card it hangs off.
const anchor = layout.cards.find((c) => c.id === 'method:routeAny')!;
expect(cap.x).toBe(anchor.x + CARD_WIDTH + LINK_WIDTH);
});
it('ignores a boundary whose symbol is not on screen', () => {
const orphan = boundary({ node: ref('nowhere') });
const layout = buildFlowLayout([flow('f1', ['alpha', 'beta'], { boundary: orphan })], 'f1');
expect(layout.endCaps).toEqual([]);
});
});
describe('buildFlowLayout — awkward shapes', () => {
it('never draws a card left of something that calls it, on a long merge', () => {
// a → b → c → d and a → d: `d`'s column must come from the LONGEST route,
// or the short path would drag it back on top of `b`.
const long = flow('f1', ['a', 'b', 'c', 'd']);
const short = flow('f2', ['a', 'd']);
const layout = buildFlowLayout([long, short], 'f1');
const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
expect(at('d').column).toBe(3);
for (const link of layout.links) {
const from = layout.cards.find((c) => c.id === link.source)!;
const to = layout.cards.find((c) => c.id === link.target)!;
expect(to.column).toBeGreaterThan(from.column);
}
});
it('still draws every card when a flow calls back into itself', () => {
// a → b → a: a real shape (recursion through a helper) and one with no
// topological order. Nothing may vanish.
const cyclic: WireFlow = {
id: 'f1',
label: 'a → a',
hops: [hop('a', { edge: null }), hop('b'), { ...hop('a'), edge: edge() }],
};
const layout = buildFlowLayout([cyclic], 'f1');
expect(layout.cards.map((c) => c.hop.node.name).sort()).toEqual(['a', 'b']);
expect(layout.links).toHaveLength(2);
expect(layout.cards.every((c) => Number.isFinite(c.x) && Number.isFinite(c.y))).toBe(true);
});
it('centres a short column against a tall one', () => {
const tall = flow('f1', ['a', 'b', 'd']);
const alt = flow('f2', ['a', 'c', 'd']);
const layout = buildFlowLayout([tall, alt], 'f1');
const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
const columnMiddle = (name: string) => at(name).y + at(name).height / 2;
// `a` is alone in its column; `b`/`c` share the next one. Their midpoints line up.
expect(columnMiddle('a')).toBeCloseTo((at('b').y + at('c').y + at('c').height) / 2, 5);
});
});