fix(ui): offer flow entries when Behavior has no entry points

For Django the Behavior start list held only "Choose an operation...":
the limited projection emits no entry points, Python entry points are
only functions named main, the auto-pick accepted only main, and the
behavior source_id 0 the server sends for "nothing selected" was taken
as a real node ID because ?? does not fall through on 0.

source_id 0 now means no selection. When the projection classifies no
entry point, the start list comes from /api/flows (ranked route handlers
and call-graph roots), and among those a conventional handler name such
as handle or run is suggested after main. A limited projection shows the
warnings that limited it in the empty journey instead of "No direct call
witnesses were returned".

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-02 19:02:16 +02:00
parent e46f3765b4
commit 8961287899
7 changed files with 105 additions and 12 deletions
@@ -1,6 +1,7 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { behaviorJourney } from './behavior-journey-model';
import type { SystemSceneModel } from './system-architecture-model';
import { projectionLimits, type SystemSceneModel } from './system-architecture-model';
import { architectureText as text } from './strings';
import type { SystemProjection, SystemSymbol } from './system-architecture-source';
import BehaviorSourceEvidence, { type BehaviorSourceSnapshot } from './BehaviorSourceEvidence';
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
@@ -78,6 +79,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
const entry = journey?.entry ?? entries.find(item => item.id === entryId);
const availableEntries = entry && !entries.some(item => item.id === entry.id) ? [entry, ...entries] : entries;
const selectedTarget = targetOptions.find(item => item.id === targetId) ?? path?.nodes.at(-1);
const limits = data?.status === 'limited' ? projectionLimits(data) : [];
const sourceKey = caller ? JSON.stringify([project, generation, caller.qualified_name, call?.callsite?.file_path ?? caller.file_path, call?.callsite?.line ?? caller.start_line]) : undefined;
useSelectionEvidence(onSelectionEvidence, !overview && !pending && journey && symbol ? {
project, generation, view: 'architecture-behavior', source: 'indexed behavior projection and call-site evidence',
@@ -158,6 +160,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
selectedNode={selectedEdge ? undefined : selectedNode?.id} selectedEdge={selectedEdge?.id}
onSelectNode={selectNode} onSelectEdge={selectEdge} onExpandNode={follow} onClearSelection={clearSelection}
resetKey={resetKey} planar={planar} active={active} showConnectionLoad={false} presentation="journey" /></Suspense>
: limits.length ? <div className="behavior-loading" role="status"><div className="behavior-limited"><strong>{text.behaviorLimited}</strong>{limits.map(limit => <p key={limit}>{limit}</p>)}</div></div>
: <div className="behavior-loading">{filter ? 'No connected path matches this filter.' : targetId !== undefined ? 'No connected call-chain witness was returned for this destination.' : 'No direct call witnesses were returned for this operation.'}</div>}
<div className="system-map-caption"><span>{path ? `Showing operations ${(scene.nodes[0]?.depth ?? 0) + 1} to ${(scene.nodes.at(-1)?.depth ?? 0) + 1} of ${path.nodes.length}` : 'Branches are possible calls, not an execution sequence.'}</span>
<span>Double-click an operation to follow its calls</span></div>
@@ -2,7 +2,7 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import SystemArchitecture, { suggestedBehaviorEntry, type SystemArchitectureProps } from './SystemArchitecture';
import SystemArchitecture, { flowEntries, suggestedBehaviorEntry, type FlowsLoader, type SystemArchitectureProps } from './SystemArchitecture';
import { isContiguousPath, systemBehaviorGraph, systemComponentGraph, systemComponents, type SystemSceneModel } from './system-architecture-model';
import { loadSystemArchitecture, readSystemArchitecture, type SystemArchitectureLoader, type SystemArchitectureResponse, type SystemProjection, type SystemSymbol } from './system-architecture-source';
import { callToolJson } from '../provider/rpc-transport';
@@ -348,6 +348,22 @@ describe('system architecture workspace', () => {
];
expect(suggestedBehaviorEntry(entries)?.id).toBe(4);
expect(suggestedBehaviorEntry([symbol(5, 'helper', 'format')])).toBeUndefined();
const handlers = [{ ...symbol(6, 'commands', 'handle'), file_path: 'django/core/management/commands/runserver.py' },
{ ...symbol(7, 'tests', 'handle'), file_path: 'tests/admin_scripts/management/commands/base_command.py' },
{ ...symbol(8, 'app', 'render'), file_path: 'django/shortcuts.py' }];
expect(suggestedBehaviorEntry(handlers)).toBeUndefined();
expect(suggestedBehaviorEntry(handlers, true)?.id).toBe(6);
// Ranked flows keep their order: a shallower but lower-ranked handler does not win.
expect(suggestedBehaviorEntry([handlers[0], { ...symbol(9, 'docs', 'run'), file_path: 'docs/_ext/djangodocs.py' }], true)?.id).toBe(6);
expect(suggestedBehaviorEntry([...handlers, ...entries], true)?.id).toBe(4);
});
it('reads flow entries once each and never invents an identity', () => {
const node = (id: number | undefined, name: string, filePath?: string) => ({ ...(id === undefined ? {} : { id }), name, ...(filePath ? { filePath } : {}) });
expect(flowEntries([
{ id: 0, label: 'handle → tick', entry: node(36163, 'handle', 'django/core/management/commands/runserver.py'), terminal: node(44340, 'tick'), steps: 40 },
{ id: 1, label: 'handle → len', entry: node(36163, 'handle', 'django/core/management/commands/runserver.py'), terminal: node(8040, 'len'), steps: 3 },
{ id: 2, label: 'unknown', entry: node(undefined, 'anonymous'), terminal: node(1, 'x'), steps: 1 },
])).toEqual([{ id: 36163, name: 'handle', qualified_name: '', label: '', file_path: 'django/core/management/commands/runserver.py', component_id: '' }]);
});
it('automatically requests the executable entry once and leaves manual selection authoritative', async () => {
const data = fixture(); data.entrypoints[0] = { ...data.entrypoints[0], name: 'main', file_path: 'src/main.c' };
@@ -529,6 +545,35 @@ describe('system architecture workspace', () => {
await render(vi.fn<SystemArchitectureLoader>().mockResolvedValue(response(data)));
expect(container.querySelector('.system-scene-empty[role="status"]')?.textContent).toBe('No component projection is available within this analysis budget.');
});
it('fills an empty behavior start list from ranked flows and requests a conventional handler', async () => {
const flowsLoader = vi.fn<FlowsLoader>().mockResolvedValue([
{ id: 0, label: 'run_tests → setup', entry: { id: 9, name: 'run_tests', filePath: 'tests/runtests.py' }, terminal: { id: 10, name: 'setup' }, steps: 12 },
{ id: 1, label: 'handle → tick', entry: { id: 36163, name: 'handle', filePath: 'django/core/management/commands/runserver.py' }, terminal: { id: 44340, name: 'tick' }, steps: 40 },
]);
const chosen = limitedEmptyFixture();
chosen.behavior = { ...chosen.behavior!, source_id: 36163 };
chosen.warnings = [...chosen.warnings, 'Optional behavior evidence was omitted entirely to keep the base graph within the response memory budget.'];
const loader = vi.fn<SystemArchitectureLoader>().mockResolvedValueOnce(response(limitedEmptyFixture())).mockResolvedValue(response(chosen));
await render(loader, { view: 'behavior', flowsLoader });
expect(flowsLoader).toHaveBeenCalledExactlyOnceWith('sample');
expect(loader.mock.calls.at(-1)?.[0]).toEqual({ project: 'sample', entryNodeId: 36163, expectedGeneration: 'g1', includeBehaviorEvidence: true });
const start = container.querySelector<HTMLSelectElement>('[aria-label="Behavior entry point"]')!;
expect([...start.options].map(option => option.textContent)).toEqual(['Choose an operation…', 'run_tests · tests/runtests.py',
'handle · django/core/management/commands/runserver.py']);
expect(start.value).toBe('36163');
const state = container.querySelector('.behavior-loading[role="status"]')!;
expect(state.textContent).toContain('The analysis returned no call evidence.');
expect(state.textContent).toContain('exceeded its memory budget');
expect(state.textContent).toContain('Optional behavior evidence was omitted entirely');
await choose('Behavior entry point', '9');
expect(loader.mock.calls.at(-1)?.[0].entryNodeId).toBe(9);
expect([...start.options].map(option => option.value)).toEqual(['', '9', '36163']);
});
it('does not ask for flows while the projection classifies entry points', async () => {
const flowsLoader = vi.fn<FlowsLoader>().mockResolvedValue([]);
await render(vi.fn<SystemArchitectureLoader>().mockResolvedValue(response()), { view: 'behavior', flowsLoader });
expect(flowsLoader).not.toHaveBeenCalled();
});
it('shows failures without retrying continuously', async () => {
vi.useFakeTimers(); const loader = vi.fn<SystemArchitectureLoader>().mockResolvedValue({ status: 'failed', generation: 'g1', error: 'Analysis budget exceeded.' });
await render(loader); await act(async () => { await vi.advanceTimersByTimeAsync(5000); });
@@ -5,6 +5,8 @@ import { loadSystemArchitecture, type SystemArchitectureLoader, type SystemArchi
import { componentBasis, isContiguousPath, projectionLimits, projectionUnavailable, systemOverviewGraph, systemComponents, type SystemSceneEdge, type SystemSceneModel } from './system-architecture-model';
import { architectureText as text } from './strings';
import { connectionLoad } from '../graph/connection-load';
import { AtlasApi } from '../app/atlas-api';
import type { FlowSummary } from '../traces/trace-schemas';
import BehaviorJourney from './BehaviorJourney';
import './system-architecture.css';
@@ -15,6 +17,19 @@ export interface SystemArchitectureProps {
onSelectionEvidence?: SelectionEvidenceListener;
onNavigate: (path: string, line?: number, name?: string) => void;
loader?: SystemArchitectureLoader;
flowsLoader?: FlowsLoader;
}
/** Ranked call-graph flows (route handlers, call-graph roots) from /api/flows. */
export type FlowsLoader = (project: string) => Promise<FlowSummary[]>;
const loadFlows: FlowsLoader = project => new AtlasApi().flows(project);
/** Starting operations from flows, once each. The projection supplies qualified name and component on request. */
export function flowEntries(flows: FlowSummary[]): SystemSymbol[] {
const entries = new Map<number, SystemSymbol>();
for (const { entry } of flows) if (entry.id !== undefined && entry.id > 0 && !entries.has(entry.id)) {
entries.set(entry.id, { id: entry.id, name: entry.name, qualified_name: '', label: '', file_path: entry.filePath, component_id: '' });
}
return [...entries.values()];
}
/** A limited or empty projection explains itself with the warnings that limited it. */
@@ -28,13 +43,18 @@ function EvidenceSource({ symbol, onOpen }: { symbol: SystemSymbol; onOpen: (sym
<small>{symbol.file_path ? `${symbol.file_path}${symbol.start_line ? `:${symbol.start_line}` : ''}` : 'Source location unavailable'}</small></div>;
}
/** Prefer a conventional executable entry when one exists; keep every indexed entry selectable. */
export function suggestedBehaviorEntry(entries: SystemSymbol[]): SystemSymbol | undefined {
return entries.filter(entry => /^(main|Main)$/.test(entry.name)
/**
* Prefer a conventional executable entry when one exists; keep every indexed entry selectable.
* Ranked flows are unclassified call-graph roots, so among them a conventional handler name also qualifies.
*/
export function suggestedBehaviorEntry(entries: SystemSymbol[], handlers = false): SystemSymbol | undefined {
const candidates = entries.filter(entry => (/^(main|Main)$/.test(entry.name) || (handlers && /^(run|start|serve|handle|execute|dispatch)$/i.test(entry.name)))
&& entry.file_path && !/(^|\/)(tests?|__tests__|examples?|fixtures?|vendor|node_modules)(\/|$)/i.test(entry.file_path)
&& /\.(c|cc|cpp|go|rs|py|java|cs|js|ts)$/.test(entry.file_path))
&& /\.(c|cc|cpp|go|rs|py|java|cs|js|ts)$/.test(entry.file_path));
// Flows arrive ranked by the server, so its first conventional handler is the best of them.
return candidates.filter(entry => /^(main|Main)$/.test(entry.name))
.sort((a, b) => Number(!/^src\/main\./.test(a.file_path!)) - Number(!/^src\/main\./.test(b.file_path!))
|| a.file_path!.split('/').length - b.file_path!.split('/').length || a.file_path!.localeCompare(b.file_path!))[0];
|| a.file_path!.split('/').length - b.file_path!.split('/').length || a.file_path!.localeCompare(b.file_path!))[0] ?? candidates[0];
}
const handoffs = (symbols: SystemSymbol[]) => symbols.slice(1).filter((symbol, index) => symbol.component_id !== symbols[index].component_id).length;
@@ -43,7 +63,7 @@ const connectionAllowed = (type: string, view: ConnectionView) => view === 'all'
|| (view === 'calls' ? ['CALLS', 'IMPORTS', 'HTTP_CALLS', 'ASYNC_CALLS'] : ['INHERITS', 'IMPLEMENTS']).includes(type);
/** Poll only while this view is active. A new request key hides stale results before its effect runs. */
export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture }: SystemArchitectureProps) {
export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows }: SystemArchitectureProps) {
const [entryChoice, setEntryChoice] = useState<{ project: string; generation?: string; expectedGeneration?: string; id?: number; targetId?: number }>();
const [entryChoices, setEntryChoices] = useState<{ project: string; generation?: string; analysisGeneration: string; entries: SystemSymbol[] }>();
const [snapshot, setSnapshot] = useState<{ project: string; generation?: string; analysisGeneration: string; data: SystemProjection }>();
@@ -64,6 +84,7 @@ export default function SystemArchitecture({ project, generation, view, filter,
const [focusId, setFocusId] = useState<string>();
const [stepIndex, setStepIndex] = useState(0);
const [showConnectionLoad, setShowConnectionLoad] = useState(true);
const [flows, setFlows] = useState<{ project: string; generation?: string; entries: SystemSymbol[] }>();
useEffect(() => { setSelection(undefined); setPathIndex(0); setStepIndex(0); }, [filter, cyclesOnly, includeUnconnected, includeTests, connectionView]);
const requestedEntry = view === 'behavior' && entryChoice?.project === project && entryChoice.generation === generation ? entryChoice.id : undefined;
const requestedTarget = requestedEntry === undefined ? undefined : entryChoice?.targetId;
@@ -125,12 +146,23 @@ export default function SystemArchitecture({ project, generation, view, filter,
useEffect(() => { setFocusId(undefined); setExpandedGroups([]); setScopeHistory([]); setSelection(undefined); }, [project, generation]);
const structureData = useMemo(() => data ? { ...data, dependencies: data.dependencies.filter(edge => connectionAllowed(edge.type, connectionView)) } : undefined, [data, connectionView]);
const error = current?.error ?? (current?.response?.status === 'failed' ? current.response.error ?? 'Architecture analysis failed.' : undefined);
const entries = entryChoices?.project === project && entryChoices.generation === generation ? entryChoices.entries : data?.entrypoints ?? [];
const chosenEntries = entryChoices?.project === project && entryChoices.generation === generation ? entryChoices.entries : data?.entrypoints ?? [];
// Without classified entry points (limited projections, Python without main) the ranked flows offer the starts.
const needsFlows = view === 'behavior' && active && Boolean(data) && !data?.entrypoints.length;
useEffect(() => {
if (!needsFlows || (flows?.project === project && flows.generation === generation)) return;
let live = true;
void flowsLoader(project).then(flowEntries, () => []).then(entries => { if (live) setFlows({ project, generation, entries }); });
return () => { live = false; };
}, [needsFlows, flows, project, generation, flowsLoader]);
const flowStarts = useMemo(() => flows?.project === project && flows.generation === generation ? flows.entries : [], [flows, project, generation]);
const entries = useMemo(() => data?.entrypoints.length || !flowStarts.length ? chosenEntries
: [...new Map([...flowStarts, ...chosenEntries].map(item => [item.id, item])).values()], [data, flowStarts, chosenEntries]);
useEffect(() => {
if (view !== 'behavior' || !data || (entryChoice?.project === project && entryChoice.generation === generation)) return;
const suggested = suggestedBehaviorEntry(data.entrypoints);
const suggested = data.entrypoints.length ? suggestedBehaviorEntry(data.entrypoints) : suggestedBehaviorEntry(flowStarts, true);
if (suggested) setEntryChoice({ project, generation, expectedGeneration: current?.response?.generation ?? snapshot?.analysisGeneration, id: suggested.id });
}, [view, data, project, generation, entryChoice, current?.response?.generation, snapshot?.analysisGeneration]);
}, [view, data, project, generation, entryChoice, current?.response?.generation, snapshot?.analysisGeneration, flowStarts]);
const returnedPaths = useMemo(() => {
const valid = queryData?.paths.filter(path => isContiguousPath(path)) ?? [];
if (queryData?.behavior && requestedTarget === undefined) return [];
@@ -34,6 +34,15 @@ describe('focused Behavior journeys', () => {
expect(result.scene.focusId).toBeUndefined();
});
it('treats the source_id 0 of an unselected projection as no selection', () => {
const data = projection({ paths: [path([1, 2])], behavior: behavior([], [], { source_id: 0, complete: false, limits_hit: ['select_source'] }) });
const result = behaviorJourney(data);
expect(result.entry?.id).toBe(1);
expect(result.mode).toBe('choices');
expect(drawnPairs(result)).toEqual([[1, 2]]);
expect(behaviorJourney({ ...data, entrypoints: [], paths: [] }).mode).toBe('empty');
});
it('uses exact-source witnesses and valid first hops but never aggregate counts or references', () => {
const data = projection({ paths: [path([1, 2, 8])], dependencies: [
{ source: 'app', target: 'other', type: 'CALLS', count: 500, witnesses: [] },
@@ -101,7 +101,8 @@ function corridorPath(data: SystemProjection, source: number, target: number, hi
/** A focused static journey: positions describe hops, never catalog distance or runtime order. */
export function behaviorJourney(data: SystemProjection, options: BehaviorJourneyOptions = {}): BehaviorJourney {
const entryId = options.entryId ?? data.behavior?.source_id ?? data.entrypoints[0]?.id
// The server reports source_id 0 when no operation was requested; node IDs start at 1.
const entryId = options.entryId ?? (data.behavior?.source_id || undefined) ?? data.entrypoints[0]?.id
?? data.paths.find(validPath)?.entrypoint_id;
const scopedBehavior = data.behavior?.source_id === entryId ? data.behavior : undefined;
const dependencies = [...data.dependencies, ...(data.overview?.connections ?? [])];
@@ -65,6 +65,8 @@
.behavior-immediate-list button { display: flex; align-items: center; gap: 12px; width: 100%; border-bottom: 1px solid #91a6bb12; }
.behavior-immediate-list small { margin-left: auto; color: #849aaf; font-size: 9px; white-space: nowrap; }
.behavior-loading { min-height: 310px; display: flex; align-items: center; justify-content: center; color: #829bb2; font-size: 12px; padding: 25px; text-align: center; }
.behavior-limited { display: grid; gap: 8px; max-width: 560px; }
.behavior-limited strong { color: #c4d6e6; font-weight: 500; }
.system-architecture .system-scene .system-scene-node-label[data-presentation=journey] { width: 116px; height: 58px; gap: 3px; padding: 7px; background: #14212ef2; border-radius: 8px; }
.system-architecture .system-scene .system-scene-node-label[data-presentation=journey] small { font-size: 8px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.system-architecture .system-scene .system-scene-node-label[data-presentation=journey] strong { font-size: 10px; }
+1
View File
@@ -77,4 +77,5 @@ export const architectureText = {
cohesion: (value: number) => `${Math.round(value * 100)}% cohesion`,
projectionLimited: 'The analysis returned no system structure for this project.',
projectionEmpty: 'No component projection is available within this analysis budget.',
behaviorLimited: 'The analysis returned no call evidence.',
};