#!/usr/bin/env node // ═══════════════════════════════════════════════════════════════════════════ // SIL Architecture Policy — Regler som kod // Erik-krav: "SIL kan kontrollera att arkitekturregler följs" // ═══════════════════════════════════════════════════════════════════════════ import { readFileSync, existsSync, writeFileSync, appendFileSync } from 'fs'; import { execSync } from 'child_process'; const GRAPH_PATH = '/home/bernt/.openclaw/workspace/SYSTEM_GRAPH.json'; const POLICY_LOG = '/home/bernt/.openclaw/workspace/SIL/policy-violations.jsonl'; /** * Arkitekturregler som kod. * Varje regel har: * - id: unikt nummer * - name: beskrivande namn * - severity: CRITICAL, HIGH, MEDIUM, LOW * - check: funktion som returnerar true om regeln bryts * - message: förklaring av överträdelsen */ const ARCHITECTURE_RULES = [ { id: 1, name: 'UI får aldrig anropa databasen direkt', severity: 'CRITICAL', category: 'layer_separation', check: (graph, files) => { // Kontrollera om frontend-filer innehåller databasanrop const frontendFiles = files.filter(f => f.match(/\.tsx$|\.jsx$|\.ts$|\.js$/) && (f.includes('frontend') || f.includes('app') || f.includes('ui')) ); for (const file of frontendFiles) { try { const content = readFileSync(file, 'utf8'); if (content.match(/import.*pg|import.*mysql|import.*prisma|new Pool\(|createConnection/i)) { return { violated: true, file, evidence: 'Direkt databas-import hittad' }; } } catch {} } return { violated: false }; }, message: 'Frontend-kod får inte innehålla direkta databasanrop. Använd API-lager.' }, { id: 2, name: 'Domänlogik får inte ligga i API-lagret', severity: 'HIGH', category: 'layer_separation', check: (graph, files) => { // Kontrollera om API-handlers innehåller affärslogik const apiFiles = files.filter(f => f.match(/handler|controller|route/i) && !f.includes('service') ); for (const file of apiFiles) { try { const content = readFileSync(file, 'utf8'); // Enkel heuristik: om filen är >100 rader och innehåller affärslogik const lines = content.split('\n'); if (lines.length > 100) { const businessLogic = content.match(/if.*balance|if.*amount|calculate|validate.*payment/i); if (businessLogic) { return { violated: true, file, evidence: 'Affärslogik i API-handler' }; } } } catch {} } return { violated: false }; }, message: 'Affärslogik ska ligga i service-lagret, inte i API-handlers.' }, { id: 3, name: 'Alla externa integrationer ska gå genom adapterlager', severity: 'HIGH', category: 'integration', check: (graph, files) => { // Kontrollera om externa anrop görs direkt från service const serviceFiles = files.filter(f => f.includes('service')); for (const file of serviceFiles) { try { const content = readFileSync(file, 'utf8'); // Om filen anropar extern API direkt utan adapter const directCall = content.match(/fetch\(.*stripe|fetch\(.*aws|axios\(.*stripe/i); const hasAdapter = content.match(/adapter|client|gateway/i); if (directCall && !hasAdapter) { return { violated: true, file, evidence: 'Direkt extern anrop utan adapter' }; } } catch {} } return { violated: false }; }, message: 'Externa integrationer ska gå genom adapter/client-lager.' }, { id: 4, name: 'Alla betalningar måste vara idempotenta', severity: 'CRITICAL', category: 'payment', check: (graph, files) => { // Kontrollera betalnings-relaterade filer const paymentFiles = files.filter(f => f.match(/payment|stripe|wallet|payout/i) ); for (const file of paymentFiles) { try { const content = readFileSync(file, 'utf8'); // Ska innehålla idempotens-kontroll const hasIdempotency = content.match(/idempot|idempotent|duplicate.*check|already.*processed/i); if (!hasIdempotency && content.match(/charge|payment|payout/i)) { return { violated: true, file, evidence: 'Betalningsflöde utan idempotens-kontroll' }; } } catch {} } return { violated: false }; }, message: 'Alla betalningsoperationer måste ha idempotens-kontroll.' }, { id: 5, name: 'Alla KYC-flöden måste loggas', severity: 'CRITICAL', category: 'compliance', check: (graph, files) => { // Kontrollera KYC-filer const kycFiles = files.filter(f => f.match(/kyc|identity|verify/i)); for (const file of kycFiles) { try { const content = readFileSync(file, 'utf8'); // Ska innehålla loggning const hasLogging = content.match(/log\.|logger\.|audit|console\.log/i); if (!hasLogging) { return { violated: true, file, evidence: 'KYC-flöde utan loggning' }; } } catch {} } return { violated: false }; }, message: 'Alla KYC-operationer måste loggas för compliance.' }, { id: 6, name: 'Alla nya API:er ska ha integrationstester', severity: 'HIGH', category: 'testing', check: (graph, files) => { // Kontrollera om nya endpoints har tester const newEndpoints = files.filter(f => f.match(/route|endpoint|handler/i) && !f.match(/\.test\.|\.spec\./i) ); for (const endpoint of newEndpoints) { const testFile = endpoint.replace(/\.(ts|js)$/, '.test.$1'); if (!existsSync(testFile)) { return { violated: true, file: endpoint, evidence: 'Saknar integrationstest' }; } } return { violated: false }; }, message: 'Varje ny endpoint måste ha ett integrationstest.' }, { id: 7, name: 'Kritiska tjänster ska ha circuit breaker', severity: 'HIGH', category: 'resilience', check: (graph, files) => { const criticalServices = ['wallet', 'payment', 'auth', 'kyc']; for (const service of criticalServices) { const serviceFiles = files.filter(f => f.includes(service)); for (const file of serviceFiles) { try { const content = readFileSync(file, 'utf8'); const hasCircuitBreaker = content.match(/circuit|circuitbreaker|breaker|fallback/i); if (!hasCircuitBreaker && content.match(/fetch|axios|request/i)) { return { violated: true, file, evidence: 'Kritisk tjänst utan circuit breaker' }; } } catch {} } } return { violated: false }; }, message: 'Kritiska tjänster måste ha circuit breaker på externa anrop.' }, { id: 8, name: 'Ingen hårdkodad konfiguration i källkod', severity: 'MEDIUM', category: 'configuration', check: (graph, files) => { const configFiles = files.filter(f => !f.match(/\.env|config|settings|yaml|yml/i) ); for (const file of configFiles) { try { const content = readFileSync(file, 'utf8'); // Hitta hårdkodade värden const hardcoded = content.match(/api_key.*=.*['"][a-zA-Z0-9]{20,}['"]|password.*=.*['"][^'"]+['"]/i); if (hardcoded) { return { violated: true, file, evidence: 'Hårdkodad konfiguration hittad' }; } } catch {} } return { violated: false }; }, message: 'Konfiguration ska läsas från miljövariabler eller config-filer.' }, { id: 9, name: 'Databasändringar ska ha rollback-script', severity: 'CRITICAL', category: 'database', check: (graph, files) => { const migrationFiles = files.filter(f => f.match(/migration|\.sql$/i)); for (const file of migrationFiles) { try { const content = readFileSync(file, 'utf8'); // Ska innehålla rollback eller down-migration const hasRollback = content.match(/rollback|down|revert|DROP.*IF.*EXISTS/i); if (!hasRollback) { return { violated: true, file, evidence: 'Migration utan rollback' }; } } catch {} } return { violated: false }; }, message: 'Varje databasändring måste ha ett rollback-script.' }, { id: 10, name: 'Alla PR:er ska uppdatera dokumentation', severity: 'LOW', category: 'documentation', check: (graph, files) => { // Kontrollera om PR innehåller API-ändringar utan doc-uppdatering const apiChanges = files.filter(f => f.match(/openapi|swagger|\.yaml$/i)); const docChanges = files.filter(f => f.match(/README|CHANGELOG|docs/i)); if (apiChanges.length > 0 && docChanges.length === 0) { return { violated: true, file: apiChanges[0], evidence: 'API-ändring utan dokumentationsuppdatering' }; } return { violated: false }; }, message: 'API-ändringar ska dokumenteras.' } ]; class ArchitecturePolicy { constructor() { this.graph = this.loadGraph(); this.rules = ARCHITECTURE_RULES; } loadGraph() { try { return JSON.parse(readFileSync(GRAPH_PATH, 'utf8')); } catch { return { nodes: [], edges: [] }; } } /** * Kontrollera alla regler mot en uppsättning filer */ checkPR(files, prInfo = {}) { console.log(`🔍 Kontrollerar ${files.length} filer mot ${this.rules.length} arkitekturregler...\n`); const violations = []; let criticalCount = 0; let highCount = 0; for (const rule of this.rules) { const result = rule.check(this.graph, files); if (result.violated) { violations.push({ ruleId: rule.id, ruleName: rule.name, severity: rule.severity, category: rule.category, file: result.file, evidence: result.evidence, message: rule.message, timestamp: new Date().toISOString() }); if (rule.severity === 'CRITICAL') criticalCount++; if (rule.severity === 'HIGH') highCount++; // Logga överträdelse appendFileSync(POLICY_LOG, JSON.stringify({ ...violations[violations.length - 1], pr: prInfo }) + '\n', 'utf8'); } } const report = { timestamp: new Date().toISOString(), pr: prInfo, summary: { totalRules: this.rules.length, violations: violations.length, critical: criticalCount, high: highCount, medium: violations.filter(v => v.severity === 'MEDIUM').length, low: violations.filter(v => v.severity === 'LOW').length }, violations, passed: violations.length === 0, // Kan merge blockeras? blockMerge: criticalCount > 0, requiresReview: highCount > 0 }; this.printReport(report); return report; } /** * Kontrollera regler för ett repo */ checkRepo(repoPath) { // Hämta alla källkodsfiler try { const files = execSync( `find ${repoPath} -type f \( -name "*.ts" -o -name "*.js" -o -name "*.tsx" -o -name "*.jsx" -o -name "*.sql" -o -name "*.yaml" -o -name "*.yml" \) | head -100`, { encoding: 'utf8' } ).trim().split('\n').filter(f => f); return this.checkPR(files, { repo: repoPath, type: 'full_scan' }); } catch (e) { console.error('Fel vid repo-scan:', e.message); return { error: e.message }; } } printReport(report) { console.log('╔═══════════════════════════════════════════════════════════════╗'); console.log('║ ARKITEKTURPOLICY-KONTROLL ║'); console.log('╚═══════════════════════════════════════════════════════════════╝'); console.log(); console.log('📊 SAMMANFATTNING\n'); console.log(` Regler kontrollerade: ${report.summary.totalRules}`); console.log(` Överträdelser: ${report.summary.violations}`); console.log(` 🔴 Critical: ${report.summary.critical}`); console.log(` 🟠 High: ${report.summary.high}`); console.log(` 🟡 Medium: ${report.summary.medium}`); console.log(` 🟢 Low: ${report.summary.low}`); console.log(); if (report.violations.length > 0) { console.log('⚠️ ÖVERTRÄDELSER\n'); for (const v of report.violations) { const icon = v.severity === 'CRITICAL' ? '🔴' : v.severity === 'HIGH' ? '🟠' : v.severity === 'MEDIUM' ? '🟡' : '🟢'; console.log(` ${icon} Regel ${v.ruleId}: ${v.ruleName}`); console.log(` Fil: ${v.file}`); console.log(` Bevis: ${v.evidence}`); console.log(` ${v.message}`); console.log(); } } else { console.log('✅ Inga överträdelser hittade!\n'); } console.log('⚡ BESLUT\n'); if (report.blockMerge) { console.log(' 🔴 BLOCKERA MERGE — Kritiska överträdelser måste åtgärdas'); } else if (report.requiresReview) { console.log(' 🟠 KRÄV REVIEW — Högprioriterade överträdelser bör granskas'); } else { console.log(' ✅ GODKÄNN — Inga blockerande överträdelser'); } console.log(); console.log('═══════════════════════════════════════════════════════════════\n'); } /** * Lista alla regler */ listRules() { console.log('📋 ARKITEKTURREGLER\n'); for (const rule of this.rules) { const icon = rule.severity === 'CRITICAL' ? '🔴' : rule.severity === 'HIGH' ? '🟠' : rule.severity === 'MEDIUM' ? '🟡' : '🟢'; console.log(` ${icon} Regel ${rule.id}: ${rule.name}`); console.log(` Kategori: ${rule.category}`); console.log(` Allvarlighet: ${rule.severity}`); console.log(); } } /** * Hämta statistik över överträdelser */ getViolationStats() { if (!existsSync(POLICY_LOG)) { return { error: 'Inga överträdelser loggade ännu' }; } const violations = readFileSync(POLICY_LOG, 'utf8') .split('\n') .filter(line => line.trim()) .map(line => { try { return JSON.parse(line); } catch { return null; } }) .filter(Boolean); const byRule = {}; const bySeverity = {}; const byFile = {}; for (const v of violations) { byRule[v.ruleId] = (byRule[v.ruleId] || 0) + 1; bySeverity[v.severity] = (bySeverity[v.severity] || 0) + 1; byFile[v.file] = (byFile[v.file] || 0) + 1; } return { total: violations.length, byRule: Object.entries(byRule).map(([id, count]) => ({ ruleId: id, ruleName: this.rules.find(r => r.id == id)?.name || 'Unknown', count })).sort((a, b) => b.count - a.count), bySeverity, topFiles: Object.entries(byFile) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([file, count]) => ({ file, count })) }; } } // ── Main ────────────────────────────────────────────────────────────────── const policy = new ArchitecturePolicy(); const command = process.argv[2] || '--check'; if (command === '--check') { const repoPath = process.argv[3] || '/home/bernt/repos/quixzoom.com'; policy.checkRepo(repoPath); } else if (command === '--pr') { // Simulera PR-kontroll med filer const files = process.argv.slice(3); if (files.length === 0) { console.log('Användning: node architecture-policy.mjs --pr ...'); process.exit(1); } policy.checkPR(files); } else if (command === '--list') { policy.listRules(); } else if (command === '--stats') { const stats = policy.getViolationStats(); console.log(JSON.stringify(stats, null, 2)); } else { console.log('Användning:'); console.log(' node architecture-policy.mjs --check [repo-path] # Skanna hela repo'); console.log(' node architecture-policy.mjs --pr # Kontrollera specifika filer'); console.log(' node architecture-policy.mjs --list # Lista alla regler'); console.log(' node architecture-policy.mjs --stats # Visa överträdelse-statistik'); }