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= 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> 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 messagingFailClosedEvidenceGuard = { String taskName, List relativePaths -> List evidenceFiles = relativePaths.collect { rootProject.file(it) } List 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:' } if (expectedProfileHash.isBlank()) { violations << 'missing -PmessagingProfileHash=sha256:' } 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 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> spotBugsAnalysisFailures = { File reportFile -> List 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() ? '' : className}" } errorsElement.Error.each { error -> String message = error.ErrorMessage.text().trim() failures << "analysis error ${message.isBlank() ? '' : 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 fixtures = [ clean : '', missing : 'fixture.MissingType', error : 'fixture analysis error', advisory: '' ] Map> 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 /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 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> 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 -> 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 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 resultDirectories -> List> 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 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 exactRootKeys = [ 'schemaVersion', 'sourceDigest', 'artifactDigest', 'producerTask', 'scenarioIds', 'counts', 'command', 'generatedAt', 'hashes', 'failures', 'skips', 'unsupportedClaims' ] as Set Set exactCountKeys = ['executed', 'passed', 'failed', 'skipped'] as Set Set exactHashKeys = ['profile', 'catalog', 'schema', 'settings'] as Set List 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 resultDirectories, List 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= -PmessagingArtifactDigest= ' + '-PmessagingProfileHash= --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 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 tokens = line.substring(gradleIndex + './gradlew '.length()) .split(/\s+/).toList() List 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 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 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] : '' 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 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> allowedProjectDependencies = moduleRegistry.modules.collectEntries { module -> String moduleName = (module.gradle_path as String).replaceFirst('^:', '') Set 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 declaredModules = subprojects.findAll { it.childProjects.isEmpty() } .collect { it.path.replaceFirst('^:', '') }.toSet() Set governedModules = allowedProjectDependencies.keySet() Set missingFromBuild = governedModules - declaredModules Set 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 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 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 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 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> validateJpaReadinessRegistry = { Map registry, String rawRegistry, Closure taskExists -> List violations = [] Set rootKeys = registry.keySet().collect { it as String }.toSet() Set 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 legacy = registry['legacy-adoption'] instanceof Map ? registry['legacy-adoption'] as Map : [:] Set 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 cards = registry.cards instanceof Map ? registry.cards as Map : [:] Set actualCardIds = cards.keySet().collect { it as String }.toSet() Set missingCards = expectedJpaReadinessCardIds - actualCardIds Set unknownCards = actualCardIds - expectedJpaReadinessCardIds if (!missingCards.isEmpty()) { violations << "missing card ids ${missingCards.toSorted()}" } if (!unknownCards.isEmpty()) { violations << "unknown card ids ${unknownCards.toSorted()}" } List rawCardKeys = [] def rawCardKeyMatcher = rawRegistry =~ /"(?jpa-[a-z0-9.-]+)"\s*:/ while (rawCardKeyMatcher.find()) { rawCardKeys << rawCardKeyMatcher.group('card') } Set duplicateRawCardKeys = rawCardKeys.countBy { it }.findAll { String ignored, Integer count -> count > 1 }.keySet() if (!duplicateRawCardKeys.isEmpty()) { violations << "duplicate raw card keys ${duplicateRawCardKeys.toSorted()}" } Set allowedCardKeys = [ 'state', 'schema-stream', 'prerequisites', 'external-prerequisites', 'readiness-task', 'support-tasks', 'required-evidence', 'evidence', 'dispatch-modes', 'migration' ] as Set Set allowedStates = ['selected', 'implemented-candidate', 'not-implemented'] as Set Set allowedSchemaStreams = ['none', 'owned', 'contributes-to-core'] as Set Map taskOwners = [:] Map migrationLocationOwners = [:] Map migrationHistoryOwners = [:] Map evidenceSelectorOwners = [:] Set actualOwnedMigrationCards = [] cards.each { String cardId, Object rawCard -> if (!(rawCard instanceof Map)) { violations << "${cardId}: card value must be an object" return } Map card = rawCard as Map Set 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 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 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 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 allowedEvidenceClaims = requiredEvidence .findAll { String requirement -> requirement != 'no-skip' } .toSet() Map migrationForEvidence = migrationNode instanceof Map ? migrationNode as Map : [:] 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 evidence = evidenceNode as Map Set evidenceKeys = evidence.keySet().collect { it as String }.toSet() Set expectedEvidenceKeys = ['scenarios', 'task-claims'] as Set if (evidenceKeys != expectedEvidenceKeys) { violations << "${cardId}: evidence keys must be exactly ${expectedEvidenceKeys}" } List scenarios = evidence.scenarios instanceof List ? evidence.scenarios as List : [] if (!(evidence.scenarios instanceof List)) { violations << "${cardId}: evidence scenarios must be a list" } List taskClaims = evidence['task-claims'] instanceof List ? evidence['task-claims'] as List : [] 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 scenario = rawScenario as Map Set 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 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 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 claim = rawClaim as Map Set 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 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 migration = migrationNode as Map Set expectedMigrationKeys = [ 'location', 'history-table', 'required-core-epoch', 'feature-revision', 'lifecycle-evidence' ] as Set Set 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 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 external = rawExternal as Map Set 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 visitState = [:].withDefault { 0 } Closure 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 card = cards[cardId] as Map 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 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 baseline = new JsonSlurper().parseText(raw) as Map Closure> copyRegistry = { new JsonSlurper().parseText(JsonOutput.toJson(baseline)) as Map } Closure expectViolation = { String scenario, String expectedText, Closure mutation, Closure taskExists = { String ignored -> true } -> Map candidate = copyRegistry() mutation(candidate) List 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 candidate -> (candidate.cards as Map)['jpa-primary-foundation-alias'] = (candidate.cards as Map)['jpa-primary-foundation'] }) expectViolation('duplicate-task', 'duplicate task', { Map 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 candidate -> ((candidate.cards as Map)['jpa-security-baseline'] as Map).prerequisites = ['jpa-does-not-exist'] }) expectViolation('cycle', 'prerequisite cycle', { Map candidate -> ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).prerequisites = ['jpa-security-baseline'] }) expectViolation('duplicate-location', 'duplicate migration location', { Map 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 ignored -> }, { String taskPath -> taskPath != ':adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest' }) expectViolation('missing-active-evidence', 'active card requires evidence', { Map candidate -> ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map) .remove('evidence') }) expectViolation('unknown-evidence-requirement', 'evidence covers unknown requirement', { Map 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 candidate -> Map card = (candidate.cards as Map)['jpa-observability-lifecycle'] as Map 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 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 registry try { registry = new JsonSlurper().parseText(raw) as Map } catch (RuntimeException ex) { throw new GradleException( "verifyJpaReadinessRegistry: registry is not valid JSON-compatible YAML", ex) } List 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 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 ?: ''}:${dependency.name}" } } } Closure 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 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 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 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 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 requiredPlaceholders = new TreeSet<>() Set 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 environmentSecretReferences = new TreeSet<>() def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/) while (sm.find()) { environmentSecretReferences << sm.group(1) } Set applicationAppReferences = new TreeSet<>( allPlaceholders.findAll { it.startsWith('APP_') }) applicationAppReferences.addAll(environmentSecretReferences) // A. Every required (no inline default) placeholder must exist in .env. Set 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 knownApplicationReferences = new TreeSet<>(allPlaceholders) knownApplicationReferences.addAll(environmentSecretReferences) Set 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 registryAppKeys = registryFile.readLines().findResults { String line -> def m = registryNamePattern.matcher(line) m.find() ? m.group(1) : null }.toSet() Set envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet() Set 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 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 A–D 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 metadataScopes = [ 'app.redis.': 'adapter/outbound/cache-redis' ] Set typedProperties = new TreeSet<>() Set 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 registryProperties = registryFile.readLines().findResults { String line -> def m = registryPropertyPattern.matcher(line) m.find() ? m.group(1) : null }.toSet() Set 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 scopedRegistryProperties = registryProperties.findAll { String property -> metadataScopes.keySet().any { property.startsWith(it) } }.toSet() Set 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> 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 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 unconsumed = new TreeSet<>(registryRows.keySet().findAll { String name -> Map 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 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 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 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 violations = [] int entryCount = 0 String section = null int entryIndent = -1 Map entry = null int entryLine = -1 Closure validate = { Map 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> entries = [] List entryLines = [] boolean inSection = false int entryIndent = -1 Map entry = null int entryLine = -1 Closure 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 violations = [] Set registeredClasses = new HashSet<>() entries.eachWithIndex { Map 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).") } }