Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
at main 181 lines 6.0 kB view raw
1/** 2 * Boundary Validator (Architectural Linter) 3 * 4 * Validates extracted dependencies against an IU's boundary policy. 5 * Produces diagnostics for violations. 6 */ 7 8import type { ImplementationUnit, BoundaryPolicy, EnforcementConfig } from './models/iu.js'; 9import type { DependencyGraph } from './dep-extractor.js'; 10import type { Diagnostic } from './models/diagnostic.js'; 11 12/** 13 * Validate a dependency graph against an IU's boundary policy. 14 */ 15export function validateBoundary( 16 depGraph: DependencyGraph, 17 iu: ImplementationUnit, 18 iuIdToName?: Map<string, string>, 19): Diagnostic[] { 20 const diagnostics: Diagnostic[] = []; 21 const policy = iu.boundary_policy; 22 const enforcement = iu.enforcement; 23 24 // Validate imports 25 for (const dep of depGraph.imports) { 26 if (dep.is_relative) { 27 // Check against forbidden_paths 28 for (const forbidden of policy.code.forbidden_paths) { 29 if (matchGlob(dep.source, forbidden)) { 30 diagnostics.push({ 31 severity: enforcement.dependency_violation.severity, 32 category: 'dependency_violation', 33 subject: dep.source, 34 message: `Import "${dep.source}" matches forbidden path pattern "${forbidden}"`, 35 iu_id: iu.iu_id, 36 source_file: depGraph.file_path, 37 source_line: dep.source_line, 38 recommended_actions: ['Remove the import or update boundary policy'], 39 }); 40 } 41 } 42 } else { 43 // Package import — check forbidden + allowed 44 const pkgName = extractPackageName(dep.source); 45 46 // Check forbidden packages (takes priority) 47 if (policy.code.forbidden_packages.includes(pkgName)) { 48 diagnostics.push({ 49 severity: enforcement.dependency_violation.severity, 50 category: 'dependency_violation', 51 subject: pkgName, 52 message: `Package "${pkgName}" is forbidden by boundary policy`, 53 iu_id: iu.iu_id, 54 source_file: depGraph.file_path, 55 source_line: dep.source_line, 56 recommended_actions: ['Remove the dependency or update boundary policy'], 57 }); 58 } else if (policy.code.allowed_packages.length > 0 && !policy.code.allowed_packages.includes(pkgName)) { 59 // If allowed_packages is non-empty, check allowlist (only if not already caught by forbidden) 60 diagnostics.push({ 61 severity: enforcement.dependency_violation.severity, 62 category: 'dependency_violation', 63 subject: pkgName, 64 message: `Package "${pkgName}" is not in the allowed packages list`, 65 iu_id: iu.iu_id, 66 source_file: depGraph.file_path, 67 source_line: dep.source_line, 68 recommended_actions: [`Add "${pkgName}" to allowed_packages or remove the import`], 69 }); 70 } 71 } 72 } 73 74 // Validate side channels 75 for (const sc of depGraph.side_channels) { 76 const policyKey = sideChannelPolicyKey(sc.kind); 77 const allowed = (policy.side_channels as Record<string, string[]>)[policyKey] ?? []; 78 79 if (allowed.length === 0 || !allowed.includes(sc.identifier)) { 80 diagnostics.push({ 81 severity: enforcement.side_channel_violation.severity, 82 category: 'side_channel_violation', 83 subject: sc.identifier, 84 message: `Undeclared ${sc.kind} side channel: "${sc.identifier}"`, 85 iu_id: iu.iu_id, 86 source_file: depGraph.file_path, 87 source_line: sc.source_line, 88 recommended_actions: [ 89 `Declare "${sc.identifier}" in boundary_policy.side_channels.${policyKey}`, 90 'Or remove the side-channel usage', 91 ], 92 }); 93 } 94 } 95 96 return diagnostics; 97} 98 99/** 100 * Validate multiple files for one IU. 101 */ 102export function validateIU( 103 depGraphs: DependencyGraph[], 104 iu: ImplementationUnit, 105): Diagnostic[] { 106 return depGraphs.flatMap(g => validateBoundary(g, iu)); 107} 108 109/** 110 * Detect boundary policy changes between two versions of an IU. 111 */ 112export interface UnitBoundaryChange { 113 iu_id: string; 114 iu_name: string; 115 changes: string[]; 116} 117 118export function detectBoundaryChanges( 119 before: ImplementationUnit, 120 after: ImplementationUnit, 121): UnitBoundaryChange | null { 122 const changes: string[] = []; 123 const bp = before.boundary_policy; 124 const ap = after.boundary_policy; 125 126 // Compare code policies 127 for (const key of ['allowed_ius', 'allowed_packages', 'forbidden_ius', 'forbidden_packages', 'forbidden_paths'] as const) { 128 const bv = JSON.stringify(bp.code[key].sort()); 129 const av = JSON.stringify(ap.code[key].sort()); 130 if (bv !== av) { 131 changes.push(`code.${key} changed`); 132 } 133 } 134 135 // Compare side channel policies 136 for (const key of ['databases', 'queues', 'caches', 'config', 'external_apis', 'files'] as const) { 137 const bv = JSON.stringify(bp.side_channels[key].sort()); 138 const av = JSON.stringify(ap.side_channels[key].sort()); 139 if (bv !== av) { 140 changes.push(`side_channels.${key} changed`); 141 } 142 } 143 144 if (changes.length === 0) return null; 145 return { iu_id: after.iu_id, iu_name: after.name, changes }; 146} 147 148/** 149 * Simple glob matching (supports * and ** wildcards). 150 */ 151function matchGlob(path: string, pattern: string): boolean { 152 const regex = pattern 153 .replace(/\*\*/g, '<<<GLOBSTAR>>>') 154 .replace(/\*/g, '[^/]*') 155 .replace(/<<<GLOBSTAR>>>/g, '.*'); 156 return new RegExp(`^${regex}$`).test(path); 157} 158 159/** 160 * Extract npm package name from import specifier. 161 * Handles scoped packages: @scope/pkg/sub → @scope/pkg 162 */ 163function extractPackageName(specifier: string): string { 164 if (specifier.startsWith('@')) { 165 const parts = specifier.split('/'); 166 return parts.slice(0, 2).join('/'); 167 } 168 return specifier.split('/')[0]; 169} 170 171function sideChannelPolicyKey(kind: string): string { 172 switch (kind) { 173 case 'external_api': return 'external_apis'; 174 case 'database': return 'databases'; 175 case 'queue': return 'queues'; 176 case 'cache': return 'caches'; 177 case 'file': return 'files'; 178 case 'config': return 'config'; 179 default: return kind; 180 } 181}