Files
clean-architecture-backend-…/src/build.gradle
T
DongHyeonka d646c2f12f feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가
messaging-superpowers-package 설계서/계획서 기반 구현.
registry를 19 → 43 leaf로 확장하고 src/messaging 아래 24개 leaf를 등록.

- core-api: M1 publish/consume + M2 batch·delayed·pause-resume
- policy/transport-spi: 재시도 결정, DLQ orchestration, admission control, lifecycle
- kafka·rabbit(Stable): contiguous commit, confirm/return 상관, 배치, 보안 설정
- pulsar·nats(Experimental): 기본 비활성, live 인증 없음을 코드로 기록
- outbox/inbox/claim-check: 트랜잭션 결합, lease, 무결성 검증
- admin: plan → approve → execute를 타입으로 강제
- 문서 9종, infra compose 7종, JMH 벤치마크 3종

검증: 아키텍처 게이트 3종 통과, 24개 leaf 전부 check 통과,
messaging 테스트 604개 통과/0 실패.

미완: 계획서가 요구한 실 브로커 IT 40개 중 7개만 작성.
Rabbit 13 / Outbox 6 / Inbox 4 / NATS·Pulsar·Share 5 / testkit 2 /
starter·admin 3, 그리고 TLS·ACL 2개가 남음.
2026-08-14 14:55:38 +09:00

2522 lines
126 KiB
Groovy
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import groovy.xml.XmlSlurper
import java.time.Duration
import java.time.Instant
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
// Messaging first-R2 task names are reserved early, but qualification is deliberately fail-closed.
// Follow-up owner tasks replace these skeleton actions only when matching tests write schema-valid,
// source/profile-bound, payload-free evidence. Merely placing a manifest on disk cannot pass.
Map<String, List<String>> messagingVerificationSkeletons = [
'verifyMessagingPollingOutboxR2': [
'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json'
],
'verifyMessagingKafkaProducerR2': [
'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json'
],
'verifyMessagingSecurityR2': [
'app-bootstrap/build/messaging-evidence/security-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json'
],
'verifyMessagingReleaseProfile': [
'build/messaging-evidence/contracts-schema/manifest.json',
'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/cutover-rehearsal/manifest.json',
'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/security-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json'
],
'verifyMessagingTargetBindingPreflight': [
'app-bootstrap/build/messaging-evidence/target-binding-preflight/manifest.json'
],
'verifyMessagingTargetBinding': [
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json'
],
'verifyMessagingDeploymentCutover': [
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json',
'app-bootstrap/build/messaging-evidence/deployment-cutover/manifest.json'
],
'verifyMessagingCleanupTargetBinding': [
'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json'
],
'verifyMessagingFinalR2Profile': [
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json',
'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json',
'build/messaging-evidence/final-r2-profile/manifest.json'
]
]
Closure<Void> messagingFailClosedEvidenceGuard = { String taskName, List<String> relativePaths ->
List<File> evidenceFiles = relativePaths.collect { rootProject.file(it) }
List<String> violations = evidenceFiles.findAll { !it.isFile() }.collect {
"missing evidence ${rootProject.relativePath(it)}"
}
String expectedSourceDigest = providers.gradleProperty('messagingSourceDigest').getOrElse('')
String expectedProfileHash = providers.gradleProperty('messagingProfileHash').getOrElse('')
if (expectedSourceDigest.isBlank()) {
violations << 'missing -PmessagingSourceDigest=sha256:<exact-source-digest>'
}
if (expectedProfileHash.isBlank()) {
violations << 'missing -PmessagingProfileHash=sha256:<exact-profile-hash>'
}
evidenceFiles.findAll { it.isFile() }.each { File evidenceFile ->
try {
def manifest = new JsonSlurper().parse(evidenceFile)
if (manifest.sourceDigest != expectedSourceDigest) {
violations << "${rootProject.relativePath(evidenceFile)} has wrong source digest"
}
if (manifest.hashes?.profile != expectedProfileHash) {
violations << "${rootProject.relativePath(evidenceFile)} has mismatched profile hash"
}
if ((manifest.counts?.skipped ?: 0) != 0 || !(manifest.skips instanceof List) ||
!manifest.skips.isEmpty()) {
violations << "${rootProject.relativePath(evidenceFile)} contains skipped evidence"
}
if ((manifest.counts?.failed ?: 0) != 0 || !(manifest.failures instanceof List) ||
!manifest.failures.isEmpty()) {
violations << "${rootProject.relativePath(evidenceFile)} contains failed evidence"
}
try {
Instant generatedAt = Instant.parse(manifest.generatedAt as String)
if (generatedAt.isBefore(Instant.now().minus(Duration.ofHours(24))) ||
generatedAt.isAfter(Instant.now().plus(Duration.ofMinutes(5)))) {
violations << "${rootProject.relativePath(evidenceFile)} is stale or future-dated"
}
} catch (RuntimeException ignored) {
violations << "${rootProject.relativePath(evidenceFile)} has invalid generatedAt"
}
} catch (RuntimeException ignored) {
violations << "${rootProject.relativePath(evidenceFile)} is not valid JSON evidence"
}
}
// Task 2 intentionally has no matching qualification Test tasks or complete schema validator.
// This unconditional violation prevents hand-written evidence from manufacturing an R2 PASS.
violations << 'qualification producer/tests and common-schema validator are not implemented'
throw new GradleException(
"${taskName}: FAIL_CLOSED — no R2 claim is available:\n ${violations.join('\n ')}")
}
messagingVerificationSkeletons.each { String taskName, List<String> evidencePaths ->
tasks.register(taskName) {
group = 'verification'
description = "Fail-closed Messaging qualification skeleton for ${taskName}."
inputs.files(evidencePaths.collect { rootProject.file(it) }).optional()
outputs.upToDateWhen { false }
doLast {
messagingFailClosedEvidenceGuard(taskName, evidencePaths)
}
}
}
// 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'
apply from: "${rootProject.projectDir}/gradle/archive-hygiene.gradle"
apply from: "${rootProject.projectDir}/gradle/public-path-snapshot.gradle"
apply from: "${rootProject.projectDir}/gradle/runtime-membership.gradle"
apply from: "${rootProject.projectDir}/gradle/junit-evidence.gradle"
apply from: "${rootProject.projectDir}/gradle/test-jvm-agents.gradle"
Closure<List<String>> spotBugsAnalysisFailures = { File reportFile ->
List<String> failures = []
if (!reportFile.isFile()) {
failures << "missing XML report ${reportFile}"
return failures
}
try {
XmlSlurper parser = new XmlSlurper(false, false)
parser.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true)
def report = parser.parse(reportFile)
def errors = report.Errors
if (errors.size() != 1) {
failures << "expected one Errors element in ${reportFile.name}"
return failures
}
def errorsElement = errors[0]
errorsElement.MissingClass.each { missingClass ->
String className = missingClass.text().trim()
failures << "missing analysis class ${className.isBlank() ? '<unnamed>' : className}"
}
errorsElement.Error.each { error ->
String message = error.ErrorMessage.text().trim()
failures << "analysis error ${message.isBlank() ? '<no message>' : message}"
}
[missingClasses: errorsElement.MissingClass.size(), errors: errorsElement.Error.size()].each {
String attribute, int observed ->
String declared = errorsElement.attributes()[attribute]?.toString()
if (!(declared ==~ /\d+/)) {
failures << "invalid ${attribute} count '${declared}'"
} else if (declared.toInteger() > observed) {
failures << "${declared} ${attribute} reported but only ${observed} detailed"
}
}
} catch (Exception ex) {
failures << "unreadable XML report: ${ex.message}"
}
failures
}
ext.spotBugsAnalysisFailures = spotBugsAnalysisFailures
def verifySpotBugsAnalysisFailureContract =
tasks.register('verifySpotBugsAnalysisFailureContract') {
group = 'verification'
description = 'Proves SpotBugs missing classes and analysis errors fail closed without promoting advisory bug findings.'
notCompatibleWithConfigurationCache(
'Exercises the root-owned SpotBugs XML verifier at execution time')
outputs.upToDateWhen { false }
doLast {
if (!rootProject.ext.has('spotBugsAnalysisFailures')) {
throw new GradleException(
'verifySpotBugsAnalysisFailureContract: analysis report verifier is not configured')
}
// Raw Closure on purpose. The parameterised form, wrapped across two lines, is
// valid Groovy and Gradle runs it, but the IDE's Gradle parser reads the trailing
// `>>` as the end of a block and reports a syntax error for the rest of the file.
def analysisFailures = rootProject.ext.spotBugsAnalysisFailures as Closure
Map<String, String> fixtures = [
clean : '<BugCollection><Errors errors="0" missingClasses="0"/></BugCollection>',
missing : '<BugCollection><Errors errors="0" missingClasses="1"><MissingClass>fixture.MissingType</MissingClass></Errors></BugCollection>',
error : '<BugCollection><Errors errors="1" missingClasses="0"><Error><ErrorMessage>fixture analysis error</ErrorMessage></Error></Errors></BugCollection>',
advisory: '<BugCollection><BugInstance type="DMI_RANDOM_USED_ONLY_ONCE"/><Errors errors="0" missingClasses="0"/></BugCollection>'
]
Map<String, List<String>> results = fixtures.collectEntries { String name, String xml ->
File fixture = new File(temporaryDir, "${name}.xml")
fixture.setText(xml, 'UTF-8')
[(name): analysisFailures(fixture)]
}
if (!results.clean.isEmpty() || !results.advisory.isEmpty() ||
!results.missing.any { it.contains('fixture.MissingType') } ||
!results.error.any { it.contains('fixture analysis error') }) {
throw new GradleException(
"verifySpotBugsAnalysisFailureContract: unexpected fixture results ${results}")
}
logger.lifecycle(
'verifySpotBugsAnalysisFailureContract: OK — clean and advisory bug-only reports pass; missing classes and analysis errors fail closed.')
}
}
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
)
}
}
// 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 {
// Pinned, not inherited from the platform. Sources carry non-ASCII — Korean comments and
// em dashes inside string literals — so a builder whose default charset is not UTF-8
// compiles different bytes than this one does. It is also what the Gradle model hands the
// IDE as the project encoding; without it every imported project reports "no explicit
// encoding set".
options.encoding = 'UTF-8'
['-parameters', '-Werror', '-Xlint:deprecation', '-Xlint:unchecked'].each { String compilerArg ->
if (!options.compilerArgs.contains(compilerArg)) {
options.compilerArgs.add(compilerArg)
}
}
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 — Checkstyle findings in both main and test sources are blocking.
// 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')
}
sourceSets.configureEach { sourceSet ->
String taskName = "spotbugs${sourceSet.name.capitalize()}"
tasks.named(taskName, com.github.spotbugs.snom.SpotBugsTask) {
auxClassPaths.from(sourceSet.runtimeClasspath - sourceSet.output)
def xmlAnalysisReport = reports.maybeCreate('xml')
xmlAnalysisReport.required.set(true)
doLast {
List<String> analysisFailures =
spotBugsAnalysisFailures(xmlAnalysisReport.outputLocation.get().asFile)
if (!analysisFailures.isEmpty()) {
throw new GradleException(
"${path}: SpotBugs analysis incomplete:\n " +
analysisFailures.join('\n '))
}
}
}
}
// 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'
// Netty security floor. The Spring Boot BOM pinned 4.2.7.Final, which sits inside two published
// advisory ranges that reach productionRuntimeClasspath, not just a test tool classpath:
// - CVE-2026-42577, netty-transport-native-epoll >=4.2.0,<4.2.13 (GHSA-rwm7-x88c-3g2p)
// - CVE-2026-59901, netty-codec-compression >=4.2.0,<4.2.16 (GHSA-558v-64gr-wgg4)
// Netty is shared runtime surface here — HTTP, Reactor Netty and the Redis driver all sit on it
// — so the fix is the BOM-managed version property rather than a per-artifact exclusion, and it
// is the latest 4.2 patch rather than the exact advisory floor. Regenerate every lockfile after
// changing this (`./gradlew resolveAndLockAll --write-locks`).
ext['netty.version'] = '4.2.17.Final'
// §4 routing — SpotBugs findings in both main and test sources are blocking.
dependencyManagement {
imports {
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
}
}
dependencies {
// The messaging platform leaves own a broker-neutral public contract. Keeping their test
// classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no Spring
// dependency" verifiable rather than aspirational; leaves that genuinely need a Spring
// test context add it in their own build file.
if (project.path in [':domain-core', ':application-core', ':shared-contract'] ||
project.path.startsWith(':messaging:')) {
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
}
// The three messaging leaves that carry JMH benchmarks get a `jmh` source set. It is a source
// set rather than a plugin because the benchmarks are compiled and reviewed on every build but
// only *run* on demand: a benchmark that stops compiling is a defect, while a benchmark that
// runs in CI is a flaky test measuring the build agent.
if (project.path in [':messaging:messaging-kafka',
':messaging:messaging-rabbit',
':messaging:messaging-testkit']) {
sourceSets {
jmh {
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += sourceSets.main.output + sourceSets.test.output
}
}
configurations {
jmhImplementation.extendsFrom implementation, testImplementation
jmhRuntimeOnly.extendsFrom runtimeOnly, testRuntimeOnly
}
dependencies {
jmhImplementation 'org.openjdk.jmh:jmh-core:1.37'
jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
// ErrorProne's -Werror would reject JMH's generated sources, which the platform does
// not own and cannot fix.
jmhAnnotationProcessor 'com.google.errorprone:error_prone_core:2.49.0'
}
tasks.named('compileJmhJava') {
options.errorprone.enabled = false
options.compilerArgs.removeAll { it == '-Werror' }
}
// JMH's annotation processor emits the generated harness into this source set, and its
// generated code trips DLS_DEAD_LOCAL_STORE by design (the dead stores are how it defeats
// dead-code elimination). Analysing code the platform neither wrote nor can fix would make
// the gate unactionable, so the jmh source set is excluded from the bug and style checks.
// The benchmarks themselves are still compiled, which is what catches a real breakage.
tasks.named('spotbugsJmh') { enabled = false }
tasks.named('checkstyleJmh') { enabled = false }
tasks.register('jmh', JavaExec) {
group = 'verification'
description = 'Runs the JMH benchmarks in this leaf.'
classpath = sourceSets.jmh.runtimeClasspath
mainClass = 'org.openjdk.jmh.Main'
}
}
// 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 verifySpotBugsAnalysisFailureContract
dependsOn rootProject.tasks.named('verifyCleanArchitectureDependencies')
dependsOn rootProject.tasks.named('verifyRuntimeModuleMembership')
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')
}
}
Map<String, Provider<Directory>> conditionalTransportEvidence = [
'conditional-transport-graphql':
project(':adapter:inbound:graphql').layout.buildDirectory.dir(
'test-results/graphqlTransportQualificationTest'),
'conditional-transport-grpc':
project(':adapter:inbound:grpc').layout.buildDirectory.dir(
'test-results/grpcTransportQualificationTest'),
'conditional-transport-websocket':
project(':adapter:inbound:websocket').layout.buildDirectory.dir(
'test-results/websocketTransportQualificationTest'),
'conditional-transport-composition':
project(':app-bootstrap').layout.buildDirectory.dir(
'test-results/conditionalTransportCompositionTest')
]
tasks.register('conditionalTransportQualification') {
group = 'verification'
description = 'Runs the exact no-skip GraphQL, gRPC, and WebSocket P1 qualification evidence.'
dependsOn ':adapter:inbound:graphql:graphqlTransportQualificationTest'
dependsOn ':adapter:inbound:grpc:grpcTransportQualificationTest'
dependsOn ':adapter:inbound:websocket:websocketTransportQualificationTest'
dependsOn ':app-bootstrap:conditionalTransportCompositionTest'
dependsOn tasks.named('verifyRuntimeModuleMembership')
inputs.files(conditionalTransportEvidence.values())
doLast {
conditionalTransportEvidence.each { String evidenceName, Provider<Directory> directory ->
rootProject.ext.verifyNoSkipJUnitXml(
evidenceName, directory.get().asFile)
}
}
}
// Task 6 replaces only the contract/schema skeletons with real, no-match-failing Test lanes.
// The manifest is payload-free and is rebuilt only after exact source/artifact/profile properties
// and every selected Task 3-6 test have passed in the current invocation.
def messagingEvidenceResultRoot = layout.buildDirectory.dir('test-results/messaging-evidence')
def messagingEvidenceFile = layout.buildDirectory.file(
'messaging-evidence/contracts-schema/manifest.json')
def messagingProfileFile = file('config/messaging/profile-compatibility.yaml')
def messagingDigestProperty = { String propertyName ->
String value = providers.gradleProperty(propertyName).getOrElse('')
if (!(value ==~ /sha256:[a-f0-9]{64}/)) {
throw new GradleException(
"-P${propertyName}=sha256:<64-lowercase-hex> is required for Messaging evidence.")
}
value
}
def messagingSha256Bytes = { byte[] bytes ->
'sha256:' + java.util.HexFormat.of().formatHex(
MessageDigest.getInstance('SHA-256').digest(bytes))
}
def messagingSha256FileSet = { String domain, List<File> files ->
MessageDigest digest = MessageDigest.getInstance('SHA-256')
digest.update(domain.getBytes(java.nio.charset.StandardCharsets.UTF_8))
digest.update((byte) 0)
files.sort { rootProject.relativePath(it) }.each { File input ->
if (!input.isFile()) {
throw new GradleException(
"Messaging evidence input is missing: ${rootProject.relativePath(input)}")
}
byte[] path = rootProject.relativePath(input)
.getBytes(java.nio.charset.StandardCharsets.UTF_8)
byte[] content = input.bytes
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(path.length).array())
digest.update(path)
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(content.length).array())
digest.update(content)
}
'sha256:' + java.util.HexFormat.of().formatHex(digest.digest())
}
def prepareMessagingContractEvidence = tasks.register('prepareMessagingContractEvidence') {
group = 'verification'
outputs.upToDateWhen { false }
doLast {
File output = messagingEvidenceFile.get().asFile
if (output.exists() && !output.delete()) {
throw new GradleException("Could not delete stale Messaging evidence ${output}")
}
messagingDigestProperty('messagingSourceDigest')
messagingDigestProperty('messagingArtifactDigest')
String suppliedProfile = messagingDigestProperty('messagingProfileHash')
String exactProfile = messagingSha256Bytes(messagingProfileFile.bytes)
if (suppliedProfile != exactProfile) {
throw new GradleException(
"messagingProfileHash does not match exact config/messaging/profile-compatibility.yaml bytes.")
}
}
}
def messagingEvidenceFromXml = { List<String> resultDirectories ->
List<Map<String, String>> cases = []
resultDirectories.each { String directory ->
File resultDirectory = messagingEvidenceResultRoot.get().dir(directory).asFile
fileTree(resultDirectory).matching { include 'TEST-*.xml' }.files.sort().each { File xml ->
def suite = new XmlSlurper(false, false).parse(xml)
suite.testcase.each { testCase ->
boolean failed = !testCase.failure.isEmpty() || !testCase.error.isEmpty()
boolean skipped = !testCase.skipped.isEmpty()
String simpleClass = testCase.@classname.text().tokenize('.').last()
String rawId = "${simpleClass}.${testCase.@name.text()}"
String scenarioId = rawId
.replace('()', '')
.replaceAll('[^A-Za-z0-9._:-]', '-')
.replaceAll('-+', '-')
cases << [id: scenarioId, failed: failed.toString(), skipped: skipped.toString()]
}
}
}
if (cases.isEmpty()) {
throw new GradleException('Messaging qualification XML contains no discovered test cases.')
}
List<String> scenarioIds = cases.collect { it.id }.sort()
if (scenarioIds.toSet().size() != scenarioIds.size()) {
throw new GradleException('Messaging qualification scenario IDs are not unique.')
}
int failed = cases.count { it.failed == 'true' }
int skipped = cases.count { it.skipped == 'true' }
[
scenarioIds: scenarioIds,
counts: [
executed: cases.size(),
passed: cases.size() - failed - skipped,
failed: failed,
skipped: skipped
]
]
}
def validateMessagingEvidenceStructure = { Map manifest, String expectedProducer ->
Set<String> exactRootKeys = [
'schemaVersion', 'sourceDigest', 'artifactDigest', 'producerTask', 'scenarioIds',
'counts', 'command', 'generatedAt', 'hashes', 'failures', 'skips',
'unsupportedClaims'
] as Set
Set<String> exactCountKeys = ['executed', 'passed', 'failed', 'skipped'] as Set
Set<String> exactHashKeys = ['profile', 'catalog', 'schema', 'settings'] as Set
List<String> violations = []
if (manifest.keySet() != exactRootKeys) {
violations << 'root fields do not match the common manifest schema'
}
if (manifest.schemaVersion != 1 || manifest.producerTask != expectedProducer) {
violations << 'schemaVersion or producerTask is wrong'
}
['sourceDigest', 'artifactDigest'].each { String field ->
if (!(manifest[field] instanceof String) ||
!(manifest[field] ==~ /sha256:[a-f0-9]{64}/)) {
violations << "${field} is not a canonical SHA-256"
}
}
if (!(manifest.scenarioIds instanceof List) || manifest.scenarioIds.isEmpty() ||
manifest.scenarioIds.toSet().size() != manifest.scenarioIds.size() ||
manifest.scenarioIds.any {
!(it instanceof String) ||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
}) {
violations << 'scenarioIds violate the common schema'
}
if (!(manifest.counts instanceof Map) || manifest.counts.keySet() != exactCountKeys ||
!(manifest.counts.executed instanceof Integer) || manifest.counts.executed < 1 ||
manifest.counts.values().any { !(it instanceof Integer) || it < 0 } ||
manifest.counts.executed !=
manifest.counts.passed + manifest.counts.failed + manifest.counts.skipped) {
violations << 'counts are invalid or inconsistent'
}
if (manifest.counts?.failed != 0 || manifest.counts?.skipped != 0 ||
manifest.failures != [] || manifest.skips != []) {
violations << 'failed or skipped qualification cannot produce PASS evidence'
}
if (!(manifest.hashes instanceof Map) || manifest.hashes.keySet() != exactHashKeys ||
manifest.hashes.values().any {
!(it instanceof String) || !(it ==~ /sha256:[a-f0-9]{64}/)
}) {
violations << 'hashes violate the common schema'
}
if (!(manifest.command instanceof String) || manifest.command.isBlank() ||
manifest.command.length() > 2048) {
violations << 'command is missing or unbounded'
}
try {
Instant.parse(manifest.generatedAt as String)
} catch (RuntimeException ignored) {
violations << 'generatedAt is not UTC date-time evidence'
}
if (!(manifest.unsupportedClaims instanceof List) ||
manifest.unsupportedClaims.toSet().size() != manifest.unsupportedClaims.size() ||
manifest.unsupportedClaims.any {
!(it instanceof String) ||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
}) {
violations << 'unsupportedClaims violate the common schema'
}
if (!violations.isEmpty()) {
throw new GradleException(
"Messaging evidence fails the common schema structural validator:\n " +
violations.join('\n '))
}
}
def writeMessagingEvidence = {
String producerTask, List<String> resultDirectories, List<String> commandTasks ->
Map result = messagingEvidenceFromXml(resultDirectories)
Map manifest = [
schemaVersion: 1,
sourceDigest: messagingDigestProperty('messagingSourceDigest'),
artifactDigest: messagingDigestProperty('messagingArtifactDigest'),
producerTask: producerTask,
scenarioIds: result.scenarioIds,
counts: result.counts,
command: './gradlew ' + commandTasks.join(' ') +
' -PmessagingSourceDigest=<sha256> -PmessagingArtifactDigest=<sha256> ' +
'-PmessagingProfileHash=<exact-sha256> --console=plain',
generatedAt: Instant.now().toString(),
hashes: [
profile: messagingSha256Bytes(messagingProfileFile.bytes),
catalog: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.catalog.v1',
[file('config/messaging/readiness-cards.yaml')]),
schema: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.schema-set.v1',
[
file('shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json'),
file('sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json')
] + fileTree(
'adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12'
).files.toList()),
settings: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.settings.v1',
[
file('adapter/outbound/messaging/build.gradle'),
file('adapter/outbound/messaging/gradle.lockfile')
])
],
failures: [],
skips: [],
unsupportedClaims: [
'consumer-compatibility-full-suite',
'durable-outbox-r2',
'kafka-acknowledged-r2',
'regex-engine-timeout',
'remote-schema-resolution'
]
]
validateMessagingEvidenceStructure(manifest, producerTask)
File commonSchema =
file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
if (!commonSchema.isFile()) {
throw new GradleException('Common Messaging evidence schema is missing.')
}
File output = messagingEvidenceFile.get().asFile
output.parentFile.mkdirs()
output.text = JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + System.lineSeparator()
Map reloaded = new JsonSlurper().parse(output) as Map
validateMessagingEvidenceStructure(reloaded, producerTask)
logger.lifecycle(
"${producerTask}: wrote payload-free evidence with ${result.counts.executed} scenarios.")
}
def verifyMessagingJsonSchemaV1 = tasks.register('verifyMessagingJsonSchemaV1') {
group = 'verification'
description = 'Qualifies the deterministic local Draft 2020-12 envelope candidate.'
dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest'
dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph'
outputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
doLast {
writeMessagingEvidence(
'verifyMessagingJsonSchemaV1',
['json-schema'],
[':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
'verifyMessagingJsonSchemaV1'])
}
}
def validateMessagingJsonSchemaV1EvidenceManifestSchema =
tasks.register('validateMessagingJsonSchemaV1EvidenceManifestSchema', JavaExec) {
group = 'verification'
description =
'Validates the exact generated JSON qualification manifest bytes against the common Draft 2020-12 schema.'
dependsOn verifyMessagingJsonSchemaV1
classpath =
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
mainClass =
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
.absolutePath,
messagingEvidenceFile.get().asFile.absolutePath
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
inputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
}
verifyMessagingJsonSchemaV1.configure {
finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema
}
def verifyMessagingContracts = tasks.register('verifyMessagingContracts') {
group = 'verification'
description = 'Qualifies the closed Task 3-6 contract, catalog, binding and schema candidate.'
dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema
dependsOn ':application-core:messagingApplicationContractQualificationTest'
dependsOn ':shared-contract:messagingSharedSchemaQualificationTest'
dependsOn ':sample-portfolio:messagingSampleContractQualificationTest'
dependsOn ':adapter:outbound:messaging:messagingCompiledContractsQualificationTest'
dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest'
dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph'
outputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
doLast {
writeMessagingEvidence(
'verifyMessagingContracts',
['application', 'shared', 'sample', 'compiled', 'json-schema'],
[
':application-core:messagingApplicationContractQualificationTest',
':shared-contract:messagingSharedSchemaQualificationTest',
':sample-portfolio:messagingSampleContractQualificationTest',
':adapter:outbound:messaging:messagingCompiledContractsQualificationTest',
':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
'verifyMessagingContracts'
])
}
}
def validateMessagingContractsEvidenceManifestSchema =
tasks.register('validateMessagingContractsEvidenceManifestSchema', JavaExec) {
group = 'verification'
description =
'Validates the exact generated combined qualification manifest bytes against the common Draft 2020-12 schema.'
dependsOn verifyMessagingContracts
classpath =
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
mainClass =
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
.absolutePath,
messagingEvidenceFile.get().asFile.absolutePath
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
inputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
}
verifyMessagingContracts.configure {
finalizedBy validateMessagingContractsEvidenceManifestSchema
}
// 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') }
}
// 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-fileserver-metadata-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-fileserver-metadata-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
}
}
Project applicationCoreProject = project(':application-core')
def verifyApplicationCoreDependencyPurity = tasks.register('verifyApplicationCoreDependencyPurity') {
group = 'verification'
description = 'Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths.'
notCompatibleWithConfigurationCache('Inspects project configurations at execution time')
doLast {
Project application = applicationCoreProject
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.')
}
}
applicationCoreProject.tasks.named('check') {
dependsOn verifyApplicationCoreDependencyPurity
}
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")
// Check E reads the annotation processor's output, so the owning module has to have been
// compiled. Without this the check would quietly cover nothing on a clean checkout.
File redisSdkMetadata = file("${rootProject.projectDir}/adapter/outbound/cache-redis/build/" +
'classes/java/main/META-INF/spring-configuration-metadata.json')
dependsOn ':adapter:outbound:cache-redis:compileJava'
inputs.files(envFile, appYml, registryFile)
inputs.file(redisSdkMetadata).optional()
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}")
}
// E. Typed properties that are deliberately absent from application.yml and src/.env.
//
// Checks AD compare three text files, so a property that exists only as a typed
// @ConfigurationProperties field is invisible to them: the Redis SDK shipped 34 settings
// with no registered env name at all and verifyEnvKeys passed. Conditionally-composed
// adapters cannot be fixed by adding their settings to application.yml — that is what
// would make a Redis-free deployment carry Redis configuration — so the third SSOT for
// them is the annotation processor's own metadata, compared against the registry in both
// directions: a typed property with no row, and a row naming a property that no longer
// exists, are both failures.
Map<String, String> metadataScopes = [
'app.redis.': 'adapter/outbound/cache-redis'
]
Set<String> typedProperties = new TreeSet<>()
Set<String> missingMetadata = new TreeSet<>()
metadataScopes.each { propertyPrefix, modulePath ->
File metadata = file(
"${rootProject.projectDir}/${modulePath}/build/classes/java/main/" +
'META-INF/spring-configuration-metadata.json')
if (!metadata.exists()) {
missingMetadata << "${propertyPrefix} (${metadata})".toString()
return
}
def parsed = new groovy.json.JsonSlurper().parse(metadata)
(parsed.properties ?: []).each { property ->
if (property.name?.startsWith(propertyPrefix)) {
typedProperties << property.name.toString()
}
}
}
if (!missingMetadata.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: configuration metadata is missing for ' + missingMetadata +
' — run the owning module\'s compileJava first (the annotation ' +
'processor writes it), or the typed-property check silently covers ' +
'nothing.')
}
def registryPropertyPattern = ~/^\s*property:\s*(\S+)/
Set<String> registryProperties = registryFile.readLines().findResults { String line ->
def m = registryPropertyPattern.matcher(line)
m.find() ? m.group(1) : null
}.toSet()
Set<String> unregisteredTypedProperties = new TreeSet<>(typedProperties - registryProperties)
if (!unregisteredTypedProperties.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: typed configuration properties absent from ' +
"docs/registries/env-keys.yaml: ${unregisteredTypedProperties} — every " +
'bindable property needs a registry row carrying its official env ' +
'name, type, default, secret classification and required_when.')
}
Set<String> scopedRegistryProperties = registryProperties.findAll { String property ->
metadataScopes.keySet().any { property.startsWith(it) }
}.toSet()
Set<String> orphanedRegistryProperties =
new TreeSet<>(scopedRegistryProperties - typedProperties)
if (!orphanedRegistryProperties.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: docs/registries/env-keys.yaml declares properties that no ' +
"typed settings class binds any more: ${orphanedRegistryProperties} — " +
'remove the row or restore the property.')
}
// F. Every registered key has a consumer, or says out loud that it does not.
// Checks A-E each compare two SSOTs, and a row that appears in none of them falls
// through all of them: APP_CACHE_REDIS_TRUST_PEM and four namespace keys sat in the
// registry with no typed property, no application.yml reference and no .env entry,
// documented as if a deployment could still use them. A key nothing reads is worse
// than an undocumented one — an operator sets it, nothing happens, and the
// configuration looks correct.
Map<String, Map<String, String>> registryRows = [:]
String currentRow = null
registryFile.readLines().each { String line ->
def nameMatch = (line =~ /^\s*- name: (APP_[A-Z0-9_]+)/)
if (nameMatch.find()) {
currentRow = nameMatch.group(1)
registryRows[currentRow] = [:]
return
}
if (currentRow == null) {
return
}
def fieldMatch = (line =~ /^\s*([a-z_]+):\s*(\S.*)?$/)
if (fieldMatch.find()) {
registryRows[currentRow][fieldMatch.group(1)] = (fieldMatch.group(2) ?: '').trim()
}
}
Set<String> consumed = new TreeSet<>()
consumed.addAll(applicationAppReferences)
consumed.addAll(envAppKeys)
// A key can be read in ways checks A-D never look at: a module's own application.yml (the
// sample's, for one) and Java that names a secret directly, as SecretSourceValidator does.
// Counting only the composition root's yaml would report those as orphans, which is the
// opposite failure — a check that cries wolf gets an exclusion list and then gets ignored.
def appKeyPattern = ~/APP_[A-Z][A-Z0-9_]*/
rootProject.projectDir.eachFileRecurse { File candidate ->
if (!candidate.isFile()) {
return
}
boolean interesting =
(candidate.name == 'application.yml' && candidate.path.contains('/main/')) ||
(candidate.name.endsWith('.java') && candidate.path.contains('/src/main/'))
if (!interesting) {
return
}
def matcher = appKeyPattern.matcher(candidate.text)
while (matcher.find()) {
consumed << matcher.group()
}
}
Set<String> unconsumed = new TreeSet<>(registryRows.keySet().findAll { String name ->
Map<String, String> row = registryRows[name]
!consumed.contains(name) &&
!row.containsKey('property') &&
row['deprecated_orphaned'] != 'true'
})
// Enforced for the surfaces this branch owns; reported for the rest. A key nothing reads is
// a defect wherever it lives, but silently adopting another feature's backlog into a
// blocking gate is how a gate acquires an exclusion list. The rest are named on every run so
// they cannot be forgotten, and their owning branch turns them into failures here.
def enforcedPrefixes = ['APP_REDIS_', 'APP_CACHE_REDIS_', 'APP_RATE_LIMIT_REDIS_',
'APP_IDEMPOTENCY_REDIS_', 'APP_LEASE_REDIS_', 'APP_SESSION_REDIS_']
Set<String> unconsumedOwned =
new TreeSet<>(unconsumed.findAll { String name -> enforcedPrefixes.any { name.startsWith(it) } })
if (!unconsumedOwned.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: registered Redis keys that nothing reads — no typed property, ' +
'no application.yml reference, no src/.env entry, no Java consumer, ' +
"and not marked deprecated_orphaned: ${unconsumedOwned}. Wire the key " +
'to a consumer, or mark the row deprecated_orphaned with a ' +
'removal_deadline so a deployment still setting it is told rather ' +
'than silently ignored.')
}
Set<String> unconsumedElsewhere = new TreeSet<>(unconsumed - unconsumedOwned)
if (!unconsumedElsewhere.isEmpty()) {
logger.warn('verifyEnvKeys: registered keys outside the Redis surface that nothing ' +
"reads yet: ${unconsumedElsewhere} — owned by the branch that registered them.")
}
logger.lifecycle("verifyEnvKeys: OK — ${envKeys.size()} env keys, " +
"${requiredPlaceholders.size()} required placeholders covered, " +
"${applicationAppReferences.size()} application APP_ references registered, " +
"${typedProperties.size()} typed properties registered, " +
"${registryRows.size() - unconsumed.size()} rows with a consumer or a deprecation.")
}
}
// 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).")
}
}