diff --git a/build.gradle.kts b/build.gradle.kts index 24eb009e..d914203a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,11 @@ -import groovy.json.JsonSlurper +import at.mocode.gradle.BundleBudgetTask +import at.mocode.gradle.FeatureIsolationTask +import at.mocode.gradle.ForbiddenAuthorizationHeaderTask import io.gitlab.arturbosch.detekt.Detekt import io.gitlab.arturbosch.detekt.extensions.DetektExtension import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jlleitschuh.gradle.ktlint.KtlintExtension import org.jlleitschuh.gradle.ktlint.reporter.ReporterType -import java.io.ByteArrayOutputStream -import java.util.zip.GZIPOutputStream plugins { // Version management plugin for dependency updates @@ -181,222 +181,23 @@ subprojects { // Fails if any source file contains manual Authorization header setting. // Policy: Authorization must be injected by the DI-provided HttpClient (apiClient). -tasks.register("archGuardForbiddenAuthorizationHeader") { +tasks.register("archGuardForbiddenAuthorizationHeader") { group = "verification" description = "Fail build if code sets Authorization header manually." - doLast { - val forbiddenPatterns = - listOf( - ".header(\"Authorization\"", - "setHeader(\"Authorization\"", - "headers[\"Authorization\"]", - "headers[\'Authorization\']", - ".header(HttpHeaders.Authorization", - "header(HttpHeaders.Authorization", - ) - // Scope: Frontend-only enforcement. Backend/Test code is excluded. - val srcDirs = listOf("clients", "frontend") - val violations = mutableListOf() - srcDirs.map { file(it) } - .filter { it.exists() } - .forEach { rootDir -> - rootDir.walkTopDown() - .filter { it.isFile && (it.extension == "kt" || it.extension == "kts") } - .forEach { f -> - val text = f.readText() - // Skip test sources - val path = f.invariantSeparatorsPath - val isTest = - path.contains("/src/commonTest/") || - path.contains("/src/jsTest/") || - path.contains("/src/jvmTest/") || - path.contains("/src/test/") - if (!isTest && forbiddenPatterns.any { text.contains(it) }) { - violations += f - } - } - } - if (violations.isNotEmpty()) { - val msg = - buildString { - appendLine("Forbidden manual Authorization header usage found in:") - violations.take(50).forEach { appendLine(" - ${it.path}") } - if (violations.size > 50) appendLine(" ... and ${violations.size - 50} more files") - appendLine() - appendLine("Policy: Use DI-provided apiClient (Koin named \"apiClient\").") - } - throw GradleException(msg) - } - } } // Guard: Frontend Feature Isolation (no feature -> feature project dependencies) -tasks.register("archGuardNoFeatureToFeatureDeps") { +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(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 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) // ------------------------------------------------------------------ -// ✅ FIX: Klasse auf Top-Level verschieben -data class Budget(val rawBytes: Long, val gzipBytes: Long) - -tasks.register("checkBundleBudget") { +tasks.register("checkBundleBudget") { group = "verification" description = "Checks JS bundle sizes of frontend shells against configured budgets" - doLast { - 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 = - 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 = ByteArrayOutputStream() - 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 a colon form for budgets keys below - val colonKey = shell.path.trimStart(':').replace('/', ':').trim() // ensure ":a:b:c" - // Budgets are keyed by a Gradle path with colons but without leading colon in config for readability - val budgetKeyCandidates = - listOf( - // e.g., frontend:shells:meldestelle-portal - shell.path.removePrefix(":"), - colonKey.removePrefix(":"), - shell.name, - ) - - val budgetEntry = budgetKeyCandidates.firstNotNullOfOrNull { budgets[it] } - 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 diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..5e20abca --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(gradleApi()) +} diff --git a/buildSrc/src/main/kotlin/at/mocode/gradle/BundleBudgetTask.kt b/buildSrc/src/main/kotlin/at/mocode/gradle/BundleBudgetTask.kt new file mode 100644 index 00000000..66a6b83c --- /dev/null +++ b/buildSrc/src/main/kotlin/at/mocode/gradle/BundleBudgetTask.kt @@ -0,0 +1,121 @@ +package at.mocode.gradle + +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.tasks.TaskAction +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.GZIPOutputStream + +data class Budget(val rawBytes: Long, val gzipBytes: Long) + +abstract class BundleBudgetTask : DefaultTask() { + + @TaskAction + fun check() { + val budgetsFile = project.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 = 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 = ByteArrayOutputStream() + 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 = project.rootProject.subprojects.filter { it.path.startsWith(shellPrefix) } + if (shells.isEmpty()) { + report.appendLine("No frontend shells found under $shellPrefix") + } + + shells.forEach { shell -> + val colonKey = shell.path.trimStart(':').replace('/', ':').trim() // ensure ":a:b:c" + // Budgets are keyed by a Gradle path with colons but without leading colon in config for readability + val budgetKeyCandidates = listOf( + // e.g., frontend:shells:meldestelle-portal + shell.path.removePrefix(":"), + colonKey.removePrefix(":"), + shell.name, + ) + + val budgetEntry = budgetKeyCandidates.firstNotNullOfOrNull { budgets[it] } + 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 = project.layout.buildDirectory.dir("reports/bundles").get().asFile + outDir.mkdirs() + project.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}") + } + } +} diff --git a/buildSrc/src/main/kotlin/at/mocode/gradle/FeatureIsolationTask.kt b/buildSrc/src/main/kotlin/at/mocode/gradle/FeatureIsolationTask.kt new file mode 100644 index 00000000..99853f52 --- /dev/null +++ b/buildSrc/src/main/kotlin/at/mocode/gradle/FeatureIsolationTask.kt @@ -0,0 +1,52 @@ +package at.mocode.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.artifacts.ProjectDependency +import org.gradle.api.tasks.TaskAction + +abstract class FeatureIsolationTask : DefaultTask() { + + @TaskAction + fun check() { + val featurePrefix = ":frontend:features:" + val violations = mutableListOf() + + project.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(ProjectDependency::class.java).forEach { dep -> + // Use reflection to avoid compile-time issues with dependencyProject property + val target = try { + val method = dep.javaClass.getMethod("getDependencyProject") + val proj = method.invoke(dep) as Project + proj.path + } catch (e: Exception) { + "" + } + 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) + } + } +} diff --git a/buildSrc/src/main/kotlin/at/mocode/gradle/ForbiddenAuthorizationHeaderTask.kt b/buildSrc/src/main/kotlin/at/mocode/gradle/ForbiddenAuthorizationHeaderTask.kt new file mode 100644 index 00000000..eb9f5738 --- /dev/null +++ b/buildSrc/src/main/kotlin/at/mocode/gradle/ForbiddenAuthorizationHeaderTask.kt @@ -0,0 +1,54 @@ +package at.mocode.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.tasks.TaskAction +import java.io.File + +abstract class ForbiddenAuthorizationHeaderTask : DefaultTask() { + + @TaskAction + fun check() { + val forbiddenPatterns = listOf( + ".header(\"Authorization\"", + "setHeader(\"Authorization\"", + "headers[\"Authorization\"]", + "headers['Authorization']", + ".header(HttpHeaders.Authorization", + "header(HttpHeaders.Authorization", + ) + // Scope: Frontend-only enforcement. Backend/Test code is excluded. + val srcDirs = listOf("clients", "frontend") + val violations = mutableListOf() + + srcDirs.map { project.file(it) } + .filter { it.exists() } + .forEach { rootDir -> + rootDir.walkTopDown() + .filter { it.isFile && (it.extension == "kt" || it.extension == "kts") } + .forEach { f -> + val text = f.readText() + // Skip test sources + val path = f.invariantSeparatorsPath + val isTest = path.contains("/src/commonTest/") || + path.contains("/src/jsTest/") || + path.contains("/src/jvmTest/") || + path.contains("/src/test/") + if (!isTest && forbiddenPatterns.any { text.contains(it) }) { + violations += f + } + } + } + + if (violations.isNotEmpty()) { + val msg = buildString { + appendLine("Forbidden manual Authorization header usage found in:") + violations.take(50).forEach { appendLine(" - ${it.path}") } + if (violations.size > 50) appendLine(" ... and ${violations.size - 50} more files") + appendLine() + appendLine("Policy: Use DI-provided apiClient (Koin named \"apiClient\").") + } + throw GradleException(msg) + } + } +}