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 <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-02 18:42:55 +02:00
parent 6e400a35e7
commit e46f3765b4
6 changed files with 83 additions and 4 deletions
@@ -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<SystemArchitectureLoader>().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<SystemArchitectureLoader>().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<SystemArchitectureLoader>().mockResolvedValue({ status: 'failed', generation: 'g1', error: 'Analysis budget exceeded.' });
await render(loader); await act(async () => { await vi.advanceTimersByTimeAsync(5000); });
@@ -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 <div className="system-scene-empty" role="status" data-limited={limits.length > 0}>
<strong>{limits.length ? text.projectionLimited : text.projectionEmpty}</strong>{limits.map(limit => <p key={limit}>{limit}</p>)}</div>;
}
function EvidenceSource({ symbol, onOpen }: { symbol: SystemSymbol; onOpen: (symbol: SystemSymbol) => void }) {
return <div className="system-source"><button disabled={!symbol.file_path} onClick={() => onOpen(symbol)} title={symbol.qualified_name}>{symbol.name}</button>
<small>{symbol.file_path ? `${symbol.file_path}${symbol.start_line ? `:${symbol.start_line}` : ''}` : 'Source location unavailable'}</small></div>;
@@ -294,9 +301,11 @@ export default function SystemArchitecture({ project, generation, view, filter,
<div className="system-view-switch" role="group" aria-label="System camera"><button aria-pressed={!planar} onClick={() => setPlanar(false)}>3D</button><button aria-pressed={planar} onClick={() => setPlanar(true)}>Plan</button></div>
</div>
<div className="system-workspace"><div className="system-map">
{/* The reason for an empty projection outranks any filter that would also find nothing. */}
{model.nodes.length ? <Suspense fallback={<div className="system-scene-empty">Preparing 3D view…</div>}><Scene model={model} selectedNode={selectedSceneNode} selectedEdge={selection?.edge}
onSelectNode={selectNode} onSelectEdge={selectEdge} onExpandNode={id => { toggleGroup(id); focus(id); }} onClearSelection={clearSelection} highlightedPathIndex={view === 'behavior' ? highlightedPathIndex : undefined} resetKey={resetKey} planar={planar} active={active} showConnectionLoad={showConnectionLoad} /></Suspense>
: <div className="system-scene-empty">{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.'}</div>}
: view === 'structure' && projectionUnavailable(data) ? <UnavailableProjection limits={projectionLimits(data)} />
: <div className="system-scene-empty">{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}</div>}
<div className="system-map-caption"><span>{view === 'behavior' ? 'Arrows are indexed calls, not an execution timeline.' : 'Select to inspect · expand a group for its members'}{showConnectionLoad ? ' · rings show link load' : ''}</span>
<button onClick={() => setResetKey(value => value + 1)}>Fit view</button></div>
</div><aside className="system-inspector" aria-label="System evidence inspector">
+2
View File
@@ -75,4 +75,6 @@ export const architectureText = {
focusGroup: (name: string) => `Show relationships for ${name}`,
groupStats: (symbols: number) => `${symbols.toLocaleString()} symbols`,
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.',
};
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isContiguousPath, rankSystemPaths, systemBehaviorGraph, systemComponentGraph, systemOverviewGraph, systemBehaviorOverviewGraph } from './system-architecture-model';
import { isContiguousPath, projectionLimits, projectionUnavailable, rankSystemPaths, systemBehaviorGraph, systemComponentGraph, systemOverviewGraph, systemBehaviorOverviewGraph } from './system-architecture-model';
import type { SystemComponent, SystemDependency, SystemPath, SystemProjection, SystemSymbol } from './system-architecture-source';
const symbol = (id: number, component = 'app'): SystemSymbol => ({ id, name: `f${id}`, qualified_name: `app.f${id}`, label: 'Function', component_id: component, file_path: `src/${component}.ts`, start_line: id });
@@ -21,6 +21,22 @@ const overviewProjection = (ids: string[], dependencies: SystemDependency[]): Sy
components, connections: dependencies, totals: { groups: ids.length, components: ids.length }, limits: { omitted_connections: 0 } } };
};
describe('projection limits', () => {
it('drops only the two standing caveats and keeps every other or reworded warning', () => {
const data = projection([component('app')]);
data.warnings = ['Component candidates are inferred from indexed declarations and interactions.', 'Paths are bounded static witnesses, not observed executions.',
'Graph exceeds the node budget; no sampled architecture was inferred.', 'Component candidates may be inferred differently in a future version.'];
expect(projectionLimits(data)).toEqual(['Graph exceeds the node budget; no sampled architecture was inferred.', 'Component candidates may be inferred differently in a future version.']);
});
it('treats a limited answer or one without groups as unavailable, independent of filters', () => {
expect(projectionUnavailable(projection([component('app')]))).toBe(false);
expect(projectionUnavailable({ ...projection([component('app')]), status: 'limited' })).toBe(true);
expect(projectionUnavailable(projection([]))).toBe(true);
expect(projectionUnavailable({ ...projection([component('app')]), overview: { complete: false, grouping_basis: 'common_source_directory_aggregate',
groups: [], components: [], connections: [], totals: {}, limits: {} } })).toBe(true);
});
});
describe('persistent system overview', () => {
it('keeps directory siblings on a shared platform without assigning execution order', () => {
const data = overviewProjection(['pkg/a', 'src/b', 'tools', 'src/a', 'pkg/b'], [dependency('pkg/a', 'src/a')]);
@@ -32,6 +32,20 @@ export const componentBasis = (basis: string): string => basis === 'declared_mod
: basis === 'interaction_community' ? 'Inferred collaboration' : basis === 'unassigned' ? 'Unassigned'
: basis === 'common_source_directory_aggregate' ? 'Source directory group' : basis === 'aggregate_remainder' ? 'Other components' : 'Inferred component';
/*
* Every projection opens its warnings with the same two interpretation caveats
* and appends what actually limited this answer. Only the caveats are left out;
* an unknown or reworded warning stays visible, so a change in the server text
* can never hide a limit.
*/
const STANDING_CAVEATS = [/^Component candidates are inferred\b/, /^Paths are bounded static witnesses\b/];
/** The warnings that explain this particular projection, without the standing caveats. */
export const projectionLimits = (data: SystemProjection): string[] =>
data.warnings.filter(warning => !STANDING_CAVEATS.some(caveat => caveat.test(warning)));
/** A limited or empty projection has nothing to draw, whatever the current filters are. */
export const projectionUnavailable = (data: SystemProjection): boolean =>
data.status === 'limited' || !(data.overview ? data.overview.groups.length : data.components.length);
export interface SystemOverviewOptions {
expandedGroupIds?: readonly string[]; focusId?: string; relationshipTypes?: readonly string[];
includeTests?: boolean; includeUnconnected?: boolean; filter?: string; cyclesOnly?: boolean; layoutKey?: string;
@@ -73,7 +73,8 @@
.system-path-list li > span { color: var(--atlas-dim); font-size: 10px; }
.system-scene-empty, .system-analysis-state { display: flex; align-items: center; justify-content: center; text-align: center; flex-direction: column; gap: 12px; height: 100%; min-height: 260px; padding: 30px; color: var(--atlas-dim); }
.system-analysis-state { border: 1px solid var(--atlas-line); border-radius: 12px; }
.system-analysis-state strong { color: var(--atlas-fg); font-weight: 500; }
.system-analysis-state strong, .system-scene-empty[data-limited] strong { color: var(--atlas-fg); font-weight: 500; }
.system-scene-empty[data-limited="true"] p { color: var(--atlas-fg); max-width: 560px; }
.system-progress { width: 26px; height: 26px; border: 1px solid #869f9638; border-top-color: #a0b6aa; border-radius: 50%; animation: system-progress-turn 1.3s linear infinite; }
@keyframes system-progress-turn { to { transform: rotate(360deg); } }
.system-architecture .system-scene-node-label { display: grid; align-content: center; gap: 0; width: 96px; height: 28px; min-height: 0; box-sizing: border-box; margin: 0; background: #101816ec; color: #c0cec7; border: 1px solid #78928638; border-radius: 6px; padding: 6px 7px; text-align: center; line-height: 14px; font-family: var(--atlas-ui-font, system-ui, sans-serif); box-shadow: 0 2px 8px #0002; }