diff --git a/graph-ui/src/architecture/SpatialArchitecture.evidence.test.tsx b/graph-ui/src/architecture/SpatialArchitecture.evidence.test.tsx
new file mode 100644
index 000000000..b5ccfadd0
--- /dev/null
+++ b/graph-ui/src/architecture/SpatialArchitecture.evidence.test.tsx
@@ -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) =>
{props.model.nodes.map(node => props.onSelect(node.id)}>{node.label} )}
}));
+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).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( ));
+ 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( ));
+ 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( ));
+ 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.');
+});
diff --git a/graph-ui/src/architecture/SpatialArchitecture.tsx b/graph-ui/src/architecture/SpatialArchitecture.tsx
index 9f2df3a4d..7d1c34e2c 100644
--- a/graph-ui/src/architecture/SpatialArchitecture.tsx
+++ b/graph-ui/src/architecture/SpatialArchitecture.tsx
@@ -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.' },
diff --git a/graph-ui/src/browser-ai/architecture-evidence.ts b/graph-ui/src/browser-ai/architecture-evidence.ts
index c41bccf08..b0f39dfc1 100644
--- a/graph-ui/src/browser-ai/architecture-evidence.ts
+++ b/graph-ui/src/browser-ai/architecture-evidence.ts
@@ -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 = new Set()): string[] {
+ const sides = new Map>();
+ 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();
+ 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>();
- 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();
- 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) };
diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts
index 4933a0241..295d3f6a8 100644
--- a/graph-ui/src/browser-ai/strings.ts
+++ b/graph-ui/src/browser-ai/strings.ts
@@ -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)}`,