Spaces:
Paused
Paused
| import {SymbolGraph, Symbol, DependencyEdge} from './symbol-graph' | |
| export interface LayerDefinition { | |
| name: string | |
| patterns: string[] | |
| priority: number | |
| } | |
| export interface ArchitecturalRule { | |
| fromLayer: string | |
| toLayer: string | |
| allowed: boolean | |
| severity: 'critical' | 'major' | 'minor' | |
| description: string | |
| } | |
| export interface ArchitecturalViolation { | |
| from: string | |
| to: string | |
| fromLayer: string | |
| toLayer: string | |
| severity: 'critical' | 'major' | 'minor' | |
| description: string | |
| file?: string | |
| suggestedFix?: string | |
| } | |
| export interface ArchitectureAnalysis { | |
| violations: ArchitecturalViolation[] | |
| layerAssignments: Map<string, string> | |
| unassignedFiles: string[] | |
| statistics: { | |
| totalViolations: number | |
| criticalCount: number | |
| majorCount: number | |
| minorCount: number | |
| } | |
| } | |
| const DEFAULT_LAYERS: LayerDefinition[] = [ | |
| { | |
| name: 'ui', | |
| patterns: [ | |
| '**/components/**', | |
| '**/pages/**', | |
| '**/views/**', | |
| '**/screens/**', | |
| '**/ui/**', | |
| '**/frontend/**' | |
| ], | |
| priority: 1 | |
| }, | |
| { | |
| name: 'api', | |
| patterns: [ | |
| '**/api/**', | |
| '**/controllers/**', | |
| '**/handlers/**', | |
| '**/routes/**', | |
| '**/endpoints/**' | |
| ], | |
| priority: 2 | |
| }, | |
| { | |
| name: 'services', | |
| patterns: [ | |
| '**/services/**', | |
| '**/business/**', | |
| '**/domain/**', | |
| '**/usecases/**' | |
| ], | |
| priority: 3 | |
| }, | |
| { | |
| name: 'data', | |
| patterns: [ | |
| '**/models/**', | |
| '**/schemas/**', | |
| '**/entities/**', | |
| '**/repositories/**', | |
| '**/dal/**' | |
| ], | |
| priority: 4 | |
| }, | |
| { | |
| name: 'infrastructure', | |
| patterns: [ | |
| '**/db/**', | |
| '**/database/**', | |
| '**/cache/**', | |
| '**/queue/**', | |
| '**/storage/**', | |
| '**/external/**' | |
| ], | |
| priority: 5 | |
| }, | |
| { | |
| name: 'shared', | |
| patterns: [ | |
| '**/shared/**', | |
| '**/common/**', | |
| '**/utils/**', | |
| '**/lib/**', | |
| '**/types/**' | |
| ], | |
| priority: 0 | |
| } | |
| ] | |
| const DEFAULT_RULES: ArchitecturalRule[] = [ | |
| { | |
| fromLayer: 'ui', | |
| toLayer: 'data', | |
| allowed: false, | |
| severity: 'critical', | |
| description: 'UI should not directly access data layer' | |
| }, | |
| { | |
| fromLayer: 'ui', | |
| toLayer: 'infrastructure', | |
| allowed: false, | |
| severity: 'critical', | |
| description: 'UI should not directly access infrastructure' | |
| }, | |
| { | |
| fromLayer: 'api', | |
| toLayer: 'ui', | |
| allowed: false, | |
| severity: 'major', | |
| description: 'API layer should not depend on UI' | |
| }, | |
| { | |
| fromLayer: 'api', | |
| toLayer: 'infrastructure', | |
| allowed: true, | |
| severity: 'minor', | |
| description: 'API may access infrastructure (database, cache)' | |
| }, | |
| { | |
| fromLayer: 'services', | |
| toLayer: 'ui', | |
| allowed: false, | |
| severity: 'major', | |
| description: 'Services should not depend on UI' | |
| }, | |
| { | |
| fromLayer: 'services', | |
| toLayer: 'infrastructure', | |
| allowed: true, | |
| severity: 'minor', | |
| description: 'Services may access infrastructure' | |
| }, | |
| { | |
| fromLayer: 'data', | |
| toLayer: 'api', | |
| allowed: false, | |
| severity: 'major', | |
| description: 'Data layer should not depend on API layer' | |
| }, | |
| { | |
| fromLayer: 'data', | |
| toLayer: 'infrastructure', | |
| allowed: false, | |
| severity: 'critical', | |
| description: 'Data layer should not depend on infrastructure' | |
| }, | |
| { | |
| fromLayer: 'shared', | |
| toLayer: 'ui', | |
| allowed: false, | |
| severity: 'minor', | |
| description: 'Shared code should not depend on UI' | |
| }, | |
| { | |
| fromLayer: 'shared', | |
| toLayer: 'api', | |
| allowed: false, | |
| severity: 'minor', | |
| description: 'Shared code should not depend on API' | |
| } | |
| ] | |
| export class ArchitecturalAnalyzer { | |
| private layers: LayerDefinition[] | |
| private rules: ArchitecturalRule[] | |
| private layerCache: Map<string, string> | |
| constructor( | |
| layers: LayerDefinition[] = DEFAULT_LAYERS, | |
| rules: ArchitecturalRule[] = DEFAULT_RULES | |
| ) { | |
| this.layers = layers | |
| this.rules = rules | |
| this.layerCache = new Map() | |
| } | |
| analyze(symbolGraph: SymbolGraph): ArchitectureAnalysis { | |
| const violations: ArchitecturalViolation[] = [] | |
| const layerAssignments = new Map<string, string>() | |
| const unassignedFiles: string[] = [] | |
| for (const [key, symbol] of symbolGraph.symbols) { | |
| const file = symbol.file | |
| if (!layerAssignments.has(file)) { | |
| const layer = this.assignLayer(file) | |
| layerAssignments.set(file, layer) | |
| if (layer === 'unassigned') { | |
| unassignedFiles.push(file) | |
| } | |
| } | |
| } | |
| for (const edge of symbolGraph.edges) { | |
| const fromLayer = layerAssignments.get(edge.from) || 'unassigned' | |
| const toLayer = this.resolveImportToLayer(edge.to, symbolGraph) | |
| if (fromLayer === 'unassigned' || toLayer === 'unassigned') continue | |
| const rule = this.findRule(fromLayer, toLayer) | |
| if (rule && !rule.allowed) { | |
| violations.push({ | |
| from: edge.from, | |
| to: edge.to, | |
| fromLayer, | |
| toLayer, | |
| severity: rule.severity, | |
| description: rule.description, | |
| file: edge.from | |
| }) | |
| } | |
| } | |
| const statistics = { | |
| totalViolations: violations.length, | |
| criticalCount: violations.filter(v => v.severity === 'critical').length, | |
| majorCount: violations.filter(v => v.severity === 'major').length, | |
| minorCount: violations.filter(v => v.severity === 'minor').length | |
| } | |
| return { | |
| violations, | |
| layerAssignments, | |
| unassignedFiles, | |
| statistics | |
| } | |
| } | |
| private assignLayer(filePath: string): string { | |
| const cached = this.layerCache.get(filePath) | |
| if (cached) return cached | |
| const normalizedPath = filePath.replace(/\\/g, '/').toLowerCase() | |
| for (const layer of this.layers) { | |
| for (const pattern of layer.patterns) { | |
| if (this.matchPattern(normalizedPath, pattern.toLowerCase())) { | |
| this.layerCache.set(filePath, layer.name) | |
| return layer.name | |
| } | |
| } | |
| } | |
| this.layerCache.set(filePath, 'unassigned') | |
| return 'unassigned' | |
| } | |
| private matchPattern(path: string, pattern: string): boolean { | |
| const regexPattern = pattern | |
| .replace(/\*\*/g, '<<DOUBLE_STAR>>') | |
| .replace(/\*/g, '[^/]*') | |
| .replace(/<<DOUBLE_STAR>>/g, '.*') | |
| .replace(/\?/g, '.') | |
| try { | |
| const regex = new RegExp(`^${regexPattern}$`) | |
| return regex.test(path) || this.matchGlob(path, pattern) | |
| } catch { | |
| return this.matchGlob(path, pattern) | |
| } | |
| } | |
| private matchGlob(path: string, pattern: string): boolean { | |
| const parts = path.split('/') | |
| const patternParts = pattern.split('/') | |
| let pi = 0 | |
| let psi = 0 | |
| while (pi < parts.length && psi < patternParts.length) { | |
| const patternPart = patternParts[psi] | |
| if (patternPart === '**') { | |
| if (psi === patternParts.length - 1) { | |
| return true | |
| } | |
| const nextPattern = patternParts[psi + 1] | |
| while (pi < parts.length) { | |
| if (this.matchSingle(parts[pi], nextPattern)) { | |
| if ( | |
| this.matchGlob( | |
| parts.slice(pi).join('/'), | |
| patternParts.slice(psi + 1).join('/') | |
| ) | |
| ) { | |
| return true | |
| } | |
| } | |
| pi++ | |
| } | |
| return false | |
| } | |
| if (!this.matchSingle(parts[pi], patternPart)) { | |
| return false | |
| } | |
| pi++ | |
| psi++ | |
| } | |
| return pi === parts.length && psi === patternParts.length | |
| } | |
| private matchSingle(part: string, pattern: string): boolean { | |
| if (pattern === '*') return part.length > 0 | |
| if (pattern === '**') return true | |
| const regexPattern = pattern | |
| .replace(/\./g, '\\.') | |
| .replace(/\*/g, '[^/]*') | |
| .replace(/\?/g, '.') | |
| try { | |
| const regex = new RegExp(`^${regexPattern}$`) | |
| return regex.test(part) | |
| } catch { | |
| return part === pattern | |
| } | |
| } | |
| private resolveImportToLayer( | |
| importPath: string, | |
| symbolGraph: SymbolGraph | |
| ): string { | |
| const normalizedImport = importPath.replace(/['"]/g, '').replace(/\\/g, '/') | |
| for (const [key, symbol] of symbolGraph.symbols) { | |
| const symbolPath = symbol.file | |
| if ( | |
| symbolPath.includes(normalizedImport) || | |
| normalizedImport.includes(symbolPath) | |
| ) { | |
| return this.assignLayer(symbolPath) | |
| } | |
| } | |
| if (normalizedImport.startsWith('.')) { | |
| return 'unassigned' | |
| } | |
| if (normalizedImport.includes('/node_modules/')) { | |
| return 'external' | |
| } | |
| if ( | |
| normalizedImport.includes('/shared/') || | |
| normalizedImport.includes('/common/') | |
| ) { | |
| return 'shared' | |
| } | |
| return 'unassigned' | |
| } | |
| private findRule( | |
| fromLayer: string, | |
| toLayer: string | |
| ): ArchitecturalRule | undefined { | |
| return this.rules.find( | |
| r => r.fromLayer === fromLayer && r.toLayer === toLayer | |
| ) | |
| } | |
| getDependencyGraph( | |
| layerAssignments: Map<string, string> | |
| ): Map<string, Set<string>> { | |
| const graph = new Map<string, Set<string>>() | |
| for (const [file, layer] of layerAssignments) { | |
| if (!graph.has(layer)) { | |
| graph.set(layer, new Set()) | |
| } | |
| } | |
| return graph | |
| } | |
| generateReport(analysis: ArchitectureAnalysis): string { | |
| const lines: string[] = [] | |
| lines.push('# Architectural Violation Report') | |
| lines.push('') | |
| lines.push(`Total Violations: ${analysis.statistics.totalViolations}`) | |
| lines.push(` - Critical: ${analysis.statistics.criticalCount}`) | |
| lines.push(` - Major: ${analysis.statistics.majorCount}`) | |
| lines.push(` - Minor: ${analysis.statistics.minorCount}`) | |
| lines.push('') | |
| if (analysis.violations.length > 0) { | |
| lines.push('## Violations') | |
| lines.push('') | |
| const groupedBySeverity = { | |
| critical: analysis.violations.filter(v => v.severity === 'critical'), | |
| major: analysis.violations.filter(v => v.severity === 'major'), | |
| minor: analysis.violations.filter(v => v.severity === 'minor') | |
| } | |
| for (const severity of ['critical', 'major', 'minor'] as const) { | |
| const violations = groupedBySeverity[severity] | |
| if (violations.length === 0) continue | |
| lines.push(`### ${severity.toUpperCase()} (${violations.length})`) | |
| lines.push('') | |
| for (const v of violations) { | |
| lines.push( | |
| `- **${v.fromLayer}** → **${v.toLayer}**: ${v.description}` | |
| ) | |
| lines.push(` - File: \`${v.file || v.from}\``) | |
| lines.push(` - Import: \`${v.to}\``) | |
| } | |
| lines.push('') | |
| } | |
| } | |
| return lines.join('\n') | |
| } | |
| } | |
| export function analyzeArchitecture( | |
| symbolGraph: SymbolGraph | |
| ): ArchitectureAnalysis { | |
| const analyzer = new ArchitecturalAnalyzer() | |
| return analyzer.analyze(symbolGraph) | |
| } | |