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 => )}
})); +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)}`,