From df2562ea2375e8aba654dc0bd4e565b7dea8b5cd Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Mon, 8 Dec 2025 12:19:41 +0100 Subject: [PATCH] =?UTF-8?q?chore(MP-28):=20add=20arch=20guards,=20bundle?= =?UTF-8?q?=20budgets=20&=20detekt=20consolidation=20Summary=20-=20Detekt?= =?UTF-8?q?=20Config=20zentralisiert=20(`config/detekt/detekt.yml`),=20alt?= =?UTF-8?q?e=20Config=20gel=C3=B6scht.=20-=20Gradle=20Task=20`archGuardNoF?= =?UTF-8?q?eatureToFeatureDeps`:=20Verbietet=20Abh=C3=A4ngigkeiten=20zwisc?= =?UTF-8?q?hen=20Feature-Modulen.=20-=20Gradle=20Task=20`checkBundleBudget?= =?UTF-8?q?`:=20Pr=C3=BCft=20JS-Bundle-Gr=C3=B6=C3=9Fen=20gegen=20`config/?= =?UTF-8?q?bundles/budgets.json`.=20-=20CI=20Integration:=20Budgets=20werd?= =?UTF-8?q?en=20nach=20dem=20Build=20gepr=C3=BCft.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification - `./gradlew archGuards` -> SUCCESS - `./gradlew checkBundleBudget` -> SUCCESS - CI Pipeline -> SUCCESS Ref: MP-28 --- .github/workflows/ci-main.yml | 3 + build.gradle.kts | 161 ++++++++++++++++++ config/bundles/budgets.json | 6 + .../infrastructure => }/detekt/detekt.yml | 0 4 files changed, 170 insertions(+) create mode 100644 config/bundles/budgets.json rename config/{backend/infrastructure => }/detekt/detekt.yml (100%) diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index 034e7f44..c0955e19 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -109,6 +109,9 @@ jobs: - name: Build run: ./gradlew staticAnalysis build -x test + - name: Check Bundle Budgets + run: ./gradlew checkBundleBudget + - name: Test run: ./gradlew test diff --git a/build.gradle.kts b/build.gradle.kts index a35ef060..6d2c603d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -203,11 +203,172 @@ tasks.register("archGuardForbiddenAuthorizationHeader") { } } +// Guard: Frontend Feature Isolation (no feature -> feature project dependencies) +tasks.register("archGuardNoFeatureToFeatureDeps") { + group = "verification" + description = "Fail build if a :frontend:features:* module depends on another :frontend:features:* module" + doLast { + val featurePrefix = ":frontend:features:" + val violations = mutableListOf() + + rootProject.subprojects.forEach { p -> + if (p.path.startsWith(featurePrefix)) { + // Check all configurations except test-related ones + p.configurations + .matching { cfg -> + val n = cfg.name.lowercase() + !n.contains("test") && !n.contains("debug") // ignore test/debug configs + } + .forEach { cfg -> + cfg.dependencies.withType(org.gradle.api.artifacts.ProjectDependency::class.java).forEach { dep -> + // Use reflection to avoid compile-time issues with dependencyProject property + val proj = try { + dep.javaClass.getMethod("getDependencyProject").invoke(dep) as org.gradle.api.Project + } catch (e: Throwable) { + null + } + val target = proj?.path ?: "" + if (target.startsWith(featurePrefix) && target != p.path) { + violations += "${p.path} -> ${target} (configuration: ${cfg.name})" + } + } + } + } + } + + if (violations.isNotEmpty()) { + val msg = buildString { + appendLine("Feature isolation violation(s) detected:") + violations.forEach { appendLine(" - $it") } + appendLine() + appendLine("Policy: frontend features must not depend on other features. Use navigation/shared domain in :frontend:core instead.") + } + throw GradleException(msg) + } + } +} + +// ------------------------------------------------------------------ +// Bundle Size Budgets for Frontend Shells (Kotlin/JS) +// ------------------------------------------------------------------ +tasks.register("checkBundleBudget") { + group = "verification" + description = "Checks JS bundle sizes of frontend shells against configured budgets" + doLast { + data class Budget(val rawBytes: Long, val gzipBytes: Long) + + val budgetsFile = file("config/bundles/budgets.json") + if (!budgetsFile.exists()) { + throw GradleException("Budgets file not found: ${budgetsFile.path}") + } + + // Load budgets JSON as simple Map + val text = budgetsFile.readText() + @Suppress("UNCHECKED_CAST") + val parsed = + groovy.json.JsonSlurper().parseText(text) as Map> + val budgets = parsed.mapValues { (_, v) -> + val raw = (v["rawBytes"] as Number).toLong() + val gz = (v["gzipBytes"] as Number).toLong() + Budget(raw, gz) + } + + fun gzipSize(bytes: ByteArray): Long { + val baos = java.io.ByteArrayOutputStream() + java.util.zip.GZIPOutputStream(baos).use { it.write(bytes) } + return baos.toByteArray().size.toLong() + } + + val errors = mutableListOf() + val report = StringBuilder() + report.appendLine("Bundle Budget Report (per shell)") + + // Consider modules under :frontend:shells: as shells + val shellPrefix = ":frontend:shells:" + val shells = rootProject.subprojects.filter { it.path.startsWith(shellPrefix) } + if (shells.isEmpty()) { + report.appendLine("No frontend shells found under $shellPrefix") + } + + shells.forEach { shell -> + val key = shell.path.trimStart(':').replace(':', '/') // or use colon form for budgets keys below + val colonKey = shell.path.trimStart(':').replace('/', ':').trim() // ensure ":a:b:c" + // Budgets are keyed by Gradle path with colons but without leading colon in config for readability + val budgetKeyCandidates = listOf( + shell.path.removePrefix(":"), // e.g., frontend:shells:meldestelle-portal + colonKey.removePrefix(":"), + shell.name, + ) + + val budgetEntry = budgetKeyCandidates.asSequence().mapNotNull { budgets[it] }.firstOrNull() + if (budgetEntry == null) { + report.appendLine("- ${shell.path}: No budget configured (skipping)") + return@forEach + } + + // Locate distributions directory + val distDir = shell.layout.buildDirectory.dir("distributions").get().asFile + if (!distDir.exists()) { + report.appendLine("- ${shell.path}: distributions dir not found (expected build/distributions) – did you build the JS bundle?") + return@forEach + } + + // Collect JS files under distributions (avoid .map and .txt) + val jsFiles = distDir.walkTopDown().filter { it.isFile && it.extension == "js" && !it.name.endsWith(".map") }.toList() + if (jsFiles.isEmpty()) { + report.appendLine("- ${shell.path}: no JS artifacts found in ${distDir.path}") + return@forEach + } + + var totalRaw = 0L + var totalGzip = 0L + val topFiles = mutableListOf>() + jsFiles.forEach { f -> + val bytes = f.readBytes() + val raw = bytes.size.toLong() + val gz = gzipSize(bytes) + totalRaw += raw + totalGzip += gz + topFiles += f.name to raw + } + val top = topFiles.sortedByDescending { it.second }.take(5) + + report.appendLine("- ${shell.path}:") + report.appendLine(" raw: ${totalRaw} bytes (budget ${budgetEntry.rawBytes})") + report.appendLine(" gzip: ${totalGzip} bytes (budget ${budgetEntry.gzipBytes})") + report.appendLine(" top files:") + top.forEach { (n, s) -> report.appendLine(" - $n: ${s} bytes") } + + if (totalRaw > budgetEntry.rawBytes || totalGzip > budgetEntry.gzipBytes) { + errors += "${shell.path}: raw=${totalRaw}/${budgetEntry.rawBytes}, gzip=${totalGzip}/${budgetEntry.gzipBytes}" + } + } + + val outDir = layout.buildDirectory.dir("reports/bundles").get().asFile + outDir.mkdirs() + file(outDir.resolve("bundle-budgets.txt")).writeText(report.toString()) + + if (errors.isNotEmpty()) { + val msg = buildString { + appendLine("Bundle budget violations:") + errors.forEach { appendLine(" - $it") } + appendLine() + appendLine("See report: ${outDir.resolve("bundle-budgets.txt").path}") + } + throw GradleException(msg) + } else { + println(report.toString()) + println("Bundle budgets OK. Report saved to ${outDir.resolve("bundle-budgets.txt").path}") + } + } +} + // Aggregate convenience task tasks.register("archGuards") { group = "verification" description = "Run all architecture guard checks" dependsOn("archGuardForbiddenAuthorizationHeader") + dependsOn("archGuardNoFeatureToFeatureDeps") } // Composite verification task including static analyzers if present diff --git a/config/bundles/budgets.json b/config/bundles/budgets.json new file mode 100644 index 00000000..75345299 --- /dev/null +++ b/config/bundles/budgets.json @@ -0,0 +1,6 @@ +{ + "frontend:shells:meldestelle-portal": { + "rawBytes": 2097152, + "gzipBytes": 512000 + } +} diff --git a/config/backend/infrastructure/detekt/detekt.yml b/config/detekt/detekt.yml similarity index 100% rename from config/backend/infrastructure/detekt/detekt.yml rename to config/detekt/detekt.yml