Files
clean-architecture-backend-…/src/build.gradle
T
donghyeon-ka a05a8ada92 merge: integrate JPA production capability
# Conflicts:
#	.github/ci-gate-matrix.yml
#	.github/scripts/verify-gate-matrix.sh
#	.github/workflows/ci-quality-gates.yml
#	src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
2026-07-31 23:57:29 +09:00

3269 lines
161 KiB
Groovy

import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import org.gradle.api.artifacts.dsl.LockMode
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.gradle.api.tasks.bundling.AbstractArchiveTask
import org.gradle.api.tasks.bundling.Jar
import java.security.MessageDigest
plugins {
id 'org.springframework.boot' version '4.0.0' apply false
id 'io.spring.dependency-management' version '1.1.6' apply false
// feature-static-analysis-quality-contract — static analysis / code quality baseline.
id 'com.diffplug.spotless' version '8.6.0' apply false // D1 formatter (google-java-format)
id 'com.github.spotbugs' version '6.5.6' apply false // D3 bytecode bug finder (+ D4 FindSecBugs)
id 'net.ltgt.errorprone' version '5.1.0' apply false // D5 compile-time checker
}
// feature-build-release-supply-chain-contract D1/D9 — every archive carries an exact SemVer
// release coordinate plus the source revision that produced it. The MAJOR.MINOR.PATCH base can be
// supplied with -PreleaseVersion or RELEASE_VERSION. The revision can be supplied with
// -PgitRevision, GIT_SHA, or GITHUB_SHA; local builds read the current Git commit.
String releaseVersion = providers.gradleProperty('releaseVersion')
.orElse(providers.environmentVariable('RELEASE_VERSION'))
.getOrElse('0.0.1')
if (!(releaseVersion ==~ /\d+\.\d+\.\d+/)) {
throw new GradleException(
"releaseVersion must be MAJOR.MINOR.PATCH without a leading 'v', pre-release, or build metadata; got '${releaseVersion}'.")
}
def localGitRevision = providers.exec {
commandLine 'git', 'rev-parse', '--short=12', 'HEAD'
ignoreExitValue = true
}.standardOutput.asText.map { it.trim() }
String sourceRevision = providers.gradleProperty('gitRevision')
.orElse(providers.environmentVariable('GIT_SHA'))
.orElse(providers.environmentVariable('GITHUB_SHA'))
.orElse(localGitRevision)
.getOrElse('')
if (!(sourceRevision ==~ /(?i)[0-9a-f]{7,40}/)) {
throw new GradleException(
"A 7-40 character hexadecimal source revision is required; use -PgitRevision=<sha> when Git metadata is unavailable.")
}
sourceRevision = sourceRevision.toLowerCase(Locale.ROOT).take(12)
String traceableVersion = "${releaseVersion}+${sourceRevision}"
ext.releaseVersion = releaseVersion
ext.sourceRevision = sourceRevision
ext.traceableVersion = traceableVersion
// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or
// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the
// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
// platforms at MODULE scope (not the shared dependencyManagement block below) — keeping the
// strict-locking blast radius to the grpc module alone.
ext.grpcVersion = '1.68.1'
ext.protobufVersion = '3.25.5'
// Outbound objectstorage adapter (adapter:outbound:objectstorage) — the Spring Boot BOM does NOT
// manage software.amazon.awssdk:* versions, and this repo has no version catalog. Pin the AWS SDK
// v2 BOM here as the single SSOT so the objectstorage module imports software.amazon.awssdk:bom as
// a platform at MODULE scope (not the shared dependencyManagement block below) — mirroring the grpc
// approach above and keeping the strict-locking blast radius to the objectstorage module alone.
ext.awsSdkVersion = '2.30.0'
Closure<Boolean> isTraceableArchiveFor = { Jar archiveTask, String fileName ->
String baseName = java.util.regex.Pattern.quote(archiveTask.archiveBaseName.get())
String classifier = archiveTask.archiveClassifier.orNull
String classifierPart = classifier == null || classifier.isBlank()
? ''
: "-${java.util.regex.Pattern.quote(classifier)}"
fileName ==~ /^${baseName}-\d+\.\d+\.\d+\+[0-9a-f]{7,40}${classifierPart}\.jar$/
}
Closure<List<File>> staleTraceableArchivesFor = { Jar archiveTask ->
File outputDir = archiveTask.destinationDirectory.get().asFile
if (!outputDir.isDirectory()) {
return []
}
String currentName = archiveTask.archiveFileName.get()
List<File> stale = outputDir.listFiles({ File ignored, String fileName ->
isTraceableArchiveFor(archiveTask, fileName) && fileName != currentName
} as FilenameFilter)?.toList() ?: []
stale.sort { it.name }
}
allprojects {
group = 'dev.caskeleton'
version = rootProject.ext.traceableVersion
repositories {
mavenCentral()
}
}
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'
// feature-static-analysis-quality-contract — apply the static analysis baseline to every
// module (D8: extend the existing subprojects {} block rather than a convention plugin).
apply plugin: 'com.diffplug.spotless' // D1 formatter
apply plugin: 'checkstyle' // D2 style linter (Gradle built-in — no plugins{} id)
apply plugin: 'com.github.spotbugs' // D3 bytecode bug finder
apply plugin: 'net.ltgt.errorprone' // D5 compile-time checker
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
// D8 — Gradle-default <project>/gradle.lockfile files are Renovate-compatible. STRICT means a
// missing or stale lock state fails resolution instead of silently selecting a new version.
dependencyLocking {
lockAllConfigurations()
lockMode = LockMode.STRICT
}
// D10 — normalize every archive, including Spring Boot's BootJar. Fixed timestamps/order and
// permissions remove host filesystem, locale-adjacent, and umask entropy from archive bytes.
tasks.withType(AbstractArchiveTask).configureEach {
preserveFileTimestamps = false
reproducibleFileOrder = true
dirPermissions { unix('755') }
filePermissions { unix('644') }
}
// D1/D9 — a JAR is independently traceable even when copied out of its container/release.
tasks.withType(Jar).configureEach {
manifest {
attributes(
'Implementation-Version': project.version.toString(),
'Build-Revision': rootProject.ext.sourceRevision
)
}
doFirst {
staleTraceableArchivesFor(it).each { File stale ->
logger.lifecycle("${path}: deleting stale traceable archive ${stale.name}")
if (!stale.delete()) {
throw new GradleException("${path}: failed to delete stale traceable archive ${stale}")
}
}
}
}
// Official Gradle pattern: resolve every resolvable configuration while --write-locks is set.
// This captures transitive compile/test/analysis dependencies, not only direct declarations.
tasks.register('resolveAndLockAll') {
group = 'build setup'
description = 'Resolves every configuration and writes this project\'s dependency lock state.'
notCompatibleWithConfigurationCache('Filters configurations at execution time')
doFirst {
if (!gradle.startParameter.writeDependencyLocks) {
throw new GradleException("${path} requires the --write-locks command-line flag.")
}
}
doLast {
configurations.findAll { it.canBeResolved }.each { it.resolve() }
}
}
// Unlike Gradle's diagnostic `dependencies` report, this task performs strict resolution and
// propagates a missing/stale lock entry as a non-zero build failure.
tasks.register('verifyDependencyLocks') {
group = 'verification'
description = 'Resolves every configuration and fails when strict dependency locks drift.'
notCompatibleWithConfigurationCache('Filters configurations at execution time')
doLast {
configurations.findAll { it.canBeResolved }.each { it.resolve() }
}
}
// Keep method parameter names in bytecode for Spring MVC @PathVariable/@RequestParam
// binding (rationale in README.md). ErrorProne (D5) hooks the same compile tasks: it
// auto-injects the JDK 16+ --add-exports/--add-opens forking args, so none are added here.
tasks.withType(JavaCompile).configureEach {
options.compilerArgs << '-parameters'
options.errorprone {
disableWarningsInGeneratedCode = true // D5 — MapStruct/Lombok generated code (errorprone README C5)
}
}
// D1 — google-java-format owns formatting + import order; spotlessApply auto-fixes,
// spotlessCheck (wired into check) verifies. CI must NEVER run spotlessApply.
spotless {
java {
googleJavaFormat('1.35.0')
importOrder()
removeUnusedImports()
}
}
// D2 — naming + logical ruleset; formatter-owned modules suppressed in the XML.
checkstyle {
toolVersion = '13.5.0'
configFile = rootProject.file('config/checkstyle/checkstyle.xml')
configDirectory = rootProject.file('config/checkstyle')
ignoreFailures = false
// No warning-tier checks in the default build. Javadoc coverage is a documentation backlog,
// not a signal to print on every migration/build run.
maxWarnings = Integer.MAX_VALUE
}
// §4 routing — checkstyleMain blocking; checkstyleTest warning-only (test-helper exception).
tasks.named('checkstyleTest') {
ignoreFailures = true
}
// D3/D4 — bytecode bug finder; FindSecBugs plugin loaded via spotbugsPlugins below.
// reportLevel='high' implements §4 "blocking (high priority)": only high-confidence findings
// block, which keeps the gate signal-rich (the medium tier is dominated by EI_EXPOSE_REP
// defensive-copy noise on DI'd collaborators). effort left at default (UNSUPPORTED_IMPL_DECISION
// — strictness is a user trade-off; default is functionally valid). Confirmed false positives
// go in config/spotbugs/exclude.xml.
spotbugs {
toolVersion = '4.10.2'
reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH')
excludeFilter = rootProject.file('config/spotbugs/exclude.xml')
}
// SpotBugs 4.10.2 needs commons-lang3 3.20.0 (uses org.apache.commons.lang3.Strings); the
// Spring Boot BOM otherwise pins commons-lang3 to 3.17.0 — and io.spring.dependency-management
// overrides resolutionStrategy.force — so the analysis worker crashes with NoClassDefFoundError.
// Override the BOM-managed version property (the documented Spring mechanism). No production
// module imports commons.lang3, so this only affects the SpotBugs tool classpath in practice.
ext['commons-lang3.version'] = '3.20.0'
// §4 routing — spotbugsMain blocking; spotbugsTest warning-only (test-source trade-off).
tasks.named('spotbugsTest') {
ignoreFailures = true
}
dependencyManagement {
imports {
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
}
}
dependencies {
if (project.path in [':domain-core', ':application-core', ':shared-contract']) {
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.assertj:assertj-core'
} else {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0' // D4 code-level security
errorprone 'com.google.errorprone:error_prone_core:2.49.0' // D5 compile-time checker
}
// feature-ci-quality-gates-contract §4 (D7) — the main release gate EXCLUDES the flaky
// quarantine bucket so a quarantined test can never block merge. Quarantined tests carry
// JUnit's built-in @Tag("quarantine"); they run separately via `quarantineTest` (non-blocking)
// and their 14-day sunset is enforced by verifyQuarantineSunset. With zero quarantined tests
// (the skeleton default) excludeTags is a no-op.
tasks.named('test') {
useJUnitPlatform {
excludeTags 'quarantine'
}
}
// feature-ci-quality-gates-contract §4 (D7/D9) — flaky quarantine bucket. Runs ONLY
// @Tag("quarantine") tests, isolated from `check`, never blocking the build (ignoreFailures).
// failOnNoDiscoveredTests=false so the empty bucket (skeleton ships zero flaky tests) passes.
tasks.register('quarantineTest', Test) {
group = 'verification'
description = 'Flaky-test quarantine bucket (feature-ci-quality-gates-contract §4): runs only ' +
'@Tag("quarantine") tests, non-blocking, isolated from the release gate.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'quarantine'
}
ignoreFailures = true
failOnNoDiscoveredTests = false
// Always re-run; a flaky bucket must never serve a stale UP-TO-DATE result.
outputs.upToDateWhen { false }
// Pin UTC like the main test task for host-locale independence.
jvmArgs '-Duser.timezone=UTC'
}
tasks.named('check') {
dependsOn rootProject.tasks.named('verifyCleanArchitectureDependencies')
dependsOn rootProject.tasks.named('verifyEnvKeys')
dependsOn rootProject.tasks.named('verifyNoStaleTraceableJars')
dependsOn rootProject.tasks.named('verifyOneTypePerFile')
dependsOn rootProject.tasks.named('verifyTrivyignore')
dependsOn rootProject.tasks.named('verifyQuarantineSunset')
}
}
// One explicit command regenerates every module's Gradle-default lockfile.
tasks.register('resolveAndLockAll') {
group = 'build setup'
description = 'Regenerates dependency locks for all subprojects (requires --write-locks).'
dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('resolveAndLockAll') }
}
tasks.register('verifyDependencyLocks') {
group = 'verification'
description = 'Verifies strict dependency lock state for all subprojects.'
dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('verifyDependencyLocks') }
}
tasks.register('cleanStaleTraceableJars') {
group = 'build'
description = 'Deletes older git-revision JARs from build/libs so IDE runtime classpaths cannot load stale module artifacts.'
doLast {
int deleted = 0
subprojects.each { sub ->
sub.tasks.withType(Jar).each { Jar archiveTask ->
staleTraceableArchivesFor(archiveTask).each { File stale ->
if (!stale.delete()) {
throw new GradleException("cleanStaleTraceableJars: failed to delete ${stale}")
}
deleted++
logger.lifecycle("cleanStaleTraceableJars: deleted ${stale}")
}
}
}
logger.lifecycle("cleanStaleTraceableJars: deleted ${deleted} stale archive(s).")
}
}
tasks.register('verifyNoStaleTraceableJars') {
group = 'verification'
description = 'Verifies build/libs does not retain older git-revision JARs that can poison IDE runtime classpaths.'
dependsOn tasks.named('cleanStaleTraceableJars')
doLast {
List<String> violations = []
subprojects.each { sub ->
sub.tasks.withType(Jar).each { Jar archiveTask ->
List<String> staleJars = staleTraceableArchivesFor(archiveTask).collect { it.name }
if (!staleJars.isEmpty()) {
violations << ":${sub.name}:${archiveTask.name}: stale JAR(s) ${staleJars}; current archive is ${archiveTask.archiveFileName.get()}"
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyNoStaleTraceableJars: ${violations.size()} module(s) retain old traceable JARs.\n " +
violations.join('\n '))
}
logger.lifecycle('verifyNoStaleTraceableJars: OK — no stale traceable JARs in build/libs.')
}
}
// feature-developer-experience-contract D3 — one ordered first-run entrypoint. Each stage is a
// separate task so the task name and exit code identify the failed phase without log archaeology.
def repositoryDir = rootProject.projectDir.parentFile
def baseComposeFile = new File(repositoryDir, 'docker-compose.yml')
def localComposeFile = new File(repositoryDir, 'docker-compose.local.yml')
def composeCommand = ['docker', 'compose', '-f', baseComposeFile.absolutePath,
'-f', localComposeFile.absolutePath]
def bootstrapCompile = tasks.register('bootstrapCompile') {
group = 'developer experience'
description = 'Stage 1/5: compiles every main and test source set as a local sanity check.'
dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('compileTestJava') }
}
def bootstrapDockerPreflight = tasks.register('bootstrapDockerPreflight', Exec) {
group = 'developer experience'
description = 'Checks that the Docker CLI can reach a running Docker daemon.'
commandLine 'docker', 'info'
ignoreExitValue = true
standardOutput = new ByteArrayOutputStream()
errorOutput = new ByteArrayOutputStream()
doLast {
if (executionResult.get().exitValue != 0) {
throw new GradleException(
'bootstrap: Docker가 필요합니다. Docker Desktop/daemon을 시작한 뒤 ' +
'`docker info`가 성공하는지 확인하세요.\n' + errorOutput.toString())
}
}
}
bootstrapDockerPreflight.configure { dependsOn bootstrapCompile }
def bootstrapDependencies = tasks.register('bootstrapDependencies', Exec) {
group = 'developer experience'
description = 'Stage 2/5: starts the local PostgreSQL dependency and waits for readiness.'
commandLine composeCommand + ['up', '-d', '--wait', 'db']
}
bootstrapDependencies.configure { dependsOn bootstrapCompile }
bootstrapDependencies.configure { dependsOn bootstrapDockerPreflight }
def bootstrapMigrateAndStart = tasks.register('bootstrapMigrateAndStart', Exec) {
group = 'developer experience'
description = 'Stage 3/5: builds/starts the app; startup Flyway must finish before health is ready.'
commandLine composeCommand + ['up', '-d', '--build', '--wait', 'app']
}
bootstrapMigrateAndStart.configure { dependsOn bootstrapDependencies }
def bootstrapSampleContract = tasks.register('bootstrapSampleContract') {
group = 'developer experience'
description = 'Stage 4/5: verifies the delegated sample production-isolation/build contract.'
dependsOn project(':app-bootstrap').tasks.named('bootstrapSampleContract')
}
bootstrapSampleContract.configure { dependsOn bootstrapMigrateAndStart }
def bootstrapSmoke = tasks.register('bootstrapSmoke') {
group = 'developer experience'
description = 'Stage 5/5: requires HTTP 200 and status=UP from GET /api/healthcheck.'
doLast {
URI endpoint = URI.create('http://localhost:8080/api/healthcheck')
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60)
String lastFailure = 'no response'
while (System.nanoTime() < deadline) {
HttpURLConnection connection = null
try {
connection = (HttpURLConnection) endpoint.toURL().openConnection()
connection.connectTimeout = 2_000
connection.readTimeout = 2_000
connection.requestMethod = 'GET'
int status = connection.responseCode
String body = status >= 200 && status < 400 ? connection.inputStream.text :
connection.errorStream?.text
if (status == 200 && body != null && body.contains('"status":"UP"')) {
logger.lifecycle('bootstrapSmoke: OK — GET /api/healthcheck returned HTTP 200 and status=UP.')
return
}
lastFailure = "HTTP ${status}: ${body}"
} catch (IOException ex) {
lastFailure = ex.message
} finally {
connection?.disconnect()
}
sleep(1_000)
}
throw new GradleException(
"bootstrapSmoke: /api/healthcheck did not become healthy within 60s; last result: ${lastFailure}")
}
}
bootstrapSmoke.configure { dependsOn bootstrapSampleContract }
tasks.register('bootstrap') {
group = 'developer experience'
description = 'Runs the complete five-stage local bootstrap contract.'
dependsOn bootstrapSmoke
}
// feature-developer-experience-contract D4 — README is an entrypoint, not an unchecked second
// build script. Validate only executable command blocks (`bash`/`sh`); prose examples stay prose.
tasks.register('verifyReadmeCommands') {
group = 'verification'
description = 'Verifies README Gradle/Compose/Make commands refer to real tasks, files, and targets.'
File readmeFile = project.hasProperty('readmeFile')
? file(project.property('readmeFile'))
: new File(repositoryDir, 'README.md')
inputs.file(readmeFile)
doLast {
if (!readmeFile.isFile()) {
throw new GradleException("verifyReadmeCommands: missing ${readmeFile}")
}
List<String> violations = []
boolean inShellBlock = false
readmeFile.eachLine { String rawLine, int lineNumber ->
String line = rawLine.trim()
if (line == '```bash' || line == '```sh') {
inShellBlock = true
return
}
if (line == '```' && inShellBlock) {
inShellBlock = false
return
}
if (!inShellBlock || line.isEmpty() || line.startsWith('#')) {
return
}
int gradleIndex = line.indexOf('./gradlew ')
if (gradleIndex >= 0) {
List<String> tokens = line.substring(gradleIndex + './gradlew '.length())
.split(/\s+/).toList()
List<String> requestedTasks = []
for (String token : tokens) {
if (token.startsWith('-') || token in ['&&', '||', '|']) {
break
}
requestedTasks << token
}
if (requestedTasks.isEmpty()) {
violations << "${readmeFile}:${lineNumber}: ./gradlew command has no task"
}
requestedTasks.each { String taskPath ->
boolean exists
if (taskPath.startsWith(':') && taskPath.count(':') >= 2) {
int lastSeparator = taskPath.lastIndexOf(':')
String projectPath = taskPath.substring(0, lastSeparator)
String taskName = taskPath.substring(lastSeparator + 1)
Project targetProject = rootProject.findProject(projectPath)
exists = targetProject != null && targetProject.tasks.findByName(taskName) != null
} else {
exists = !rootProject.getTasksByName(taskPath, true).isEmpty()
}
if (!exists) {
violations << "${readmeFile}:${lineNumber}: unknown Gradle task '${taskPath}' in `${line}`"
}
}
}
if (line.startsWith('docker compose ')) {
List<String> tokens = line.split(/\s+/).toList()
int index = 2
while (index < tokens.size() && tokens[index].startsWith('-')) {
String option = tokens[index++]
if (option in ['-f', '--file', '--env-file', '-p', '--project-name']) {
if (index >= tokens.size()) {
violations << "${readmeFile}:${lineNumber}: '${option}' has no value in `${line}`"
break
}
String value = tokens[index++]
if (option in ['-f', '--file']) {
File composeFile = new File(repositoryDir, value)
if (!composeFile.isFile()) {
violations << "${readmeFile}:${lineNumber}: missing Compose file '${value}'"
}
}
}
}
Set<String> supported = ['build', 'config', 'down', 'logs', 'ps', 'pull', 'restart',
'run', 'start', 'stop', 'up'] as Set
if (index >= tokens.size() || !supported.contains(tokens[index])) {
String actual = index < tokens.size() ? tokens[index] : '<missing>'
violations << "${readmeFile}:${lineNumber}: unsupported Compose subcommand '${actual}'"
}
}
if (line.startsWith('make ')) {
File makefile = new File(repositoryDir, 'Makefile')
String target = line.substring('make '.length()).split(/\s+/)[0]
if (!makefile.isFile()) {
violations << "${readmeFile}:${lineNumber}: make command documented but Makefile is absent"
} else if (!(makefile.text =~ /(?m)^${java.util.regex.Pattern.quote(target)}\s*:/).find()) {
violations << "${readmeFile}:${lineNumber}: unknown Make target '${target}'"
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyReadmeCommands: ${violations.size()} command drift violation(s):\n " +
violations.join('\n '))
}
logger.lifecycle("verifyReadmeCommands: OK — executable commands in ${readmeFile} resolve.")
}
}
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
tasks.named('check') {
dependsOn rootProject.tasks.named('verifyReadmeCommands')
}
}
tasks.register('verifyCleanArchitectureDependencies') {
group = 'verification'
description = 'Verifies Clean Architecture project dependency direction.'
File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json')
inputs.file(moduleRegistryFile)
doLast {
if (!moduleRegistryFile.isFile()) {
throw new GradleException("Missing module registry: ${moduleRegistryFile}")
}
def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile)
if (!(moduleRegistry.modules instanceof List) || moduleRegistry.modules.isEmpty()) {
throw new GradleException("Module registry has no modules: ${moduleRegistryFile}")
}
Map<String, Object> modulesById = moduleRegistry.modules.collectEntries { module ->
[(module.id as String): module]
}
if (modulesById.size() != moduleRegistry.modules.size()) {
throw new GradleException('Module registry contains duplicate module ids.')
}
Map<String, Set<String>> allowedProjectDependencies = moduleRegistry.modules.collectEntries { module ->
String moduleName = (module.gradle_path as String).replaceFirst('^:', '')
Set<String> allowed = (module.allowed_dependencies as List).collect { dependencyId ->
def dependency = modulesById[dependencyId as String]
if (dependency == null) {
throw new GradleException(
"Module registry '${module.id}' references unknown allowed dependency id '${dependencyId}'.")
}
(dependency.gradle_path as String).replaceFirst('^:', '')
}.toSet()
[(moduleName): allowed]
}
Set<String> declaredModules = subprojects.findAll { it.childProjects.isEmpty() }
.collect { it.path.replaceFirst('^:', '') }.toSet()
Set<String> governedModules = allowedProjectDependencies.keySet()
Set<String> missingFromBuild = governedModules - declaredModules
Set<String> missingFromPolicy = declaredModules - governedModules
if (!missingFromBuild.isEmpty()) {
throw new GradleException(
"Clean Architecture dependency policy references missing Gradle modules ${missingFromBuild}. " +
"Declared modules are ${declaredModules}."
)
}
if (!missingFromPolicy.isEmpty()) {
throw new GradleException(
"Gradle modules ${missingFromPolicy} are not covered by verifyCleanArchitectureDependencies. " +
"Add an explicit dependency policy before using them."
)
}
allowedProjectDependencies.each { moduleName, allowed ->
Project module = rootProject.project(":${moduleName}")
Set<String> actual = ['api', 'implementation', 'compileOnly', 'runtimeOnly']
.collect { configurationName -> module.configurations.findByName(configurationName) }
.findAll { it != null }
.collectMany { configuration ->
configuration.dependencies.withType(ProjectDependency).collect { dependency ->
dependency.path.replaceFirst('^:', '')
}
}
.toSet()
if (moduleName != 'sample-portfolio' && actual.contains('sample-portfolio')) {
throw new GradleException(
"Module ':${moduleName}' has a forbidden production dependency on " +
"':sample-portfolio'. The sample module may only be consumed through " +
"non-production fixture configurations."
)
}
Set<String> forbidden = actual - allowed
if (!forbidden.isEmpty()) {
throw new GradleException(
"Module ':${moduleName}' has forbidden project dependencies ${forbidden}. " +
"Allowed dependencies are ${allowed}. " +
"Production modules must not depend on ':sample-portfolio'; " +
"all project edges must be explicitly registered."
)
}
}
}
}
Set<String> expectedJpaReadinessCardIds = [
'jpa-observability-lifecycle',
'jpa-security-baseline',
'jpa-flyway-migration',
'jpa-transaction-runtime',
'jpa-aggregate-store',
'jpa-query-model',
'jpa-primary-foundation',
'jpa-idempotency-owner-safe-v2',
'jpa-outbox-storage-v2',
'jpa-outbox-polling-delivery-v2',
'jpa-outbox-cdc-retention-v1',
'jpa-inbox-same-store-v1',
'jpa-primary-replica',
'jpa-tenant-discriminator-rls',
'jpa-jdbc-efficiency-coordination'
] as Set
Set<String> expectedJpaOwnedMigrationCardIds = [
'jpa-flyway-migration',
'jpa-idempotency-owner-safe-v2',
'jpa-outbox-storage-v2',
'jpa-outbox-polling-delivery-v2',
'jpa-inbox-same-store-v1',
'jpa-tenant-discriminator-rls',
'jpa-jdbc-efficiency-coordination'
] as Set
Closure<List<String>> validateJpaReadinessRegistry = {
Map<String, Object> registry,
String rawRegistry,
Closure<Boolean> taskExists ->
List<String> violations = []
Set<String> rootKeys = registry.keySet().collect { it as String }.toSet()
Set<String> expectedRootKeys = ['schema-version', 'legacy-adoption', 'cards'] as Set
if (rootKeys != expectedRootKeys) {
violations << "root keys must be exactly ${expectedRootKeys}; got ${rootKeys}"
}
if (registry['schema-version'] != 1) {
violations << "schema-version must be integer 1; got ${registry['schema-version']}"
}
Map<String, Object> legacy = registry['legacy-adoption'] instanceof Map
? registry['legacy-adoption'] as Map<String, Object>
: [:]
Set<String> expectedLegacyKeys = [
'state',
'location',
'history-table',
'immutable-applied-versions',
'allowed-origin'
] as Set
if (legacy.keySet().collect { it as String }.toSet() != expectedLegacyKeys) {
violations << "legacy-adoption keys must be exactly ${expectedLegacyKeys}"
}
if (legacy.state != 'transition-only') {
violations << "legacy-adoption.state must be transition-only"
}
if (legacy.location != 'db/migration/postgresql') {
violations << "legacy-adoption.location must be db/migration/postgresql"
}
if (legacy['history-table'] != 'flyway_schema_history') {
violations << "legacy-adoption.history-table must be flyway_schema_history"
}
if (legacy['immutable-applied-versions'] != [1, 3, 4, 5]) {
violations << "legacy-adoption immutable versions must be exactly [1, 3, 4, 5]"
}
if (legacy['allowed-origin'] != 'LEGACY_ADOPTED') {
violations << "legacy-adoption.allowed-origin must be LEGACY_ADOPTED"
}
Map<String, Object> cards = registry.cards instanceof Map
? registry.cards as Map<String, Object>
: [:]
Set<String> actualCardIds = cards.keySet().collect { it as String }.toSet()
Set<String> missingCards = expectedJpaReadinessCardIds - actualCardIds
Set<String> unknownCards = actualCardIds - expectedJpaReadinessCardIds
if (!missingCards.isEmpty()) {
violations << "missing card ids ${missingCards.toSorted()}"
}
if (!unknownCards.isEmpty()) {
violations << "unknown card ids ${unknownCards.toSorted()}"
}
List<String> rawCardKeys = []
def rawCardKeyMatcher = rawRegistry =~ /"(?<card>jpa-[a-z0-9.-]+)"\s*:/
while (rawCardKeyMatcher.find()) {
rawCardKeys << rawCardKeyMatcher.group('card')
}
Set<String> duplicateRawCardKeys = rawCardKeys.countBy { it }.findAll {
String ignored, Integer count -> count > 1
}.keySet()
if (!duplicateRawCardKeys.isEmpty()) {
violations << "duplicate raw card keys ${duplicateRawCardKeys.toSorted()}"
}
Set<String> allowedCardKeys = [
'state',
'schema-stream',
'prerequisites',
'external-prerequisites',
'readiness-task',
'support-tasks',
'required-evidence',
'evidence',
'dispatch-modes',
'migration'
] as Set
Set<String> allowedStates = ['selected', 'implemented-candidate', 'not-implemented'] as Set
Set<String> allowedSchemaStreams = ['none', 'owned', 'contributes-to-core'] as Set
Map<String, String> taskOwners = [:]
Map<String, String> migrationLocationOwners = [:]
Map<String, String> migrationHistoryOwners = [:]
Map<String, String> evidenceSelectorOwners = [:]
Set<String> actualOwnedMigrationCards = []
cards.each { String cardId, Object rawCard ->
if (!(rawCard instanceof Map)) {
violations << "${cardId}: card value must be an object"
return
}
Map<String, Object> card = rawCard as Map<String, Object>
Set<String> unknownKeys = card.keySet().collect { it as String }.toSet() - allowedCardKeys
if (!unknownKeys.isEmpty()) {
violations << "${cardId}: unknown keys ${unknownKeys.toSorted()}"
}
String state = card.state as String
String schemaStream = card['schema-stream'] as String
if (!allowedStates.contains(state)) {
violations << "${cardId}: invalid state '${state}'"
}
if (!allowedSchemaStreams.contains(schemaStream)) {
violations << "${cardId}: invalid schema-stream '${schemaStream}'"
}
if (!(card.prerequisites instanceof List)) {
violations << "${cardId}: prerequisites must be a list"
}
List<String> prerequisites = card.prerequisites instanceof List
? (card.prerequisites as List).collect { it as String }
: []
if (prerequisites.toSet().size() != prerequisites.size()) {
violations << "${cardId}: duplicate prerequisites ${prerequisites}"
}
prerequisites.each { String prerequisite ->
if (!cards.containsKey(prerequisite)) {
violations << "${cardId}: unknown prerequisite '${prerequisite}'"
} else if (state == 'selected' &&
((cards[prerequisite] as Map).state as String) != 'selected') {
violations << "${cardId}: selected card requires non-selected '${prerequisite}'"
}
}
String readinessTask = card['readiness-task'] as String
if (readinessTask == null || !readinessTask.startsWith(':')) {
violations << "${cardId}: readiness-task must be an absolute Gradle task path"
}
List<String> supportTasks = card['support-tasks'] instanceof List
? (card['support-tasks'] as List).collect { it as String }
: []
if (supportTasks.toSet().size() != supportTasks.size()) {
violations << "${cardId}: duplicate support-tasks ${supportTasks}"
}
([readinessTask] + supportTasks).findAll { it != null }.each { String taskPath ->
if (!taskPath.startsWith(':')) {
violations << "${cardId}: task '${taskPath}' must be an absolute Gradle task path"
return
}
String previousOwner = taskOwners.putIfAbsent(taskPath, cardId)
if (previousOwner != null) {
violations << "duplicate task '${taskPath}' owned by ${previousOwner} and ${cardId}"
}
if (state == 'selected' && !taskExists(taskPath)) {
violations << "${cardId}: selected task does not exist '${taskPath}'"
}
}
List<String> requiredEvidence = card['required-evidence'] instanceof List
? (card['required-evidence'] as List).collect { it as String }
: []
if (requiredEvidence.isEmpty()) {
violations << "${cardId}: required-evidence must be a non-empty list"
} else {
if (requiredEvidence.toSet().size() != requiredEvidence.size()) {
violations << "${cardId}: duplicate required-evidence ${requiredEvidence}"
}
if (!requiredEvidence.contains('no-skip')) {
violations << "${cardId}: required-evidence must include no-skip"
}
}
Object migrationNode = card.migration
Set<String> allowedEvidenceClaims = requiredEvidence
.findAll { String requirement -> requirement != 'no-skip' }
.toSet()
Map<String, Object> migrationForEvidence = migrationNode instanceof Map
? migrationNode as Map<String, Object>
: [:]
Object lifecycleEvidenceNode = migrationForEvidence['lifecycle-evidence']
if (lifecycleEvidenceNode instanceof List) {
(lifecycleEvidenceNode as List).each {
Object lifecycle ->
allowedEvidenceClaims <<
"migration-lifecycle:${lifecycle as String}".toString()
}
}
Object evidenceNode = card.evidence
if (state == 'not-implemented') {
if (evidenceNode != null) {
violations << "${cardId}: not-implemented card forbids evidence"
}
} else if (!(evidenceNode instanceof Map)) {
violations << "${cardId}: active card requires evidence"
} else {
Map<String, Object> evidence = evidenceNode as Map<String, Object>
Set<String> evidenceKeys = evidence.keySet().collect { it as String }.toSet()
Set<String> expectedEvidenceKeys = ['scenarios', 'task-claims'] as Set
if (evidenceKeys != expectedEvidenceKeys) {
violations << "${cardId}: evidence keys must be exactly ${expectedEvidenceKeys}"
}
List<Object> scenarios = evidence.scenarios instanceof List
? evidence.scenarios as List<Object>
: []
if (!(evidence.scenarios instanceof List)) {
violations << "${cardId}: evidence scenarios must be a list"
}
List<Object> taskClaims = evidence['task-claims'] instanceof List
? evidence['task-claims'] as List<Object>
: []
if (!(evidence['task-claims'] instanceof List)) {
violations << "${cardId}: evidence task-claims must be a list"
}
if (scenarios.isEmpty() && taskClaims.isEmpty()) {
violations << "${cardId}: evidence must contain a scenario or task claim"
}
scenarios.eachWithIndex { Object rawScenario, int index ->
if (!(rawScenario instanceof Map)) {
violations << "${cardId}: evidence scenario ${index} must be an object"
return
}
Map<String, Object> scenario = rawScenario as Map<String, Object>
Set<String> scenarioKeys =
scenario.keySet().collect { it as String }.toSet()
if (scenarioKeys != ['selector', 'covers'] as Set) {
violations << "${cardId}: evidence scenario ${index} has invalid keys ${scenarioKeys}"
}
String selector = scenario.selector as String
if (selector == null ||
!(selector ==~ /dev\.caskeleton\.[A-Za-z0-9_.]+\#[A-Za-z][A-Za-z0-9_]*/)) {
violations << "${cardId}: invalid evidence selector '${selector}'"
} else {
String previousOwner = evidenceSelectorOwners.putIfAbsent(selector, cardId)
if (previousOwner != null) {
violations << "duplicate evidence selector '${selector}' owned by " +
"${previousOwner} and ${cardId}"
}
}
List<String> covers = scenario.covers instanceof List
? (scenario.covers as List).collect { it as String }
: []
if (covers.isEmpty()) {
violations << "${cardId}: evidence scenario ${index} covers must be non-empty"
}
if (covers.toSet().size() != covers.size()) {
violations << "${cardId}: evidence scenario ${index} has duplicate covers ${covers}"
}
covers.each { String claim ->
if (!allowedEvidenceClaims.contains(claim)) {
violations << "${cardId}: evidence covers unknown requirement '${claim}'"
}
}
}
Set<String> ownedTasks = ([readinessTask] + supportTasks)
.findAll { it != null }
.toSet()
taskClaims.eachWithIndex { Object rawClaim, int index ->
if (!(rawClaim instanceof Map)) {
violations << "${cardId}: evidence task claim ${index} must be an object"
return
}
Map<String, Object> claim = rawClaim as Map<String, Object>
Set<String> claimKeys = claim.keySet().collect { it as String }.toSet()
if (claimKeys != ['task', 'covers'] as Set) {
violations << "${cardId}: evidence task claim ${index} has invalid keys ${claimKeys}"
}
String taskPath = claim.task as String
if (!ownedTasks.contains(taskPath)) {
violations << "${cardId}: evidence task claim is not owned by card '${taskPath}'"
}
List<String> covers = claim.covers instanceof List
? (claim.covers as List).collect { it as String }
: []
if (covers.isEmpty()) {
violations << "${cardId}: evidence task claim ${index} covers must be non-empty"
}
if (covers.toSet().size() != covers.size()) {
violations << "${cardId}: evidence task claim ${index} has duplicate covers ${covers}"
}
covers.each { String evidenceClaim ->
if (!allowedEvidenceClaims.contains(evidenceClaim)) {
violations << "${cardId}: evidence covers unknown requirement '${evidenceClaim}'"
}
}
}
}
if (schemaStream == 'owned') {
actualOwnedMigrationCards << cardId
if (!(migrationNode instanceof Map)) {
violations << "${cardId}: owned schema-stream requires migration"
}
} else if (migrationNode != null) {
violations << "${cardId}: schema-stream ${schemaStream} forbids migration"
}
if (migrationNode instanceof Map) {
Map<String, Object> migration = migrationNode as Map<String, Object>
Set<String> expectedMigrationKeys = [
'location',
'history-table',
'required-core-epoch',
'feature-revision',
'lifecycle-evidence'
] as Set
Set<String> migrationKeys = migration.keySet().collect { it as String }.toSet()
if (migrationKeys != expectedMigrationKeys) {
violations << "${cardId}: migration keys must be exactly ${expectedMigrationKeys}"
}
String location = migration.location as String
String historyTable = migration['history-table'] as String
if (location == null || !(location ==~ /db\/migration\/jpa\/[a-z0-9-]+/)) {
violations << "${cardId}: invalid migration location '${location}'"
} else {
String previousOwner = migrationLocationOwners.putIfAbsent(location, cardId)
if (previousOwner != null) {
violations << "duplicate migration location '${location}' for ${previousOwner} and ${cardId}"
}
}
if (historyTable == null || !(historyTable ==~ /flyway_jpa_[a-z0-9_]+_history/)) {
violations << "${cardId}: invalid migration history-table '${historyTable}'"
} else {
String previousOwner = migrationHistoryOwners.putIfAbsent(historyTable, cardId)
if (previousOwner != null) {
violations << "duplicate migration history-table '${historyTable}' for ${previousOwner} and ${cardId}"
}
}
Object coreEpoch = migration['required-core-epoch']
Object featureRevision = migration['feature-revision']
if (!(coreEpoch instanceof Integer) || (coreEpoch as Integer) < 0) {
violations << "${cardId}: required-core-epoch must be a non-negative integer"
}
if (!(featureRevision instanceof Integer) || (featureRevision as Integer) <= 0) {
violations << "${cardId}: feature-revision must be a positive integer"
}
List<String> lifecycleEvidence = migration['lifecycle-evidence'] instanceof List
? (migration['lifecycle-evidence'] as List).collect { it as String }
: []
if (lifecycleEvidence.isEmpty()) {
violations << "${cardId}: lifecycle-evidence must be a non-empty list"
} else if (lifecycleEvidence.toSet().size() != lifecycleEvidence.size()) {
violations << "${cardId}: duplicate lifecycle-evidence ${lifecycleEvidence}"
}
}
if (card['external-prerequisites'] != null) {
if (!(card['external-prerequisites'] instanceof List)) {
violations << "${cardId}: external-prerequisites must be a list"
} else {
(card['external-prerequisites'] as List).eachWithIndex {
Object rawExternal, int index ->
if (!(rawExternal instanceof Map)) {
violations << "${cardId}: external prerequisite ${index} must be an object"
return
}
Map<String, Object> external = rawExternal as Map<String, Object>
Set<String> externalKeys = external.keySet()
.collect { it as String }
.toSet()
if (externalKeys != ['registry', 'card-id', 'minimum-readiness'] as Set) {
violations << "${cardId}: external prerequisite ${index} has invalid keys ${externalKeys}"
}
if (!((external.registry as String)?.startsWith('src/config/'))) {
violations << "${cardId}: external prerequisite ${index} has invalid registry"
}
if (!((external['card-id'] as String) ==~ /[a-z0-9.-]+/)) {
violations << "${cardId}: external prerequisite ${index} has invalid card-id"
}
if (!((external['minimum-readiness'] as String) ==~ /R[0-3]/)) {
violations << "${cardId}: external prerequisite ${index} has invalid minimum-readiness"
}
}
}
}
}
if (actualOwnedMigrationCards != expectedJpaOwnedMigrationCardIds) {
violations << "owned migration cards must be exactly ${expectedJpaOwnedMigrationCardIds}; " +
"got ${actualOwnedMigrationCards}"
}
Map<String, Integer> visitState = [:].withDefault { 0 }
Closure<Void> visitCard
visitCard = { String cardId ->
if (visitState[cardId] == 1) {
violations << "readiness prerequisite cycle includes '${cardId}'"
return
}
if (visitState[cardId] == 2 || !cards.containsKey(cardId)) {
return
}
visitState[cardId] = 1
Map<String, Object> card = cards[cardId] as Map<String, Object>
if (card.prerequisites instanceof List) {
(card.prerequisites as List).each { Object prerequisite ->
visitCard(prerequisite as String)
}
}
visitState[cardId] = 2
}
cards.keySet().each { Object cardId -> visitCard(cardId as String) }
boolean pollingSelected =
((cards['jpa-outbox-polling-delivery-v2'] as Map)?.state as String) == 'selected'
boolean cdcSelected =
((cards['jpa-outbox-cdc-retention-v1'] as Map)?.state as String) == 'selected'
if (pollingSelected && cdcSelected) {
violations << 'polling and CDC outbox delivery cards cannot both be selected'
}
violations
}
Closure<Boolean> jpaTaskExists = { String absoluteTaskPath ->
int separator = absoluteTaskPath.lastIndexOf(':')
if (separator < 0 || separator == absoluteTaskPath.length() - 1) {
return false
}
String projectPath = separator == 0 ? ':' : absoluteTaskPath.substring(0, separator)
String taskName = absoluteTaskPath.substring(separator + 1)
Project targetProject = rootProject.findProject(projectPath)
targetProject != null && targetProject.tasks.findByName(taskName) != null
}
def verifyJpaReadinessRegistryContract = tasks.register('verifyJpaReadinessRegistryContract') {
group = 'verification'
description = 'Mutation-tests the fail-closed JPA readiness registry validator.'
File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml")
inputs.file(registryFile)
doLast {
String raw = registryFile.getText('UTF-8')
Map<String, Object> baseline = new JsonSlurper().parseText(raw) as Map<String, Object>
Closure<Map<String, Object>> copyRegistry = {
new JsonSlurper().parseText(JsonOutput.toJson(baseline)) as Map<String, Object>
}
Closure<Void> expectViolation = {
String scenario,
String expectedText,
Closure<Void> mutation,
Closure<Boolean> taskExists = { String ignored -> true } ->
Map<String, Object> candidate = copyRegistry()
mutation(candidate)
List<String> candidateViolations = validateJpaReadinessRegistry(
candidate,
JsonOutput.toJson(candidate),
taskExists)
if (!candidateViolations.any { String violation ->
violation.contains(expectedText)
}) {
throw new GradleException(
"verifyJpaReadinessRegistryContract: scenario '${scenario}' did not " +
"produce '${expectedText}'; got ${candidateViolations}")
}
}
expectViolation('unknown-card', 'unknown card ids', { Map<String, Object> candidate ->
(candidate.cards as Map)['jpa-primary-foundation-alias'] =
(candidate.cards as Map)['jpa-primary-foundation']
})
expectViolation('duplicate-task', 'duplicate task', { Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-security-baseline'] as Map)['readiness-task'] =
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)['readiness-task']
})
expectViolation('missing-prerequisite', 'unknown prerequisite', {
Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-security-baseline'] as Map).prerequisites =
['jpa-does-not-exist']
})
expectViolation('cycle', 'prerequisite cycle', { Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).prerequisites =
['jpa-security-baseline']
})
expectViolation('duplicate-location', 'duplicate migration location', {
Map<String, Object> candidate ->
(((candidate.cards as Map)['jpa-idempotency-owner-safe-v2'] as Map).migration
as Map).location = 'db/migration/jpa/core'
})
expectViolation(
'missing-selected-task',
'selected task does not exist',
{ Map<String, Object> ignored -> },
{ String taskPath ->
taskPath !=
':adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest'
})
expectViolation('missing-active-evidence', 'active card requires evidence', {
Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)
.remove('evidence')
})
expectViolation('unknown-evidence-requirement', 'evidence covers unknown requirement', {
Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).evidence = [
scenarios: [[
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
covers: ['not-a-card-requirement']
]],
'task-claims': []
]
})
expectViolation('duplicate-evidence-selector', 'duplicate evidence selector', {
Map<String, Object> candidate ->
Map<String, Object> card =
(candidate.cards as Map)['jpa-observability-lifecycle'] as Map<String, Object>
card.evidence = [
scenarios: [
[
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
covers: ['real-postgresql']
],
[
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
covers: ['lifecycle']
]
],
'task-claims': []
]
})
expectViolation('unknown-evidence-task', 'evidence task claim is not owned by card', {
Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-primary-foundation'] as Map).evidence = [
scenarios: [],
'task-claims': [[
task: ':test',
covers: ['architecture']
]]
]
})
logger.lifecycle(
'verifyJpaReadinessRegistryContract: OK — unknown card, duplicate task, ' +
'missing prerequisite, cycle, duplicate migration ownership, missing ' +
'selected task, and malformed evidence ownership all fail closed.')
}
}
def verifyJpaReadinessRegistry = tasks.register('verifyJpaReadinessRegistry') {
group = 'verification'
description = 'Validates the JPA readiness card, prerequisite, task, and migration registry.'
dependsOn verifyJpaReadinessRegistryContract
File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml")
inputs.file(registryFile)
doLast {
if (!registryFile.isFile()) {
throw new GradleException(
"verifyJpaReadinessRegistry: missing registry ${registryFile}")
}
String raw = registryFile.getText('UTF-8')
Map<String, Object> registry
try {
registry = new JsonSlurper().parseText(raw) as Map<String, Object>
} catch (RuntimeException ex) {
throw new GradleException(
"verifyJpaReadinessRegistry: registry is not valid JSON-compatible YAML",
ex)
}
List<String> violations =
validateJpaReadinessRegistry(registry, raw, jpaTaskExists)
if (!violations.isEmpty()) {
throw new GradleException(
"verifyJpaReadinessRegistry: ${violations.size()} violation(s):\n " +
violations.toSorted().join('\n '))
}
logger.lifecycle(
"verifyJpaReadinessRegistry: OK — ${expectedJpaReadinessCardIds.size()} exact " +
"cards, ${expectedJpaOwnedMigrationCardIds.size()} owned migration " +
'streams, acyclic prerequisites, unique tasks/locations/history tables, ' +
'and selected task existence verified.')
}
}
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
tasks.named('check') {
dependsOn verifyJpaReadinessRegistry
}
}
def verifyApplicationCoreDependencyPurity = tasks.register('verifyApplicationCoreDependencyPurity') {
group = 'verification'
description = 'Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths.'
doLast {
Project application = project(':application-core')
List<String> violations = []
['api', 'implementation', 'compileOnly', 'runtimeOnly'].each { configurationName ->
def configuration = application.configurations.findByName(configurationName)
if (configuration == null) {
return
}
configuration.dependencies.each { dependency ->
if (!(dependency instanceof ProjectDependency)) {
violations << "${configurationName}: non-project production dependency " +
"${dependency.group ?: '<no-group>'}:${dependency.name}"
}
}
}
Closure<Boolean> forbiddenGroup = { String groupName ->
groupName != null && (
groupName.startsWith('org.springframework') ||
groupName == 'org.slf4j' ||
groupName == 'ch.qos.logback' ||
groupName == 'org.apache.logging.log4j' ||
groupName == 'io.micrometer')
}
['compileClasspath', 'runtimeClasspath', 'testCompileClasspath', 'testRuntimeClasspath']
.each { configurationName ->
def configuration = application.configurations.getByName(configurationName)
configuration.incoming.resolutionResult.allComponents.each { component ->
if (component.id instanceof ModuleComponentIdentifier &&
forbiddenGroup(component.id.group)) {
violations << "${configurationName}: forbidden resolved dependency " +
"${component.id.group}:${component.id.module}:${component.id.version}"
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyApplicationCoreDependencyPurity: ${violations.size()} violation(s):\n " +
violations.toSorted().join('\n '))
}
logger.lifecycle(
'verifyApplicationCoreDependencyPurity: OK — application-core production declarations are project-only and application classpaths contain no Spring/logging/metrics frameworks.')
}
}
project(':application-core').tasks.named('check') {
dependsOn verifyApplicationCoreDependencyPurity
}
Closure<Map<String, Map<String, Object>>> loadRedisReadinessCards = { File registryFile ->
if (!registryFile.isFile()) {
throw new GradleException("Missing Redis readiness registry: ${registryFile}")
}
Map<String, Map<String, Object>> cards = new LinkedHashMap<>()
String currentCard = null
boolean rootSeen = false
boolean readingEvidence = false
int lineNumber = 0
registryFile.eachLine('UTF-8') { String raw ->
lineNumber++
if (raw.contains('\t')) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: tabs are not allowed")
}
String line = raw.stripTrailing()
if (line.isBlank() || line.stripLeading().startsWith('#')) {
return
}
if (!rootSeen) {
if (line != 'cards:') {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: expected cards:")
}
rootSeen = true
return
}
def cardMatch = line =~ /^ ([a-z][a-z0-9-]+):$/
if (cardMatch.matches()) {
currentCard = cardMatch.group(1)
if (cards.containsKey(currentCard)) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: duplicate card ID ${currentCard}")
}
cards[currentCard] = [requiredEvidence: []]
readingEvidence = false
return
}
if (currentCard == null) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: card field found before a card ID")
}
if (line.startsWith(' - ')) {
if (!readingEvidence) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: list item is only valid under required-evidence")
}
String evidence = line.substring(8)
if (!(evidence in [
'standalone',
'security',
'sentinel',
'cluster',
'fault',
'compatibility',
'selected-topology'
])) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: unsupported required evidence ${evidence}")
}
if ((cards[currentCard].requiredEvidence as List<String>).contains(evidence)) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: duplicate evidence ${evidence}")
}
(cards[currentCard].requiredEvidence as List<String>) << evidence
return
}
if (!line.startsWith(' ') || line.startsWith(' ')) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: unsupported indentation")
}
readingEvidence = false
String field = line.substring(4)
if (field == 'required-evidence:') {
if (cards[currentCard].evidenceDeclared == true) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: duplicate required-evidence field")
}
cards[currentCard].evidenceDeclared = true
readingEvidence = true
return
}
int separator = field.indexOf(': ')
if (separator < 1) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: expected field: value")
}
String name = field.substring(0, separator)
String value = field.substring(separator + 2)
if (value.isBlank()) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: ${name} must not be blank")
}
switch (name) {
case 'state':
if (cards[currentCard].state != null) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: duplicate state field")
}
if (!(value in ['selected', 'implemented-candidate', 'not-implemented'])) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: unsupported state ${value}")
}
cards[currentCard].state = value
break
case 'selected-topology':
if (cards[currentCard].selectedTopology != null) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: duplicate selected-topology field")
}
if (!(value in ['standalone', 'sentinel', 'cluster'])) {
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: unsupported selected-topology ${value}")
}
cards[currentCard].selectedTopology = value
break
default:
throw new GradleException(
"Malformed Redis readiness registry at line ${lineNumber}: unknown field ${name}")
}
}
if (!rootSeen) {
throw new GradleException('Redis readiness registry is missing cards:')
}
Set<String> expected = [
'redis-cache',
'redis-edge-rate-limit',
'redis-request-replay-idempotency',
'redis-cache-refresh-soft-lease',
'redis-fenced-coordination',
'redis-session'
] as Set
if (cards.keySet() != expected) {
throw new GradleException(
"Redis readiness registry cards must be exactly ${expected}; got ${cards.keySet()}")
}
cards.each { String cardId, Map<String, Object> card ->
if (card.state == null) {
throw new GradleException("Redis readiness card ${cardId} has no valid state")
}
if (card.state == 'not-implemented') {
if (card.selectedTopology != null ||
card.evidenceDeclared == true ||
!(card.requiredEvidence as List).isEmpty()) {
throw new GradleException(
"Redis readiness card ${cardId} is not-implemented and must not declare topology or evidence")
}
return
}
if (card.selectedTopology == null || (card.requiredEvidence as List).isEmpty()) {
throw new GradleException(
"Redis readiness card ${cardId} requires topology and evidence")
}
if (!(card.requiredEvidence as List).contains('selected-topology')) {
throw new GradleException(
"Redis readiness card ${cardId} required-evidence must include selected-topology")
}
}
cards
}
Map<String, Map<String, Object>> redisReadinessCards = loadRedisReadinessCards(
rootProject.file('config/redis/readiness-cards.yaml'))
ext.redisReadinessCards = redisReadinessCards
Map<String, String> redisReadinessTaskStems = [
'redis-cache' : 'Cache',
'redis-edge-rate-limit' : 'RateLimit',
'redis-request-replay-idempotency' : 'Idempotency',
'redis-cache-refresh-soft-lease' : 'SoftLease',
'redis-fenced-coordination' : 'FencedCoordination',
'redis-session' : 'Session'
]
Map<String, String> redisEvidenceTaskStems = [
standalone : 'Standalone',
security : 'Security',
sentinel : 'Sentinel',
cluster : 'Cluster',
fault : 'Fault',
compatibility: 'Compatibility'
]
Map<String, String> redisPublicReadinessTasks = [
'redis-cache' : 'redisCacheReadiness',
'redis-edge-rate-limit' : 'redisRateLimitReadiness',
'redis-request-replay-idempotency' : 'redisIdempotencyReadiness',
'redis-cache-refresh-soft-lease' : 'redisSoftLeaseReadiness',
'redis-fenced-coordination' : 'redisFencedCoordinationReadiness',
'redis-session' : 'redisSessionReadiness'
]
Map<String, Map<String, Object>> redisCapabilityMetadata = [
'redis-cache' : [
providerIds : ['redis'],
roles : ['CACHE'],
programs : [
'set-if-absent-with-ttl-v1',
'replace-if-observed-with-ttl-v1',
'region-generation-init-v1',
'region-generation-bump-v1'
],
keyVersions : ['cache-key-v1'],
codecVersions: ['cache-envelope-v2'],
guarantees : ['bounded standalone semantic cache and generation fencing'],
nonGuarantees: [
'no multi-process L1 coherence or HA topology qualification',
'runtime-resolved image attestation and actual fault event timeline are not captured'
],
requiredSettings: [
[name: 'ca-skeleton.capabilities.cache.bindings.default', type: 'enum',
constraint: 'disabled or redis; redis is required to activate this card'],
[name: 'ca-skeleton.providers.redis.roles.cache', type: 'role-binding',
constraint: 'optional CACHE role with finite timeouts and bounds'],
[name: 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference',
type: 'secret-reference-name',
constraint: 'nonblank reference name; resolved secret value is never evidence']
]
],
'redis-edge-rate-limit' : [
providerIds : ['redis'],
roles : ['COORDINATION'],
programs : [
'rate-fixed-window-v2',
'rate-sliding-counter-v2',
'rate-token-bucket-v2'
],
keyVersions : ['rate-limit-key-v1'],
codecVersions: ['rate-limit-reply-v2'],
guarantees : ['atomic standalone quota evaluation with bounded state'],
nonGuarantees: [
'no Sentinel, Cluster, failover, or R3 qualification',
'runtime-resolved image attestation and actual fault event timeline are not captured'
],
requiredSettings: [
[name: 'ca-skeleton.capabilities.rate-limit.provider', type: 'enum',
constraint: 'disabled or redis; checked-in registry remains release authority'],
[name: 'ca-skeleton.providers.redis.roles.coordination',
type: 'role-binding',
constraint: 'required COORDINATION role bound to one deployment'],
[name: 'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference',
type: 'secret-reference-name',
constraint: 'nonblank reference name; resolved secret value is never evidence']
]
],
'redis-request-replay-idempotency' : [
providerIds : ['redis'],
roles : ['COORDINATION'],
programs : [
'idempotency-claim-v1',
'idempotency-start-v1',
'idempotency-renew-v1',
'idempotency-complete-v1',
'idempotency-fail-v1',
'idempotency-release-v1',
'idempotency-inspect-v1'
],
keyVersions : ['idempotency-key-v1'],
codecVersions: ['idempotency-program-schema-v2'],
guarantees : ['standalone request replay state transitions'],
nonGuarantees: [
'no cross-store exactly-once guarantee',
'runtime-resolved image attestation and actual fault event timeline are not captured'
],
requiredSettings: [
[name: 'ca-skeleton.capabilities.idempotency.provider', type: 'enum',
constraint: 'provider selection is explicit and exclusive'],
[name: 'ca-skeleton.providers.redis.roles.coordination',
type: 'role-binding',
constraint: 'required COORDINATION role bound to one deployment'],
[name: 'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference',
type: 'secret-reference-name',
constraint: 'nonblank reference name; resolved secret value is never evidence']
]
],
'redis-cache-refresh-soft-lease' : [
providerIds : ['redis'],
roles : ['CACHE'],
programs : ['cache-refresh-claim-v1', 'compare-and-delete-v1'],
keyVersions : ['cache-refresh-key-v1'],
codecVersions: ['cache-refresh-owner-v1'],
guarantees : ['bounded duplicate refresh suppression'],
nonGuarantees: [
'not a correctness lock and no fencing token',
'runtime-resolved image attestation and actual fault event timeline are not captured'
],
requiredSettings: [
[name: 'ca-skeleton.capabilities.cache.bindings.default', type: 'enum',
constraint: 'disabled or redis; redis activates the cache-owned soft lease'],
[name: 'ca-skeleton.providers.redis.roles.cache', type: 'role-binding',
constraint: 'optional CACHE role with finite timeouts and bounds'],
[name: 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference',
type: 'secret-reference-name',
constraint: 'nonblank reference name; resolved secret value is never evidence']
]
],
'redis-fenced-coordination' : [
providerIds : [],
roles : ['COORDINATION'],
programs : [],
keyVersions : [],
codecVersions: [],
guarantees : [],
nonGuarantees: ['provider and stale fencing-token rejection are not implemented'],
requiredSettings: []
],
'redis-session' : [
providerIds : ['redis-session'],
roles : ['SESSION'],
programs : [
'session-create-v1',
'session-inspect-v1',
'session-save-if-live-v1',
'session-touch-if-live-v1',
'session-tombstone-and-delete-v1',
'session-rotate-v1'
],
keyVersions : ['session-key-v1'],
codecVersions: ['session-envelope-v2', 'session-envelope-v1-read'],
guarantees : ['standalone versioned session repository and stale-save rejection'],
nonGuarantees: [
'same-JVM two-client evidence is not multi-process or pod qualification',
'runtime-resolved image attestation and actual fault event timeline are not captured'
],
requiredSettings: [
[name: 'ca-skeleton.security.auth-mode', type: 'enum',
constraint: 'session mode is explicit'],
[name: 'ca-skeleton.providers.redis.roles.session', type: 'role-binding',
constraint: 'required SESSION role bound to one standalone deployment'],
[name: 'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference',
type: 'secret-reference-name',
constraint: 'nonblank reference name; resolved secret value is never evidence']
]
]
]
if (redisCapabilityMetadata.keySet() != redisReadinessCards.keySet()) {
throw new GradleException(
"Redis capability metadata IDs must equal readiness cards; metadata=${redisCapabilityMetadata.keySet()}, cards=${redisReadinessCards.keySet()}")
}
ext.redisCapabilityMetadata = redisCapabilityMetadata
def fullGitRevision = providers.exec {
commandLine 'git', 'rev-parse', 'HEAD'
ignoreExitValue = true
}.standardOutput.asText.map { it.trim() }
String checkedOutHeadRevision = fullGitRevision.getOrElse('')
if (!(checkedOutHeadRevision ==~ /[0-9a-f]{40}/)) {
throw new GradleException(
'Redis evidence requires an exact 40-character checked-out Git HEAD.')
}
String redisEvidenceSourceRevision = providers.environmentVariable('GITHUB_SHA')
.orElse(providers.environmentVariable('GIT_SHA'))
.getOrElse(checkedOutHeadRevision)
if (!(redisEvidenceSourceRevision ==~ /[0-9a-f]{40}/)) {
throw new GradleException(
'Redis evidence requires the exact 40-character commit SHA from Git or GITHUB_SHA/GIT_SHA.')
}
if (redisEvidenceSourceRevision != checkedOutHeadRevision) {
throw new GradleException(
"Redis evidence source revision ${redisEvidenceSourceRevision} does not equal checked-out HEAD ${checkedOutHeadRevision}.")
}
ext.redisEvidenceSourceRevision = redisEvidenceSourceRevision
def gitStatusPorcelain = providers.exec {
commandLine 'git', 'status', '--porcelain', '--untracked-files=normal'
ignoreExitValue = true
}.standardOutput.asText.map { it }
String redisEvidenceSourceTreeState = gitStatusPorcelain.getOrElse('').isBlank()
? 'CLEAN'
: 'DIRTY'
ext.redisEvidenceSourceTreeState = redisEvidenceSourceTreeState
Closure<String> redisSha256 = { File file ->
if (!file.isFile()) {
throw new GradleException("Redis evidence digest input is missing: ${file}")
}
MessageDigest digest = MessageDigest.getInstance('SHA-256')
file.withInputStream { stream ->
byte[] buffer = new byte[8192]
int read
while ((read = stream.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read)
}
}
}
"sha256:${digest.digest().encodeHex()}"
}
Closure<String> redisAggregateSha256 = { Collection<File> files ->
MessageDigest digest = MessageDigest.getInstance('SHA-256')
List<File> sortedFiles = files.toSorted {
rootProject.projectDir.toPath().relativize(it.toPath()).toString()
}
Set<String> paths = new LinkedHashSet<>()
sortedFiles.each { File file ->
if (!file.isFile()) {
throw new GradleException("Redis evidence digest input is missing: ${file}")
}
if (java.nio.file.Files.isSymbolicLink(file.toPath())) {
throw new GradleException("Redis evidence digest input must not be a symlink: ${file}")
}
java.nio.file.Path normalized = file.toPath().toAbsolutePath().normalize()
java.nio.file.Path root = rootProject.projectDir.toPath().toAbsolutePath().normalize()
if (!normalized.startsWith(root)) {
throw new GradleException("Redis evidence digest input escapes the source root: ${file}")
}
String relative = root.relativize(normalized).toString()
if (!paths.add(relative)) {
throw new GradleException("Duplicate Redis evidence digest path: ${relative}")
}
byte[] pathBytes = relative.getBytes('UTF-8')
byte[] contentBytes = file.bytes
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(pathBytes.length).array())
digest.update(pathBytes)
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array())
digest.update(contentBytes)
}
"sha256:${digest.digest().encodeHex()}"
}
Closure<Map<String, String>> redisEvidenceDigests = {
File registry = rootProject.file('config/redis/readiness-cards.yaml')
File images = rootProject.file('gradle/redis-test-images.properties')
File redisResourceRoot = rootProject.file(
'adapter/outbound/cache-redis/src/main/resources')
File redisResourceDirectory = new File(redisResourceRoot, 'redis')
List<File> programAssets = fileTree(redisResourceDirectory) {
include '*.json'
include 'scripts/*.lua'
}.files.toList()
Set<File> referencedScripts = new LinkedHashSet<>()
Closure<Void> validateScriptReferences
validateScriptReferences = { Object node ->
if (node instanceof Map) {
Map<?, ?> object = node as Map<?, ?>
if (object.containsKey('scriptResource') || object.containsKey('sha256')) {
if (!(object.scriptResource instanceof String) ||
!(object.sha256 instanceof String) ||
!(object.sha256 ==~ /[0-9a-f]{64}/)) {
throw new GradleException(
'Redis program metadata must pair scriptResource with lowercase SHA-256')
}
File script = new File(redisResourceRoot, object.scriptResource as String)
java.nio.file.Path normalized = script.toPath().toAbsolutePath().normalize()
java.nio.file.Path resourceRoot = redisResourceRoot.toPath()
.toAbsolutePath().normalize()
if (!normalized.startsWith(resourceRoot) ||
!script.isFile() ||
java.nio.file.Files.isSymbolicLink(script.toPath())) {
throw new GradleException(
"Redis program script reference is missing or escapes resources: ${object.scriptResource}")
}
String actual = redisSha256(script).substring('sha256:'.length())
if (actual != object.sha256) {
throw new GradleException(
"Redis program script digest mismatch for ${object.scriptResource}")
}
referencedScripts.add(script.canonicalFile)
}
object.values().each { validateScriptReferences(it) }
} else if (node instanceof Collection) {
(node as Collection).each { validateScriptReferences(it) }
}
}
programAssets.findAll { it.name.endsWith('.json') }.each { File manifest ->
validateScriptReferences(new JsonSlurper().parse(manifest))
}
Set<File> allScripts = programAssets.findAll {
it.name.endsWith('.lua')
}.collect { it.canonicalFile } as Set<File>
if (referencedScripts != allScripts) {
throw new GradleException(
"Redis program bundle scripts must be referenced exactly; missing=${allScripts - referencedScripts}, unknown=${referencedScripts - allScripts}")
}
Map<String, Object> safeConfigurationProjection = redisCapabilityMetadata.collectEntries {
String cardId, Map<String, Object> metadata ->
[(cardId): [
readiness : redisReadinessCards[cardId].state,
selectedTopology: redisReadinessCards[cardId].selectedTopology,
providerIds : metadata.providerIds,
roles : metadata.roles,
programIds : metadata.programs,
keyVersions : metadata.keyVersions,
codecVersions : metadata.codecVersions,
guarantees : metadata.guarantees,
nonGuarantees : metadata.nonGuarantees,
requiredSettings: metadata.requiredSettings,
evidenceProfile : redisReadinessCards[cardId].requiredEvidence
]]
}
byte[] projectionBytes = JsonOutput.toJson(safeConfigurationProjection).getBytes('UTF-8')
String configurationDigest = "sha256:${MessageDigest.getInstance('SHA-256')
.digest(projectionBytes).encodeHex()}"
[
registrySha256 : redisSha256(registry),
imageRegistrySha256: redisSha256(images),
programSetSha256 : redisAggregateSha256(programAssets),
configurationSha256: configurationDigest
]
}
ext.redisEvidenceDigests = redisEvidenceDigests
def redisControlDirectory = layout.buildDirectory.dir('redis-evidence/control')
def redisCiMatrixFile = layout.buildDirectory.file(
'redis-evidence/control/redis-readiness-matrix.json')
def verifyRedisReadinessRegistryStrictness = tasks.register(
'verifyRedisReadinessRegistryStrictness') {
group = 'redis verification'
description = 'Runs malformed registry fixtures through the CI/build canonical strict parser.'
inputs.file rootProject.file('config/redis/readiness-cards.yaml')
doLast {
String canonical = rootProject.file('config/redis/readiness-cards.yaml').getText('UTF-8')
Map<String, String> malformed = [
wrongRoot: canonical.replaceFirst('cards:', 'capabilities:'),
duplicateCard: canonical.replace(
' redis-cache:\n',
' redis-cache:\n redis-cache:\n'),
duplicateField: canonical.replaceFirst(
' state: implemented-candidate',
' state: implemented-candidate\n state: implemented-candidate'),
unknownField: canonical.replaceFirst(
' state: implemented-candidate',
' unknown-field: value\n state: implemented-candidate'),
invalidState: canonical.replaceFirst(
'state: implemented-candidate',
'state: candidate'),
invalidTopology: canonical.replaceFirst(
'selected-topology: standalone',
'selected-topology: replicated'),
invalidEvidence: canonical.replaceFirst(
' - standalone',
' - unsupported'),
duplicateEvidence: canonical.replaceFirst(
' - standalone',
' - standalone\n - standalone'),
notImplementedMetadata: canonical.replace(
' redis-fenced-coordination:\n state: not-implemented\n',
' redis-fenced-coordination:\n' +
' state: not-implemented\n' +
' selected-topology: standalone\n' +
' required-evidence:\n' +
' - selected-topology\n'),
notImplementedEmptyEvidence: canonical.replace(
' redis-fenced-coordination:\n state: not-implemented\n',
' redis-fenced-coordination:\n' +
' state: not-implemented\n' +
' required-evidence:\n'),
missingTopologyMarker: canonical.replaceFirst(
' - selected-topology\\n',
''),
unsupportedIndentation: canonical.replaceFirst(
' state: implemented-candidate',
' state: implemented-candidate'),
extraCard: canonical +
' redis-unknown:\n' +
' state: not-implemented\n',
missingCard: canonical.replace(
' redis-fenced-coordination:\n state: not-implemented\n',
''),
blankCard: canonical.replaceFirst(
' redis-cache:',
' :')
]
malformed.each { String fixtureName, String fixtureText ->
File fixture = new File(temporaryDir, "${fixtureName}.yaml")
fixture.setText(fixtureText, 'UTF-8')
boolean rejected = false
try {
loadRedisReadinessCards(fixture)
} catch (GradleException expected) {
rejected = true
}
if (!rejected) {
throw new GradleException(
"Strict Redis readiness parser accepted malformed fixture ${fixtureName}")
}
}
logger.lifecycle(
"verifyRedisReadinessRegistryStrictness: rejected ${malformed.size()} malformed fixtures")
}
}
def verifyRedisCapabilityMetadata = tasks.register('verifyRedisCapabilityMetadata') {
group = 'redis verification'
description = 'Validates generated card provider, program, setting, and non-guarantee truth.'
File redisSource = rootProject.file(
'adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis')
inputs.files fileTree(redisSource) {
include '**/*.java'
}
inputs.files fileTree(rootProject.file(
'adapter/outbound/cache-redis/src/main/resources/redis')) {
include '*.json'
}
doLast {
Map<String, List<String>> expectedProviders = [
'redis-cache' : ['redis'],
'redis-edge-rate-limit' : ['redis'],
'redis-request-replay-idempotency': ['redis'],
'redis-cache-refresh-soft-lease' : ['redis'],
'redis-fenced-coordination' : [],
'redis-session' : ['redis-session']
]
redisCapabilityMetadata.each { String cardId, Map<String, Object> metadata ->
if (metadata.providerIds != expectedProviders[cardId]) {
throw new GradleException(
"${cardId}: capability provider IDs do not match canonical selection values")
}
}
String canonicalConfig = new File(redisSource, 'RedisCanonicalConfig.java')
.getText('UTF-8')
Map<String, String> selectionContracts = [
'redis-cache' :
'"ca-skeleton.capabilities.cache.bindings.default", "redis"',
'redis-edge-rate-limit' :
'"ca-skeleton.capabilities.rate-limit.provider", "redis"',
'redis-request-replay-idempotency':
'"ca-skeleton.capabilities.idempotency.provider", "redis"',
'redis-cache-refresh-soft-lease' :
'"ca-skeleton.capabilities.cache.bindings.default", "redis"',
'redis-session' :
'"ca-skeleton.security.auth-mode", "redis-session"'
]
selectionContracts.each { String cardId, String sourceContract ->
if (!canonicalConfig.contains(sourceContract)) {
throw new GradleException(
"${cardId}: canonical provider selection contract is missing: ${sourceContract}")
}
}
String programIdSource = new File(redisSource, 'RedisProgramId.java').getText('UTF-8')
def programMatcher = programIdSource =~ /(?s)([A-Z][A-Z0-9_]+)\s*\(\s*"([^"]+)"/
Map<String, String> enumByExternalId = new LinkedHashMap<>()
programMatcher.each { ignored, String enumName, String externalId ->
enumByExternalId[externalId] = enumName
}
Set<String> claimedPrograms = redisCapabilityMetadata.values().collectMany {
it.programs as List<String>
} as Set<String>
Set<String> unknownPrograms = claimedPrograms.findAll {
!enumByExternalId.containsKey(it)
} as Set<String>
if (!unknownPrograms.isEmpty()) {
throw new GradleException(
"Redis capability cards claim unknown program IDs: ${unknownPrograms}")
}
Map<String, List<String>> implementationSources = [
'redis-cache': [
'RedisStringCacheRegion.java',
'RedisCacheConsistencyStore.java',
'RedisAtomicPrimitives.java'
],
'redis-edge-rate-limit': [
'RedisEdgeRateLimitProvider.java'
],
'redis-request-replay-idempotency': [
'RedisIdempotencyStoreProvider.java'
],
'redis-cache-refresh-soft-lease': [
'RedisCacheRefreshCoordinator.java'
],
'redis-session': [
'RedisLuaVersionedSessionStore.java'
]
]
implementationSources.each { String cardId, List<String> sources ->
String implementation = sources.collect {
new File(redisSource, it).getText('UTF-8')
}.join('\n')
(redisCapabilityMetadata[cardId].programs as List<String>).each { String programId ->
String enumName = enumByExternalId[programId]
if (!implementation.contains("RedisProgramId.${enumName}")) {
throw new GradleException(
"${cardId}: claimed program ${programId} is not referenced by its implementation")
}
}
}
Map<String, String> settingSourceContracts = [
'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference':
'RedisCanonicalCacheSettings.java',
'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference':
'RedisRateLimitSettings.java',
'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference':
'RedisIdempotencySettings.java',
'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference':
'RedisSessionSettings.java'
]
settingSourceContracts.each { String settingName, String sourceName ->
String source = new File(redisSource, sourceName).getText('UTF-8')
String prefix = settingName.substring(0, settingName.lastIndexOf('.'))
if (!source.contains("@ConfigurationProperties(prefix = \"${prefix}\")") ||
!source.contains('String keyHmacSecretReference')) {
throw new GradleException(
"Capability card setting is not backed by typed settings: ${settingName}")
}
}
List<String> declaredSettingNames = redisCapabilityMetadata.values().collectMany {
(it.requiredSettings as List<Map<String, String>>).collect { setting -> setting.name }
}
if (declaredSettingNames.any { it.contains('.roles.CACHE') }) {
throw new GradleException(
'Generated Redis setting names must use canonical lowercase map keys')
}
Map<String, Set<String>> expectedSettingNames = [
'redis-cache': [
'ca-skeleton.capabilities.cache.bindings.default',
'ca-skeleton.providers.redis.roles.cache',
'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference'
] as Set<String>,
'redis-edge-rate-limit': [
'ca-skeleton.capabilities.rate-limit.provider',
'ca-skeleton.providers.redis.roles.coordination',
'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference'
] as Set<String>,
'redis-request-replay-idempotency': [
'ca-skeleton.capabilities.idempotency.provider',
'ca-skeleton.providers.redis.roles.coordination',
'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference'
] as Set<String>,
'redis-cache-refresh-soft-lease': [
'ca-skeleton.capabilities.cache.bindings.default',
'ca-skeleton.providers.redis.roles.cache',
'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference'
] as Set<String>,
'redis-fenced-coordination': [] as Set<String>,
'redis-session': [
'ca-skeleton.security.auth-mode',
'ca-skeleton.providers.redis.roles.session',
'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference'
] as Set<String>
]
redisCapabilityMetadata.each { String cardId, Map<String, Object> metadata ->
Set<String> actual = (metadata.requiredSettings as List<Map<String, String>>)
.collect { it.name } as Set<String>
if (actual != expectedSettingNames[cardId]) {
throw new GradleException(
"${cardId}: generated required settings are incomplete or non-canonical; expected=${expectedSettingNames[cardId]}, actual=${actual}")
}
}
String bootstrapConfiguration = rootProject.file(
'app-bootstrap/src/main/resources/application.yml').getText('UTF-8')
if (!bootstrapConfiguration.contains(
'ca-skeleton.providers.redis.roles.cache')) {
throw new GradleException(
'Canonical lowercase ca-skeleton.providers.redis.roles.cache binding is missing')
}
Map<String, Object> fenced = redisCapabilityMetadata['redis-fenced-coordination']
if (!(fenced.providerIds as List).isEmpty() ||
!(fenced.programs as List).isEmpty() ||
!(fenced.guarantees as List).isEmpty()) {
throw new GradleException(
'redis-fenced-coordination must not claim a provider, program, or guarantee')
}
}
}
tasks.register('writeRedisCiMatrix') {
group = 'redis verification'
description = 'Writes the strict, deterministic Redis readiness matrix consumed by CI.'
dependsOn verifyRedisCapabilityMetadata
dependsOn verifyRedisReadinessRegistryStrictness
inputs.file rootProject.file('config/redis/readiness-cards.yaml')
inputs.file rootProject.file('gradle/redis-test-images.properties')
inputs.files fileTree(rootProject.file(
'adapter/outbound/cache-redis/src/main/resources/redis')) {
include '*.json'
include 'scripts/*.lua'
}
outputs.dir redisControlDirectory
outputs.upToDateWhen { false }
doLast {
Closure<List<String>> resolvedEvidence = { Map<String, Object> card ->
Set<String> resolved = new LinkedHashSet<>(card.requiredEvidence as List<String>)
if (resolved.remove('selected-topology')) {
resolved.add(card.selectedTopology as String)
}
resolved.toList().sort()
}
List<Map<String, Object>> selected = []
List<Map<String, Object>> candidates = []
redisReadinessCards.each { String cardId, Map<String, Object> card ->
Map<String, Object> entry = [
cardId : cardId,
readinessTask : redisPublicReadinessTasks[cardId],
selectedTopology : card.selectedTopology,
resolvedEvidence : resolvedEvidence(card)
]
if (card.state == 'selected') {
selected << entry
}
if (card.state in ['selected', 'implemented-candidate']) {
candidates << entry
}
}
selected.sort { it.cardId }
candidates.sort { it.cardId }
Map<String, Object> matrix = [
schemaVersion : 1,
sourceRevision : redisEvidenceSourceRevision,
sourceTreeState : redisEvidenceSourceTreeState,
releaseQualification : 'NOT_CLAIMED',
registryDigest : redisEvidenceDigests().registrySha256,
imageRegistryDigest : redisEvidenceDigests().imageRegistrySha256,
programBundleDigest : redisEvidenceDigests().programSetSha256,
configProjectionDigest: redisEvidenceDigests().configurationSha256,
selectedCount : selected.size(),
selected : selected,
implementedCandidates : candidates,
topologyJobs : [
sentinel: candidates.any { it.selectedTopology == 'sentinel' },
cluster : candidates.any { it.selectedTopology == 'cluster' }
]
]
File controlDirectory = redisControlDirectory.get().asFile
controlDirectory.mkdirs()
File output = redisCiMatrixFile.get().asFile
output.setText(JsonOutput.prettyPrint(JsonOutput.toJson(matrix)) + '\n', 'UTF-8')
File cardsDirectory = new File(controlDirectory, 'capability-cards')
cardsDirectory.mkdirs()
Map<String, String> digests = redisEvidenceDigests()
redisReadinessCards.keySet().toList().sort().each { String cardId ->
Map<String, Object> card = redisReadinessCards[cardId]
Map<String, Object> metadata = redisCapabilityMetadata[cardId]
Map<String, Object> generatedCard = [
schemaVersion : 1,
cardId : cardId,
readiness : card.state,
selectionResult : card.state == 'selected' ? 'SELECTED' : 'NOT_SELECTED',
releaseQualification: 'NOT_CLAIMED',
promotionTopology : card.selectedTopology,
sourceRevision : redisEvidenceSourceRevision,
sourceTreeState : redisEvidenceSourceTreeState,
digests : digests,
minimumRedisVersion : '7.2',
providerIds : metadata.providerIds,
roles : metadata.roles,
programIds : metadata.programs,
keyVersions : metadata.keyVersions,
codecVersions : metadata.codecVersions,
guarantees : metadata.guarantees,
nonGuarantees : metadata.nonGuarantees,
requiredSettings : metadata.requiredSettings,
evidenceProfile : card.requiredEvidence
]
new File(cardsDirectory, "${cardId}.json").setText(
JsonOutput.prettyPrint(JsonOutput.toJson(generatedCard)) + '\n', 'UTF-8')
}
List<File> controlFiles = [output]
controlFiles.addAll(cardsDirectory.listFiles().toList())
File checksumFile = new File(controlDirectory, 'checksums.sha256')
checksumFile.setText(controlFiles.toSorted { it.name }.collect { File file ->
String relative = controlDirectory.toPath().relativize(file.toPath()).toString()
"${redisSha256(file).substring('sha256:'.length())} ${relative}"
}.join('\n') + '\n', 'UTF-8')
logger.lifecycle("writeRedisCiMatrix: ${output}")
}
}
def verifyRedisSelectedEvidenceArtifacts = tasks.register(
'verifyRedisSelectedEvidenceArtifacts') {
group = 'redis verification'
description = 'Reconciles downloaded sanitized evidence for every selected Redis card.'
dependsOn tasks.named('writeRedisCiMatrix')
doLast {
File expectedControlDirectory = redisControlDirectory.get().asFile
String controlDirectoryProperty = providers.gradleProperty('redisControlDirectory')
.getOrElse('')
File suppliedControlDirectory = controlDirectoryProperty.isBlank()
? expectedControlDirectory
: rootProject.file(controlDirectoryProperty)
if (!suppliedControlDirectory.isDirectory()) {
throw new GradleException(
"Redis readiness control directory is missing: ${suppliedControlDirectory}")
}
Set<String> expectedControlPaths = [
'redis-readiness-matrix.json',
'checksums.sha256'
] as Set<String>
redisReadinessCards.keySet().each {
expectedControlPaths.add("capability-cards/${it}.json")
}
List<File> suppliedControlFiles = fileTree(suppliedControlDirectory).files.toList()
Set<String> suppliedControlPaths = suppliedControlFiles.collect {
suppliedControlDirectory.toPath().relativize(it.toPath()).toString()
} as Set<String>
if (suppliedControlPaths != expectedControlPaths) {
throw new GradleException(
"Redis control artifact file set mismatch; expected=${expectedControlPaths}, actual=${suppliedControlPaths}")
}
suppliedControlFiles.each { File file ->
if (file.length() > 1_048_576L ||
java.nio.file.Files.isSymbolicLink(file.toPath()) ||
!file.toPath().toRealPath().startsWith(
suppliedControlDirectory.toPath().toRealPath())) {
throw new GradleException(
"Redis control artifact is oversized, symlinked, or path-escaping: ${file}")
}
}
File suppliedChecksums = new File(suppliedControlDirectory, 'checksums.sha256')
Map<String, String> checksumEntries = new LinkedHashMap<>()
suppliedChecksums.eachLine('UTF-8') { String line ->
def match = line =~ /^([0-9a-f]{64}) ([a-z0-9.\/-]+)$/
if (!match.matches() ||
checksumEntries.put(match.group(2), match.group(1)) != null) {
throw new GradleException(
"Malformed or duplicate Redis control checksum line: ${line}")
}
}
Set<String> checksummedPaths = new LinkedHashSet<>(expectedControlPaths)
checksummedPaths.remove('checksums.sha256')
if (checksumEntries.keySet() != checksummedPaths) {
throw new GradleException(
"Redis control checksum set mismatch; expected=${checksummedPaths}, actual=${checksumEntries.keySet()}")
}
checksumEntries.each { String relative, String expectedSha ->
String actualSha = redisSha256(
new File(suppliedControlDirectory, relative))
.substring('sha256:'.length())
if (actualSha != expectedSha) {
throw new GradleException(
"Redis control checksum mismatch for ${relative}")
}
}
Map<String, Object> expectedMatrix = new JsonSlurper().parse(
redisCiMatrixFile.get().asFile) as Map<String, Object>
Map<String, Object> suppliedMatrix = new JsonSlurper().parse(
new File(suppliedControlDirectory, 'redis-readiness-matrix.json')) as Map<String, Object>
if (suppliedMatrix != expectedMatrix ||
suppliedMatrix.sourceRevision != redisEvidenceSourceRevision ||
suppliedMatrix.sourceTreeState != redisEvidenceSourceTreeState ||
suppliedMatrix.releaseQualification != 'NOT_CLAIMED') {
throw new GradleException(
'Downloaded Redis control matrix does not match this exact source revision and registry')
}
redisReadinessCards.each { String cardId, Map<String, Object> card ->
Map<String, Object> expectedCard = new JsonSlurper().parse(
new File(expectedControlDirectory, "capability-cards/${cardId}.json"))
as Map<String, Object>
Map<String, Object> suppliedCard = new JsonSlurper().parse(
new File(suppliedControlDirectory, "capability-cards/${cardId}.json"))
as Map<String, Object>
if (suppliedCard != expectedCard ||
suppliedCard.releaseQualification != 'NOT_CLAIMED' ||
suppliedCard.sourceTreeState != redisEvidenceSourceTreeState ||
suppliedCard.readiness != card.state) {
throw new GradleException(
"Downloaded Redis capability card is stale or malformed: ${cardId}")
}
}
Map<String, Map<String, Object>> selectedCards = redisReadinessCards.findAll {
ignored, card -> card.state == 'selected'
}
if ((suppliedMatrix.selectedCount as Number).intValue() != selectedCards.size()) {
throw new GradleException(
'Redis control selectedCount does not match the strict checked-in registry')
}
String ciResultFileProperty = providers.gradleProperty('redisCiResultFile')
.getOrElse('')
if (!ciResultFileProperty.isBlank()) {
File ciResultFile = rootProject.file(ciResultFileProperty)
if (!ciResultFile.isFile() ||
ciResultFile.length() > 65_536L ||
java.nio.file.Files.isSymbolicLink(ciResultFile.toPath())) {
throw new GradleException(
"Redis CI result artifact is missing, oversized, or symlinked: ${ciResultFile}")
}
Map<String, Object> ciResult = new JsonSlurper().parse(ciResultFile)
as Map<String, Object>
if (ciResult.keySet() != [
'schemaVersion',
'runId',
'selectedCount',
'selectedJobResult',
'selectedArtifactNames'
] as Set<String> ||
ciResult.schemaVersion != 1 ||
ciResult.selectedCount != selectedCards.size() ||
!(ciResult.runId ==~ /[1-9][0-9]{0,19}/)) {
throw new GradleException(
'Redis CI result artifact has malformed count, run, or schema metadata')
}
String expectedJobResult = selectedCards.isEmpty() ? 'skipped' : 'success'
Set<String> expectedArtifactNames = selectedCards.keySet().collect {
"redis-selected-${it}"
} as Set<String>
Set<String> actualArtifactNames = ciResult.selectedArtifactNames as Set<String>
if (ciResult.selectedJobResult != expectedJobResult ||
actualArtifactNames != expectedArtifactNames ||
(ciResult.selectedArtifactNames as List).size() !=
actualArtifactNames.size()) {
throw new GradleException(
"Redis CI selected job/artifact inventory mismatch; expectedResult=${expectedJobResult}, actualResult=${ciResult.selectedJobResult}, expectedArtifacts=${expectedArtifactNames}, actualArtifacts=${actualArtifactNames}")
}
} else if (!selectedCards.isEmpty() ||
providers.environmentVariable('GITHUB_ACTIONS').getOrElse('') == 'true') {
throw new GradleException(
'Redis CI/future selected reconciliation requires -PredisCiResultFile=<current-run inventory>')
}
String evidenceDirectoryProperty = providers.gradleProperty('redisEvidenceDirectory')
.getOrElse('')
if (selectedCards.isEmpty()) {
if (!evidenceDirectoryProperty.isBlank()) {
File unexpectedDirectory = rootProject.file(evidenceDirectoryProperty)
if (unexpectedDirectory.isDirectory() &&
!fileTree(unexpectedDirectory).matching {
include '**/manifest.json'
}.files.isEmpty()) {
throw new GradleException(
'No Redis card is selected but downloaded selected evidence manifests were supplied')
}
}
logger.lifecycle(
'verifyRedisSelectedEvidenceArtifacts: selectedCount=0; explicit no-evidence branch, no R2 claim.')
return
}
if (evidenceDirectoryProperty.isBlank()) {
throw new GradleException(
'Selected Redis cards require -PredisEvidenceDirectory=<downloaded sanitized artifacts>')
}
File evidenceDirectory = rootProject.file(evidenceDirectoryProperty)
if (!evidenceDirectory.isDirectory()) {
throw new GradleException(
"Redis selected evidence directory is missing: ${evidenceDirectory}")
}
Set<String> allowedEvidenceFileNames = [
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
] as Set<String>
List<File> evidenceFiles = fileTree(evidenceDirectory).files.toList()
evidenceFiles.each { File file ->
if (!allowedEvidenceFileNames.contains(file.name) ||
file.length() > 1_048_576L ||
java.nio.file.Files.isSymbolicLink(file.toPath()) ||
!file.toPath().toRealPath().startsWith(
evidenceDirectory.toPath().toRealPath())) {
throw new GradleException(
"Redis selected artifact contains an unexpected, oversized, symlinked, or path-escaping file: ${file}")
}
}
Set<File> manifestParents = evidenceFiles.findAll {
it.name == 'manifest.json'
}.collect {
it.parentFile.canonicalFile
} as Set<File>
if (evidenceFiles.size() != manifestParents.size() * allowedEvidenceFileNames.size() ||
evidenceFiles.any {
!manifestParents.contains(it.parentFile.canonicalFile)
} ||
manifestParents.any { File parent ->
parent.listFiles().findAll { it.isFile() }.collect {
it.name
} as Set<String> != allowedEvidenceFileNames ||
parent.listFiles().any { it.isDirectory() }
}) {
throw new GradleException(
'Redis selected evidence must be an exact set of three allowlisted files per manifest parent')
}
List<Map<String, Object>> manifests = fileTree(evidenceDirectory).matching {
include '**/manifest.json'
}.files.toSorted().collect { File manifest ->
Map<String, Object> parsed = new JsonSlurper().parse(manifest) as Map<String, Object>
parsed.__file = manifest
parsed
}
if (manifests.any { it.cardId == null }) {
throw new GradleException(
'Downloaded selected evidence must not contain generic or unowned manifests')
}
Map<String, String> expectedDigests = redisEvidenceDigests()
Set<String> manifestFields = [
'schemaVersion',
'taskPath',
'tagExpression',
'cardId',
'cardState',
'selectedTopology',
'evidenceCategory',
'outcome',
'tests',
'runtimeImageAttestation',
'actualEventTimeline',
'releaseQualification',
'sourceRevision',
'sourceTreeState',
'digests',
'companionSha256'
] as Set<String>
Set<String> testFields = [
'discovered',
'executed',
'passed',
'failed',
'errors',
'skipped'
] as Set<String>
Set<String> capabilityFields = [
'schemaVersion',
'cardId',
'readiness',
'releaseQualification',
'promotionTopology',
'sourceRevision',
'sourceTreeState',
'digests',
'minimumRedisVersion',
'providerIds',
'roles',
'programIds',
'keyVersions',
'codecVersions',
'guarantees',
'nonGuarantees',
'requiredSettings',
'evidenceProfile'
] as Set<String>
Set<String> timelineFields = [
'schemaVersion',
'taskName',
'cardId',
'topology',
'evidence',
'timelineKind',
'actualEventTimeline',
'sourceRevision',
'sourceTreeState',
'digests',
'events'
] as Set<String>
selectedCards.each { String cardId, Map<String, Object> card ->
Set<String> expectedEvidence = new LinkedHashSet<>(
card.requiredEvidence as List<String>)
if (expectedEvidence.remove('selected-topology')) {
expectedEvidence.add(card.selectedTopology as String)
}
List<Map<String, Object>> cardManifests = manifests.findAll {
it.cardId == cardId
}
List<Map<String, Object>> capabilityManifests = cardManifests.findAll {
it.evidenceCategory == null
}
if (capabilityManifests.size() != 1) {
throw new GradleException(
"${cardId}: expected exactly one capability manifest; got ${capabilityManifests.size()}")
}
expectedEvidence.each { String evidence ->
List<Map<String, Object>> matching = cardManifests.findAll {
it.evidenceCategory == evidence
}
if (matching.size() != 1) {
throw new GradleException(
"${cardId}/${evidence}: expected exactly one evidence manifest; got ${matching.size()}")
}
}
Set<String> actualEvidence = cardManifests.findAll {
it.evidenceCategory != null
}.collect { it.evidenceCategory as String } as Set<String>
if (actualEvidence != expectedEvidence) {
throw new GradleException(
"${cardId}: evidence mismatch; expected=${expectedEvidence}, actual=${actualEvidence}")
}
cardManifests.each { Map<String, Object> manifest ->
File manifestFile = manifest.__file as File
Set<String> actualManifestFields = new LinkedHashSet<>(manifest.keySet())
actualManifestFields.remove('__file')
Map<String, Object> tests = manifest.tests as Map<String, Object>
if (actualManifestFields != manifestFields ||
tests == null ||
tests.keySet() != testFields ||
manifest.schemaVersion != 1 ||
manifest.outcome != 'executed' ||
(tests.discovered as Number).longValue() <= 0L ||
(tests.executed as Number).longValue() <= 0L ||
(tests.passed as Number).longValue() <= 0L ||
(tests.failed as Number).longValue() != 0L ||
(tests.errors as Number).longValue() != 0L ||
(tests.skipped as Number).longValue() != 0L) {
throw new GradleException(
"${manifestFile}: malformed, non-executed, zero-test, failed, or skipped Redis evidence manifest")
}
if (manifest.cardState != 'selected' ||
manifest.selectedTopology != card.selectedTopology ||
manifest.sourceRevision != redisEvidenceSourceRevision ||
manifest.sourceTreeState != redisEvidenceSourceTreeState ||
manifest.releaseQualification != 'NOT_CLAIMED' ||
manifest.digests != expectedDigests) {
throw new GradleException(
"${manifestFile}: stale registry/topology/source/digest metadata")
}
String cardStem = redisReadinessTaskStems[cardId]
String expectedTag
String expectedTask
if (manifest.evidenceCategory == null) {
expectedTag = "card-${cardId}"
expectedTask =
":adapter:outbound:cache-redis:redis${cardStem}CapabilityTest"
} else {
String evidence = manifest.evidenceCategory as String
String evidenceStem = redisEvidenceTaskStems[evidence]
expectedTag = "card-${cardId} & redis-${evidence}"
expectedTask =
":adapter:outbound:cache-redis:redis${cardStem}${evidenceStem}EvidenceTest"
}
if (manifest.tagExpression != expectedTag || manifest.taskPath != expectedTask) {
throw new GradleException(
"${manifestFile}: wrong task path or exact tag intersection")
}
File capabilityFile = new File(manifestFile.parentFile, 'capability-card.json')
File timelineFile = new File(
manifestFile.parentFile, 'topology-fault-timeline.json')
if (!capabilityFile.isFile() || !timelineFile.isFile()) {
throw new GradleException(
"${manifestFile}: missing sanitized evidence companions")
}
Map<String, Object> companionSha = manifest.companionSha256 as Map<String, Object>
if (companionSha?.keySet() != [
'capabilityCardSha256',
'timelineSha256'
] as Set<String> ||
companionSha.capabilityCardSha256 !=
redisSha256(capabilityFile).substring('sha256:'.length()) ||
companionSha.timelineSha256 !=
redisSha256(timelineFile).substring('sha256:'.length())) {
throw new GradleException(
"${manifestFile}: companion checksum mismatch")
}
Map<String, Object> capability = new JsonSlurper().parse(
capabilityFile) as Map<String, Object>
Map<String, Object> timeline = new JsonSlurper().parse(
timelineFile) as Map<String, Object>
Map<String, Object> metadata = redisCapabilityMetadata[cardId]
if (capability.keySet() != capabilityFields ||
capability.schemaVersion != 1 ||
capability.cardId != cardId ||
capability.readiness != 'selected' ||
capability.releaseQualification != 'NOT_CLAIMED' ||
capability.promotionTopology != card.selectedTopology ||
capability.sourceRevision != redisEvidenceSourceRevision ||
capability.sourceTreeState != redisEvidenceSourceTreeState ||
capability.digests != expectedDigests ||
capability.minimumRedisVersion != '7.2' ||
capability.providerIds != metadata.providerIds ||
capability.roles != metadata.roles ||
capability.programIds != metadata.programs ||
capability.keyVersions != metadata.keyVersions ||
capability.codecVersions != metadata.codecVersions ||
capability.guarantees != metadata.guarantees ||
capability.nonGuarantees != metadata.nonGuarantees ||
capability.requiredSettings != metadata.requiredSettings ||
capability.evidenceProfile != card.requiredEvidence) {
throw new GradleException(
"${capabilityFile}: malformed or stale generated capability card")
}
if (timeline.keySet() != timelineFields ||
timeline.schemaVersion != 1 ||
timeline.taskName != manifest.taskPath.tokenize(':').last() ||
timeline.cardId != cardId ||
timeline.topology != card.selectedTopology ||
timeline.evidence != manifest.evidenceCategory ||
timeline.sourceRevision != redisEvidenceSourceRevision ||
timeline.sourceTreeState != redisEvidenceSourceTreeState ||
timeline.digests != expectedDigests ||
!(timeline.events instanceof List)) {
throw new GradleException(
"${timelineFile}: malformed or stale sanitized timeline")
}
if (manifest.evidenceCategory != null &&
(manifest.runtimeImageAttestation != 'CAPTURED' ||
manifest.actualEventTimeline != 'CAPTURED' ||
timeline.actualEventTimeline != 'CAPTURED' ||
manifest.sourceTreeState != 'CLEAN')) {
throw new GradleException(
"${manifestFile}: selected promotion is blocked until actual-used image attestation and actual event timeline are captured")
}
}
Set<String> capabilityCardDigests = cardManifests.collect {
Map<String, Object> companion = it.companionSha256 as Map<String, Object>
companion.capabilityCardSha256 as String
} as Set<String>
if (capabilityCardDigests.size() != 1) {
throw new GradleException(
"${cardId}: capability card must be byte-identical across all evidence tasks")
}
}
Set<String> unknownSelectedCards = manifests.findAll {
it.cardId != null
}.collect { it.cardId as String }.findAll {
!selectedCards.containsKey(it)
} as Set<String>
if (!unknownSelectedCards.isEmpty()) {
throw new GradleException(
"Downloaded selected evidence contains unselected cards: ${unknownSelectedCards}")
}
int expectedManifestCount = selectedCards.collect { String ignored, Map<String, Object> card ->
Set<String> resolved = new LinkedHashSet<>(card.requiredEvidence as List<String>)
if (resolved.remove('selected-topology')) {
resolved.add(card.selectedTopology as String)
}
1 + resolved.size()
}.sum() as int
if (manifests.size() != expectedManifestCount) {
throw new GradleException(
"Downloaded selected evidence manifest count mismatch; expected=${expectedManifestCount}, actual=${manifests.size()}")
}
logger.lifecycle(
"verifyRedisSelectedEvidenceArtifacts: reconciled selected cards ${selectedCards.keySet()}")
}
}
redisPublicReadinessTasks.each { String cardId, String taskName ->
Map<String, Object> card = redisReadinessCards[cardId]
tasks.register(taskName) {
group = 'redis verification'
description = "Qualifies checked-in Redis capability card ${cardId}."
if (card.state != 'not-implemented') {
String cardStem = redisReadinessTaskStems[cardId]
dependsOn project(':adapter:outbound:cache-redis').tasks.named(
"redis${cardStem}CapabilityTest")
Set<String> evidence = new LinkedHashSet<>(card.requiredEvidence as List<String>)
if (evidence.remove('selected-topology')) {
evidence.add(card.selectedTopology as String)
}
evidence.each { String category ->
String evidenceStem = redisEvidenceTaskStems[category]
if (evidenceStem == null) {
throw new GradleException(
"Redis readiness card ${cardId} has unknown evidence ${category}")
}
dependsOn project(':adapter:outbound:cache-redis').tasks.named(
"redis${cardStem}${evidenceStem}EvidenceTest")
}
}
doLast {
if (card.state == 'not-implemented') {
logger.lifecycle("${cardId}: not selected (state=not-implemented)")
} else {
logger.lifecycle(
"${cardId}: ${card.state} evidence passed for topology ${card.selectedTopology}")
}
}
}
}
tasks.register('redisProductionReadiness') {
group = 'redis verification'
description = 'Runs the checked-in selected Redis card release gate and architecture contracts.'
dependsOn project(':application-core').tasks.named('redisPolicyContractTest')
dependsOn project(':shared-contract').tasks.named('edgeRateLimitContractTest')
dependsOn project(':app-bootstrap').tasks.named('redisCompositionTest')
dependsOn tasks.named('verifyCleanArchitectureDependencies')
dependsOn tasks.named('verifyEnvKeys')
dependsOn tasks.named('verifyPublicPathSnapshot')
dependsOn tasks.named('verifyConfigurationPropertiesProcessor')
dependsOn verifyRedisSelectedEvidenceArtifacts
redisReadinessCards.findAll { ignored, card -> card.state == 'selected' }.each {
String cardId, Map<String, Object> ignored ->
dependsOn tasks.named(redisPublicReadinessTasks[cardId])
}
doLast {
List<String> selected = redisReadinessCards.findAll {
ignored, card -> card.state == 'selected'
}.keySet().toList()
if (selected.isEmpty()) {
logger.lifecycle(
'redisProductionReadiness: no selected card; verified provider-disabled composition and no release R2 claim.')
} else {
logger.lifecycle("redisProductionReadiness: selected cards passed ${selected}")
}
}
}
tasks.register('redisAllImplementedCandidates') {
group = 'redis verification'
description = 'Runs selected and implemented-candidate Redis cards without changing release labels.'
redisReadinessCards.findAll {
ignored, card -> card.state in ['selected', 'implemented-candidate']
}.each { String cardId, Map<String, Object> ignored ->
dependsOn tasks.named(redisPublicReadinessTasks[cardId])
}
}
def verifyConfigurationPropertiesProcessor = tasks.register('verifyConfigurationPropertiesProcessor') {
group = 'verification'
description = 'Verifies every registered leaf declares the Spring configuration processor exactly when its main source owns @ConfigurationProperties.'
File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json')
inputs.file(moduleRegistryFile)
doLast {
def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile)
List<String> violations = []
def processorDeclaration = ~/^\s*annotationProcessor\s+['"]org\.springframework\.boot:spring-boot-configuration-processor['"]\s*$/
moduleRegistry.modules.each { module ->
File leafDirectory = rootProject.projectDir.parentFile.toPath()
.resolve(module.source_path as String)
.normalize()
.toFile()
File mainSource = new File(leafDirectory, 'src/main')
File buildFile = new File(leafDirectory, 'build.gradle')
int propertyAnnotationCount = 0
if (mainSource.isDirectory()) {
mainSource.eachFileRecurse { File sourceFile ->
if (sourceFile.name.endsWith('.java')) {
propertyAnnotationCount += sourceFile.text.count('@ConfigurationProperties(')
}
}
}
int processorCount = buildFile.readLines().count { String line ->
processorDeclaration.matcher(line).matches()
}
boolean ownsConfigurationProperties = propertyAnnotationCount > 0
if (ownsConfigurationProperties && processorCount != 1) {
violations << "${module.id}: ${propertyAnnotationCount} @ConfigurationProperties occurrence(s), " +
"but ${processorCount} configuration-processor declaration(s)"
} else if (!ownsConfigurationProperties && processorCount != 0) {
violations << "${module.id}: no @ConfigurationProperties occurrence, but " +
"${processorCount} configuration-processor declaration(s)"
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyConfigurationPropertiesProcessor: ${violations.size()} parity violation(s):\n " +
violations.toSorted().join('\n '))
}
logger.lifecycle(
"verifyConfigurationPropertiesProcessor: OK — all ${moduleRegistry.modules.size()} registered leaves have exact configuration-processor parity.")
}
}
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
tasks.named('check') {
dependsOn verifyConfigurationPropertiesProcessor
}
}
// verifyOneTypePerFile — one public top-level type per file, file name == type name
// (code-conventions I6). Rationale in README.md.
tasks.register('verifyOneTypePerFile') {
group = 'verification'
description = 'code-conventions I6: one public top-level type per file; file name == type name.'
doLast {
def typeDecl = ~/^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(?:class|interface|record|enum|@interface)\s+([A-Za-z0-9_]+)/
List<String> violations = []
rootProject.subprojects.each { sub ->
File mainJava = sub.file('src/main/java')
if (!mainJava.exists()) {
return
}
mainJava.eachFileRecurse { File f ->
if (!f.name.endsWith('.java') || f.name == 'package-info.java' || f.name == 'module-info.java') {
return
}
List<String> names = []
f.eachLine { String line ->
def m = (line =~ typeDecl)
if (m.find()) {
names << m.group(1)
}
}
if (names.size() > 1) {
violations << "${f.path}: ${names.size()} public top-level types ${names}".toString()
} else if (names.size() == 1) {
String expected = f.name.replaceFirst(/\.java$/, '')
if (names[0] != expected) {
violations << "${f.path}: file name != public type name (type is '${names[0]}')".toString()
}
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyOneTypePerFile: ${violations.size()} violation(s) of code-conventions I6:\n " +
violations.join("\n "))
}
logger.lifecycle("verifyOneTypePerFile: OK — one public top-level type per file, names match.")
}
}
// verifyEnvKeys — keep env-keys.yaml <-> application.yml <-> src/.env in lock-step.
// Rationale in README.md.
tasks.register('verifyEnvKeys') {
group = 'verification'
description = 'Verifies application.yml APP_ references, src/.env, and env-keys.yaml stay registered.'
File envFile = file("${rootProject.projectDir}/.env")
File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml")
File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml")
inputs.files(envFile, appYml, registryFile)
doLast {
if (!envFile.exists()) {
throw new GradleException("verifyEnvKeys: missing ${envFile}")
}
if (!appYml.exists()) {
throw new GradleException("verifyEnvKeys: missing ${appYml}")
}
if (!registryFile.exists()) {
throw new GradleException("verifyEnvKeys: missing ${registryFile}")
}
def keyPattern = ~/^([A-Z][A-Z0-9_]*)=.*/
Set<String> envKeys = envFile.readLines().findResults { String line ->
def m = keyPattern.matcher(line)
m.matches() ? m.group(1) : null
}.toSet()
// Parse application.yml placeholders: ${VAR} is required, ${VAR:default} is optional.
Set<String> requiredPlaceholders = new TreeSet<>()
Set<String> allPlaceholders = new TreeSet<>()
def pm = (appYml.text =~ /\$\{([A-Z][A-Z0-9_]*)(:[^}]*)?\}/)
while (pm.find()) {
allPlaceholders << pm.group(1)
if (pm.group(2) == null) {
requiredPlaceholders << pm.group(1)
}
}
Set<String> environmentSecretReferences = new TreeSet<>()
def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/)
while (sm.find()) {
environmentSecretReferences << sm.group(1)
}
Set<String> applicationAppReferences = new TreeSet<>(
allPlaceholders.findAll { it.startsWith('APP_') })
applicationAppReferences.addAll(environmentSecretReferences)
// A. Every required (no inline default) placeholder must exist in .env.
Set<String> missingKeys = new TreeSet<>(requiredPlaceholders - envKeys)
if (!missingKeys.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: application.yml references required env absent from src/.env: ${missingKeys}")
}
// B. Every .env key must be referenced by some application.yml placeholder.
Set<String> knownApplicationReferences = new TreeSet<>(allPlaceholders)
knownApplicationReferences.addAll(environmentSecretReferences)
Set<String> orphanedKeys = new TreeSet<>(envKeys - knownApplicationReferences)
if (!orphanedKeys.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: src/.env declares keys no application.yml \${...} placeholder uses: ${orphanedKeys}")
}
// C. Every APP_ key in src/.env must be registered in env-keys.yaml (APP_-scoped;
// SPRING_* native keys are intentionally not tracked — see README.md).
def registryNamePattern = ~/^\s*- name: (APP_[A-Z0-9_]+)/
Set<String> registryAppKeys = registryFile.readLines().findResults { String line ->
def m = registryNamePattern.matcher(line)
m.find() ? m.group(1) : null
}.toSet()
Set<String> envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet()
Set<String> unregisteredAppKeys = new TreeSet<>(envAppKeys - registryAppKeys)
if (!unregisteredAppKeys.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: src/.env declares APP_ keys absent from docs/registries/env-keys.yaml " +
"(registry is the SSOT for APP_ keys): ${unregisteredAppKeys}")
}
// D. Every application-owned reference is registered, including optional placeholders
// with inline defaults and literal secret://environment/APP_* references.
Set<String> unregisteredApplicationReferences =
new TreeSet<>(applicationAppReferences - registryAppKeys)
if (!unregisteredApplicationReferences.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: application.yml references APP_ keys absent from " +
"docs/registries/env-keys.yaml (optional defaults and environment " +
"secret references are included): ${unregisteredApplicationReferences}")
}
logger.lifecycle("verifyEnvKeys: OK — ${envKeys.size()} env keys, " +
"${requiredPlaceholders.size()} required placeholders covered, " +
"${applicationAppReferences.size()} application APP_ references registered.")
}
}
// verifyPublicPathSnapshot — fail the build on an unapproved change to the deny-by-default
// public path surface (SECURITY_PUBLIC_PATHS). Approve with -PapprovePublicPathChange.
// Rationale and the snapshot-vs-reflection decision are in README.md.
tasks.register('verifyPublicPathSnapshot') {
group = 'verification'
description = 'Fails on an unapproved change to the deny-by-default public path surface.'
File envFile = file("${rootProject.projectDir}/.env")
File snapshotFile = file("${rootProject.projectDir}/../docs/security/public-paths-snapshot.txt")
boolean approved = project.hasProperty('approvePublicPathChange')
inputs.file(envFile)
inputs.property('approved', approved)
doLast {
if (!envFile.exists()) {
throw new GradleException("verifyPublicPathSnapshot: missing ${envFile}")
}
def valuePattern = ~/^SECURITY_PUBLIC_PATHS=(.*)$/
String raw = envFile.readLines().findResult { String line ->
def m = valuePattern.matcher(line)
m.matches() ? m.group(1) : null
} ?: ''
List<String> publicPaths = raw.split(',')
.collect { it.trim() }
.findAll { !it.isEmpty() }
.toSorted()
String header =
"# feature-security-operational-baseline D5 — deny-by-default public path snapshot.\n" +
"# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.\n" +
"# Regenerate after review with: ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange\n"
String canonical = header + (publicPaths.isEmpty() ? "" : publicPaths.join('\n') + '\n')
if (!snapshotFile.exists()) {
snapshotFile.parentFile.mkdirs()
snapshotFile.text = canonical
logger.lifecycle("verifyPublicPathSnapshot: snapshot created at ${snapshotFile} " +
"(${publicPaths.size()} public path(s)). Review and commit it.")
return
}
String existing = snapshotFile.text
if (existing == canonical) {
logger.lifecycle("verifyPublicPathSnapshot: OK — ${publicPaths.size()} public path(s) unchanged.")
return
}
if (approved) {
snapshotFile.text = canonical
logger.lifecycle("verifyPublicPathSnapshot: snapshot updated (approved). " +
"Now ${publicPaths.size()} public path(s).")
return
}
throw new GradleException(
"verifyPublicPathSnapshot: the deny-by-default public path surface changed.\n" +
" expected (snapshot):\n${existing}\n" +
" actual (SECURITY_PUBLIC_PATHS):\n${canonical}\n" +
"A protected endpoint may now be public. If this change is intended, get it reviewed " +
"(security:public-path-change) and regenerate with:\n" +
" ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange")
}
}
// verifyTrivyignore — feature-dependency-vulnerability-management-contract D5 (Suppression
// governance). Fails the build when a Trivy suppression entry in the repo-root .trivyignore.yaml
// lacks an expiry or a reason, is already expired, or exceeds the 90-day max window. Closes the
// 2026-05-25 ca-tmpl audit finding: a suppression could be added with no expiry/reason (a silent
// permanent bypass). This is the CI-side field check; CODEOWNERS adds the merge-time approval —
// the two controls are complementary (see .trivyignore.yaml header and src/README.md).
tasks.register('verifyTrivyignore') {
group = 'verification'
description = 'feature-dependency-vulnerability-management-contract D5: every Trivy suppression has a reason and a non-expired, bounded expiry.'
// UNSUPPORTED_IMPL_DECISION (team-policy): 90-day max suppression window. The Trivy docs only
// guarantee the `expired_at` field exists (raw/official-docs/trivy-filtering-suppression-policy
// C4); they recommend no specific bound. Trade-off: shorter forces frequent re-review; longer
// approaches a de-facto permanent ignore.
int maxWindowDays = 90
Set<String> governedSections = ['vulnerabilities', 'licenses', 'misconfigurations', 'secrets'] as Set
File suppressionFile = file("${rootProject.projectDir}/../.trivyignore.yaml")
inputs.file(suppressionFile)
doLast {
if (!suppressionFile.exists()) {
throw new GradleException("verifyTrivyignore: missing ${suppressionFile} (D5 requires the structured suppression file to exist, even if empty).")
}
java.time.LocalDate today = java.time.LocalDate.now()
java.time.LocalDate maxDate = today.plusDays(maxWindowDays)
List<String> violations = []
int entryCount = 0
String section = null
int entryIndent = -1
Map<String, String> entry = null
int entryLine = -1
Closure<Void> validate = { Map<String, String> e, String sec, int lineNo ->
entryCount++
String id = e['id']
String where = "${sec} entry at .trivyignore.yaml:${lineNo}" + (id ? " (id: ${id})" : "")
if (!id?.trim()) {
violations << "${where}: missing 'id'."
}
if (!e['statement']?.trim()) {
violations << "${where}: missing non-empty 'statement' (every suppression must record a reason)."
}
String exp = e['expired_at']?.trim()
if (!exp) {
violations << "${where}: missing 'expired_at' (a missing expiry never expires in Trivy — permanent suppression is forbidden)."
} else {
try {
java.time.LocalDate expDate = java.time.LocalDate.parse(exp)
if (!expDate.isAfter(today)) {
violations << "${where}: 'expired_at' ${exp} is not in the future (already expired — remove or renew the suppression)."
} else if (expDate.isAfter(maxDate)) {
violations << "${where}: 'expired_at' ${exp} exceeds the ${maxWindowDays}-day max window (must be on or before ${maxDate})."
}
} catch (java.time.format.DateTimeParseException ignored) {
violations << "${where}: 'expired_at' '${exp}' is not a valid ISO date (expected YYYY-MM-DD)."
}
}
return null
}
suppressionFile.eachLine { String rawLine, int number ->
String noTab = rawLine.replace('\t', ' ')
String trimmed = noTab.trim()
if (trimmed.isEmpty() || trimmed.startsWith('#')) {
return
}
int indent = noTab.length() - noTab.replaceAll('^ +', '').length()
// Top-level section header (column 0), e.g. `vulnerabilities:` or `vulnerabilities: []`.
def sectionMatch = (trimmed =~ /^([a-z_]+):(\s*\[\s*\])?\s*$/)
if (indent == 0 && sectionMatch.find()) {
if (entry != null) { validate(entry, section, entryLine); entry = null }
section = sectionMatch.group(1)
entryIndent = -1
return
}
if (section == null || !governedSections.contains(section)) {
return
}
if (trimmed.startsWith('- ') || trimmed == '-') {
if (entryIndent == -1) {
entryIndent = indent
}
if (indent == entryIndent) {
if (entry != null) { validate(entry, section, entryLine) }
entry = [:]
entryLine = number
// A field may sit inline on the dash line, e.g. `- id: CVE-2024-0001`.
String inline = trimmed.replaceFirst(/^-\s*/, '')
int colon = inline.indexOf(':')
if (colon > 0) {
String k = inline.substring(0, colon).trim()
String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
if (!k.isEmpty()) { entry[k] = v }
}
}
// indent > entryIndent → nested list item (e.g. under `paths:`) → ignore.
return
}
// A `key: value` field of the current entry (one indent level deeper than the dash).
if (entry != null && indent == entryIndent + 2) {
int colon = trimmed.indexOf(':')
if (colon > 0) {
String k = trimmed.substring(0, colon).trim()
String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
entry[k] = v
}
}
}
if (entry != null) { validate(entry, section, entryLine) }
if (!violations.isEmpty()) {
throw new GradleException(
"verifyTrivyignore: ${violations.size()} Trivy suppression governance violation(s) (D5):\n " +
violations.join("\n ") +
"\nFix the .trivyignore.yaml entries, or remove them. Every suppression needs an 'id', a " +
"'statement' reason, and a future 'expired_at' within ${maxWindowDays} days.")
}
logger.lifecycle("verifyTrivyignore: OK — ${entryCount} suppression(s) validated (reason + bounded, non-expired expiry).")
}
}
// verifyQuarantineSunset — feature-ci-quality-gates-contract §4 (D7/D9, this branch is the flaky
// quarantine SSOT). The flaky-test quarantine bucket (@Tag("quarantine"), excluded from the release
// gate) is a TEMPORARY escape, not a parking lot: every quarantined test must be registered in the
// repo-root flaky-quarantine.yaml with a reason, a tracking issue, and a quarantined_since date, and
// it must leave quarantine within 14 days (the ca-tmpl compromise on the Spotify/Google/MS-vs-Fowler
// debate — company-case-study strength only, NOT an official best practice). This is the CI-side
// field + sunset check; like verifyTrivyignore it is the field-validation half and CODEOWNERS is the
// merge-approval half. Two enforcement directions:
// (a) sunset — fail when any registered quarantined_since is older than 14 days;
// (b) drift — fail when a test is @Tag("quarantine")-tagged in source but NOT registered
// (a flaky test must not escape sunset tracking by skipping the registry).
// Ships passing on the empty skeleton (zero tagged tests, `quarantined: []`).
tasks.register('verifyQuarantineSunset') {
group = 'verification'
description = 'feature-ci-quality-gates-contract §4 (D7): every @Tag("quarantine") test is registered and within its 14-day sunset.'
// UNSUPPORTED_IMPL_DECISION (company-case-study + team-policy): 14-day sunset. The Spotify/Google/MS
// case studies establish that a quarantine bucket is legitimate; the 14-day quantum and its
// automatic enforcement are ca-tmpl's own compromise (no external standard). A fork tunes
// sunsetDays here.
int sunsetDays = 14
File registryFile = file("${rootProject.projectDir}/../flaky-quarantine.yaml")
inputs.file(registryFile)
rootProject.subprojects.each { sub ->
File testJava = sub.file('src/test/java')
if (testJava.exists()) {
inputs.dir(testJava)
}
}
doLast {
if (!registryFile.exists()) {
throw new GradleException("verifyQuarantineSunset: missing ${registryFile} " +
"(D7 requires the quarantine registry to exist, even if empty: `quarantined: []`).")
}
// --- Parse the registry (line-based, same shape family as .trivyignore.yaml). -----------
// quarantined:
// - test: "fully.qualified.TestClass" | "...TestClass#method"
// quarantined_since: "YYYY-MM-DD"
// reason: "..."
// tracking_issue: "..."
List<Map<String, String>> entries = []
List<Integer> entryLines = []
boolean inSection = false
int entryIndent = -1
Map<String, String> entry = null
int entryLine = -1
Closure<Void> closeEntry = {
if (entry != null) { entries << entry; entryLines << entryLine; entry = null }
return null
}
registryFile.eachLine { String rawLine, int number ->
String noTab = rawLine.replace('\t', ' ')
String trimmed = noTab.trim()
if (trimmed.isEmpty() || trimmed.startsWith('#')) {
return
}
int indent = noTab.length() - noTab.replaceAll('^ +', '').length()
// Top-level `quarantined:` or `quarantined: []`.
def sectionMatch = (trimmed =~ /^quarantined:(\s*\[\s*\])?\s*$/)
if (indent == 0 && sectionMatch.find()) {
closeEntry()
inSection = true
entryIndent = -1
return
}
if (indent == 0) { // some other top-level key → leave the section
closeEntry()
inSection = false
return
}
if (!inSection) {
return
}
if (trimmed.startsWith('- ') || trimmed == '-') {
if (entryIndent == -1) { entryIndent = indent }
if (indent == entryIndent) {
closeEntry()
entry = [:]
entryLine = number
String inline = trimmed.replaceFirst(/^-\s*/, '')
int colon = inline.indexOf(':')
if (colon > 0) {
String k = inline.substring(0, colon).trim()
String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
if (!k.isEmpty()) { entry[k] = v }
}
}
return
}
// A `key: value` field of the current entry (deeper than the dash).
if (entry != null && indent == entryIndent + 2) {
int colon = trimmed.indexOf(':')
if (colon > 0) {
String k = trimmed.substring(0, colon).trim()
String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
entry[k] = v
}
}
}
closeEntry()
// --- (a) Schema + sunset validation of every registered entry. -------------------------
java.time.LocalDate today = java.time.LocalDate.now()
List<String> violations = []
Set<String> registeredClasses = new HashSet<>()
entries.eachWithIndex { Map<String, String> e, int i ->
String test = e['test']?.trim()
String where = "flaky-quarantine.yaml:${entryLines[i]}" + (test ? " (test: ${test})" : "")
if (!test) {
violations << "${where}: missing 'test' (fully-qualified test class, optionally '#method')."
} else {
registeredClasses << test.replaceAll(/#.*$/, '')
}
if (!e['reason']?.trim()) {
violations << "${where}: missing non-empty 'reason' (why is it flaky / what is the suspected cause)."
}
if (!e['tracking_issue']?.trim()) {
violations << "${where}: missing 'tracking_issue' (the issue tracking the fix — quarantine is not a parking lot)."
}
String since = e['quarantined_since']?.trim()
if (!since) {
violations << "${where}: missing 'quarantined_since' (a missing date cannot be sunset — forbidden)."
} else {
try {
java.time.LocalDate sinceDate = java.time.LocalDate.parse(since)
if (sinceDate.isAfter(today)) {
violations << "${where}: 'quarantined_since' ${since} is in the future."
} else if (sinceDate.plusDays(sunsetDays).isBefore(today)) {
long age = java.time.temporal.ChronoUnit.DAYS.between(sinceDate, today)
violations << "${where}: quarantined ${age} days ago — past the ${sunsetDays}-day sunset. " +
"Fix and un-quarantine the test, or escalate; do not extend silently."
}
} catch (java.time.format.DateTimeParseException ignored) {
violations << "${where}: 'quarantined_since' '${since}' is not a valid ISO date (expected YYYY-MM-DD)."
}
}
}
// --- (b) Drift: every @Tag("quarantine") test in source must be registered. -------------
def tagPattern = ~/@Tag\(\s*["']quarantine["']\s*\)/
def classPattern = ~/(?:class|interface|enum|record)\s+([A-Za-z0-9_]+)/
int taggedCount = 0
rootProject.subprojects.each { sub ->
File testJava = sub.file('src/test/java')
if (!testJava.exists()) {
return
}
testJava.eachFileRecurse { File f ->
if (!f.name.endsWith('.java')) {
return
}
String text = f.text
if (!(text =~ tagPattern)) {
return
}
taggedCount++
def cm = (text =~ classPattern)
String simpleName = cm ? cm[0][1] : f.name.replaceFirst(/\.java$/, '')
boolean registered = registeredClasses.any { it.endsWith('.' + simpleName) || it == simpleName }
if (!registered) {
violations << "${f.path}: test '${simpleName}' is @Tag(\"quarantine\") but is not registered " +
"in flaky-quarantine.yaml — a quarantined test must be tracked with a sunset date."
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyQuarantineSunset: ${violations.size()} quarantine governance violation(s) (§4 / D7):\n " +
violations.join("\n ") +
"\nEach @Tag(\"quarantine\") test needs a flaky-quarantine.yaml entry with a 'reason', a " +
"'tracking_issue', and a 'quarantined_since' within ${sunsetDays} days.")
}
logger.lifecycle("verifyQuarantineSunset: OK — ${entries.size()} registered, ${taggedCount} tagged " +
"(${sunsetDays}-day sunset enforced).")
}
}