fix(ui): describe an opened Architecture area with nothing selected (K7)

Hand-test finding K7, Architecture part (completeness check of
2026-10-04): an area opened in the Overview with nothing selected inside
it (the hand test opened "django" at 17:55) was explained by a single
fact, "Opened: django", because the view published only the path.

SpatialArchitecture now publishes what it shows of an opened area, file
or hotspot area: its measure, the parts shown with their files and
indexed lines, its hotspot findings and the connections of its parts,
with the areas outside it named so. The chat lists them as facts: the
opened area, indexed lines and languages, "Parts shown (N, largest
first)", the hotspot findings and "Connections of its parts". Without
source the facts are the explanation and the model is not asked, as for
a selected area.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-04 00:33:17 +02:00
parent c46b3b22b9
commit a3224f7db1
4 changed files with 169 additions and 31 deletions
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import SpatialArchitecture from './SpatialArchitecture';
import type { ArchitectureSceneProps } from './ArchitectureScene';
import type { GraphData, GraphNode } from '../galaxy/types';
import type { ArchitectureOverviewDto } from '../core/intelligence-provider';
import { architectureFacts } from '../browser-ai/architecture-evidence';
/*
* K7: an area opened in the Overview with nothing selected inside it (the hand test opened
* "django" at 17:53) is explained from readable facts about that area, not "Opened: django".
*/
vi.mock('./ArchitectureScene', () => ({ ArchitectureScene: (props: ArchitectureSceneProps) => <div data-testid="scene">{props.model.nodes.map(node => <button key={node.id} onClick={() => props.onSelect(node.id)}>{node.label}</button>)}</div> }));
vi.mock('./route-graph-source', () => ({ loadRouteGraph: vi.fn(async () => ({ relationships: [], truncated: false, warnings: [] })) }));
const symbol = (id: number, name: string, path: string, label = 'Function', start = 10, end = 20): GraphNode =>
({ id, label, name, qualified_name: `sample.${name}`, file_path: path, start_line: start, end_line: end, x: 0, y: 0, z: 0, size: 1, color: '' });
const graph: GraphData = {
nodes: [
symbol(1, 'options', 'django/contrib/admin/options.py', 'Module', 1, 300), symbol(2, 'ModelAdmin', 'django/contrib/admin/options.py', 'Class'),
symbol(3, 'shortcuts', 'django/shortcuts.py', 'Module', 1, 120), symbol(4, 'render', 'django/shortcuts.py'),
symbol(5, 'query', 'django/db/models/query.py', 'Module', 1, 2000), symbol(6, 'filter', 'django/db/models/query.py', 'Method', 1487, 1500),
symbol(7, 'setup', 'setup.py', 'Module', 1, 40),
],
edges: [{ source: 4, target: 6, type: 'CALLS' }, { source: 2, target: 6, type: 'CALLS' }, { source: 1, target: 5, type: 'IMPORTS' }],
total_nodes: 7,
};
const overview: ArchitectureOverviewDto = { projectName: 'sample', totalSymbols: 7, totalRelations: 3, symbolKinds: [], relationKinds: [], languages: [], groups: [], boundaries: [], layers: [], clusters: [], entryPoints: [], routes: [],
hotspots: [{ name: 'filter', qualifiedName: 'sample.filter', filePath: 'django/db/models/query.py', line: 1487, fanIn: 1224 }, { name: 'setup', qualifiedName: 'sample.setup', filePath: 'setup.py', line: 1, fanIn: 3 }],
files: [] };
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement('div'); document.body.appendChild(host); root = createRoot(host);
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
async function click(text: string) {
const button = [...host.querySelectorAll('button')].find(item => item.textContent === text);
expect(button, text).toBeDefined(); await act(async () => button!.click());
}
it('explains an opened area with nothing selected from its files, lines, languages, parts, hotspots and connections', async () => {
const onSelectionEvidence = vi.fn();
await act(async () => root.render(<SpatialArchitecture project="sample" generation="g1" graph={graph} overview={overview} view="overview" filter="" active
onSelectionEvidence={onSelectionEvidence} onNavigate={vi.fn()} onView={vi.fn()} />));
await click('django'); await click('Open area →');
const evidence = JSON.parse(onSelectionEvidence.mock.lastCall![0].text).evidence;
expect(evidence.selected.label).toBeUndefined();
const facts = architectureFacts(evidence)?.facts ?? [];
const text = facts.join('\n');
expect(facts[0]).toBe('Opened source area: `django`.');
expect(text).toContain('2,420 indexed lines in 3 measured files of 3; files by language: Python 3.');
expect(text).toContain('Parts shown (3, largest first): `db` (area, 1 file, 2,000 indexed lines), `contrib` (area, 1 file, 300 indexed lines), `shortcuts.py` (file, 120 indexed lines).');
expect(text).toContain('1 hotspot finding: `filter` (`django/db/models/query.py:1487`) fan-in 1,224.');
expect(text).not.toContain('`setup`');
expect(text).toMatch(/Connections of its parts: .*`shortcuts\.py` → `db`: CALLS ×1/);
expect(text).toMatch(/`contrib` → `db`: (?:CALLS ×1, IMPORTS ×1|IMPORTS ×1, CALLS ×1)/);
expect(text).not.toMatch(/^Opened: /m);
});
it('names the areas outside the opened area as outside in its connections', async () => {
const onSelectionEvidence = vi.fn();
const outside: GraphData = { ...graph, edges: [...graph.edges, { source: 7, target: 4, type: 'IMPORTS' }] };
await act(async () => root.render(<SpatialArchitecture project="sample" generation="g1" graph={outside} overview={overview} view="overview" filter="" active
onSelectionEvidence={onSelectionEvidence} onNavigate={vi.fn()} onView={vi.fn()} />));
await click('django'); await click('Open area →');
const text = (architectureFacts(JSON.parse(onSelectionEvidence.mock.lastCall![0].text).evidence)?.facts ?? []).join('\n');
expect(text).toMatch(/`\(root\)` \(outside\) → `shortcuts\.py`: IMPORTS ×1/);
expect(text).not.toMatch(/Parts shown[^\n]*\(root\)/);
});
it('names the hotspot findings of an opened hotspot area', async () => {
const onSelectionEvidence = vi.fn();
await act(async () => root.render(<SpatialArchitecture project="sample" generation="g1" graph={graph} overview={overview} view="hotspots" filter="" active
place={{ planar: false, hotspotArea: 'django' }} onPlace={vi.fn()} onSelectionEvidence={onSelectionEvidence} onNavigate={vi.fn()} onView={vi.fn()} />));
const facts = architectureFacts(JSON.parse(onSelectionEvidence.mock.lastCall![0].text).evidence)?.facts ?? [];
expect(facts[0]).toBe('Opened hotspot area: `django`.');
expect(facts.join('\n')).toContain('1 hotspot finding: `filter` (`django/db/models/query.py:1487`) fan-in 1,224.');
});
@@ -124,11 +124,31 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
target: nodesById.get(edge.target)?.label ?? edge.target, type: edge.type, count: edge.count,
evidence: edge.evidence.slice(0, 24).map(item => ({ ...item, source: graphNodeEvidence(item.source), target: graphNodeEvidence(item.target) })),
omittedEvidence: Math.max(0, edge.evidence.length - 24) });
// Inside an opened area, file or hotspot area with nothing selected, the chat explains that scope (K7):
// its measure, the parts shown, its hotspot findings and the connections between the parts.
const openedEvidence = () => {
const scopeNode = (kind: 'area' | 'file', path: string): SemanticNode => ({ id: `${kind}:${path}`, kind, label: path, detail: '', position: [0, 0, 0], count: 0, members: [],
...kind === 'area' ? { areaPath: path } : { filePath: path } });
// Largest first, areas before files: the first parts named are the ones that make up the area.
const parts = model.nodes.filter(node => (node.kind === 'area' || node.kind === 'file') && !node.external)
.map(node => ({ node, measure: measureSourceNode(node, catalog) }))
.sort((a, b) => a.node.kind !== b.node.kind ? a.node.kind === 'area' ? -1 : 1 : (b.measure?.lines ?? -1) - (a.measure?.lines ?? -1) || (a.node.label < b.node.label ? -1 : 1));
const links = [...model.edges].sort((a, b) => b.count - a.count);
return {
selected: { areaPath, filePath, hotspotArea, measurement: openedScope?.measure,
hotspots: hotspotArea ? hotspotCatalog.byArea.get(hotspotArea) : filePath ? hotspotsForNode(scopeNode('file', filePath), hotspotCatalog) : areaPath ? hotspotsForNode(scopeNode('area', areaPath), hotspotCatalog) : undefined,
parts: hotspotArea ? [] : parts.slice(0, 24).map(({ node, measure }) => ({ kind: node.kind, label: node.label, files: measure?.files, lines: measure?.lines })),
partCount: hotspotArea ? 0 : parts.length, outside: hotspotArea ? [] : model.nodes.filter(node => node.external).slice(0, 24).map(node => node.label) },
relationships: hotspotArea ? undefined : { count: links.length, items: links.slice(0, 24).map(edge => ({ source: nodesById.get(edge.source)?.label ?? edge.source,
target: nodesById.get(edge.target)?.label ?? edge.target, type: edge.type, count: edge.count })), omitted: Math.max(0, links.length - 24) },
};
};
const opened = !selectedNode && !selectedEdge && (areaPath || filePath || hotspotArea) ? openedEvidence() : undefined;
useSelectionEvidence(onSelectionEvidence, selectedNode || selectedEdge || areaPath || filePath || hotspotArea ? {
project, generation, view: `architecture-${view}`, source: 'indexed repository graph and architecture summary',
label: selectedNode?.label ?? (selectedEdge ? `${nodesById.get(selectedEdge.source)?.label} → ${nodesById.get(selectedEdge.target)?.label}` : filePath ?? areaPath ?? hotspotArea ?? model.title),
selected: selectedEdge ? edgeEvidence(selectedEdge) : selectedNode ? { ...nodeEvidence(selectedNode), measurement: selectedMeasure, hotspots: selectedHotspots } : { areaPath, filePath, hotspotArea },
relationships: selectedNode ? { count: edges.length, items: edges.slice(0, 24).map(edgeEvidence), omitted: Math.max(0, edges.length - 24) } : undefined,
selected: selectedEdge ? edgeEvidence(selectedEdge) : selectedNode ? { ...nodeEvidence(selectedNode), measurement: selectedMeasure, hotspots: selectedHotspots } : opened?.selected,
relationships: selectedNode ? { count: edges.length, items: edges.slice(0, 24).map(edgeEvidence), omitted: Math.max(0, edges.length - 24) } : opened?.relationships,
scope: { view, areaPath, filePath, hotspotArea, visibleNodes: model.nodes.length, visibleEdges: model.edges.length },
limitations: { omittedNodes: model.omittedNodes, omittedEdges: model.omittedEdges, warnings: model.warnings,
graphNote, interpretation: 'Source areas group source locations. Hotspots measure static references, not runtime frequency. Relationships do not prove execution.' },
@@ -47,6 +47,57 @@ function names(items: Row[], limit: number, total?: number): string {
return `${listed.join(', ')}${more ? `; ${words.more(more)}` : ''}`;
}
/** The measured lines and languages of an area or file. */
function measuredFacts(measure: Row | undefined): string[] {
const languages = rows(measure?.languages).flatMap(item => { const name = text(item.name, 40), files = count(item.files); return name && files ? [`${name} ${number(files)}`] : []; });
return measure && count(measure.lines) !== undefined ? [words.measured(count(measure.lines)!, count(measure.measuredFiles) ?? 0, count(measure.files) ?? 0, languages)] : [];
}
/** The ranked hotspot findings, at most five by name, place and measure. */
function hotspotFacts(group: Row | undefined): string[] {
const findings = rows(group?.findings);
if (!findings.length) return [];
const ranked = findings.map(item => ({ name: text(item.name, 120), path: text(item.filePath, 240), line: count(item.line), fanIn: count(item.fanIn), complexity: count(item.complexity) }))
.filter(item => item.name).slice(0, 5)
.map(item => `${quote(item.name!)}${item.path ? ` (${quote(`${item.path}${item.line ? `:${item.line}` : ''}`)})` : ''}${item.fanIn !== undefined ? ` ${words.fanIn(item.fanIn)}` : item.complexity !== undefined ? ` ${words.complexity(item.complexity)}` : ''}`);
return [words.hotspots(findings.length, ranked)];
}
/** Connections summed by the part at the other end (or the pair, without one) and the edge type.
* Parts in `outside` lie outside the opened area and are named so. */
function connectionLines(items: Row[], label?: string, outside: ReadonlySet<string> = new Set()): string[] {
const sides = new Map<string, Map<string, number>>();
const part = (name: string) => outside.has(name) ? words.outside(quote(name)) : quote(name);
for (const item of items) {
const from = text(item.source, 120), to = text(item.target, 120), edge = text(item.type, 40);
if (!from || !to || !edge) continue;
const key = label === undefined ? `${part(from)} → ${part(to)}` : from === label ? words.to(to) : to === label ? words.from(from) : `${from} → ${to}`;
const types = sides.get(key) ?? new Map<string, number>();
types.set(edge, (types.get(edge) ?? 0) + (count(item.count) ?? 1)); sides.set(key, types);
}
return [...sides].map(([side, types]) => `${side}: ${[...types].sort((a, b) => b[1] - a[1]).map(([edge, total]) => `${edge} ×${number(total)}`).join(', ')}`);
}
/** An opened area, file or hotspot area with nothing selected inside it (K7): "Opened: django"
* said nothing, so the scope is described by what the view shows of it. */
function openedFacts(selected: Row, relationships: Row | undefined): ArchitectureFacts {
const file = text(selected.filePath, 240), area = text(selected.areaPath, 240), hotspotArea = text(selected.hotspotArea, 240);
const opened = file ?? area ?? hotspotArea;
if (!opened) return { facts: [] };
const facts = [file ? words.openedFile(quote(file)) : area ? words.openedArea(quote(area)) : words.openedHotspots(quote(opened))];
facts.push(...measuredFacts(record(selected.measurement)));
const parts = rows(selected.parts).flatMap(item => {
const label = text(item.label, 160), kind = text(item.kind, 20);
return label && (kind === 'area' || kind === 'file') ? [words.scopePart(quote(label), kind, kind === 'area' ? count(item.files) : undefined, count(item.lines))] : [];
});
if (parts.length) facts.push(words.parts(parts, Math.max(count(selected.partCount) ?? 0, parts.length)));
facts.push(...hotspotFacts(record(selected.hotspots)));
const outside = new Set((Array.isArray(selected.outside) ? selected.outside : []).flatMap(name => text(name, 120) ?? []));
const described = connectionLines(rows(relationships?.items), undefined, outside);
if (described.length) facts.push(words.partConnections(described, count(relationships?.omitted) ?? 0));
return { facts };
}
/** Overview, Hotspots, Routes and Entry points: a source area, file, symbol, route or connection. */
function spatialFacts(selected: Row, relationships: Row | undefined, scope: Row | undefined): ArchitectureFacts {
const facts: string[] = [];
@@ -62,40 +113,17 @@ function spatialFacts(selected: Row, relationships: Row | undefined, scope: Row
}
const label = text(selected.label, 160);
const kind = text(selected.kind, 20);
if (!label || !kind) {
const opened = text(selected.filePath, 240) ?? text(selected.areaPath, 240) ?? text(selected.hotspotArea, 240);
return { facts: opened ? [words.opened(quote(opened))] : [] };
}
if (!label || !kind) return openedFacts(selected, relationships);
const members = rows(selected.members);
const total = count(selected.memberCount) ?? members.length;
const symbol = kind === 'symbol' ? symbolOf(members[0]) : undefined;
facts.push(kind === 'symbol' && symbol ? words.selectedSymbol(located(symbol)) : words.selected(words.kinds[kind as keyof typeof words.kinds] ?? kind, quote(label), text(selected.detail, 160)));
const measure = record(selected.measurement);
const languages = rows(measure?.languages).flatMap(item => { const name = text(item.name, 40), files = count(item.files); return name && files ? [`${name} ${number(files)}`] : []; });
if (measure && count(measure.lines) !== undefined) facts.push(words.measured(count(measure.lines)!, count(measure.measuredFiles) ?? 0, count(measure.files) ?? 0, languages));
const findings = rows(record(selected.hotspots)?.findings);
if (findings.length) {
const ranked = findings.map(item => ({ name: text(item.name, 120), path: text(item.filePath, 240), line: count(item.line), fanIn: count(item.fanIn), complexity: count(item.complexity) }))
.filter(item => item.name).slice(0, 5)
.map(item => `${quote(item.name!)}${item.path ? ` (${quote(`${item.path}${item.line ? `:${item.line}` : ''}`)})` : ''}${item.fanIn !== undefined ? ` ${words.fanIn(item.fanIn)}` : item.complexity !== undefined ? ` ${words.complexity(item.complexity)}` : ''}`);
facts.push(words.hotspots(findings.length, ranked));
}
facts.push(...measuredFacts(record(selected.measurement)));
facts.push(...hotspotFacts(record(selected.hotspots)));
if (kind !== 'symbol' && members.length) facts.push(words.members(names(members, 8, total)));
// Connections of the selection, summed by the part at the other end and the edge type.
const items = rows(relationships?.items);
if (items.length) {
const sides = new Map<string, Map<string, number>>();
for (const item of items) {
const from = text(item.source, 120), to = text(item.target, 120), edge = text(item.type, 40);
if (!from || !to || !edge) continue;
const key = from === label ? words.to(to) : to === label ? words.from(from) : `${from} → ${to}`;
const types = sides.get(key) ?? new Map<string, number>();
types.set(edge, (types.get(edge) ?? 0) + (count(item.count) ?? 1)); sides.set(key, types);
}
const described = [...sides].map(([side, types]) => `${side}: ${[...types].sort((a, b) => b[1] - a[1]).map(([edge, total]) => `${edge} ×${number(total)}`).join(', ')}`);
const omitted = count(relationships?.omitted) ?? 0;
if (described.length) facts.push(words.connections(described, omitted));
}
const described = connectionLines(rows(relationships?.items), label);
if (described.length) facts.push(words.connections(described, count(relationships?.omitted) ?? 0));
const visible = count(scope?.visibleNodes);
if (visible !== undefined) facts.push(words.view(text(scope?.view, 40) ?? 'overview', visible, count(scope?.visibleEdges) ?? 0));
return { facts, target: targetOf(symbol) };
+8 -1
View File
@@ -196,7 +196,14 @@ export const architectureWords = {
more: (value: number) => `+${count(value)} more`,
selected: (kind: string, name: string, detail?: string) => `Selected ${kind}: ${name}${detail ? ` (${detail})` : ''}.`,
selectedSymbol: (symbol: string) => `Selected symbol: ${symbol}.`,
opened: (path: string) => `Opened: ${path}.`,
openedArea: (path: string) => `Opened source area: ${path}.`,
openedFile: (path: string) => `Opened file: ${path}.`,
openedHotspots: (path: string) => `Opened hotspot area: ${path}.`,
scopePart: (name: string, kind: string, files?: number, lines?: number) =>
`${name} (${kind}${files !== undefined ? `, ${plural(files, 'file', 'files')}` : ''}${lines !== undefined ? `, ${plural(lines, 'indexed line', 'indexed lines')}` : ''})`,
parts: (items: readonly string[], total: number) => `Parts shown (${count(total)}, largest first): ${items.join(', ')}${total > items.length ? `; ${count(total - items.length)} more` : ''}.`,
outside: (name: string) => `${name} (outside)`,
partConnections: (described: readonly string[], omitted: number) => `Connections of its parts: ${described.join('; ')}${omitted ? `; ${plural(omitted, 'more connection', 'more connections')} not listed` : ''}.`,
measured: (lines: number, measured: number, files: number, languages: readonly string[]) =>
`${plural(lines, 'indexed line', 'indexed lines')} in ${plural(measured, 'measured file', 'measured files')} of ${count(files)}${languages.length ? `; files by language: ${languages.join(', ')}` : ''}.`,
fanIn: (value: number) => `fan-in ${count(value)}`,