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' : ''}