From e46f3765b4948fe2aee0a3c9dc89a6dfbe24c91e Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 18:42:55 +0200 Subject: [PATCH] fix(ui): say why the system structure is empty For Django, get_architecture returns status "limited" with zero groups and the warning that the response exceeded its memory budget. The empty map said only "No component projection is available within this analysis budget", or, with Group cycles enabled, that no cycles matched the filters; the actual reason sat in the collapsed Evidence and limits. A limited or group-less projection now picks its empty state before any filter message and shows the warnings that limited it. The two standing interpretation caveats every response carries are left out there; any other or reworded warning stays visible. Signed-off-by: Bernhard Jackiewicz --- .../architecture/SystemArchitecture.test.tsx | 37 +++++++++++++++++++ .../src/architecture/SystemArchitecture.tsx | 13 ++++++- graph-ui/src/architecture/strings.ts | 2 + .../system-architecture-model.test.ts | 18 ++++++++- .../architecture/system-architecture-model.ts | 14 +++++++ .../src/architecture/system-architecture.css | 3 +- 6 files changed, 83 insertions(+), 4 deletions(-) diff --git a/graph-ui/src/architecture/SystemArchitecture.test.tsx b/graph-ui/src/architecture/SystemArchitecture.test.tsx index 735235913..9938d14e0 100644 --- a/graph-ui/src/architecture/SystemArchitecture.test.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.test.tsx @@ -53,6 +53,22 @@ function corridorFixture(): SystemProjection { data.behavior = { ...data.behavior!, mode: 'corridor', target_id: 3, nodes: data.paths[0].nodes, edges: data.paths[0].edges }; return data; } +/** The response the server returns when the projection exceeds its JSON memory budget (Django 5.2.7). */ +function limitedEmptyFixture(): SystemProjection { + return { + schema_version: 1, status: 'limited', kind: 'static_projection', complete: false, + components: [], dependencies: [], cycles: [], entrypoints: [], paths: [], + warnings: ['Component candidates are inferred from indexed declarations and interactions; they do not establish intended responsibilities or deployment boundaries.', + 'Paths are bounded static witnesses, not observed executions. Callback references are not invocation evidence. Index coverage gaps remain possible.', + 'The architecture response exceeded its memory budget; narrow the requested projection.'], + totals: { nodes: 52402, files: 4654, edges: 274549, accounted_nodes: 0, structural_nodes: 0, components: 0, dependencies: 0, entrypoints: 0, cycles: 0, relationship_edges: 0, unmodeled_edges: 0 }, + limits: { node_budget: 100000, edge_budget: 500000, omitted_components: 0, omitted_dependencies: 0, omitted_entrypoints: 0, omitted_cycles: 0, paths_truncated: false, path_depth: 10 }, + overview: { complete: false, grouping_basis: 'common_source_directory_aggregate', groups: [], components: [], connections: [], + totals: { groups: 0, components: 0, accounted_nodes: 0, connections: 0 }, limits: { omitted_connections: 0 } }, + behavior: { mode: 'targets', source_id: 0, complete: false, corridor_complete: false, max_hops: 10, limits_hit: ['analysis_budget'], + reachable_targets: [], nodes: [], edges: [], cycles: [], totals: { reachable_nodes: 0, corridor_nodes: 0, corridor_edges: 0 } }, + }; +} function addOverviewContext(data: SystemProjection) { const extra = { id: 'context', label: 'context', basis: 'declared_module', member_count: 1, file_count: 1, representatives: [], group_id: 'g:context' }; data.components.push(extra); @@ -158,6 +174,11 @@ describe('system architecture evidence', () => { const data = fixture(); data.status = 'limited'; data.complete = false; data.warnings = ['Node budget reached.']; expect(readSystemArchitecture(response(data)).result?.complete).toBe(false); }); + it('accepts the empty limited projection exactly as the server sends it', () => { + const wire = { status: 'ready', generation: 'g1', result: { ...limitedEmptyFixture(), project: 'django-demo', elapsed_ms: 310, + overview: { ...limitedEmptyFixture().overview, grouping_depth: 1 } } }; + expect(readSystemArchitecture(wire).result?.status).toBe('limited'); + }); it('rejects projected paths whose underlying symbols do not join', () => { const data = fixture(); data.paths[0].edges[1].source_id = 99; expect(isContiguousPath(data.paths[0])).toBe(false); @@ -492,6 +513,22 @@ describe('system architecture workspace', () => { expect(container.querySelector('[data-node="store"]')).not.toBeNull(); expect(container.textContent).toContain('Test code'); expect(container.textContent).toContain('3 components returned'); }); + it('explains an empty limited projection with its own warning before any filter message', async () => { + await render(vi.fn().mockResolvedValue(response(limitedEmptyFixture()))); + const reason = () => container.querySelector('.system-scene-empty[role="status"]')?.textContent ?? ''; + expect(reason()).toContain('The analysis returned no system structure for this project.'); + expect(reason()).toContain('The architecture response exceeded its memory budget; narrow the requested projection.'); + expect(reason()).not.toContain('Component candidates are inferred'); + const cycles = [...container.querySelectorAll('label')].find(label => label.textContent?.includes('Group cycles'))!.querySelector('input')!; + await act(async () => cycles.click()); + expect(container.textContent).not.toContain('No component cycles match'); + expect(reason()).toContain('exceeded its memory budget'); + }); + it('keeps the budget message for an empty projection that names no limit', async () => { + const data = limitedEmptyFixture(); data.status = 'ready'; data.warnings = data.warnings.slice(0, 2); + 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('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 f1513ea73..df754544b 100644 --- a/graph-ui/src/architecture/SystemArchitecture.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.tsx @@ -2,7 +2,8 @@ import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; import type { GraphData, GraphNode } from '../galaxy/types'; import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence'; import { loadSystemArchitecture, type SystemArchitectureLoader, type SystemArchitectureResponse, type SystemProjection, type SystemSymbol, type SystemWitness, type SystemCallEvidence } from './system-architecture-source'; -import { componentBasis, isContiguousPath, systemOverviewGraph, systemComponents, type SystemSceneEdge, type SystemSceneModel } from './system-architecture-model'; +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 BehaviorJourney from './BehaviorJourney'; import './system-architecture.css'; @@ -16,6 +17,12 @@ export interface SystemArchitectureProps { loader?: SystemArchitectureLoader; } +/** A limited or empty projection explains itself with the warnings that limited it. */ +function UnavailableProjection({ limits }: { limits: string[] }) { + return
0}> + {limits.length ? text.projectionLimited : text.projectionEmpty}{limits.map(limit =>

{limit}

)}
; +} + function EvidenceSource({ symbol, onOpen }: { symbol: SystemSymbol; onOpen: (symbol: SystemSymbol) => void }) { return
{symbol.file_path ? `${symbol.file_path}${symbol.start_line ? `:${symbol.start_line}` : ''}` : 'Source location unavailable'}
; @@ -294,9 +301,11 @@ export default function SystemArchitecture({ project, generation, view, filter,
+ {/* The reason for an empty projection outranks any filter that would also find nothing. */} {model.nodes.length ? Preparing 3D view…
}> { toggleGroup(id); focus(id); }} onClearSelection={clearSelection} highlightedPathIndex={view === 'behavior' ? highlightedPathIndex : undefined} resetKey={resetKey} planar={planar} active={active} showConnectionLoad={showConnectionLoad} /> - :
{view === 'behavior' ? filter ? 'No source paths match this filter. Clear it to see the entry point’s returned paths.' : 'No connected source path is available for this entry point.' : cyclesOnly ? 'No component cycles match the current filters in the returned analysis.' : filter ? 'No matching components. Try a broader filter or include unconnected components.' : data.components.length && !includeUnconnected ? 'No connected components match the current view. Change the connection filter or include unconnected components.' : 'No component projection is available within this analysis budget.'}
} + : view === 'structure' && projectionUnavailable(data) ? + :
{view === 'behavior' ? filter ? 'No source paths match this filter. Clear it to see the entry point’s returned paths.' : 'No connected source path is available for this entry point.' : cyclesOnly ? 'No component cycles match the current filters in the returned analysis.' : filter ? 'No matching components. Try a broader filter or include unconnected components.' : data.components.length && !includeUnconnected ? 'No connected components match the current view. Change the connection filter or include unconnected components.' : text.projectionEmpty}
}
{view === 'behavior' ? 'Arrows are indexed calls, not an execution timeline.' : 'Select to inspect · expand a group for its members'}{showConnectionLoad ? ' · rings show link load' : ''}