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