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:
+6
-205
@@ -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.Detekt
|
||||||
import io.gitlab.arturbosch.detekt.extensions.DetektExtension
|
import io.gitlab.arturbosch.detekt.extensions.DetektExtension
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
import org.jlleitschuh.gradle.ktlint.KtlintExtension
|
import org.jlleitschuh.gradle.ktlint.KtlintExtension
|
||||||
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
|
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.util.zip.GZIPOutputStream
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
// Version management plugin for dependency updates
|
// Version management plugin for dependency updates
|
||||||
@@ -181,222 +181,23 @@ subprojects {
|
|||||||
|
|
||||||
// Fails if any source file contains manual Authorization header setting.
|
// Fails if any source file contains manual Authorization header setting.
|
||||||
// Policy: Authorization must be injected by the DI-provided HttpClient (apiClient).
|
// Policy: Authorization must be injected by the DI-provided HttpClient (apiClient).
|
||||||
tasks.register("archGuardForbiddenAuthorizationHeader") {
|
tasks.register<ForbiddenAuthorizationHeaderTask>("archGuardForbiddenAuthorizationHeader") {
|
||||||
group = "verification"
|
group = "verification"
|
||||||
description = "Fail build if code sets Authorization header manually."
|
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<File>()
|
|
||||||
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)
|
// Guard: Frontend Feature Isolation (no feature -> feature project dependencies)
|
||||||
tasks.register("archGuardNoFeatureToFeatureDeps") {
|
tasks.register<FeatureIsolationTask>("archGuardNoFeatureToFeatureDeps") {
|
||||||
group = "verification"
|
group = "verification"
|
||||||
description = "Fail build if a :frontend:features:* module depends on another :frontend:features:* module"
|
description = "Fail build if a :frontend:features:* module depends on another :frontend:features:* module"
|
||||||
doLast {
|
|
||||||
val featurePrefix = ":frontend:features:"
|
|
||||||
val violations = mutableListOf<String>()
|
|
||||||
|
|
||||||
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)
|
// Bundle Size Budgets for Frontend Shells (Kotlin/JS)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// ✅ FIX: Klasse auf Top-Level verschieben
|
tasks.register<BundleBudgetTask>("checkBundleBudget") {
|
||||||
data class Budget(val rawBytes: Long, val gzipBytes: Long)
|
|
||||||
|
|
||||||
tasks.register("checkBundleBudget") {
|
|
||||||
group = "verification"
|
group = "verification"
|
||||||
description = "Checks JS bundle sizes of frontend shells against configured budgets"
|
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<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 = 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<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 = 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
|
// Aggregate convenience task
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user