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.
This commit is contained in:
@@ -30,6 +30,8 @@ import CodeGraph from '../src/index';
|
||||
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
|
||||
import { flowEdgeLabel, parseFlowQuery } from '../src/ui-server/api/flow';
|
||||
import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
|
||||
import { ToolHandler } from '../src/mcp/tools';
|
||||
import { continuationsFrom } from '../src/graph/dynamic-boundary-report';
|
||||
import type { Edge } from '../src/types';
|
||||
|
||||
let server: UiServerHandle;
|
||||
@@ -152,6 +154,46 @@ export function describeRow(id: string): string {
|
||||
`
|
||||
);
|
||||
|
||||
// A registry whose call target is a string key (CG-51): one site whose key is
|
||||
// a literal — so a candidate shortlist is possible — and one whose key is a
|
||||
// runtime value, where claiming a candidate would be a guess.
|
||||
write(
|
||||
projectRoot,
|
||||
'src/router/table.ts',
|
||||
`type Handler = (payload: string) => string;
|
||||
|
||||
const routerTable: Record<string, Handler> = {};
|
||||
|
||||
export function register(key: string, fn: Handler): void {
|
||||
routerTable[key] = fn;
|
||||
}
|
||||
|
||||
export function routeSave(payload: string): string {
|
||||
return routerTable['save'](payload);
|
||||
}
|
||||
|
||||
export function routeAny(name: string, payload: string): string {
|
||||
return routerTable[name](payload);
|
||||
}
|
||||
|
||||
export function beginWork(name: string, payload: string): string {
|
||||
return routeAny(name, payload);
|
||||
}
|
||||
`
|
||||
);
|
||||
write(
|
||||
projectRoot,
|
||||
'src/router/handlers.ts',
|
||||
`import { register } from './table';
|
||||
|
||||
export function onSave(payload: string): string {
|
||||
return payload;
|
||||
}
|
||||
|
||||
register('save', onSave);
|
||||
`
|
||||
);
|
||||
|
||||
// A Go interface with one implementation: the resolver synthesizes an
|
||||
// interface-impl `calls` edge across it, which is what the strip draws dashed.
|
||||
write(
|
||||
@@ -343,6 +385,130 @@ describe('GET /api/flow — a directed question', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/flow — where the graph stops', () => {
|
||||
it('caps a keyed dispatch with its form, its key and a candidate target', async () => {
|
||||
const payload = await getFlow('?from=routeSave&to=onSave');
|
||||
// No static edge crosses `routerTable['save']`, so this is not a path — it
|
||||
// is the one card where the looking stopped, plus the cap.
|
||||
expect(payload.reason).toMatch(/No chain of calls reaches onSave/);
|
||||
const flow = payload.flows[0];
|
||||
expect(flow.partial).toBe(true);
|
||||
expect(names(flow)).toEqual(['routeSave']);
|
||||
|
||||
const boundary = flow.boundary;
|
||||
expect(boundary.node.name).toBe('routeSave');
|
||||
const site = boundary.sites[0];
|
||||
expect(site.form).toBe('computed-call');
|
||||
expect(site.label).toBe('computed member call');
|
||||
expect(site.key).toBe('save');
|
||||
expect(site.line).toBeGreaterThan(boundary.node.line);
|
||||
expect(site.candidates.map((c: any) => c.display)).toContain('onSave');
|
||||
// The reader named it, so the cap says so rather than presenting it as new.
|
||||
expect(site.candidates.find((c: any) => c.display === 'onSave').named).toBe(true);
|
||||
expect(boundary.missed.map((m: any) => m.name)).toContain('onSave');
|
||||
});
|
||||
|
||||
it('opens the card at the dispatch line, with real source around it', async () => {
|
||||
const payload = await getFlow('?from=routeSave&to=onSave');
|
||||
const flow = payload.flows[0];
|
||||
const site = flow.boundary.sites[0];
|
||||
const source = flow.hops[0].source;
|
||||
expect(source.drift).toBe(false);
|
||||
expect(source.from).toBeLessThanOrEqual(site.line);
|
||||
expect(source.to).toBeGreaterThanOrEqual(site.line);
|
||||
expect(source.lines.join('\n')).toContain("routerTable['save']");
|
||||
});
|
||||
|
||||
it('claims no candidates when the key is a runtime value', async () => {
|
||||
const payload = await getFlow('?from=routeAny&to=onSave');
|
||||
const site = payload.flows[0].boundary.sites[0];
|
||||
expect(site.form).toBe('computed-call');
|
||||
expect(site.key).toBeNull();
|
||||
expect(site.candidates).toEqual([]);
|
||||
expect(site.candidateNote).toBeNull();
|
||||
});
|
||||
|
||||
it('caps a chain that connects but never reaches everything it was asked about', async () => {
|
||||
const payload = await getFlow('?symbols=beginWork,routeAny,onSave');
|
||||
const flow = payload.flows[0];
|
||||
expect(flow.partial).toBe(false);
|
||||
expect(names(flow)).toEqual(['beginWork', 'routeAny']);
|
||||
// The cap hangs off the dead end, not off the symbol that was named last.
|
||||
expect(flow.boundary.node.name).toBe('routeAny');
|
||||
expect(flow.boundary.sites[0].form).toBe('computed-call');
|
||||
expect(flow.boundary.missed.map((m: any) => m.name)).toEqual(['onSave']);
|
||||
// The last card opens at the dispatch line the cap beside it describes.
|
||||
const last = flow.hops[flow.hops.length - 1].source;
|
||||
const stop = flow.boundary.sites[0].line;
|
||||
expect(last.from).toBeLessThanOrEqual(stop);
|
||||
expect(last.to).toBeGreaterThanOrEqual(stop);
|
||||
});
|
||||
|
||||
it('never caps a flow that reaches what it was asked for', async () => {
|
||||
const payload = await getFlow('?from=bootstrap&to=toRow');
|
||||
expect(payload.flows[0].boundary).toBeNull();
|
||||
expect(payload.flows[0].partial).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent when nothing connects and no dispatch site explains it', async () => {
|
||||
// `bootstrap` and `orphanHandler` are both ordinary code. Inventing a
|
||||
// stopping point here would be a claim, not a finding.
|
||||
const payload = await getFlow('?from=bootstrap&to=orphanHandler');
|
||||
expect(payload.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts the calls the path did not need and lists them', async () => {
|
||||
const payload = await getFlow('?symbols=beginWork,routeAny,onSave');
|
||||
const { further, uncertain } = payload.flows[0].boundary;
|
||||
// The count and the list are the same fact — the rule every payload keeps.
|
||||
expect(further.shown).toBe(further.items.length);
|
||||
expect(further.total).toBeGreaterThanOrEqual(further.shown);
|
||||
expect(uncertain.shown).toBe(uncertain.items.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the end cap and codegraph_explore agree', () => {
|
||||
it('names the same site, the same key and the same candidate', async () => {
|
||||
const payload = await getFlow('?from=routeSave&to=onSave');
|
||||
const site = payload.flows[0].boundary.sites[0];
|
||||
|
||||
const cg = CodeGraph.openSync(projectRoot);
|
||||
try {
|
||||
const res = await new ToolHandler(cg).execute('codegraph_explore', {
|
||||
query: 'routeSave onSave',
|
||||
});
|
||||
const text = res.content[0].text as string;
|
||||
// Both renderings come from `findDynamicBoundaries`; if they ever drift
|
||||
// apart, a reader with the strip and the MCP answer side by side has no
|
||||
// way to tell which one is lying.
|
||||
expect(text).toContain('**Dynamic boundaries');
|
||||
expect(text).toContain(site.label);
|
||||
expect(text).toContain(`src/router/table.ts:${site.line}`);
|
||||
expect(text).toContain(`candidates for key \`${site.key}\``);
|
||||
for (const candidate of site.candidates) expect(text).toContain(candidate.display);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('splits a symbol\'s outgoing calls into the sure and the unfollowed', () => {
|
||||
const cg = CodeGraph.openSync(projectRoot);
|
||||
try {
|
||||
const node = cg.getNodesByName('handleRequest')[0]!;
|
||||
const all = continuationsFrom(cg, node);
|
||||
expect(all.resolved.map((c) => c.node.name)).toContain('loadRow');
|
||||
expect(all.uncertain.every((c) => (c.confidence ?? 1) < 0.6)).toBe(true);
|
||||
// Excluding what is already on the path is what keeps the cap from
|
||||
// listing the hop the reader just walked as an unexplored exit.
|
||||
const target = all.resolved[0]!.node.id;
|
||||
const rest = continuationsFrom(cg, node, new Set([target]));
|
||||
expect(rest.resolved.map((c) => c.node.id)).not.toContain(target);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/flow — a synthesized hop', () => {
|
||||
it('draws the interface bridge as a dashed hop that names its mechanism', async () => {
|
||||
const payload = await getFlow('?from=Tick&to=stamp');
|
||||
|
||||
@@ -28,8 +28,19 @@ import {
|
||||
NO_SOURCE_HEIGHT,
|
||||
PADDING,
|
||||
ROW_GAP,
|
||||
capId,
|
||||
endCapHeight,
|
||||
endCapText,
|
||||
END_CAP_DASH,
|
||||
END_CAP_WIDTH,
|
||||
} from '../ui/src/lib/flow-model';
|
||||
import type { WireFlow, WireFlowEdge, WireFlowHop } from '../ui/src/lib/api';
|
||||
import type {
|
||||
WireFlow,
|
||||
WireFlowBoundary,
|
||||
WireFlowEdge,
|
||||
WireFlowHop,
|
||||
WireNodeRef,
|
||||
} from '../ui/src/lib/api';
|
||||
|
||||
/* ------------------------------------------------------------- builders -- */
|
||||
|
||||
@@ -74,11 +85,54 @@ function hop(name: string, opts: { lines?: number; edge?: WireFlowEdge | null }
|
||||
};
|
||||
}
|
||||
|
||||
function flow(id: string, names: string[]): WireFlow {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +254,7 @@ describe('buildFlowLayout — one path', () => {
|
||||
it('answers an empty picture for no flows at all', () => {
|
||||
expect(buildFlowLayout([], null)).toEqual({
|
||||
cards: [],
|
||||
endCaps: [],
|
||||
links: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -251,6 +306,147 @@ describe('buildFlowLayout — two paths that merge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user