diff --git a/FEATURES.md b/FEATURES.md index f3feb8a..ca4bc96 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -42,6 +42,7 @@ - ✅ Authorization - ✅ check permissions (configured in YAML) via Symfony voter - ✅ arc42 documentation template +- ✅ add architecture visualization to Symfony Profiler ## Planned @@ -56,4 +57,3 @@ - ❌ Provision Grafana dashboard, e.g. via ConfigMap - ❌ Deploy DB via stateful set - ❌ Include Alpine image into Docker multistage build for production - diff --git a/Makefile b/Makefile index 8303581..f8410f1 100644 --- a/Makefile +++ b/Makefile @@ -191,3 +191,8 @@ open: help: @echo "Available targets:" @awk '/^## / {desc=$$0; sub(/^## /, "", desc); getline; if(match($$0, /^([a-zA-Z0-9_-]+):/)) {printf " %-20s %s\n", substr($$0, RSTART, RLENGTH-1), desc}}' $(MAKEFILE_LIST) + +## Synchronize deptrac visualization JavaScript code +sync-deptrac-visualization: + cp backend/assets/js/deptrac-visualization/index.js \ + backend/public/js/deptrac-visualization/index.js \ No newline at end of file diff --git a/backend/assets/js/deptrac-visualization/README.md b/backend/assets/js/deptrac-visualization/README.md new file mode 100644 index 0000000..9fdf148 --- /dev/null +++ b/backend/assets/js/deptrac-visualization/README.md @@ -0,0 +1,55 @@ +# Deptrac Architecture Visualization + +A pure JavaScript library for visualizing onion/hexagonal architecture diagrams from deptrac.yaml configurations. + +## Usage + +```javascript +const viz = new DeptracVisualization('container-id', { + layers: { + Core: { /* layer data */ }, + Supporting: { /* layer data */ }, + // ... + }, + dependencies: { + Core: [], + Supporting: ['Core', 'Generic'], + // ... + } +}); +``` + +## Features + +- Pure SVG rendering (no external dependencies) +- Configurable layer order and colors +- Automatic dependency arrow layout +- Legend with layer information +- Responsive design with viewBox support + +## Configuration + +The visualization accepts data with two properties: + +- **layers**: Object mapping layer names to layer definitions +- **dependencies**: Object mapping layer names to arrays of their dependencies + +## Colors + +Default color scheme: +- Core: Dark Red (#8B1A1A) +- Supporting: Dark Teal (#1B8A7E) +- Generic: Dark Blue (#0066CC) +- Tests: Orange (#FF6B35) + +## Architecture + +The visualization represents a semantic onion architecture: +1. **Core** (innermost) - Business logic, no dependencies +2. **Supporting** - Adapters and infrastructure code +3. **Tests** - Application tests +4. **Generic** (outermost) - External frameworks and libraries + +## Notes + +This module is designed for extraction as a standalone npm package while being integrated into the current project. diff --git a/backend/assets/js/deptrac-visualization/index.js b/backend/assets/js/deptrac-visualization/index.js new file mode 100644 index 0000000..a4f1ba1 --- /dev/null +++ b/backend/assets/js/deptrac-visualization/index.js @@ -0,0 +1,243 @@ +/** + * Deptrac Architecture Visualization + * Renders an onion architecture diagram from deptrac.yaml using SVG + * + * Usage: + * const viz = new DeptracVisualization('container-id', { + * layers: { Core: {...}, Supporting: {...}, ... }, + * dependencies: { Core: [], Supporting: ['Core'], ... } + * }); + */ + +class DeptracVisualization { + constructor(containerId, data) { + this.container = document.getElementById(containerId); + if (!this.container) { + console.error(`Container with id "${containerId}" not found`); + return; + } + + this.data = data; + this.width = 900; + this.height = 700; + this.centerX = this.width / 2; + this.centerY = this.height / 2; + this.margin = 80; + this.render(); + } + + render() { + this.container.innerHTML = ''; + + const layers = this.data.layers || {}; + const dependencies = this.data.dependencies || {}; + + if (Object.keys(layers).length === 0) { + this.container.innerHTML = '

No architecture data available

'; + return; + } + + const svg = this._createSvg(); + const availableRadius = Math.min(this.width, this.height) / 2 - this.margin; + + const layerOrder = ['Core', 'Supporting', 'Tests', 'Generic']; + const sortedLayers = layerOrder + .filter(name => layers[name]) + .map(name => ({ name, ...layers[name] })); + + const layerCount = sortedLayers.length; + const layerThickness = availableRadius / layerCount; + const colors = this._getColorPalette(); + const layerPositions = {}; + + sortedLayers.forEach((layer, index) => { + const depth = layerCount - 1 - index; + const innerRadius = availableRadius - ((depth + 1) * layerThickness); + const outerRadius = availableRadius - (depth * layerThickness); + const midRadius = (innerRadius + outerRadius) / 2; + + layerPositions[layer.name] = { index, innerRadius, outerRadius, midRadius }; + + this._drawLayer(svg, layer, colors[layer.name], innerRadius, outerRadius, midRadius); + }); + + this._drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions); + this._drawLegend(svg, colors); + + this.container.appendChild(svg); + } + + _createSvg() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', this.width); + svg.setAttribute('height', this.height); + svg.setAttribute('style', 'display: block;'); + svg.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`); + + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('width', this.width); + bg.setAttribute('height', this.height); + bg.setAttribute('fill', 'white'); + svg.appendChild(bg); + + this._addArrowMarker(svg); + return svg; + } + + _getColorPalette() { + return { + 'Core': { fill: '#ff00eecc', stroke: '#9E7777', text: '#ffffff' }, + 'Supporting': { fill: '#ff00ee99', stroke: '#5F9A8C', text: '#ffffff' }, + 'Generic': { fill: '#ff00ee33', stroke: '#4E7FA3', text: '#ffffff' }, + 'Tests': { fill: '#ff00ee77', stroke: '#D08842', text: '#ffffff' } + }; + } + + _addArrowMarker(svg) { + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + + marker.setAttribute('id', 'arrowhead'); + marker.setAttribute('markerWidth', '10'); + marker.setAttribute('markerHeight', '10'); + marker.setAttribute('refX', '8'); + marker.setAttribute('refY', '3'); + marker.setAttribute('orient', 'auto'); + + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + polygon.setAttribute('points', '0 0, 10 3, 0 6'); + polygon.setAttribute('fill', '#666'); + marker.appendChild(polygon); + + defs.appendChild(marker); + svg.appendChild(defs); + } + + _drawLayer(svg, layer, color, innerRadius, outerRadius, midRadius) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const outerCircle = `M ${this.centerX + outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX - outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX + outerRadius} ${this.centerY}`; + const innerCircle = `M ${this.centerX + innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX - innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX + innerRadius} ${this.centerY}`; + + path.setAttribute('d', outerCircle + ' ' + innerCircle); + path.setAttribute('fill', color.fill); + path.setAttribute('fill-rule', 'evenodd'); + path.setAttribute('stroke', color.stroke); + path.setAttribute('stroke-width', '1'); + svg.appendChild(path); + + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', this.centerX); + label.setAttribute('y', this.centerY - midRadius); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('dominant-baseline', 'middle'); + label.setAttribute('font-size', '18'); + label.setAttribute('font-weight', 'bold'); + label.setAttribute('fill', color.text); + label.textContent = layer.name; + svg.appendChild(label); + } + + _drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions) { + sortedLayers.forEach((sourceLayer) => { + const deps = dependencies[sourceLayer.name] || []; + const sourcePos = layerPositions[sourceLayer.name]; + + if (!deps || deps.length === 0) return; + + deps.forEach((targetLayerName) => { + const targetPos = layerPositions[targetLayerName]; + if (!targetPos) return; + + const depCount = deps.length; + const depIndex = deps.indexOf(targetLayerName); + const angle = (depIndex * (360 / depCount)) - 90 + 22.5; + const angleRad = (angle * Math.PI) / 180; + + const startX = this.centerX + Math.cos(angleRad) * sourcePos.midRadius; + const startY = this.centerY + Math.sin(angleRad) * sourcePos.midRadius; + const endX = this.centerX + Math.cos(angleRad) * targetPos.midRadius; + const endY = this.centerY + Math.sin(angleRad) * targetPos.midRadius; + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', `M ${startX} ${startY} L ${endX} ${endY}`); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', '#333'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(path); + }); + }); + } + + _drawLegend(svg, colors) { + const legendX = this.width - 200; + const legendY = 20; + + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('x', legendX - 10); + bg.setAttribute('y', legendY - 10); + bg.setAttribute('width', 190); + bg.setAttribute('height', 150); + bg.setAttribute('fill', '#ffffff'); + bg.setAttribute('stroke', '#ddd'); + bg.setAttribute('stroke-width', '1'); + bg.setAttribute('opacity', '0.95'); + svg.appendChild(bg); + + const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + title.setAttribute('x', legendX); + title.setAttribute('y', legendY + 15); + title.setAttribute('font-size', '14'); + title.setAttribute('font-weight', 'bold'); + title.setAttribute('fill', '#333'); + title.textContent = 'Layers'; + svg.appendChild(title); + + ['Core', 'Supporting', 'Generic', 'Tests'].forEach((layerName, i) => { + const yPos = legendY + 35 + i * 25; + + const square = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + square.setAttribute('x', legendX); + square.setAttribute('y', yPos - 8); + square.setAttribute('width', '12'); + square.setAttribute('height', '12'); + square.setAttribute('fill', colors[layerName].fill); + svg.appendChild(square); + + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', legendX + 20); + label.setAttribute('y', yPos); + label.setAttribute('font-size', '12'); + label.setAttribute('fill', '#333'); + label.textContent = layerName; + svg.appendChild(label); + }); + + const arrowY = legendY + 130; + const arrowLine = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + arrowLine.setAttribute('d', `M ${legendX} ${arrowY} L ${legendX + 15} ${arrowY}`); + arrowLine.setAttribute('stroke', '#333'); + arrowLine.setAttribute('stroke-width', '2'); + arrowLine.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(arrowLine); + + const arrowLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + arrowLabel.setAttribute('x', legendX + 20); + arrowLabel.setAttribute('y', arrowY + 3); + arrowLabel.setAttribute('font-size', '11'); + arrowLabel.setAttribute('fill', '#666'); + arrowLabel.textContent = 'Dependency'; + svg.appendChild(arrowLabel); + } +} + +// For backward compatibility +const ArchitectureVisualization = DeptracVisualization; + +// Initialize on document ready +document.addEventListener('DOMContentLoaded', function() { + const container = document.getElementById('architecture-visualization'); + if (container && window.architectureData) { + new DeptracVisualization('architecture-visualization', window.architectureData); + } +}); diff --git a/backend/assets/js/deptrac-visualization/package.json b/backend/assets/js/deptrac-visualization/package.json new file mode 100644 index 0000000..8d0c1b0 --- /dev/null +++ b/backend/assets/js/deptrac-visualization/package.json @@ -0,0 +1,22 @@ +{ + "name": "deptrac-visualization", + "version": "1.0.0", + "description": "Pure JavaScript visualization for onion architecture diagrams from deptrac.yaml", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "deptrac", + "architecture", + "visualization", + "onion-architecture", + "svg" + ], + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "TODO: add repository when extracted as standalone package" + } +} diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 6be0dce..f8c7cb4 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -44,3 +44,9 @@ services: App\Instrumentation\DataCollector\PermissionVoterCollector: tags: - { name: data_collector, template: 'profiler/permission_voter_collector.html.twig', id: 'app.permission_voter_collector' } + + App\Instrumentation\DataCollector\ArchitectureCollector: + arguments: + - '%kernel.project_dir%' + tags: + - { name: data_collector, template: 'profiler/architecture.html.twig', id: 'app.architecture_collector' } diff --git a/backend/deptrac.yaml b/backend/deptrac.yaml index cd68a2f..6dc48ff 100644 --- a/backend/deptrac.yaml +++ b/backend/deptrac.yaml @@ -7,20 +7,14 @@ deptrac: layers: - name: Core collectors: - - type: classLike - value: .*App\\Timer\\.* - type: classLike value: .*App\\Game\\.* - - name: DDD - collectors: - type: classLike - value: .*PHPMolecules\\.* + value: .*App\\Invariant\\.* - type: classLike - value: .*Tactix\\.* - - name: Invariant - collectors: + value: .*PHPMolecules\\.* - type: classLike - value: .*App\\Invariant\\.* + value: .*App\\Timer\\.* - name: Generic collectors: - type: classLike @@ -41,10 +35,14 @@ deptrac: value: .*phpDocumentor\\Reflection\\.* - type: classLike value: .*PhpParser\\.* + - type: classLike + value: .*PHPUnit\\Framework\\.* - type: classLike value: .*Psr\\.* - type: classLike value: .*Symfony\\.* + - type: classLike + value: .*Tactix\\.* - name: Supporting collectors: - type: classLike @@ -61,13 +59,6 @@ deptrac: value: .*App\\Converter\\.* - type: classLike value: .*App\\DataFixtures\\.* - - type: bool - must: - - type: classLike - value: .*App\\DDD\\.* - must_not: - - type: classLike - value: .*App\\DDD\\Tactical\\Attribute\\.* - type: classLike value: .*App\\Entity\\.* - type: classLike @@ -80,30 +71,13 @@ deptrac: collectors: - type: classLike value: .*App\\Tests\\.* - - type: classLike - value: .*PHPUnit\\Framework\\.* ruleset: - Invariant: ~ - Generic: - - Invariant - DDD: - - Invariant - - Generic - Core: - - DDD - - Invariant + Core: ~ + Generic: ~ Supporting: - Core - - DDD - - Generic - - Invariant - Tactical: - - Invariant - Generic Tests: - Core - - DDD - - Invariant - - Tactical - Generic - Supporting diff --git a/backend/public/js/deptrac-visualization/index.js b/backend/public/js/deptrac-visualization/index.js new file mode 100644 index 0000000..a4f1ba1 --- /dev/null +++ b/backend/public/js/deptrac-visualization/index.js @@ -0,0 +1,243 @@ +/** + * Deptrac Architecture Visualization + * Renders an onion architecture diagram from deptrac.yaml using SVG + * + * Usage: + * const viz = new DeptracVisualization('container-id', { + * layers: { Core: {...}, Supporting: {...}, ... }, + * dependencies: { Core: [], Supporting: ['Core'], ... } + * }); + */ + +class DeptracVisualization { + constructor(containerId, data) { + this.container = document.getElementById(containerId); + if (!this.container) { + console.error(`Container with id "${containerId}" not found`); + return; + } + + this.data = data; + this.width = 900; + this.height = 700; + this.centerX = this.width / 2; + this.centerY = this.height / 2; + this.margin = 80; + this.render(); + } + + render() { + this.container.innerHTML = ''; + + const layers = this.data.layers || {}; + const dependencies = this.data.dependencies || {}; + + if (Object.keys(layers).length === 0) { + this.container.innerHTML = '

No architecture data available

'; + return; + } + + const svg = this._createSvg(); + const availableRadius = Math.min(this.width, this.height) / 2 - this.margin; + + const layerOrder = ['Core', 'Supporting', 'Tests', 'Generic']; + const sortedLayers = layerOrder + .filter(name => layers[name]) + .map(name => ({ name, ...layers[name] })); + + const layerCount = sortedLayers.length; + const layerThickness = availableRadius / layerCount; + const colors = this._getColorPalette(); + const layerPositions = {}; + + sortedLayers.forEach((layer, index) => { + const depth = layerCount - 1 - index; + const innerRadius = availableRadius - ((depth + 1) * layerThickness); + const outerRadius = availableRadius - (depth * layerThickness); + const midRadius = (innerRadius + outerRadius) / 2; + + layerPositions[layer.name] = { index, innerRadius, outerRadius, midRadius }; + + this._drawLayer(svg, layer, colors[layer.name], innerRadius, outerRadius, midRadius); + }); + + this._drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions); + this._drawLegend(svg, colors); + + this.container.appendChild(svg); + } + + _createSvg() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', this.width); + svg.setAttribute('height', this.height); + svg.setAttribute('style', 'display: block;'); + svg.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`); + + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('width', this.width); + bg.setAttribute('height', this.height); + bg.setAttribute('fill', 'white'); + svg.appendChild(bg); + + this._addArrowMarker(svg); + return svg; + } + + _getColorPalette() { + return { + 'Core': { fill: '#ff00eecc', stroke: '#9E7777', text: '#ffffff' }, + 'Supporting': { fill: '#ff00ee99', stroke: '#5F9A8C', text: '#ffffff' }, + 'Generic': { fill: '#ff00ee33', stroke: '#4E7FA3', text: '#ffffff' }, + 'Tests': { fill: '#ff00ee77', stroke: '#D08842', text: '#ffffff' } + }; + } + + _addArrowMarker(svg) { + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + + marker.setAttribute('id', 'arrowhead'); + marker.setAttribute('markerWidth', '10'); + marker.setAttribute('markerHeight', '10'); + marker.setAttribute('refX', '8'); + marker.setAttribute('refY', '3'); + marker.setAttribute('orient', 'auto'); + + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + polygon.setAttribute('points', '0 0, 10 3, 0 6'); + polygon.setAttribute('fill', '#666'); + marker.appendChild(polygon); + + defs.appendChild(marker); + svg.appendChild(defs); + } + + _drawLayer(svg, layer, color, innerRadius, outerRadius, midRadius) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const outerCircle = `M ${this.centerX + outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX - outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX + outerRadius} ${this.centerY}`; + const innerCircle = `M ${this.centerX + innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX - innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX + innerRadius} ${this.centerY}`; + + path.setAttribute('d', outerCircle + ' ' + innerCircle); + path.setAttribute('fill', color.fill); + path.setAttribute('fill-rule', 'evenodd'); + path.setAttribute('stroke', color.stroke); + path.setAttribute('stroke-width', '1'); + svg.appendChild(path); + + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', this.centerX); + label.setAttribute('y', this.centerY - midRadius); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('dominant-baseline', 'middle'); + label.setAttribute('font-size', '18'); + label.setAttribute('font-weight', 'bold'); + label.setAttribute('fill', color.text); + label.textContent = layer.name; + svg.appendChild(label); + } + + _drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions) { + sortedLayers.forEach((sourceLayer) => { + const deps = dependencies[sourceLayer.name] || []; + const sourcePos = layerPositions[sourceLayer.name]; + + if (!deps || deps.length === 0) return; + + deps.forEach((targetLayerName) => { + const targetPos = layerPositions[targetLayerName]; + if (!targetPos) return; + + const depCount = deps.length; + const depIndex = deps.indexOf(targetLayerName); + const angle = (depIndex * (360 / depCount)) - 90 + 22.5; + const angleRad = (angle * Math.PI) / 180; + + const startX = this.centerX + Math.cos(angleRad) * sourcePos.midRadius; + const startY = this.centerY + Math.sin(angleRad) * sourcePos.midRadius; + const endX = this.centerX + Math.cos(angleRad) * targetPos.midRadius; + const endY = this.centerY + Math.sin(angleRad) * targetPos.midRadius; + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', `M ${startX} ${startY} L ${endX} ${endY}`); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', '#333'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(path); + }); + }); + } + + _drawLegend(svg, colors) { + const legendX = this.width - 200; + const legendY = 20; + + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('x', legendX - 10); + bg.setAttribute('y', legendY - 10); + bg.setAttribute('width', 190); + bg.setAttribute('height', 150); + bg.setAttribute('fill', '#ffffff'); + bg.setAttribute('stroke', '#ddd'); + bg.setAttribute('stroke-width', '1'); + bg.setAttribute('opacity', '0.95'); + svg.appendChild(bg); + + const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + title.setAttribute('x', legendX); + title.setAttribute('y', legendY + 15); + title.setAttribute('font-size', '14'); + title.setAttribute('font-weight', 'bold'); + title.setAttribute('fill', '#333'); + title.textContent = 'Layers'; + svg.appendChild(title); + + ['Core', 'Supporting', 'Generic', 'Tests'].forEach((layerName, i) => { + const yPos = legendY + 35 + i * 25; + + const square = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + square.setAttribute('x', legendX); + square.setAttribute('y', yPos - 8); + square.setAttribute('width', '12'); + square.setAttribute('height', '12'); + square.setAttribute('fill', colors[layerName].fill); + svg.appendChild(square); + + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', legendX + 20); + label.setAttribute('y', yPos); + label.setAttribute('font-size', '12'); + label.setAttribute('fill', '#333'); + label.textContent = layerName; + svg.appendChild(label); + }); + + const arrowY = legendY + 130; + const arrowLine = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + arrowLine.setAttribute('d', `M ${legendX} ${arrowY} L ${legendX + 15} ${arrowY}`); + arrowLine.setAttribute('stroke', '#333'); + arrowLine.setAttribute('stroke-width', '2'); + arrowLine.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(arrowLine); + + const arrowLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + arrowLabel.setAttribute('x', legendX + 20); + arrowLabel.setAttribute('y', arrowY + 3); + arrowLabel.setAttribute('font-size', '11'); + arrowLabel.setAttribute('fill', '#666'); + arrowLabel.textContent = 'Dependency'; + svg.appendChild(arrowLabel); + } +} + +// For backward compatibility +const ArchitectureVisualization = DeptracVisualization; + +// Initialize on document ready +document.addEventListener('DOMContentLoaded', function() { + const container = document.getElementById('architecture-visualization'); + if (container && window.architectureData) { + new DeptracVisualization('architecture-visualization', window.architectureData); + } +}); diff --git a/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php b/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php new file mode 100644 index 0000000..06e6d46 --- /dev/null +++ b/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php @@ -0,0 +1,97 @@ +projectDir = $projectDir; + } + + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void + { + $this->data = $this->parseDeptracConfig(); + } + + public function getName(): string + { + return 'app.architecture_collector'; + } + + /** + * @return array{layers: array, dependencies: array>} + */ + public function getDetails(): array + { + /** @var array{layers: array, dependencies: array>} $data */ + $data = $this->data; + + return $data; + } + + /** + * @return array{layers: array, dependencies: array>} + */ + private function parseDeptracConfig(): array + { + $deptracPath = $this->projectDir.'/deptrac.yaml'; + + if (!file_exists($deptracPath)) { + return ['layers' => [], 'dependencies' => []]; + } + + try { + $config = Yaml::parseFile($deptracPath); + } catch (ParseException) { + return ['layers' => [], 'dependencies' => []]; + } + + if (!is_array($config) || !isset($config['deptrac']) || !is_array($config['deptrac'])) { + return ['layers' => [], 'dependencies' => []]; + } + + $deptracConfig = $config['deptrac']; + + $layers = []; + if (isset($deptracConfig['layers']) && is_array($deptracConfig['layers'])) { + $position = 0; + foreach ($deptracConfig['layers'] as $layer) { + if (is_array($layer) && isset($layer['name']) && is_string($layer['name'])) { + $layers[$layer['name']] = [ + 'name' => $layer['name'], + 'position' => $position++, + ]; + } + } + } + + $dependencies = []; + if (isset($deptracConfig['ruleset']) && is_array($deptracConfig['ruleset'])) { + foreach ($deptracConfig['ruleset'] as $layer => $allowedDependencies) { + if (is_string($layer)) { + $deps = []; + if (is_array($allowedDependencies)) { + $deps = array_values(array_filter($allowedDependencies, 'is_string')); + } + $dependencies[$layer] = $deps; + } + } + } + + return [ + 'layers' => $layers, + 'dependencies' => $dependencies, + ]; + } +} diff --git a/backend/templates/profiler/architecture.html.twig b/backend/templates/profiler/architecture.html.twig new file mode 100644 index 0000000..499ca17 --- /dev/null +++ b/backend/templates/profiler/architecture.html.twig @@ -0,0 +1,75 @@ +{# templates/profiler/architecture.html.twig #} +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block toolbar %} + {% set icon %} + {{ include('profiler/skyscraper.svg') }} + {{ collector.details.layers|length }} + {% endset %} + + {% set text %} +
+ Architecture + {{ collector.details.layers|length }} layers +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: true, icon: icon, text: text }) }} +{% endblock %} + +{% block menu %} + + {{ include('profiler/skyscraper.svg') }} + Architecture + {{ collector.details.layers|length }} + +{% endblock %} + +{% block panel %} +

Architecture

+ + {% if collector.details.layers is empty %} +
+

No architecture data available. Check that deptrac.yaml is properly configured.

+
+ {% else %} +
+
+
+ +
+

Layer

+ + + + + + + + + {% for layer, info in collector.details.layers %} + + + + + {% endfor %} + +
NameCan Depend On
{{ layer }} + {% if collector.details.dependencies[layer] is defined %} + {{ collector.details.dependencies[layer]|join(', ') }} + {% else %} + None + {% endif %} +
+
+ + + + {% endif %} +{% endblock %} + diff --git a/backend/templates/profiler/skyscraper.svg b/backend/templates/profiler/skyscraper.svg new file mode 100644 index 0000000..1dc04d7 --- /dev/null +++ b/backend/templates/profiler/skyscraper.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/tests/Unit/Instrumentation/DataCollector/ArchitectureCollectorTest.php b/backend/tests/Unit/Instrumentation/DataCollector/ArchitectureCollectorTest.php new file mode 100644 index 0000000..74bb389 --- /dev/null +++ b/backend/tests/Unit/Instrumentation/DataCollector/ArchitectureCollectorTest.php @@ -0,0 +1,473 @@ +tempDir = sys_get_temp_dir().'/architecture_collector_test_'.uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->tempDir)) { + $this->removeDirectory($this->tempDir); + } + } + + private function removeDirectory(string $dir): void + { + if (is_dir($dir)) { + $files = scandir($dir); + foreach ($files as $file) { + if ('.' !== $file && '..' !== $file) { + $path = $dir.DIRECTORY_SEPARATOR.$file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + } + rmdir($dir); + } + } + + #[Test] + public function should_have_correct_name(): void + { + $collector = new ArchitectureCollector($this->tempDir); + + self::assertSame('app.architecture_collector', $collector->getName()); + } + + #[Test] + public function should_return_empty_details_when_deptrac_file_does_not_exist(): void + { + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['layers' => [], 'dependencies' => []], $details); + } + + #[Test] + public function should_return_empty_details_when_deptrac_yaml_is_invalid(): void + { + file_put_contents($this->tempDir.'/deptrac.yaml', 'invalid: {yaml: [content'); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['layers' => [], 'dependencies' => []], $details); + } + + #[Test] + public function should_return_empty_details_when_deptrac_section_is_missing(): void + { + file_put_contents($this->tempDir.'/deptrac.yaml', 'other_key: value'); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['layers' => [], 'dependencies' => []], $details); + } + + #[Test] + public function should_return_empty_details_when_deptrac_section_is_not_array(): void + { + file_put_contents($this->tempDir.'/deptrac.yaml', 'deptrac: "not an array"'); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['layers' => [], 'dependencies' => []], $details); + } + + #[Test] + public function should_parse_layers_from_deptrac_config(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - name: Generic + - name: Supporting +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertArrayHasKey('layers', $details); + self::assertCount(3, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + self::assertArrayHasKey('Supporting', $details['layers']); + + self::assertSame(['name' => 'Core', 'position' => 0], $details['layers']['Core']); + self::assertSame(['name' => 'Generic', 'position' => 1], $details['layers']['Generic']); + self::assertSame(['name' => 'Supporting', 'position' => 2], $details['layers']['Supporting']); + } + + #[Test] + public function should_skip_invalid_layers(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - invalid_layer + - name: Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertCount(2, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + } + + #[Test] + public function should_parse_dependencies_from_ruleset(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - name: Generic + - name: Supporting + ruleset: + Core: ~ + Generic: ~ + Supporting: + - Core + - Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertArrayHasKey('dependencies', $details); + self::assertSame([], $details['dependencies']['Core']); + self::assertSame([], $details['dependencies']['Generic']); + self::assertSame(['Core', 'Generic'], $details['dependencies']['Supporting']); + } + + #[Test] + public function should_handle_null_dependencies(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + ruleset: + Core: ~ +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame([], $details['dependencies']['Core']); + } + + #[Test] + public function should_filter_non_string_dependencies(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - name: Generic + - name: Supporting + ruleset: + Supporting: + - Core + - 123 + - Generic + - true + - ~ +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['Core', 'Generic'], $details['dependencies']['Supporting']); + } + + #[Test] + public function should_handle_missing_layers_section(): void + { + $yaml = <<<'YAML' +deptrac: + ruleset: + Core: ~ +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame([], $details['layers']); + self::assertSame(['Core' => []], $details['dependencies']); + } + + #[Test] + public function should_handle_missing_ruleset_section(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['Core' => ['name' => 'Core', 'position' => 0]], $details['layers']); + self::assertSame([], $details['dependencies']); + } + + #[Test] + public function should_handle_non_array_layer_entries(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - "string layer" + - name: Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertCount(2, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + } + + #[Test] + public function should_handle_layer_with_missing_name(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - collectors: [] + - name: Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertCount(2, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + } + + #[Test] + public function should_handle_non_string_layer_names(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - name: 123 + - name: Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertCount(2, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + } + + #[Test] + public function should_handle_complex_deptrac_config(): void + { + $yaml = <<<'YAML' +deptrac: + paths: + - ./src + - ./tests + layers: + - name: Core + collectors: + - type: classLike + value: .*App\\Game\\.* + - name: Generic + collectors: + - type: classLike + value: .*Symfony\\.* + - name: Supporting + collectors: + - type: classLike + value: .*App\\Controller\\.* + - name: Tests + collectors: + - type: classLike + value: .*App\\Tests\\.* + ruleset: + Core: ~ + Generic: ~ + Supporting: + - Core + - Generic + Tests: + - Core + - Generic + - Supporting +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertCount(4, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + self::assertArrayHasKey('Supporting', $details['layers']); + self::assertArrayHasKey('Tests', $details['layers']); + + self::assertSame(['Core', 'Generic'], $details['dependencies']['Supporting']); + self::assertSame(['Core', 'Generic', 'Supporting'], $details['dependencies']['Tests']); + } + + #[Test] + public function collect_should_populate_data(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $request = Request::create('/'); + $response = new Response(); + + $collector->collect($request, $response); + + $details = $collector->getDetails(); + self::assertNotEmpty($details['layers']); + } + + #[Test] + public function collect_should_handle_exception_parameter(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $request = Request::create('/'); + $response = new Response(); + $exception = new \Exception('Test exception'); + + $collector->collect($request, $response, $exception); + + $details = $collector->getDetails(); + self::assertNotEmpty($details['layers']); + } + + #[Test] + public function should_reset_positions_correctly_when_layer_is_skipped(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Layer1 + - invalid_entry + - name: Layer2 + - another_invalid + - name: Layer3 +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(0, $details['layers']['Layer1']['position']); + self::assertSame(1, $details['layers']['Layer2']['position']); + self::assertSame(2, $details['layers']['Layer3']['position']); + } + + #[Test] + public function should_handle_ruleset_with_non_string_keys(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - name: Generic + ruleset: + Core: ~ + 123: + - Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertArrayHasKey('Core', $details['dependencies']); + self::assertCount(1, $details['dependencies']); + } +}