From 8961287899b2ed7c1f3ffe845acfccacda3940ac Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 18:45:53 +0200 Subject: [PATCH] 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 --- graph-ui/src/architecture/BehaviorJourney.tsx | 5 +- .../architecture/SystemArchitecture.test.tsx | 47 ++++++++++++++++- .../src/architecture/SystemArchitecture.tsx | 50 +++++++++++++++---- .../behavior-journey-model.test.ts | 9 ++++ .../architecture/behavior-journey-model.ts | 3 +- .../src/architecture/behavior-journey.css | 2 + graph-ui/src/architecture/strings.ts | 1 + 7 files changed, 105 insertions(+), 12 deletions(-) diff --git a/graph-ui/src/architecture/BehaviorJourney.tsx b/graph-ui/src/architecture/BehaviorJourney.tsx index bdbc48c10..89e873665 100644 --- a/graph-ui/src/architecture/BehaviorJourney.tsx +++ b/graph-ui/src/architecture/BehaviorJourney.tsx @@ -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" /> + : limits.length ?
{text.behaviorLimited}{limits.map(limit =>

{limit}

)}
:
{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.'}
}
{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.'} Double-click an operation to follow its calls
diff --git a/graph-ui/src/architecture/SystemArchitecture.test.tsx b/graph-ui/src/architecture/SystemArchitecture.test.tsx index 9938d14e0..e3ab9033d 100644 --- a/graph-ui/src/architecture/SystemArchitecture.test.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.test.tsx @@ -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().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().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().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('[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().mockResolvedValue([]); + await render(vi.fn().mockResolvedValue(response()), { view: 'behavior', flowsLoader }); + expect(flowsLoader).not.toHaveBeenCalled(); + }); it('shows failures without retrying continuously', async () => { vi.useFakeTimers(); const loader = vi.fn().mockResolvedValue({ status: 'failed', generation: 'g1', error: 'Analysis budget exceeded.' }); await render(loader); await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); diff --git a/graph-ui/src/architecture/SystemArchitecture.tsx b/graph-ui/src/architecture/SystemArchitecture.tsx index df754544b..1c66642bd 100644 --- a/graph-ui/src/architecture/SystemArchitecture.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.tsx @@ -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; +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(); + 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 {symbol.file_path ? `${symbol.file_path}${symbol.start_line ? `:${symbol.start_line}` : ''}` : 'Source location unavailable'}; } -/** 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(); 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 []; diff --git a/graph-ui/src/architecture/behavior-journey-model.test.ts b/graph-ui/src/architecture/behavior-journey-model.test.ts index 24f511c4d..8eb3eb8f3 100644 --- a/graph-ui/src/architecture/behavior-journey-model.test.ts +++ b/graph-ui/src/architecture/behavior-journey-model.test.ts @@ -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: [] }, diff --git a/graph-ui/src/architecture/behavior-journey-model.ts b/graph-ui/src/architecture/behavior-journey-model.ts index 1597a8787..f3fc79532 100644 --- a/graph-ui/src/architecture/behavior-journey-model.ts +++ b/graph-ui/src/architecture/behavior-journey-model.ts @@ -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 ?? [])]; diff --git a/graph-ui/src/architecture/behavior-journey.css b/graph-ui/src/architecture/behavior-journey.css index 4b5fce2e1..e106cb02a 100644 --- a/graph-ui/src/architecture/behavior-journey.css +++ b/graph-ui/src/architecture/behavior-journey.css @@ -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; } diff --git a/graph-ui/src/architecture/strings.ts b/graph-ui/src/architecture/strings.ts index 9bcaf201e..b084f0405 100644 --- a/graph-ui/src/architecture/strings.ts +++ b/graph-ui/src/architecture/strings.ts @@ -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.', };