From 4e9ff2005f82ac3d4a5aa323ec0f68b198092982 Mon Sep 17 00:00:00 2001 From: PARTH J ROHIT Date: Fri, 7 Aug 2026 21:38:41 +0100 Subject: [PATCH 1/2] feat(frontend): add non-visual architecture graph list view (#239) Add a List View tab alongside the graph/request-flow/heatmap tabs that renders the same sealed-snapshot model as semantic tables (modules, relationships, diagnostics), so screen-reader and keyboard-only users can review the full architecture graph without traversing the canvas node by node. Selecting a row updates the shared selection, keeping the table, canvas, and inspector in sync. --- .../architecture/components/ArchWorkspace.tsx | 8 +- .../components/ArchitectureListView.test.tsx | 134 +++++++++++ .../components/ArchitectureListView.tsx | 212 ++++++++++++++++++ .../src/features/architecture/store.ts | 4 +- 4 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 apps/frontend/src/features/architecture/components/ArchitectureListView.test.tsx create mode 100644 apps/frontend/src/features/architecture/components/ArchitectureListView.tsx diff --git a/apps/frontend/src/features/architecture/components/ArchWorkspace.tsx b/apps/frontend/src/features/architecture/components/ArchWorkspace.tsx index 1661648..7b5969d 100644 --- a/apps/frontend/src/features/architecture/components/ArchWorkspace.tsx +++ b/apps/frontend/src/features/architecture/components/ArchWorkspace.tsx @@ -16,6 +16,7 @@ import { toPng, toSvg } from 'html-to-image'; import { AnimatePresence } from 'framer-motion'; import { ArchitectureNode } from './ArchitectureNode'; +import { ArchitectureListView } from './ArchitectureListView'; import { NodeInspector } from './NodeInspector'; import { ModuleExplorer } from './ModuleExplorer'; import { GraphToolbar } from './GraphToolbar'; @@ -323,6 +324,9 @@ function ArchWorkspaceInner({ model, source }: ArchWorkspaceInnerProps) { setActiveTab('heatmap')}> Heatmap + setActiveTab('list')}> + List View + {activeTab === 'heatmap' && } {isolatedSubtree && ( @@ -396,10 +400,12 @@ function ArchWorkspaceInner({ model, source }: ArchWorkspaceInnerProps) { )} - ) : ( + ) : activeTab === 'request-flow' ? (
+ ) : ( + )} diff --git a/apps/frontend/src/features/architecture/components/ArchitectureListView.test.tsx b/apps/frontend/src/features/architecture/components/ArchitectureListView.test.tsx new file mode 100644 index 0000000..65db29b --- /dev/null +++ b/apps/frontend/src/features/architecture/components/ArchitectureListView.test.tsx @@ -0,0 +1,134 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ArchitectureModel, ArchNode } from '@/shared/types/architecture'; +import { useArchitectureStore } from '../store'; +import { ArchitectureListView } from './ArchitectureListView'; + +function node(id: string, name: string, layer: string, relationshipState: ArchNode['relationshipState']): ArchNode { + return { + id, + name, + type: 'service', + description: '', + responsibilities: [], + files: [`src/${name.toLowerCase()}.ts`], + dependencies: [], + dependents: [], + estimatedComplexity: 'low', + estimatedLines: 1, + tags: [], + layer, + relationshipState, + }; +} + +function model(): ArchitectureModel { + return { + repositoryId: 'repo-1', + repositoryName: 'fixture', + architectureType: 'Repository Architecture', + detectedLayers: [ + { id: 'presentation', name: 'Presentation', nodes: ['module:beta'], order: 0 }, + { id: 'business-logic', name: 'Business Logic', nodes: ['module:alpha'], order: 1 }, + ], + nodes: [ + node('module:alpha', 'Alpha', 'business-logic', 'connected'), + node('module:beta', 'Beta', 'presentation', 'unresolved'), + ], + edges: [ + { + id: 'edge:1', + source: 'module:alpha', + target: 'module:beta', + type: 'import', + predicate: 'imports', + truthClass: 'inferred', + evidence: [{ snapshotId: 'snap_1', factId: 'edge:sha256:abc', path: 'src/alpha.ts', startLine: 4, endLine: 4 }], + }, + ], + modules: [], + requestFlow: [], + relationshipSnapshotId: 'snap_1', + diagnostics: [ + { + code: 'RI-RES-UNRESOLVED', + category: 'resolution', + severity: 'warning', + message: 'Could not resolve a relationship target', + path: 'src/beta.ts', + startLine: 9, + nodeIds: ['module:beta'], + }, + ], + summary: { + language: 'TypeScript', + framework: 'Unknown', + totalModules: 2, + totalNodes: 2, + entryPoint: '/', + architecturePattern: 'Repository Architecture', + }, + }; +} + +describe('ArchitectureListView', () => { + beforeEach(() => { + useArchitectureStore.setState({ model: model(), selectedNodeId: null }); + }); + + afterEach(() => { + useArchitectureStore.setState({ model: null, selectedNodeId: null }); + }); + + it('lists every module ordered by layer then name, with type/layer/relationship state', () => { + render(); + + const rows = screen.getAllByRole('row').slice(1, 3); // skip header row of the modules table + expect(rows[0]).toHaveTextContent('Beta'); + expect(rows[0]).toHaveTextContent('Presentation'); + expect(rows[0]).toHaveTextContent('unresolved'); + expect(rows[1]).toHaveTextContent('Alpha'); + expect(rows[1]).toHaveTextContent('Business Logic'); + expect(rows[1]).toHaveTextContent('connected'); + }); + + it('selecting a module row updates the shared selection used by the canvas and inspector', () => { + render(); + + screen.getAllByRole('button', { name: 'Alpha' })[0].click(); + + expect(useArchitectureStore.getState().selectedNodeId).toBe('module:alpha'); + }); + + it('shows relationship source/target names, predicate, and truth state, and syncs selection', () => { + render(); + + expect(screen.getByText('imports')).toBeInTheDocument(); + expect(screen.getByText('inferred')).toBeInTheDocument(); + + screen.getAllByRole('button', { name: 'Beta' })[0].click(); + expect(useArchitectureStore.getState().selectedNodeId).toBe('module:beta'); + }); + + it('shows diagnostics with severity, message, location, and a link back to the related module', () => { + render(); + + expect(screen.getByText('Could not resolve a relationship target')).toBeInTheDocument(); + expect(screen.getByText('src/beta.ts:9')).toBeInTheDocument(); + + const relatedModuleButtons = screen.getAllByRole('button', { name: 'Beta' }); + relatedModuleButtons[relatedModuleButtons.length - 1].click(); + expect(useArchitectureStore.getState().selectedNodeId).toBe('module:beta'); + }); + + it('renders explicit empty states when a snapshot has no relationships or diagnostics', () => { + useArchitectureStore.setState({ + model: { ...model(), edges: [], diagnostics: [] }, + }); + + render(); + + expect(screen.getByText('No relationships in this snapshot.')).toBeInTheDocument(); + expect(screen.getByText('No diagnostics were recorded for this snapshot.')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/features/architecture/components/ArchitectureListView.tsx b/apps/frontend/src/features/architecture/components/ArchitectureListView.tsx new file mode 100644 index 0000000..b7eda57 --- /dev/null +++ b/apps/frontend/src/features/architecture/components/ArchitectureListView.tsx @@ -0,0 +1,212 @@ +import { cn } from '@/shared/utils/cn'; +import type { ArchitectureDiagnostic } from '@/shared/types/architecture'; +import { useArchitectureStore } from '../store'; + +const SEVERITY_RANK: Record = { + fatal: 0, + error: 1, + warning: 2, + info: 3, +}; + +/** + * Complete non-visual list/table equivalent of the Architecture Graph tab (#239): + * every node, relationship, and diagnostic in the sealed snapshot, sourced from + * the same `useArchitectureStore` model the canvas reads, kept in sync via + * `setSelectedNodeId` (the same selection the canvas and NodeInspector use). + */ +export function ArchitectureListView() { + const { model, selectedNodeId, setSelectedNodeId } = useArchitectureStore(); + + if (!model) return null; + + const layerNameById = new Map(model.detectedLayers.map((layer) => [layer.id, layer.name])); + const layerOrderById = new Map(model.detectedLayers.map((layer) => [layer.id, layer.order])); + const nodeNameById = new Map(model.nodes.map((node) => [node.id, node.name])); + + const sortedNodes = [...model.nodes].sort((a, b) => { + const layerDiff = + (layerOrderById.get(a.layer) ?? Number.MAX_SAFE_INTEGER) - + (layerOrderById.get(b.layer) ?? Number.MAX_SAFE_INTEGER); + return layerDiff !== 0 ? layerDiff : a.name.localeCompare(b.name); + }); + + const sortedEdges = [...model.edges].sort((a, b) => { + const sourceCmp = (nodeNameById.get(a.source) ?? a.source).localeCompare(nodeNameById.get(b.source) ?? b.source); + if (sourceCmp !== 0) return sourceCmp; + return (nodeNameById.get(a.target) ?? a.target).localeCompare(nodeNameById.get(b.target) ?? b.target); + }); + + const sortedDiagnostics = [...model.diagnostics].sort((a, b) => { + const rankDiff = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]; + return rankDiff !== 0 ? rankDiff : a.code.localeCompare(b.code); + }); + + return ( +
+

+ Complete non-visual inventory of the same architecture graph shown in the Architecture Graph tab. Activating + a module or relationship endpoint below selects it, matching the canvas and inspector selection. +

+ +
+

+ Modules ({sortedNodes.length}) +

+ {sortedNodes.length === 0 ? ( +

No modules in this snapshot.

+ ) : ( +
+ + + + + + + + + + + {sortedNodes.map((node) => ( + + + + + + + ))} + +
NameTypeLayerRelationship state
+ + {node.type.replace(/-/g, ' ')} + {(layerNameById.get(node.layer) ?? node.layer).replace(/-/g, ' ')} + + {node.relationshipState.replace(/-/g, ' ')} +
+
+ )} +
+ +
+

+ Relationships ({sortedEdges.length}) +

+ {sortedEdges.length === 0 ? ( +

No relationships in this snapshot.

+ ) : ( +
+ + + + + + + + + + + + {sortedEdges.map((edge) => ( + + + + + + + + ))} + +
SourceTargetTypePredicateTruth state
+ + + + {edge.type.replace(/-/g, ' ')}{edge.predicate.replace(/_/g, ' ')}{edge.truthClass}
+
+ )} +
+ +
+

+ Diagnostics ({sortedDiagnostics.length}) +

+ {sortedDiagnostics.length === 0 ? ( +

No diagnostics were recorded for this snapshot.

+ ) : ( +
+ + + + + + + + + + + + {sortedDiagnostics.map((diagnostic, index) => ( + + + + + + + + ))} + +
SeverityCodeMessageLocationRelated modules
{diagnostic.severity}{diagnostic.code}{diagnostic.message} + {diagnostic.path + ? `${diagnostic.path}${diagnostic.startLine ? `:${diagnostic.startLine}` : ''}` + : 'Not localised'} + + {diagnostic.nodeIds && diagnostic.nodeIds.length > 0 ? ( +
+ {diagnostic.nodeIds.map((id) => ( + + ))} +
+ ) : ( + + )} +
+
+ )} +
+
+ ); +} + +function NodeRefButton({ + id, + name, + onSelect, + compact = false, +}: { + id: string; + name: string | undefined; + onSelect: (id: string) => void; + compact?: boolean; +}) { + return ( + + ); +} diff --git a/apps/frontend/src/features/architecture/store.ts b/apps/frontend/src/features/architecture/store.ts index da64e09..55dd6fa 100644 --- a/apps/frontend/src/features/architecture/store.ts +++ b/apps/frontend/src/features/architecture/store.ts @@ -28,8 +28,8 @@ interface ArchitectureState { showGrid: boolean; setShowGrid: (show: boolean) => void; - activeTab: 'graph' | 'request-flow' | 'heatmap'; - setActiveTab: (tab: 'graph' | 'request-flow' | 'heatmap') => void; + activeTab: 'graph' | 'request-flow' | 'heatmap' | 'list'; + setActiveTab: (tab: 'graph' | 'request-flow' | 'heatmap' | 'list') => void; inspectorOpen: boolean; setInspectorOpen: (open: boolean) => void; From 1783b28cf98084b78451f2ac6083eb7e676b5d50 Mon Sep 17 00:00:00 2001 From: PARTH J ROHIT Date: Fri, 7 Aug 2026 22:05:20 +0100 Subject: [PATCH 2/2] security(deps): bump dompurify, js-yaml, brace-expansion, undici (#256) Four advisories published since dev's last CI run made the live npm-audit gate (scripts/dependency-audit.mjs) start blocking every open PR: brace-expansion (GHSA-rgw5-rvv9-x895), dompurify (GHSA-55q2-fjhq-7xh7, GHSA-c2j3-45gr-mqc4), js-yaml (GHSA-52cp-r559-cp3m, GHSA-5p4m-2wfm-xmqj), and undici (GHSA-4cwx-7wf7-3272 and others via jsdom). All four patches land within the already-used major version, so no override needed a major bump; undici is newly added to overrides since it was previously unpinned. --- apps/frontend/package-lock.json | 24 ++++++++++++------------ apps/frontend/package.json | 7 ++++--- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index dcb1f06..a2bb573 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -2239,9 +2239,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -2678,9 +2678,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "peer": true, "optionalDependencies": { @@ -3438,9 +3438,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -5308,9 +5308,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1f5fef2..a87f73b 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -55,8 +55,9 @@ "vitest": "^4.1.10" }, "overrides": { - "dompurify": "3.4.12", - "brace-expansion": "^5.0.8", - "js-yaml": "4.3.0" + "dompurify": "3.4.13", + "brace-expansion": "^5.0.9", + "js-yaml": "4.3.1", + "undici": "7.29.0" } }