diff --git a/.github/workflows/architecture-governance.yml b/.github/workflows/architecture-governance.yml new file mode 100644 index 0000000..928392e --- /dev/null +++ b/.github/workflows/architecture-governance.yml @@ -0,0 +1,30 @@ +name: Architecture Governance + +on: + push: + branches: + - '**' + pull_request: + +jobs: + architecture: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install Dependencies + run: npm ci + + - name: Enforce Boundaries + run: npm run arch:check + + - name: Build + run: npm run build diff --git a/package.json b/package.json index bf8c052..ca7227f 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "watch": "ng build --watch --configuration development", "lavero": "ng serve --configuration=lavero --port 4202 --proxy-config proxy.conf.lavero.json", "start:lavero": "ng serve --configuration=lavero --port 4202", - "build:lavero": "ng build --configuration=lavero-production" + "build:lavero": "ng build --configuration=lavero-production", + "arch:check:boundaries": "node tools/architecture/check-boundaries.mjs", + "arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json", + "arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles" }, "private": true, "dependencies": { diff --git a/tools/architecture/check-boundaries.mjs b/tools/architecture/check-boundaries.mjs new file mode 100644 index 0000000..ea89649 --- /dev/null +++ b/tools/architecture/check-boundaries.mjs @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const ROOT = process.cwd(); +const APP_ROOT = path.join(ROOT, 'src', 'app'); + +function listTsFiles(dir) { + const files = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listTsFiles(fullPath)); + continue; + } + + if (entry.isFile() && fullPath.endsWith('.ts') && !fullPath.endsWith('.spec.ts')) { + files.push(fullPath); + } + } + + return files; +} + +function normalize(filePath) { + return filePath.split(path.sep).join('/'); +} + +function checkFile(filePath, content) { + const violations = []; + const normalized = normalize(filePath); + + const importRegex = /^\s*import\s+[^'"\n]+\s+from\s+['"]([^'"]+)['"];?/gm; + const imports = []; + let match; + + while ((match = importRegex.exec(content)) !== null) { + imports.push(match[1]); + } + + const isWidgetUi = normalized.includes('/src/app/widgets/ui/'); + const isFacade = normalized.includes('/src/app/facades/'); + const isRoutesFile = normalized.endsWith('/src/app/app.routes.ts'); + + for (const specifier of imports) { + const isEnvironmentImport = specifier.includes('environments/environment'); + if (isWidgetUi && isEnvironmentImport) { + violations.push(`${normalized}: widget UI must not import environment (${specifier})`); + } + + if (isWidgetUi && ( + specifier.includes('/services/') + || specifier.includes('/facades/') + || specifier.includes('/core/') + || specifier.includes('/interceptors/') + || specifier === '@angular/router' + || specifier === '@angular/common/http' + )) { + violations.push(`${normalized}: widget UI import boundary violation (${specifier})`); + } + + if (isFacade && specifier === '@angular/common/http') { + violations.push(`${normalized}: facade must not import HttpClient directly`); + } + + if (isRoutesFile && specifier.includes('/brands/brand-routes')) { + violations.push(`${normalized}: routes must not import tenant brand-routes`); + } + } + + return violations; +} + +const files = listTsFiles(APP_ROOT); +let violations = []; + +for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + violations = violations.concat(checkFile(file, content)); +} + +if (violations.length > 0) { + console.error('Architecture boundary violations found:'); + for (const violation of violations) { + console.error(`- ${violation}`); + } + process.exit(1); +} + +console.log('Architecture boundary checks passed.');