refactor(build): extract reusable tasks into custom Gradle classes

Replaced inlined implementations of `ForbiddenAuthorizationHeaderTask`, `FeatureIsolationTask`, and `BundleBudgetTask` with dedicated custom Gradle task classes for improved maintainability and reusability. Updated task registrations in `build.gradle.kts`.
This commit is contained in:
2026-01-03 13:37:11 +01:00
parent 6c33de1680
commit 12f9ee23d8
5 changed files with 244 additions and 205 deletions
+11
View File
@@ -0,0 +1,11 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
dependencies {
implementation(gradleApi())
}
@@ -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<String, Budget>
val text = budgetsFile.readText()
@Suppress("UNCHECKED_CAST")
val parsed = JsonSlurper().parseText(text) as Map<String, Map<String, Any?>>
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<String>()
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<Pair<String, Long>>()
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}")
}
}
}
@@ -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<String>()
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)
}
}
}
@@ -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<File>()
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)
}
}
}