fix(ui): name a Behavior start that calls itself instead of counting it

A start operation that calls itself was counted as one more direct callee
but drawn once, as the start. The heading then named one call more than the
pages, and the page counter read the gap as a filter nobody had set. The
start is no longer a callee of itself; the heading says "also calls itself",
and "matching the filter" appears only while a filter is set.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-03 20:02:52 +02:00
parent b5c9f5759b
commit 64c1916645
5 changed files with 36 additions and 7 deletions
@@ -122,6 +122,17 @@ describe('behavior journeys', () => {
// Every page draws its calls and the start beside them.
expect(container.querySelectorAll('[data-node]')).toHaveLength(3);
});
it('names a start operation that calls itself instead of counting it as a callee or claiming a filter', async () => {
const data = fixture();
const callees = Array.from({ length: 6 }, (_, index) => symbol(index + 20));
data.paths = [...callees.map((target, index) => ({ entrypoint_id: 1, nodes: [data.entrypoints[0], target], edges: [{ id: 500 + index, source_id: 1, target_id: target.id, type: 'CALLS' }] })),
{ entrypoint_id: 1, nodes: [data.entrypoints[0], data.entrypoints[0]], edges: [{ id: 599, source_id: 1, target_id: 1, type: 'CALLS' }] }];
await render(data);
expect(container.querySelector('.behavior-navigation span')?.textContent).toBe('6 direct callees with returned evidence · also calls itself');
const pages = container.querySelector('[aria-label="Direct call pages"]')?.textContent ?? '';
expect(pages).toContain('Calls 1 to 4 of 6');
expect(pages).not.toContain('matching the filter');
});
it('shows the operation the journey opened with in the Start field, not "Choose an operation"', async () => {
// Without a requested entry the journey falls back to the first entry point (Django: main in manage.py-tpl).
await render(fixture(), { entryId: undefined });
@@ -145,7 +145,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
<div className="behavior-navigation"><button disabled={!history.length} onClick={() => { const previous = history.at(-1)!; setHistory(items => items.slice(0, -1)); request(previous.entry, previous.targetId, false); }}>← Back</button>
{targetId !== undefined && <button onClick={() => request(entry)}>Immediate calls</button>}
<span>{pending ? 'Updating this journey…' : path ? `${path.edges.length} call-chain hops · ${new Set(path.nodes.map(item => item.component_id)).size} components`
: `${journey?.counts.directChoices ?? 0} direct callees with returned evidence`}</span>
: text.directCallees(journey?.counts.directChoices ?? 0, journey?.counts.selfCalls ?? false)}</span>
<span className="behavior-static-badge" title="Graph relationships describe possible calls. They do not prove execution order, path feasibility or actual runtime values.">Static evidence</span>
</div>
{error && <p role="alert">{error} <button onClick={onRefresh}>Try again</button></p>}
@@ -171,7 +171,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
<span>{activeStep + 1} / {path.nodes.length}</span><button disabled={activeStep >= path.nodes.length - 1} onClick={() => selectStep(activeStep + 1)}>Next →</button></nav>}
{!path && journey.scene.nodes.length > 5 && <nav className="behavior-walk" aria-label="Direct call pages"><button disabled={branchPage === 0} onClick={() => { setBranchPage(page => page - 1); setSelection(undefined); }}>← Earlier calls</button>
<span>{text.callPage(branchPage * 4 + 1, Math.min(branchPage * 4 + 4, journey.scene.nodes.length - 1), journey.scene.nodes.length - 1,
journey.counts.omittedNodes, journey.scene.nodes.length - 1 + journey.counts.omittedNodes < journey.counts.directChoices)}</span>
journey.counts.omittedNodes, Boolean(filter.trim()) && journey.scene.nodes.length - 1 + journey.counts.omittedNodes < journey.counts.directChoices)}</span>
<button disabled={(branchPage + 1) * 4 >= journey.scene.nodes.length - 1} onClick={() => { setBranchPage(page => page + 1); setSelection(undefined); }}>More calls →</button></nav>}
</div><aside className="behavior-inspector" aria-label="Behavior evidence inspector">
{caller ? <><span className="system-eyebrow">{call ? call.type.replaceAll('_', ' ') : path ? `Operation ${activeStep + 1}` : 'Starting operation'}</span>
@@ -152,6 +152,18 @@ describe('focused Behavior journeys', () => {
expect(result.choices.filter(choice => choice.direct)).toHaveLength(14);
});
it('counts a start operation that calls itself apart from its callees, so the heading and the pages agree', () => {
const callees = Array.from({ length: 6 }, (_, index) => symbol(index + 2));
const data = projection({ behavior: behavior([symbol(1), ...callees], [...callees.map((target, index) => edge(300 + index, 1, target.id)), edge(399, 1, 1)]) });
const result = behaviorJourney(data);
// Six callees drawn beside the start, which is drawn once; the recursion is named, not counted as a seventh callee.
expect(result.counts.directChoices).toBe(6);
expect(result.counts.selfCalls).toBe(true);
expect(result.scene.nodes).toHaveLength(7);
expect(result.counts.omittedNodes).toBe(0);
expect(behaviorJourney(projection({ behavior: behavior([symbol(1), ...callees], callees.map((target, index) => edge(300 + index, 1, target.id))) })).counts.selfCalls).toBe(false);
});
it('bounds a direct fan while reserving a real edge for every displayed callee', () => {
const targets = Array.from({ length: 60 }, (_, index) => ({ ...symbol(index + 2), name: `target${String(index).padStart(2, '0')}` }));
const edges = [...Array.from({ length: 130 }, (_, index) => edge(index, 1, 2)),
@@ -11,6 +11,8 @@ export interface BehaviorJourney {
counts: {
directChoices: number; reachableTargets: number; validPaths: number; invalidPaths: number;
filteredPaths: number; omittedChoices: number; omittedNodes: number; omittedEdges: number;
/** The start operation calls itself: named beside the callees, never counted as one of them. */
selfCalls: boolean;
};
limits: { maxChoices: number; maxNodes: number; maxEdges: number; hit: string[]; sampled: boolean };
}
@@ -132,7 +134,7 @@ export function behaviorJourney(data: SystemProjection, options: BehaviorJourney
paths: [], entry, mode: 'empty', choices: [],
counts: { directChoices: 0, reachableTargets: scopedBehavior?.reachable_targets.length ?? 0,
validPaths: 0, invalidPaths: scopedPaths.length - valid.length, filteredPaths: 0,
omittedChoices: 0, omittedNodes: 0, omittedEdges: 0 },
omittedChoices: 0, omittedNodes: 0, omittedEdges: 0, selfCalls: false },
limits: { maxChoices: MAX_CHOICES, maxNodes: MAX_NODES, maxEdges: MAX_EDGES,
hit: [], sampled: !data.complete || data.status === 'limited' || scopedBehavior?.complete === false
|| scopedBehavior?.corridor_complete === false || (options.targetId === undefined && (sampledWitnesses || data.overview?.complete === false)) },
@@ -184,16 +186,18 @@ export function behaviorJourney(data: SystemProjection, options: BehaviorJourney
callsite: witness.callsite, resolution: witness.resolution,
});
}
const callees = [...new Set([...direct.values()].map(edge => edge.target_id))].map(id => symbols.get(id)!)
// The start is drawn once; calling itself is not one more callee.
const callees = [...new Set([...direct.values()].map(edge => edge.target_id))].filter(id => id !== entry.id).map(id => symbols.get(id)!)
.sort((a, b) => compareText(a.file_path ?? '', b.file_path ?? '') || compareText(a.name, b.name) || a.id - b.id);
result.counts.directChoices = callees.length;
result.counts.selfCalls = [...direct.values()].some(edge => edge.target_id === entry.id);
const candidates = callees.filter(symbol => matches(entry) || matches(symbol));
const shown = candidates.slice(0, MAX_CHOICES), shownIds = new Set(shown.map(symbol => symbol.id));
if (candidates.length > shown.length) hit.add('direct-choices');
result.choices = shown.map(symbol => ({ ...symbol, distance: 1, direct: true }));
const calleeIds = new Set(callees.map(symbol => symbol.id));
const catalog = new Map<number, BehaviorJourneyChoice>();
for (const target of scopedBehavior?.reachable_targets ?? []) if (matches(target) && !calleeIds.has(target.id)) {
for (const target of scopedBehavior?.reachable_targets ?? []) if (matches(target) && target.id !== entry.id && !calleeIds.has(target.id)) {
catalog.set(target.id, { ...target, direct: false });
}
// Older projections expose sampled paths without a separate destination catalog.
@@ -205,7 +209,7 @@ export function behaviorJourney(data: SystemProjection, options: BehaviorJourney
result.choices.push(...[...catalog.values()].slice(0, MAX_NODES - result.choices.length));
result.counts.omittedChoices = new Set([...candidates.map(symbol => symbol.id), ...catalog.keys()]).size - result.choices.length;
if (result.counts.omittedChoices > 0) hit.add('target-choices');
const visible = [entry, ...shown.filter(symbol => symbol.id !== entry.id)];
const visible = [entry, ...shown];
const nodes: SystemSceneNode[] = visible.map((symbol, index) => ({ id: `journey-choice:${symbol.id}`, label: symbol.name,
symbol, detail: symbol.file_path ?? symbol.qualified_name, depth: index === 0 ? 0 : 1,
size: [48, 18, 3], position: index === 0 ? [0, 0, 0] : [88, (index - 1 - (visible.length - 2) / 2) * 28, 0] }));
@@ -218,7 +222,7 @@ export function behaviorJourney(data: SystemProjection, options: BehaviorJourney
const edges = eligibleEdges.filter(edge => admitted.has(edgeKey(edge))).map(edge => ({ id: `journey-choice-edge:${edgeKey(edge)}`,
source: `journey-choice:${entry.id}`, target: `journey-choice:${edge.target_id}`, type: edge.type, count: 1,
pathEdge: edge, depth: 1, handoff: symbols.get(edge.target_id)!.component_id !== entry.component_id }));
result.counts.omittedNodes = candidates.filter(symbol => symbol.id !== entry.id && !shownIds.has(symbol.id)).length;
result.counts.omittedNodes = candidates.filter(symbol => !shownIds.has(symbol.id)).length;
result.counts.omittedEdges = [...direct.values()].filter(edge => matches(entry) || matches(symbols.get(edge.target_id)!)).length - edges.length;
if (eligibleEdges.length > edges.length) hit.add('direct-edges');
result.mode = 'choices';
+2
View File
@@ -88,6 +88,8 @@ export const architectureText = {
compareFailed: 'Could not read that project. Its index may be unavailable.',
},
showEndpoints: 'Show endpoints →',
/** Behavior: the callees of the start operation; a call to itself is named, not counted. */
directCallees: (count: number, selfCalls: boolean) => `${count.toLocaleString()} direct callees with returned evidence${selfCalls ? ' · also calls itself' : ''}`,
/** Behavior: the page of direct calls, the same calls the "direct callees" count names. */
callPage: (from: number, to: number, drawn: number, beyond: number, filtered: boolean) => `Calls ${from} to ${to} of ${drawn.toLocaleString()}${filtered ? ' matching the filter' : ''}${beyond ? ` · ${beyond.toLocaleString()} more beyond the display limit` : ''}`,
openedArea: 'Opened area',