messaging-superpowers-package 설계서/계획서 기반 구현. registry를 19 → 43 leaf로 확장하고 src/messaging 아래 24개 leaf를 등록. - core-api: M1 publish/consume + M2 batch·delayed·pause-resume - policy/transport-spi: 재시도 결정, DLQ orchestration, admission control, lifecycle - kafka·rabbit(Stable): contiguous commit, confirm/return 상관, 배치, 보안 설정 - pulsar·nats(Experimental): 기본 비활성, live 인증 없음을 코드로 기록 - outbox/inbox/claim-check: 트랜잭션 결합, lease, 무결성 검증 - admin: plan → approve → execute를 타입으로 강제 - 문서 9종, infra compose 7종, JMH 벤치마크 3종 검증: 아키텍처 게이트 3종 통과, 24개 leaf 전부 check 통과, messaging 테스트 604개 통과/0 실패. 미완: 계획서가 요구한 실 브로커 IT 40개 중 7개만 작성. Rabbit 13 / Outbox 6 / Inbox 4 / NATS·Pulsar·Share 5 / testkit 2 / starter·admin 3, 그리고 TLS·ACL 2개가 남음.
2522 lines
126 KiB
Groovy
2522 lines
126 KiB
Groovy
import groovy.json.JsonSlurper
|
||
import groovy.json.JsonOutput
|
||
import groovy.xml.XmlSlurper
|
||
import java.time.Duration
|
||
import java.time.Instant
|
||
import org.gradle.api.artifacts.dsl.LockMode
|
||
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
||
import org.gradle.api.tasks.bundling.AbstractArchiveTask
|
||
import org.gradle.api.tasks.bundling.Jar
|
||
import java.security.MessageDigest
|
||
|
||
plugins {
|
||
id 'org.springframework.boot' version '4.0.0' apply false
|
||
id 'io.spring.dependency-management' version '1.1.6' apply false
|
||
// feature-static-analysis-quality-contract — static analysis / code quality baseline.
|
||
id 'com.diffplug.spotless' version '8.6.0' apply false // D1 formatter (google-java-format)
|
||
id 'com.github.spotbugs' version '6.5.6' apply false // D3 bytecode bug finder (+ D4 FindSecBugs)
|
||
id 'net.ltgt.errorprone' version '5.1.0' apply false // D5 compile-time checker
|
||
}
|
||
|
||
// feature-build-release-supply-chain-contract D1/D9 — every archive carries an exact SemVer
|
||
// release coordinate plus the source revision that produced it. The MAJOR.MINOR.PATCH base can be
|
||
// supplied with -PreleaseVersion or RELEASE_VERSION. The revision can be supplied with
|
||
// -PgitRevision, GIT_SHA, or GITHUB_SHA; local builds read the current Git commit.
|
||
String releaseVersion = providers.gradleProperty('releaseVersion')
|
||
.orElse(providers.environmentVariable('RELEASE_VERSION'))
|
||
.getOrElse('0.0.1')
|
||
if (!(releaseVersion ==~ /\d+\.\d+\.\d+/)) {
|
||
throw new GradleException(
|
||
"releaseVersion must be MAJOR.MINOR.PATCH without a leading 'v', pre-release, or build metadata; got '${releaseVersion}'.")
|
||
}
|
||
|
||
def localGitRevision = providers.exec {
|
||
commandLine 'git', 'rev-parse', '--short=12', 'HEAD'
|
||
ignoreExitValue = true
|
||
}.standardOutput.asText.map { it.trim() }
|
||
String sourceRevision = providers.gradleProperty('gitRevision')
|
||
.orElse(providers.environmentVariable('GIT_SHA'))
|
||
.orElse(providers.environmentVariable('GITHUB_SHA'))
|
||
.orElse(localGitRevision)
|
||
.getOrElse('')
|
||
if (!(sourceRevision ==~ /(?i)[0-9a-f]{7,40}/)) {
|
||
throw new GradleException(
|
||
"A 7-40 character hexadecimal source revision is required; use -PgitRevision=<sha> when Git metadata is unavailable.")
|
||
}
|
||
sourceRevision = sourceRevision.toLowerCase(Locale.ROOT).take(12)
|
||
String traceableVersion = "${releaseVersion}+${sourceRevision}"
|
||
|
||
ext.releaseVersion = releaseVersion
|
||
ext.sourceRevision = sourceRevision
|
||
ext.traceableVersion = traceableVersion
|
||
|
||
// Messaging first-R2 task names are reserved early, but qualification is deliberately fail-closed.
|
||
// Follow-up owner tasks replace these skeleton actions only when matching tests write schema-valid,
|
||
// source/profile-bound, payload-free evidence. Merely placing a manifest on disk cannot pass.
|
||
Map<String, List<String>> messagingVerificationSkeletons = [
|
||
'verifyMessagingPollingOutboxR2': [
|
||
'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json'
|
||
],
|
||
'verifyMessagingKafkaProducerR2': [
|
||
'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json'
|
||
],
|
||
'verifyMessagingSecurityR2': [
|
||
'app-bootstrap/build/messaging-evidence/security-r2/manifest.json',
|
||
'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json',
|
||
'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json'
|
||
],
|
||
'verifyMessagingReleaseProfile': [
|
||
'build/messaging-evidence/contracts-schema/manifest.json',
|
||
'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json',
|
||
'app-bootstrap/build/messaging-evidence/cutover-rehearsal/manifest.json',
|
||
'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json',
|
||
'app-bootstrap/build/messaging-evidence/security-r2/manifest.json',
|
||
'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json',
|
||
'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json'
|
||
],
|
||
'verifyMessagingTargetBindingPreflight': [
|
||
'app-bootstrap/build/messaging-evidence/target-binding-preflight/manifest.json'
|
||
],
|
||
'verifyMessagingTargetBinding': [
|
||
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json'
|
||
],
|
||
'verifyMessagingDeploymentCutover': [
|
||
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json',
|
||
'app-bootstrap/build/messaging-evidence/deployment-cutover/manifest.json'
|
||
],
|
||
'verifyMessagingCleanupTargetBinding': [
|
||
'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json'
|
||
],
|
||
'verifyMessagingFinalR2Profile': [
|
||
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json',
|
||
'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json',
|
||
'build/messaging-evidence/final-r2-profile/manifest.json'
|
||
]
|
||
]
|
||
|
||
Closure<Void> messagingFailClosedEvidenceGuard = { String taskName, List<String> relativePaths ->
|
||
List<File> evidenceFiles = relativePaths.collect { rootProject.file(it) }
|
||
List<String> violations = evidenceFiles.findAll { !it.isFile() }.collect {
|
||
"missing evidence ${rootProject.relativePath(it)}"
|
||
}
|
||
|
||
String expectedSourceDigest = providers.gradleProperty('messagingSourceDigest').getOrElse('')
|
||
String expectedProfileHash = providers.gradleProperty('messagingProfileHash').getOrElse('')
|
||
if (expectedSourceDigest.isBlank()) {
|
||
violations << 'missing -PmessagingSourceDigest=sha256:<exact-source-digest>'
|
||
}
|
||
if (expectedProfileHash.isBlank()) {
|
||
violations << 'missing -PmessagingProfileHash=sha256:<exact-profile-hash>'
|
||
}
|
||
|
||
evidenceFiles.findAll { it.isFile() }.each { File evidenceFile ->
|
||
try {
|
||
def manifest = new JsonSlurper().parse(evidenceFile)
|
||
if (manifest.sourceDigest != expectedSourceDigest) {
|
||
violations << "${rootProject.relativePath(evidenceFile)} has wrong source digest"
|
||
}
|
||
if (manifest.hashes?.profile != expectedProfileHash) {
|
||
violations << "${rootProject.relativePath(evidenceFile)} has mismatched profile hash"
|
||
}
|
||
if ((manifest.counts?.skipped ?: 0) != 0 || !(manifest.skips instanceof List) ||
|
||
!manifest.skips.isEmpty()) {
|
||
violations << "${rootProject.relativePath(evidenceFile)} contains skipped evidence"
|
||
}
|
||
if ((manifest.counts?.failed ?: 0) != 0 || !(manifest.failures instanceof List) ||
|
||
!manifest.failures.isEmpty()) {
|
||
violations << "${rootProject.relativePath(evidenceFile)} contains failed evidence"
|
||
}
|
||
try {
|
||
Instant generatedAt = Instant.parse(manifest.generatedAt as String)
|
||
if (generatedAt.isBefore(Instant.now().minus(Duration.ofHours(24))) ||
|
||
generatedAt.isAfter(Instant.now().plus(Duration.ofMinutes(5)))) {
|
||
violations << "${rootProject.relativePath(evidenceFile)} is stale or future-dated"
|
||
}
|
||
} catch (RuntimeException ignored) {
|
||
violations << "${rootProject.relativePath(evidenceFile)} has invalid generatedAt"
|
||
}
|
||
} catch (RuntimeException ignored) {
|
||
violations << "${rootProject.relativePath(evidenceFile)} is not valid JSON evidence"
|
||
}
|
||
}
|
||
|
||
// Task 2 intentionally has no matching qualification Test tasks or complete schema validator.
|
||
// This unconditional violation prevents hand-written evidence from manufacturing an R2 PASS.
|
||
violations << 'qualification producer/tests and common-schema validator are not implemented'
|
||
throw new GradleException(
|
||
"${taskName}: FAIL_CLOSED — no R2 claim is available:\n ${violations.join('\n ')}")
|
||
}
|
||
|
||
messagingVerificationSkeletons.each { String taskName, List<String> evidencePaths ->
|
||
tasks.register(taskName) {
|
||
group = 'verification'
|
||
description = "Fail-closed Messaging qualification skeleton for ${taskName}."
|
||
inputs.files(evidencePaths.collect { rootProject.file(it) }).optional()
|
||
outputs.upToDateWhen { false }
|
||
doLast {
|
||
messagingFailClosedEvidenceGuard(taskName, evidencePaths)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or
|
||
// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the
|
||
// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
|
||
// platforms at MODULE scope (not the shared dependencyManagement block below) — keeping the
|
||
// strict-locking blast radius to the grpc module alone.
|
||
ext.grpcVersion = '1.68.1'
|
||
ext.protobufVersion = '3.25.5'
|
||
|
||
// Outbound objectstorage adapter (adapter:outbound:objectstorage) — the Spring Boot BOM does NOT
|
||
// manage software.amazon.awssdk:* versions, and this repo has no version catalog. Pin the AWS SDK
|
||
// v2 BOM here as the single SSOT so the objectstorage module imports software.amazon.awssdk:bom as
|
||
// a platform at MODULE scope (not the shared dependencyManagement block below) — mirroring the grpc
|
||
// approach above and keeping the strict-locking blast radius to the objectstorage module alone.
|
||
ext.awsSdkVersion = '2.30.0'
|
||
|
||
apply from: "${rootProject.projectDir}/gradle/archive-hygiene.gradle"
|
||
apply from: "${rootProject.projectDir}/gradle/public-path-snapshot.gradle"
|
||
apply from: "${rootProject.projectDir}/gradle/runtime-membership.gradle"
|
||
apply from: "${rootProject.projectDir}/gradle/junit-evidence.gradle"
|
||
apply from: "${rootProject.projectDir}/gradle/test-jvm-agents.gradle"
|
||
|
||
Closure<List<String>> spotBugsAnalysisFailures = { File reportFile ->
|
||
List<String> failures = []
|
||
if (!reportFile.isFile()) {
|
||
failures << "missing XML report ${reportFile}"
|
||
return failures
|
||
}
|
||
try {
|
||
XmlSlurper parser = new XmlSlurper(false, false)
|
||
parser.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true)
|
||
def report = parser.parse(reportFile)
|
||
def errors = report.Errors
|
||
if (errors.size() != 1) {
|
||
failures << "expected one Errors element in ${reportFile.name}"
|
||
return failures
|
||
}
|
||
def errorsElement = errors[0]
|
||
errorsElement.MissingClass.each { missingClass ->
|
||
String className = missingClass.text().trim()
|
||
failures << "missing analysis class ${className.isBlank() ? '<unnamed>' : className}"
|
||
}
|
||
errorsElement.Error.each { error ->
|
||
String message = error.ErrorMessage.text().trim()
|
||
failures << "analysis error ${message.isBlank() ? '<no message>' : message}"
|
||
}
|
||
[missingClasses: errorsElement.MissingClass.size(), errors: errorsElement.Error.size()].each {
|
||
String attribute, int observed ->
|
||
String declared = errorsElement.attributes()[attribute]?.toString()
|
||
if (!(declared ==~ /\d+/)) {
|
||
failures << "invalid ${attribute} count '${declared}'"
|
||
} else if (declared.toInteger() > observed) {
|
||
failures << "${declared} ${attribute} reported but only ${observed} detailed"
|
||
}
|
||
}
|
||
} catch (Exception ex) {
|
||
failures << "unreadable XML report: ${ex.message}"
|
||
}
|
||
failures
|
||
}
|
||
ext.spotBugsAnalysisFailures = spotBugsAnalysisFailures
|
||
|
||
def verifySpotBugsAnalysisFailureContract =
|
||
tasks.register('verifySpotBugsAnalysisFailureContract') {
|
||
group = 'verification'
|
||
description = 'Proves SpotBugs missing classes and analysis errors fail closed without promoting advisory bug findings.'
|
||
notCompatibleWithConfigurationCache(
|
||
'Exercises the root-owned SpotBugs XML verifier at execution time')
|
||
outputs.upToDateWhen { false }
|
||
doLast {
|
||
if (!rootProject.ext.has('spotBugsAnalysisFailures')) {
|
||
throw new GradleException(
|
||
'verifySpotBugsAnalysisFailureContract: analysis report verifier is not configured')
|
||
}
|
||
// Raw Closure on purpose. The parameterised form, wrapped across two lines, is
|
||
// valid Groovy and Gradle runs it, but the IDE's Gradle parser reads the trailing
|
||
// `>>` as the end of a block and reports a syntax error for the rest of the file.
|
||
def analysisFailures = rootProject.ext.spotBugsAnalysisFailures as Closure
|
||
Map<String, String> fixtures = [
|
||
clean : '<BugCollection><Errors errors="0" missingClasses="0"/></BugCollection>',
|
||
missing : '<BugCollection><Errors errors="0" missingClasses="1"><MissingClass>fixture.MissingType</MissingClass></Errors></BugCollection>',
|
||
error : '<BugCollection><Errors errors="1" missingClasses="0"><Error><ErrorMessage>fixture analysis error</ErrorMessage></Error></Errors></BugCollection>',
|
||
advisory: '<BugCollection><BugInstance type="DMI_RANDOM_USED_ONLY_ONCE"/><Errors errors="0" missingClasses="0"/></BugCollection>'
|
||
]
|
||
Map<String, List<String>> results = fixtures.collectEntries { String name, String xml ->
|
||
File fixture = new File(temporaryDir, "${name}.xml")
|
||
fixture.setText(xml, 'UTF-8')
|
||
[(name): analysisFailures(fixture)]
|
||
}
|
||
if (!results.clean.isEmpty() || !results.advisory.isEmpty() ||
|
||
!results.missing.any { it.contains('fixture.MissingType') } ||
|
||
!results.error.any { it.contains('fixture analysis error') }) {
|
||
throw new GradleException(
|
||
"verifySpotBugsAnalysisFailureContract: unexpected fixture results ${results}")
|
||
}
|
||
logger.lifecycle(
|
||
'verifySpotBugsAnalysisFailureContract: OK — clean and advisory bug-only reports pass; missing classes and analysis errors fail closed.')
|
||
}
|
||
}
|
||
|
||
allprojects {
|
||
group = 'dev.caskeleton'
|
||
version = rootProject.ext.traceableVersion
|
||
|
||
repositories {
|
||
mavenCentral()
|
||
}
|
||
}
|
||
|
||
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
||
apply plugin: 'java'
|
||
apply plugin: 'io.spring.dependency-management'
|
||
|
||
// feature-static-analysis-quality-contract — apply the static analysis baseline to every
|
||
// module (D8: extend the existing subprojects {} block rather than a convention plugin).
|
||
apply plugin: 'com.diffplug.spotless' // D1 formatter
|
||
apply plugin: 'checkstyle' // D2 style linter (Gradle built-in — no plugins{} id)
|
||
apply plugin: 'com.github.spotbugs' // D3 bytecode bug finder
|
||
apply plugin: 'net.ltgt.errorprone' // D5 compile-time checker
|
||
|
||
java {
|
||
toolchain {
|
||
languageVersion = JavaLanguageVersion.of(21)
|
||
}
|
||
}
|
||
|
||
// D8 — Gradle-default <project>/gradle.lockfile files are Renovate-compatible. STRICT means a
|
||
// missing or stale lock state fails resolution instead of silently selecting a new version.
|
||
dependencyLocking {
|
||
lockAllConfigurations()
|
||
lockMode = LockMode.STRICT
|
||
}
|
||
|
||
// D10 — normalize every archive, including Spring Boot's BootJar. Fixed timestamps/order and
|
||
// permissions remove host filesystem, locale-adjacent, and umask entropy from archive bytes.
|
||
tasks.withType(AbstractArchiveTask).configureEach {
|
||
preserveFileTimestamps = false
|
||
reproducibleFileOrder = true
|
||
dirPermissions { unix('755') }
|
||
filePermissions { unix('644') }
|
||
}
|
||
|
||
// D1/D9 — a JAR is independently traceable even when copied out of its container/release.
|
||
tasks.withType(Jar).configureEach {
|
||
manifest {
|
||
attributes(
|
||
'Implementation-Version': project.version.toString(),
|
||
'Build-Revision': rootProject.ext.sourceRevision
|
||
)
|
||
}
|
||
}
|
||
|
||
// Official Gradle pattern: resolve every resolvable configuration while --write-locks is set.
|
||
// This captures transitive compile/test/analysis dependencies, not only direct declarations.
|
||
tasks.register('resolveAndLockAll') {
|
||
group = 'build setup'
|
||
description = 'Resolves every configuration and writes this project\'s dependency lock state.'
|
||
notCompatibleWithConfigurationCache('Filters configurations at execution time')
|
||
doFirst {
|
||
if (!gradle.startParameter.writeDependencyLocks) {
|
||
throw new GradleException("${path} requires the --write-locks command-line flag.")
|
||
}
|
||
}
|
||
doLast {
|
||
configurations.findAll { it.canBeResolved }.each { it.resolve() }
|
||
}
|
||
}
|
||
|
||
// Unlike Gradle's diagnostic `dependencies` report, this task performs strict resolution and
|
||
// propagates a missing/stale lock entry as a non-zero build failure.
|
||
tasks.register('verifyDependencyLocks') {
|
||
group = 'verification'
|
||
description = 'Resolves every configuration and fails when strict dependency locks drift.'
|
||
notCompatibleWithConfigurationCache('Filters configurations at execution time')
|
||
doLast {
|
||
configurations.findAll { it.canBeResolved }.each { it.resolve() }
|
||
}
|
||
}
|
||
|
||
// Keep method parameter names in bytecode for Spring MVC @PathVariable/@RequestParam
|
||
// binding (rationale in README.md). ErrorProne (D5) hooks the same compile tasks: it
|
||
// auto-injects the JDK 16+ --add-exports/--add-opens forking args, so none are added here.
|
||
tasks.withType(JavaCompile).configureEach {
|
||
// Pinned, not inherited from the platform. Sources carry non-ASCII — Korean comments and
|
||
// em dashes inside string literals — so a builder whose default charset is not UTF-8
|
||
// compiles different bytes than this one does. It is also what the Gradle model hands the
|
||
// IDE as the project encoding; without it every imported project reports "no explicit
|
||
// encoding set".
|
||
options.encoding = 'UTF-8'
|
||
['-parameters', '-Werror', '-Xlint:deprecation', '-Xlint:unchecked'].each { String compilerArg ->
|
||
if (!options.compilerArgs.contains(compilerArg)) {
|
||
options.compilerArgs.add(compilerArg)
|
||
}
|
||
}
|
||
options.errorprone {
|
||
disableWarningsInGeneratedCode = true // D5 — MapStruct/Lombok generated code (errorprone README C5)
|
||
}
|
||
}
|
||
|
||
// D1 — google-java-format owns formatting + import order; spotlessApply auto-fixes,
|
||
// spotlessCheck (wired into check) verifies. CI must NEVER run spotlessApply.
|
||
spotless {
|
||
java {
|
||
googleJavaFormat('1.35.0')
|
||
importOrder()
|
||
removeUnusedImports()
|
||
}
|
||
}
|
||
|
||
// D2 — naming + logical ruleset; formatter-owned modules suppressed in the XML.
|
||
checkstyle {
|
||
toolVersion = '13.5.0'
|
||
configFile = rootProject.file('config/checkstyle/checkstyle.xml')
|
||
configDirectory = rootProject.file('config/checkstyle')
|
||
ignoreFailures = false
|
||
// No warning-tier checks in the default build. Javadoc coverage is a documentation backlog,
|
||
// not a signal to print on every migration/build run.
|
||
maxWarnings = Integer.MAX_VALUE
|
||
}
|
||
// §4 routing — Checkstyle findings in both main and test sources are blocking.
|
||
|
||
// D3/D4 — bytecode bug finder; FindSecBugs plugin loaded via spotbugsPlugins below.
|
||
// reportLevel='high' implements §4 "blocking (high priority)": only high-confidence findings
|
||
// block, which keeps the gate signal-rich (the medium tier is dominated by EI_EXPOSE_REP
|
||
// defensive-copy noise on DI'd collaborators). effort left at default (UNSUPPORTED_IMPL_DECISION
|
||
// — strictness is a user trade-off; default is functionally valid). Confirmed false positives
|
||
// go in config/spotbugs/exclude.xml.
|
||
spotbugs {
|
||
toolVersion = '4.10.2'
|
||
reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH')
|
||
excludeFilter = rootProject.file('config/spotbugs/exclude.xml')
|
||
}
|
||
sourceSets.configureEach { sourceSet ->
|
||
String taskName = "spotbugs${sourceSet.name.capitalize()}"
|
||
tasks.named(taskName, com.github.spotbugs.snom.SpotBugsTask) {
|
||
auxClassPaths.from(sourceSet.runtimeClasspath - sourceSet.output)
|
||
def xmlAnalysisReport = reports.maybeCreate('xml')
|
||
xmlAnalysisReport.required.set(true)
|
||
doLast {
|
||
List<String> analysisFailures =
|
||
spotBugsAnalysisFailures(xmlAnalysisReport.outputLocation.get().asFile)
|
||
if (!analysisFailures.isEmpty()) {
|
||
throw new GradleException(
|
||
"${path}: SpotBugs analysis incomplete:\n " +
|
||
analysisFailures.join('\n '))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// SpotBugs 4.10.2 needs commons-lang3 3.20.0 (uses org.apache.commons.lang3.Strings); the
|
||
// Spring Boot BOM otherwise pins commons-lang3 to 3.17.0 — and io.spring.dependency-management
|
||
// overrides resolutionStrategy.force — so the analysis worker crashes with NoClassDefFoundError.
|
||
// Override the BOM-managed version property (the documented Spring mechanism). No production
|
||
// module imports commons.lang3, so this only affects the SpotBugs tool classpath in practice.
|
||
ext['commons-lang3.version'] = '3.20.0'
|
||
// Netty security floor. The Spring Boot BOM pinned 4.2.7.Final, which sits inside two published
|
||
// advisory ranges that reach productionRuntimeClasspath, not just a test tool classpath:
|
||
// - CVE-2026-42577, netty-transport-native-epoll >=4.2.0,<4.2.13 (GHSA-rwm7-x88c-3g2p)
|
||
// - CVE-2026-59901, netty-codec-compression >=4.2.0,<4.2.16 (GHSA-558v-64gr-wgg4)
|
||
// Netty is shared runtime surface here — HTTP, Reactor Netty and the Redis driver all sit on it
|
||
// — so the fix is the BOM-managed version property rather than a per-artifact exclusion, and it
|
||
// is the latest 4.2 patch rather than the exact advisory floor. Regenerate every lockfile after
|
||
// changing this (`./gradlew resolveAndLockAll --write-locks`).
|
||
ext['netty.version'] = '4.2.17.Final'
|
||
// §4 routing — SpotBugs findings in both main and test sources are blocking.
|
||
|
||
dependencyManagement {
|
||
imports {
|
||
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
|
||
}
|
||
}
|
||
|
||
dependencies {
|
||
// The messaging platform leaves own a broker-neutral public contract. Keeping their test
|
||
// classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no Spring
|
||
// dependency" verifiable rather than aspirational; leaves that genuinely need a Spring
|
||
// test context add it in their own build file.
|
||
if (project.path in [':domain-core', ':application-core', ':shared-contract'] ||
|
||
project.path.startsWith(':messaging:')) {
|
||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||
testImplementation 'org.assertj:assertj-core'
|
||
} else {
|
||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
|
||
}
|
||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||
|
||
spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0' // D4 code-level security
|
||
errorprone 'com.google.errorprone:error_prone_core:2.49.0' // D5 compile-time checker
|
||
}
|
||
|
||
// The three messaging leaves that carry JMH benchmarks get a `jmh` source set. It is a source
|
||
// set rather than a plugin because the benchmarks are compiled and reviewed on every build but
|
||
// only *run* on demand: a benchmark that stops compiling is a defect, while a benchmark that
|
||
// runs in CI is a flaky test measuring the build agent.
|
||
if (project.path in [':messaging:messaging-kafka',
|
||
':messaging:messaging-rabbit',
|
||
':messaging:messaging-testkit']) {
|
||
sourceSets {
|
||
jmh {
|
||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||
runtimeClasspath += sourceSets.main.output + sourceSets.test.output
|
||
}
|
||
}
|
||
configurations {
|
||
jmhImplementation.extendsFrom implementation, testImplementation
|
||
jmhRuntimeOnly.extendsFrom runtimeOnly, testRuntimeOnly
|
||
}
|
||
dependencies {
|
||
jmhImplementation 'org.openjdk.jmh:jmh-core:1.37'
|
||
jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
|
||
// ErrorProne's -Werror would reject JMH's generated sources, which the platform does
|
||
// not own and cannot fix.
|
||
jmhAnnotationProcessor 'com.google.errorprone:error_prone_core:2.49.0'
|
||
}
|
||
tasks.named('compileJmhJava') {
|
||
options.errorprone.enabled = false
|
||
options.compilerArgs.removeAll { it == '-Werror' }
|
||
}
|
||
// JMH's annotation processor emits the generated harness into this source set, and its
|
||
// generated code trips DLS_DEAD_LOCAL_STORE by design (the dead stores are how it defeats
|
||
// dead-code elimination). Analysing code the platform neither wrote nor can fix would make
|
||
// the gate unactionable, so the jmh source set is excluded from the bug and style checks.
|
||
// The benchmarks themselves are still compiled, which is what catches a real breakage.
|
||
tasks.named('spotbugsJmh') { enabled = false }
|
||
tasks.named('checkstyleJmh') { enabled = false }
|
||
tasks.register('jmh', JavaExec) {
|
||
group = 'verification'
|
||
description = 'Runs the JMH benchmarks in this leaf.'
|
||
classpath = sourceSets.jmh.runtimeClasspath
|
||
mainClass = 'org.openjdk.jmh.Main'
|
||
}
|
||
}
|
||
|
||
// feature-ci-quality-gates-contract §4 (D7) — the main release gate EXCLUDES the flaky
|
||
// quarantine bucket so a quarantined test can never block merge. Quarantined tests carry
|
||
// JUnit's built-in @Tag("quarantine"); they run separately via `quarantineTest` (non-blocking)
|
||
// and their 14-day sunset is enforced by verifyQuarantineSunset. With zero quarantined tests
|
||
// (the skeleton default) excludeTags is a no-op.
|
||
tasks.named('test') {
|
||
useJUnitPlatform {
|
||
excludeTags 'quarantine'
|
||
}
|
||
}
|
||
|
||
// feature-ci-quality-gates-contract §4 (D7/D9) — flaky quarantine bucket. Runs ONLY
|
||
// @Tag("quarantine") tests, isolated from `check`, never blocking the build (ignoreFailures).
|
||
// failOnNoDiscoveredTests=false so the empty bucket (skeleton ships zero flaky tests) passes.
|
||
tasks.register('quarantineTest', Test) {
|
||
group = 'verification'
|
||
description = 'Flaky-test quarantine bucket (feature-ci-quality-gates-contract §4): runs only ' +
|
||
'@Tag("quarantine") tests, non-blocking, isolated from the release gate.'
|
||
testClassesDirs = sourceSets.test.output.classesDirs
|
||
classpath = sourceSets.test.runtimeClasspath
|
||
useJUnitPlatform {
|
||
includeTags 'quarantine'
|
||
}
|
||
ignoreFailures = true
|
||
failOnNoDiscoveredTests = false
|
||
// Always re-run; a flaky bucket must never serve a stale UP-TO-DATE result.
|
||
outputs.upToDateWhen { false }
|
||
// Pin UTC like the main test task for host-locale independence.
|
||
jvmArgs '-Duser.timezone=UTC'
|
||
}
|
||
|
||
tasks.named('check') {
|
||
dependsOn verifySpotBugsAnalysisFailureContract
|
||
dependsOn rootProject.tasks.named('verifyCleanArchitectureDependencies')
|
||
dependsOn rootProject.tasks.named('verifyRuntimeModuleMembership')
|
||
dependsOn rootProject.tasks.named('verifyEnvKeys')
|
||
dependsOn rootProject.tasks.named('verifyNoStaleTraceableJars')
|
||
dependsOn rootProject.tasks.named('verifyOneTypePerFile')
|
||
dependsOn rootProject.tasks.named('verifyTrivyignore')
|
||
dependsOn rootProject.tasks.named('verifyQuarantineSunset')
|
||
}
|
||
}
|
||
|
||
Map<String, Provider<Directory>> conditionalTransportEvidence = [
|
||
'conditional-transport-graphql':
|
||
project(':adapter:inbound:graphql').layout.buildDirectory.dir(
|
||
'test-results/graphqlTransportQualificationTest'),
|
||
'conditional-transport-grpc':
|
||
project(':adapter:inbound:grpc').layout.buildDirectory.dir(
|
||
'test-results/grpcTransportQualificationTest'),
|
||
'conditional-transport-websocket':
|
||
project(':adapter:inbound:websocket').layout.buildDirectory.dir(
|
||
'test-results/websocketTransportQualificationTest'),
|
||
'conditional-transport-composition':
|
||
project(':app-bootstrap').layout.buildDirectory.dir(
|
||
'test-results/conditionalTransportCompositionTest')
|
||
]
|
||
tasks.register('conditionalTransportQualification') {
|
||
group = 'verification'
|
||
description = 'Runs the exact no-skip GraphQL, gRPC, and WebSocket P1 qualification evidence.'
|
||
dependsOn ':adapter:inbound:graphql:graphqlTransportQualificationTest'
|
||
dependsOn ':adapter:inbound:grpc:grpcTransportQualificationTest'
|
||
dependsOn ':adapter:inbound:websocket:websocketTransportQualificationTest'
|
||
dependsOn ':app-bootstrap:conditionalTransportCompositionTest'
|
||
dependsOn tasks.named('verifyRuntimeModuleMembership')
|
||
inputs.files(conditionalTransportEvidence.values())
|
||
doLast {
|
||
conditionalTransportEvidence.each { String evidenceName, Provider<Directory> directory ->
|
||
rootProject.ext.verifyNoSkipJUnitXml(
|
||
evidenceName, directory.get().asFile)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Task 6 replaces only the contract/schema skeletons with real, no-match-failing Test lanes.
|
||
// The manifest is payload-free and is rebuilt only after exact source/artifact/profile properties
|
||
// and every selected Task 3-6 test have passed in the current invocation.
|
||
def messagingEvidenceResultRoot = layout.buildDirectory.dir('test-results/messaging-evidence')
|
||
|
||
def messagingEvidenceFile = layout.buildDirectory.file(
|
||
'messaging-evidence/contracts-schema/manifest.json')
|
||
def messagingProfileFile = file('config/messaging/profile-compatibility.yaml')
|
||
def messagingDigestProperty = { String propertyName ->
|
||
String value = providers.gradleProperty(propertyName).getOrElse('')
|
||
if (!(value ==~ /sha256:[a-f0-9]{64}/)) {
|
||
throw new GradleException(
|
||
"-P${propertyName}=sha256:<64-lowercase-hex> is required for Messaging evidence.")
|
||
}
|
||
value
|
||
}
|
||
def messagingSha256Bytes = { byte[] bytes ->
|
||
'sha256:' + java.util.HexFormat.of().formatHex(
|
||
MessageDigest.getInstance('SHA-256').digest(bytes))
|
||
}
|
||
def messagingSha256FileSet = { String domain, List<File> files ->
|
||
MessageDigest digest = MessageDigest.getInstance('SHA-256')
|
||
digest.update(domain.getBytes(java.nio.charset.StandardCharsets.UTF_8))
|
||
digest.update((byte) 0)
|
||
files.sort { rootProject.relativePath(it) }.each { File input ->
|
||
if (!input.isFile()) {
|
||
throw new GradleException(
|
||
"Messaging evidence input is missing: ${rootProject.relativePath(input)}")
|
||
}
|
||
byte[] path = rootProject.relativePath(input)
|
||
.getBytes(java.nio.charset.StandardCharsets.UTF_8)
|
||
byte[] content = input.bytes
|
||
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(path.length).array())
|
||
digest.update(path)
|
||
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(content.length).array())
|
||
digest.update(content)
|
||
}
|
||
'sha256:' + java.util.HexFormat.of().formatHex(digest.digest())
|
||
}
|
||
|
||
def prepareMessagingContractEvidence = tasks.register('prepareMessagingContractEvidence') {
|
||
group = 'verification'
|
||
outputs.upToDateWhen { false }
|
||
doLast {
|
||
File output = messagingEvidenceFile.get().asFile
|
||
if (output.exists() && !output.delete()) {
|
||
throw new GradleException("Could not delete stale Messaging evidence ${output}")
|
||
}
|
||
messagingDigestProperty('messagingSourceDigest')
|
||
messagingDigestProperty('messagingArtifactDigest')
|
||
String suppliedProfile = messagingDigestProperty('messagingProfileHash')
|
||
String exactProfile = messagingSha256Bytes(messagingProfileFile.bytes)
|
||
if (suppliedProfile != exactProfile) {
|
||
throw new GradleException(
|
||
"messagingProfileHash does not match exact config/messaging/profile-compatibility.yaml bytes.")
|
||
}
|
||
}
|
||
}
|
||
|
||
def messagingEvidenceFromXml = { List<String> resultDirectories ->
|
||
List<Map<String, String>> cases = []
|
||
resultDirectories.each { String directory ->
|
||
File resultDirectory = messagingEvidenceResultRoot.get().dir(directory).asFile
|
||
fileTree(resultDirectory).matching { include 'TEST-*.xml' }.files.sort().each { File xml ->
|
||
def suite = new XmlSlurper(false, false).parse(xml)
|
||
suite.testcase.each { testCase ->
|
||
boolean failed = !testCase.failure.isEmpty() || !testCase.error.isEmpty()
|
||
boolean skipped = !testCase.skipped.isEmpty()
|
||
String simpleClass = testCase.@classname.text().tokenize('.').last()
|
||
String rawId = "${simpleClass}.${testCase.@name.text()}"
|
||
String scenarioId = rawId
|
||
.replace('()', '')
|
||
.replaceAll('[^A-Za-z0-9._:-]', '-')
|
||
.replaceAll('-+', '-')
|
||
cases << [id: scenarioId, failed: failed.toString(), skipped: skipped.toString()]
|
||
}
|
||
}
|
||
}
|
||
if (cases.isEmpty()) {
|
||
throw new GradleException('Messaging qualification XML contains no discovered test cases.')
|
||
}
|
||
List<String> scenarioIds = cases.collect { it.id }.sort()
|
||
if (scenarioIds.toSet().size() != scenarioIds.size()) {
|
||
throw new GradleException('Messaging qualification scenario IDs are not unique.')
|
||
}
|
||
int failed = cases.count { it.failed == 'true' }
|
||
int skipped = cases.count { it.skipped == 'true' }
|
||
[
|
||
scenarioIds: scenarioIds,
|
||
counts: [
|
||
executed: cases.size(),
|
||
passed: cases.size() - failed - skipped,
|
||
failed: failed,
|
||
skipped: skipped
|
||
]
|
||
]
|
||
}
|
||
|
||
def validateMessagingEvidenceStructure = { Map manifest, String expectedProducer ->
|
||
Set<String> exactRootKeys = [
|
||
'schemaVersion', 'sourceDigest', 'artifactDigest', 'producerTask', 'scenarioIds',
|
||
'counts', 'command', 'generatedAt', 'hashes', 'failures', 'skips',
|
||
'unsupportedClaims'
|
||
] as Set
|
||
Set<String> exactCountKeys = ['executed', 'passed', 'failed', 'skipped'] as Set
|
||
Set<String> exactHashKeys = ['profile', 'catalog', 'schema', 'settings'] as Set
|
||
List<String> violations = []
|
||
if (manifest.keySet() != exactRootKeys) {
|
||
violations << 'root fields do not match the common manifest schema'
|
||
}
|
||
if (manifest.schemaVersion != 1 || manifest.producerTask != expectedProducer) {
|
||
violations << 'schemaVersion or producerTask is wrong'
|
||
}
|
||
['sourceDigest', 'artifactDigest'].each { String field ->
|
||
if (!(manifest[field] instanceof String) ||
|
||
!(manifest[field] ==~ /sha256:[a-f0-9]{64}/)) {
|
||
violations << "${field} is not a canonical SHA-256"
|
||
}
|
||
}
|
||
if (!(manifest.scenarioIds instanceof List) || manifest.scenarioIds.isEmpty() ||
|
||
manifest.scenarioIds.toSet().size() != manifest.scenarioIds.size() ||
|
||
manifest.scenarioIds.any {
|
||
!(it instanceof String) ||
|
||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
|
||
}) {
|
||
violations << 'scenarioIds violate the common schema'
|
||
}
|
||
if (!(manifest.counts instanceof Map) || manifest.counts.keySet() != exactCountKeys ||
|
||
!(manifest.counts.executed instanceof Integer) || manifest.counts.executed < 1 ||
|
||
manifest.counts.values().any { !(it instanceof Integer) || it < 0 } ||
|
||
manifest.counts.executed !=
|
||
manifest.counts.passed + manifest.counts.failed + manifest.counts.skipped) {
|
||
violations << 'counts are invalid or inconsistent'
|
||
}
|
||
if (manifest.counts?.failed != 0 || manifest.counts?.skipped != 0 ||
|
||
manifest.failures != [] || manifest.skips != []) {
|
||
violations << 'failed or skipped qualification cannot produce PASS evidence'
|
||
}
|
||
if (!(manifest.hashes instanceof Map) || manifest.hashes.keySet() != exactHashKeys ||
|
||
manifest.hashes.values().any {
|
||
!(it instanceof String) || !(it ==~ /sha256:[a-f0-9]{64}/)
|
||
}) {
|
||
violations << 'hashes violate the common schema'
|
||
}
|
||
if (!(manifest.command instanceof String) || manifest.command.isBlank() ||
|
||
manifest.command.length() > 2048) {
|
||
violations << 'command is missing or unbounded'
|
||
}
|
||
try {
|
||
Instant.parse(manifest.generatedAt as String)
|
||
} catch (RuntimeException ignored) {
|
||
violations << 'generatedAt is not UTC date-time evidence'
|
||
}
|
||
if (!(manifest.unsupportedClaims instanceof List) ||
|
||
manifest.unsupportedClaims.toSet().size() != manifest.unsupportedClaims.size() ||
|
||
manifest.unsupportedClaims.any {
|
||
!(it instanceof String) ||
|
||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
|
||
}) {
|
||
violations << 'unsupportedClaims violate the common schema'
|
||
}
|
||
if (!violations.isEmpty()) {
|
||
throw new GradleException(
|
||
"Messaging evidence fails the common schema structural validator:\n " +
|
||
violations.join('\n '))
|
||
}
|
||
}
|
||
|
||
def writeMessagingEvidence = {
|
||
String producerTask, List<String> resultDirectories, List<String> commandTasks ->
|
||
Map result = messagingEvidenceFromXml(resultDirectories)
|
||
Map manifest = [
|
||
schemaVersion: 1,
|
||
sourceDigest: messagingDigestProperty('messagingSourceDigest'),
|
||
artifactDigest: messagingDigestProperty('messagingArtifactDigest'),
|
||
producerTask: producerTask,
|
||
scenarioIds: result.scenarioIds,
|
||
counts: result.counts,
|
||
command: './gradlew ' + commandTasks.join(' ') +
|
||
' -PmessagingSourceDigest=<sha256> -PmessagingArtifactDigest=<sha256> ' +
|
||
'-PmessagingProfileHash=<exact-sha256> --console=plain',
|
||
generatedAt: Instant.now().toString(),
|
||
hashes: [
|
||
profile: messagingSha256Bytes(messagingProfileFile.bytes),
|
||
catalog: messagingSha256FileSet(
|
||
'ca-skeleton.messaging.evidence.catalog.v1',
|
||
[file('config/messaging/readiness-cards.yaml')]),
|
||
schema: messagingSha256FileSet(
|
||
'ca-skeleton.messaging.evidence.schema-set.v1',
|
||
[
|
||
file('shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json'),
|
||
file('sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json')
|
||
] + fileTree(
|
||
'adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12'
|
||
).files.toList()),
|
||
settings: messagingSha256FileSet(
|
||
'ca-skeleton.messaging.evidence.settings.v1',
|
||
[
|
||
file('adapter/outbound/messaging/build.gradle'),
|
||
file('adapter/outbound/messaging/gradle.lockfile')
|
||
])
|
||
],
|
||
failures: [],
|
||
skips: [],
|
||
unsupportedClaims: [
|
||
'consumer-compatibility-full-suite',
|
||
'durable-outbox-r2',
|
||
'kafka-acknowledged-r2',
|
||
'regex-engine-timeout',
|
||
'remote-schema-resolution'
|
||
]
|
||
]
|
||
validateMessagingEvidenceStructure(manifest, producerTask)
|
||
File commonSchema =
|
||
file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
|
||
if (!commonSchema.isFile()) {
|
||
throw new GradleException('Common Messaging evidence schema is missing.')
|
||
}
|
||
File output = messagingEvidenceFile.get().asFile
|
||
output.parentFile.mkdirs()
|
||
output.text = JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + System.lineSeparator()
|
||
Map reloaded = new JsonSlurper().parse(output) as Map
|
||
validateMessagingEvidenceStructure(reloaded, producerTask)
|
||
logger.lifecycle(
|
||
"${producerTask}: wrote payload-free evidence with ${result.counts.executed} scenarios.")
|
||
}
|
||
|
||
def verifyMessagingJsonSchemaV1 = tasks.register('verifyMessagingJsonSchemaV1') {
|
||
group = 'verification'
|
||
description = 'Qualifies the deterministic local Draft 2020-12 envelope candidate.'
|
||
dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest'
|
||
dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph'
|
||
outputs.file(messagingEvidenceFile)
|
||
outputs.upToDateWhen { false }
|
||
doLast {
|
||
writeMessagingEvidence(
|
||
'verifyMessagingJsonSchemaV1',
|
||
['json-schema'],
|
||
[':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
|
||
'verifyMessagingJsonSchemaV1'])
|
||
}
|
||
}
|
||
|
||
def validateMessagingJsonSchemaV1EvidenceManifestSchema =
|
||
tasks.register('validateMessagingJsonSchemaV1EvidenceManifestSchema', JavaExec) {
|
||
group = 'verification'
|
||
description =
|
||
'Validates the exact generated JSON qualification manifest bytes against the common Draft 2020-12 schema.'
|
||
dependsOn verifyMessagingJsonSchemaV1
|
||
classpath =
|
||
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
|
||
mainClass =
|
||
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
|
||
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
|
||
.absolutePath,
|
||
messagingEvidenceFile.get().asFile.absolutePath
|
||
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
|
||
inputs.file(messagingEvidenceFile)
|
||
outputs.upToDateWhen { false }
|
||
}
|
||
verifyMessagingJsonSchemaV1.configure {
|
||
finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema
|
||
}
|
||
|
||
def verifyMessagingContracts = tasks.register('verifyMessagingContracts') {
|
||
group = 'verification'
|
||
description = 'Qualifies the closed Task 3-6 contract, catalog, binding and schema candidate.'
|
||
dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema
|
||
dependsOn ':application-core:messagingApplicationContractQualificationTest'
|
||
dependsOn ':shared-contract:messagingSharedSchemaQualificationTest'
|
||
dependsOn ':sample-portfolio:messagingSampleContractQualificationTest'
|
||
dependsOn ':adapter:outbound:messaging:messagingCompiledContractsQualificationTest'
|
||
dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest'
|
||
dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph'
|
||
outputs.file(messagingEvidenceFile)
|
||
outputs.upToDateWhen { false }
|
||
doLast {
|
||
writeMessagingEvidence(
|
||
'verifyMessagingContracts',
|
||
['application', 'shared', 'sample', 'compiled', 'json-schema'],
|
||
[
|
||
':application-core:messagingApplicationContractQualificationTest',
|
||
':shared-contract:messagingSharedSchemaQualificationTest',
|
||
':sample-portfolio:messagingSampleContractQualificationTest',
|
||
':adapter:outbound:messaging:messagingCompiledContractsQualificationTest',
|
||
':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
|
||
'verifyMessagingContracts'
|
||
])
|
||
}
|
||
}
|
||
|
||
def validateMessagingContractsEvidenceManifestSchema =
|
||
tasks.register('validateMessagingContractsEvidenceManifestSchema', JavaExec) {
|
||
group = 'verification'
|
||
description =
|
||
'Validates the exact generated combined qualification manifest bytes against the common Draft 2020-12 schema.'
|
||
dependsOn verifyMessagingContracts
|
||
classpath =
|
||
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
|
||
mainClass =
|
||
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
|
||
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
|
||
.absolutePath,
|
||
messagingEvidenceFile.get().asFile.absolutePath
|
||
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
|
||
inputs.file(messagingEvidenceFile)
|
||
outputs.upToDateWhen { false }
|
||
}
|
||
verifyMessagingContracts.configure {
|
||
finalizedBy validateMessagingContractsEvidenceManifestSchema
|
||
}
|
||
|
||
// One explicit command regenerates every module's Gradle-default lockfile.
|
||
tasks.register('resolveAndLockAll') {
|
||
group = 'build setup'
|
||
description = 'Regenerates dependency locks for all subprojects (requires --write-locks).'
|
||
dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('resolveAndLockAll') }
|
||
}
|
||
|
||
tasks.register('verifyDependencyLocks') {
|
||
group = 'verification'
|
||
description = 'Verifies strict dependency lock state for all subprojects.'
|
||
dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('verifyDependencyLocks') }
|
||
}
|
||
|
||
// feature-developer-experience-contract D3 — one ordered first-run entrypoint. Each stage is a
|
||
// separate task so the task name and exit code identify the failed phase without log archaeology.
|
||
def repositoryDir = rootProject.projectDir.parentFile
|
||
def baseComposeFile = new File(repositoryDir, 'docker-compose.yml')
|
||
def localComposeFile = new File(repositoryDir, 'docker-compose.local.yml')
|
||
def composeCommand = ['docker', 'compose', '-f', baseComposeFile.absolutePath,
|
||
'-f', localComposeFile.absolutePath]
|
||
|
||
def bootstrapCompile = tasks.register('bootstrapCompile') {
|
||
group = 'developer experience'
|
||
description = 'Stage 1/5: compiles every main and test source set as a local sanity check.'
|
||
dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('compileTestJava') }
|
||
}
|
||
|
||
def bootstrapDockerPreflight = tasks.register('bootstrapDockerPreflight', Exec) {
|
||
group = 'developer experience'
|
||
description = 'Checks that the Docker CLI can reach a running Docker daemon.'
|
||
commandLine 'docker', 'info'
|
||
ignoreExitValue = true
|
||
standardOutput = new ByteArrayOutputStream()
|
||
errorOutput = new ByteArrayOutputStream()
|
||
doLast {
|
||
if (executionResult.get().exitValue != 0) {
|
||
throw new GradleException(
|
||
'bootstrap: Docker가 필요합니다. Docker Desktop/daemon을 시작한 뒤 ' +
|
||
'`docker info`가 성공하는지 확인하세요.\n' + errorOutput.toString())
|
||
}
|
||
}
|
||
}
|
||
bootstrapDockerPreflight.configure { dependsOn bootstrapCompile }
|
||
|
||
def bootstrapDependencies = tasks.register('bootstrapDependencies', Exec) {
|
||
group = 'developer experience'
|
||
description = 'Stage 2/5: starts the local PostgreSQL dependency and waits for readiness.'
|
||
commandLine composeCommand + ['up', '-d', '--wait', 'db']
|
||
}
|
||
bootstrapDependencies.configure { dependsOn bootstrapCompile }
|
||
bootstrapDependencies.configure { dependsOn bootstrapDockerPreflight }
|
||
|
||
def bootstrapMigrateAndStart = tasks.register('bootstrapMigrateAndStart', Exec) {
|
||
group = 'developer experience'
|
||
description = 'Stage 3/5: builds/starts the app; startup Flyway must finish before health is ready.'
|
||
commandLine composeCommand + ['up', '-d', '--build', '--wait', 'app']
|
||
}
|
||
bootstrapMigrateAndStart.configure { dependsOn bootstrapDependencies }
|
||
|
||
def bootstrapSampleContract = tasks.register('bootstrapSampleContract') {
|
||
group = 'developer experience'
|
||
description = 'Stage 4/5: verifies the delegated sample production-isolation/build contract.'
|
||
dependsOn project(':app-bootstrap').tasks.named('bootstrapSampleContract')
|
||
}
|
||
bootstrapSampleContract.configure { dependsOn bootstrapMigrateAndStart }
|
||
|
||
def bootstrapSmoke = tasks.register('bootstrapSmoke') {
|
||
group = 'developer experience'
|
||
description = 'Stage 5/5: requires HTTP 200 and status=UP from GET /api/healthcheck.'
|
||
doLast {
|
||
URI endpoint = URI.create('http://localhost:8080/api/healthcheck')
|
||
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60)
|
||
String lastFailure = 'no response'
|
||
while (System.nanoTime() < deadline) {
|
||
HttpURLConnection connection = null
|
||
try {
|
||
connection = (HttpURLConnection) endpoint.toURL().openConnection()
|
||
connection.connectTimeout = 2_000
|
||
connection.readTimeout = 2_000
|
||
connection.requestMethod = 'GET'
|
||
int status = connection.responseCode
|
||
String body = status >= 200 && status < 400 ? connection.inputStream.text :
|
||
connection.errorStream?.text
|
||
if (status == 200 && body != null && body.contains('"status":"UP"')) {
|
||
logger.lifecycle('bootstrapSmoke: OK — GET /api/healthcheck returned HTTP 200 and status=UP.')
|
||
return
|
||
}
|
||
lastFailure = "HTTP ${status}: ${body}"
|
||
} catch (IOException ex) {
|
||
lastFailure = ex.message
|
||
} finally {
|
||
connection?.disconnect()
|
||
}
|
||
sleep(1_000)
|
||
}
|
||
throw new GradleException(
|
||
"bootstrapSmoke: /api/healthcheck did not become healthy within 60s; last result: ${lastFailure}")
|
||
}
|
||
}
|
||
bootstrapSmoke.configure { dependsOn bootstrapSampleContract }
|
||
|
||
tasks.register('bootstrap') {
|
||
group = 'developer experience'
|
||
description = 'Runs the complete five-stage local bootstrap contract.'
|
||
dependsOn bootstrapSmoke
|
||
}
|
||
|
||
// feature-developer-experience-contract D4 — README is an entrypoint, not an unchecked second
|
||
// build script. Validate only executable command blocks (`bash`/`sh`); prose examples stay prose.
|
||
tasks.register('verifyReadmeCommands') {
|
||
group = 'verification'
|
||
description = 'Verifies README Gradle/Compose/Make commands refer to real tasks, files, and targets.'
|
||
|
||
File readmeFile = project.hasProperty('readmeFile')
|
||
? file(project.property('readmeFile'))
|
||
: new File(repositoryDir, 'README.md')
|
||
inputs.file(readmeFile)
|
||
|
||
doLast {
|
||
if (!readmeFile.isFile()) {
|
||
throw new GradleException("verifyReadmeCommands: missing ${readmeFile}")
|
||
}
|
||
|
||
List<String> violations = []
|
||
boolean inShellBlock = false
|
||
readmeFile.eachLine { String rawLine, int lineNumber ->
|
||
String line = rawLine.trim()
|
||
if (line == '```bash' || line == '```sh') {
|
||
inShellBlock = true
|
||
return
|
||
}
|
||
if (line == '```' && inShellBlock) {
|
||
inShellBlock = false
|
||
return
|
||
}
|
||
if (!inShellBlock || line.isEmpty() || line.startsWith('#')) {
|
||
return
|
||
}
|
||
|
||
int gradleIndex = line.indexOf('./gradlew ')
|
||
if (gradleIndex >= 0) {
|
||
List<String> tokens = line.substring(gradleIndex + './gradlew '.length())
|
||
.split(/\s+/).toList()
|
||
List<String> requestedTasks = []
|
||
for (String token : tokens) {
|
||
if (token.startsWith('-') || token in ['&&', '||', '|']) {
|
||
break
|
||
}
|
||
requestedTasks << token
|
||
}
|
||
if (requestedTasks.isEmpty()) {
|
||
violations << "${readmeFile}:${lineNumber}: ./gradlew command has no task"
|
||
}
|
||
requestedTasks.each { String taskPath ->
|
||
boolean exists
|
||
if (taskPath.startsWith(':') && taskPath.count(':') >= 2) {
|
||
int lastSeparator = taskPath.lastIndexOf(':')
|
||
String projectPath = taskPath.substring(0, lastSeparator)
|
||
String taskName = taskPath.substring(lastSeparator + 1)
|
||
Project targetProject = rootProject.findProject(projectPath)
|
||
exists = targetProject != null && targetProject.tasks.findByName(taskName) != null
|
||
} else {
|
||
exists = !rootProject.getTasksByName(taskPath, true).isEmpty()
|
||
}
|
||
if (!exists) {
|
||
violations << "${readmeFile}:${lineNumber}: unknown Gradle task '${taskPath}' in `${line}`"
|
||
}
|
||
}
|
||
}
|
||
|
||
if (line.startsWith('docker compose ')) {
|
||
List<String> tokens = line.split(/\s+/).toList()
|
||
int index = 2
|
||
while (index < tokens.size() && tokens[index].startsWith('-')) {
|
||
String option = tokens[index++]
|
||
if (option in ['-f', '--file', '--env-file', '-p', '--project-name']) {
|
||
if (index >= tokens.size()) {
|
||
violations << "${readmeFile}:${lineNumber}: '${option}' has no value in `${line}`"
|
||
break
|
||
}
|
||
String value = tokens[index++]
|
||
if (option in ['-f', '--file']) {
|
||
File composeFile = new File(repositoryDir, value)
|
||
if (!composeFile.isFile()) {
|
||
violations << "${readmeFile}:${lineNumber}: missing Compose file '${value}'"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Set<String> supported = ['build', 'config', 'down', 'logs', 'ps', 'pull', 'restart',
|
||
'run', 'start', 'stop', 'up'] as Set
|
||
if (index >= tokens.size() || !supported.contains(tokens[index])) {
|
||
String actual = index < tokens.size() ? tokens[index] : '<missing>'
|
||
violations << "${readmeFile}:${lineNumber}: unsupported Compose subcommand '${actual}'"
|
||
}
|
||
}
|
||
|
||
if (line.startsWith('make ')) {
|
||
File makefile = new File(repositoryDir, 'Makefile')
|
||
String target = line.substring('make '.length()).split(/\s+/)[0]
|
||
if (!makefile.isFile()) {
|
||
violations << "${readmeFile}:${lineNumber}: make command documented but Makefile is absent"
|
||
} else if (!(makefile.text =~ /(?m)^${java.util.regex.Pattern.quote(target)}\s*:/).find()) {
|
||
violations << "${readmeFile}:${lineNumber}: unknown Make target '${target}'"
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!violations.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyReadmeCommands: ${violations.size()} command drift violation(s):\n " +
|
||
violations.join('\n '))
|
||
}
|
||
logger.lifecycle("verifyReadmeCommands: OK — executable commands in ${readmeFile} resolve.")
|
||
}
|
||
}
|
||
|
||
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
||
tasks.named('check') {
|
||
dependsOn rootProject.tasks.named('verifyReadmeCommands')
|
||
}
|
||
}
|
||
|
||
tasks.register('verifyCleanArchitectureDependencies') {
|
||
group = 'verification'
|
||
description = 'Verifies Clean Architecture project dependency direction.'
|
||
|
||
File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json')
|
||
inputs.file(moduleRegistryFile)
|
||
|
||
doLast {
|
||
if (!moduleRegistryFile.isFile()) {
|
||
throw new GradleException("Missing module registry: ${moduleRegistryFile}")
|
||
}
|
||
def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile)
|
||
if (!(moduleRegistry.modules instanceof List) || moduleRegistry.modules.isEmpty()) {
|
||
throw new GradleException("Module registry has no modules: ${moduleRegistryFile}")
|
||
}
|
||
|
||
Map<String, Object> modulesById = moduleRegistry.modules.collectEntries { module ->
|
||
[(module.id as String): module]
|
||
}
|
||
if (modulesById.size() != moduleRegistry.modules.size()) {
|
||
throw new GradleException('Module registry contains duplicate module ids.')
|
||
}
|
||
|
||
Map<String, Set<String>> allowedProjectDependencies = moduleRegistry.modules.collectEntries { module ->
|
||
String moduleName = (module.gradle_path as String).replaceFirst('^:', '')
|
||
Set<String> allowed = (module.allowed_dependencies as List).collect { dependencyId ->
|
||
def dependency = modulesById[dependencyId as String]
|
||
if (dependency == null) {
|
||
throw new GradleException(
|
||
"Module registry '${module.id}' references unknown allowed dependency id '${dependencyId}'.")
|
||
}
|
||
(dependency.gradle_path as String).replaceFirst('^:', '')
|
||
}.toSet()
|
||
[(moduleName): allowed]
|
||
}
|
||
|
||
Set<String> declaredModules = subprojects.findAll { it.childProjects.isEmpty() }
|
||
.collect { it.path.replaceFirst('^:', '') }.toSet()
|
||
Set<String> governedModules = allowedProjectDependencies.keySet()
|
||
Set<String> missingFromBuild = governedModules - declaredModules
|
||
Set<String> missingFromPolicy = declaredModules - governedModules
|
||
|
||
if (!missingFromBuild.isEmpty()) {
|
||
throw new GradleException(
|
||
"Clean Architecture dependency policy references missing Gradle modules ${missingFromBuild}. " +
|
||
"Declared modules are ${declaredModules}."
|
||
)
|
||
}
|
||
|
||
if (!missingFromPolicy.isEmpty()) {
|
||
throw new GradleException(
|
||
"Gradle modules ${missingFromPolicy} are not covered by verifyCleanArchitectureDependencies. " +
|
||
"Add an explicit dependency policy before using them."
|
||
)
|
||
}
|
||
|
||
allowedProjectDependencies.each { moduleName, allowed ->
|
||
Project module = rootProject.project(":${moduleName}")
|
||
Set<String> actual = ['api', 'implementation', 'compileOnly', 'runtimeOnly']
|
||
.collect { configurationName -> module.configurations.findByName(configurationName) }
|
||
.findAll { it != null }
|
||
.collectMany { configuration ->
|
||
configuration.dependencies.withType(ProjectDependency).collect { dependency ->
|
||
dependency.path.replaceFirst('^:', '')
|
||
}
|
||
}
|
||
.toSet()
|
||
|
||
if (moduleName != 'sample-portfolio' && actual.contains('sample-portfolio')) {
|
||
throw new GradleException(
|
||
"Module ':${moduleName}' has a forbidden production dependency on " +
|
||
"':sample-portfolio'. The sample module may only be consumed through " +
|
||
"non-production fixture configurations."
|
||
)
|
||
}
|
||
|
||
Set<String> forbidden = actual - allowed
|
||
if (!forbidden.isEmpty()) {
|
||
throw new GradleException(
|
||
"Module ':${moduleName}' has forbidden project dependencies ${forbidden}. " +
|
||
"Allowed dependencies are ${allowed}. " +
|
||
"Production modules must not depend on ':sample-portfolio'; " +
|
||
"all project edges must be explicitly registered."
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Set<String> expectedJpaReadinessCardIds = [
|
||
'jpa-observability-lifecycle',
|
||
'jpa-security-baseline',
|
||
'jpa-flyway-migration',
|
||
'jpa-transaction-runtime',
|
||
'jpa-aggregate-store',
|
||
'jpa-query-model',
|
||
'jpa-primary-foundation',
|
||
'jpa-idempotency-owner-safe-v2',
|
||
'jpa-outbox-storage-v2',
|
||
'jpa-outbox-polling-delivery-v2',
|
||
'jpa-outbox-cdc-retention-v1',
|
||
'jpa-inbox-same-store-v1',
|
||
'jpa-fileserver-metadata-v1',
|
||
'jpa-primary-replica',
|
||
'jpa-tenant-discriminator-rls',
|
||
'jpa-jdbc-efficiency-coordination'
|
||
] as Set
|
||
|
||
Set<String> expectedJpaOwnedMigrationCardIds = [
|
||
'jpa-flyway-migration',
|
||
'jpa-idempotency-owner-safe-v2',
|
||
'jpa-outbox-storage-v2',
|
||
'jpa-outbox-polling-delivery-v2',
|
||
'jpa-inbox-same-store-v1',
|
||
'jpa-fileserver-metadata-v1',
|
||
'jpa-tenant-discriminator-rls',
|
||
'jpa-jdbc-efficiency-coordination'
|
||
] as Set
|
||
|
||
Closure<List<String>> validateJpaReadinessRegistry = {
|
||
Map<String, Object> registry,
|
||
String rawRegistry,
|
||
Closure<Boolean> taskExists ->
|
||
List<String> violations = []
|
||
Set<String> rootKeys = registry.keySet().collect { it as String }.toSet()
|
||
Set<String> expectedRootKeys = ['schema-version', 'legacy-adoption', 'cards'] as Set
|
||
if (rootKeys != expectedRootKeys) {
|
||
violations << "root keys must be exactly ${expectedRootKeys}; got ${rootKeys}"
|
||
}
|
||
if (registry['schema-version'] != 1) {
|
||
violations << "schema-version must be integer 1; got ${registry['schema-version']}"
|
||
}
|
||
|
||
Map<String, Object> legacy = registry['legacy-adoption'] instanceof Map
|
||
? registry['legacy-adoption'] as Map<String, Object>
|
||
: [:]
|
||
Set<String> expectedLegacyKeys = [
|
||
'state',
|
||
'location',
|
||
'history-table',
|
||
'immutable-applied-versions',
|
||
'allowed-origin'
|
||
] as Set
|
||
if (legacy.keySet().collect { it as String }.toSet() != expectedLegacyKeys) {
|
||
violations << "legacy-adoption keys must be exactly ${expectedLegacyKeys}"
|
||
}
|
||
if (legacy.state != 'transition-only') {
|
||
violations << "legacy-adoption.state must be transition-only"
|
||
}
|
||
if (legacy.location != 'db/migration/postgresql') {
|
||
violations << "legacy-adoption.location must be db/migration/postgresql"
|
||
}
|
||
if (legacy['history-table'] != 'flyway_schema_history') {
|
||
violations << "legacy-adoption.history-table must be flyway_schema_history"
|
||
}
|
||
if (legacy['immutable-applied-versions'] != [1, 3, 4, 5]) {
|
||
violations << "legacy-adoption immutable versions must be exactly [1, 3, 4, 5]"
|
||
}
|
||
if (legacy['allowed-origin'] != 'LEGACY_ADOPTED') {
|
||
violations << "legacy-adoption.allowed-origin must be LEGACY_ADOPTED"
|
||
}
|
||
|
||
Map<String, Object> cards = registry.cards instanceof Map
|
||
? registry.cards as Map<String, Object>
|
||
: [:]
|
||
Set<String> actualCardIds = cards.keySet().collect { it as String }.toSet()
|
||
Set<String> missingCards = expectedJpaReadinessCardIds - actualCardIds
|
||
Set<String> unknownCards = actualCardIds - expectedJpaReadinessCardIds
|
||
if (!missingCards.isEmpty()) {
|
||
violations << "missing card ids ${missingCards.toSorted()}"
|
||
}
|
||
if (!unknownCards.isEmpty()) {
|
||
violations << "unknown card ids ${unknownCards.toSorted()}"
|
||
}
|
||
|
||
List<String> rawCardKeys = []
|
||
def rawCardKeyMatcher = rawRegistry =~ /"(?<card>jpa-[a-z0-9.-]+)"\s*:/
|
||
while (rawCardKeyMatcher.find()) {
|
||
rawCardKeys << rawCardKeyMatcher.group('card')
|
||
}
|
||
Set<String> duplicateRawCardKeys = rawCardKeys.countBy { it }.findAll {
|
||
String ignored, Integer count -> count > 1
|
||
}.keySet()
|
||
if (!duplicateRawCardKeys.isEmpty()) {
|
||
violations << "duplicate raw card keys ${duplicateRawCardKeys.toSorted()}"
|
||
}
|
||
|
||
Set<String> allowedCardKeys = [
|
||
'state',
|
||
'schema-stream',
|
||
'prerequisites',
|
||
'external-prerequisites',
|
||
'readiness-task',
|
||
'support-tasks',
|
||
'required-evidence',
|
||
'evidence',
|
||
'dispatch-modes',
|
||
'migration'
|
||
] as Set
|
||
Set<String> allowedStates = ['selected', 'implemented-candidate', 'not-implemented'] as Set
|
||
Set<String> allowedSchemaStreams = ['none', 'owned', 'contributes-to-core'] as Set
|
||
Map<String, String> taskOwners = [:]
|
||
Map<String, String> migrationLocationOwners = [:]
|
||
Map<String, String> migrationHistoryOwners = [:]
|
||
Map<String, String> evidenceSelectorOwners = [:]
|
||
Set<String> actualOwnedMigrationCards = []
|
||
|
||
cards.each { String cardId, Object rawCard ->
|
||
if (!(rawCard instanceof Map)) {
|
||
violations << "${cardId}: card value must be an object"
|
||
return
|
||
}
|
||
Map<String, Object> card = rawCard as Map<String, Object>
|
||
Set<String> unknownKeys = card.keySet().collect { it as String }.toSet() - allowedCardKeys
|
||
if (!unknownKeys.isEmpty()) {
|
||
violations << "${cardId}: unknown keys ${unknownKeys.toSorted()}"
|
||
}
|
||
|
||
String state = card.state as String
|
||
String schemaStream = card['schema-stream'] as String
|
||
if (!allowedStates.contains(state)) {
|
||
violations << "${cardId}: invalid state '${state}'"
|
||
}
|
||
if (!allowedSchemaStreams.contains(schemaStream)) {
|
||
violations << "${cardId}: invalid schema-stream '${schemaStream}'"
|
||
}
|
||
|
||
if (!(card.prerequisites instanceof List)) {
|
||
violations << "${cardId}: prerequisites must be a list"
|
||
}
|
||
List<String> prerequisites = card.prerequisites instanceof List
|
||
? (card.prerequisites as List).collect { it as String }
|
||
: []
|
||
if (prerequisites.toSet().size() != prerequisites.size()) {
|
||
violations << "${cardId}: duplicate prerequisites ${prerequisites}"
|
||
}
|
||
prerequisites.each { String prerequisite ->
|
||
if (!cards.containsKey(prerequisite)) {
|
||
violations << "${cardId}: unknown prerequisite '${prerequisite}'"
|
||
} else if (state == 'selected' &&
|
||
((cards[prerequisite] as Map).state as String) != 'selected') {
|
||
violations << "${cardId}: selected card requires non-selected '${prerequisite}'"
|
||
}
|
||
}
|
||
|
||
String readinessTask = card['readiness-task'] as String
|
||
if (readinessTask == null || !readinessTask.startsWith(':')) {
|
||
violations << "${cardId}: readiness-task must be an absolute Gradle task path"
|
||
}
|
||
List<String> supportTasks = card['support-tasks'] instanceof List
|
||
? (card['support-tasks'] as List).collect { it as String }
|
||
: []
|
||
if (supportTasks.toSet().size() != supportTasks.size()) {
|
||
violations << "${cardId}: duplicate support-tasks ${supportTasks}"
|
||
}
|
||
([readinessTask] + supportTasks).findAll { it != null }.each { String taskPath ->
|
||
if (!taskPath.startsWith(':')) {
|
||
violations << "${cardId}: task '${taskPath}' must be an absolute Gradle task path"
|
||
return
|
||
}
|
||
String previousOwner = taskOwners.putIfAbsent(taskPath, cardId)
|
||
if (previousOwner != null) {
|
||
violations << "duplicate task '${taskPath}' owned by ${previousOwner} and ${cardId}"
|
||
}
|
||
if (state == 'selected' && !taskExists(taskPath)) {
|
||
violations << "${cardId}: selected task does not exist '${taskPath}'"
|
||
}
|
||
}
|
||
|
||
List<String> requiredEvidence = card['required-evidence'] instanceof List
|
||
? (card['required-evidence'] as List).collect { it as String }
|
||
: []
|
||
if (requiredEvidence.isEmpty()) {
|
||
violations << "${cardId}: required-evidence must be a non-empty list"
|
||
} else {
|
||
if (requiredEvidence.toSet().size() != requiredEvidence.size()) {
|
||
violations << "${cardId}: duplicate required-evidence ${requiredEvidence}"
|
||
}
|
||
if (!requiredEvidence.contains('no-skip')) {
|
||
violations << "${cardId}: required-evidence must include no-skip"
|
||
}
|
||
}
|
||
|
||
Object migrationNode = card.migration
|
||
Set<String> allowedEvidenceClaims = requiredEvidence
|
||
.findAll { String requirement -> requirement != 'no-skip' }
|
||
.toSet()
|
||
Map<String, Object> migrationForEvidence = migrationNode instanceof Map
|
||
? migrationNode as Map<String, Object>
|
||
: [:]
|
||
Object lifecycleEvidenceNode = migrationForEvidence['lifecycle-evidence']
|
||
if (lifecycleEvidenceNode instanceof List) {
|
||
(lifecycleEvidenceNode as List).each {
|
||
Object lifecycle ->
|
||
allowedEvidenceClaims <<
|
||
"migration-lifecycle:${lifecycle as String}".toString()
|
||
}
|
||
}
|
||
|
||
Object evidenceNode = card.evidence
|
||
if (state == 'not-implemented') {
|
||
if (evidenceNode != null) {
|
||
violations << "${cardId}: not-implemented card forbids evidence"
|
||
}
|
||
} else if (!(evidenceNode instanceof Map)) {
|
||
violations << "${cardId}: active card requires evidence"
|
||
} else {
|
||
Map<String, Object> evidence = evidenceNode as Map<String, Object>
|
||
Set<String> evidenceKeys = evidence.keySet().collect { it as String }.toSet()
|
||
Set<String> expectedEvidenceKeys = ['scenarios', 'task-claims'] as Set
|
||
if (evidenceKeys != expectedEvidenceKeys) {
|
||
violations << "${cardId}: evidence keys must be exactly ${expectedEvidenceKeys}"
|
||
}
|
||
|
||
List<Object> scenarios = evidence.scenarios instanceof List
|
||
? evidence.scenarios as List<Object>
|
||
: []
|
||
if (!(evidence.scenarios instanceof List)) {
|
||
violations << "${cardId}: evidence scenarios must be a list"
|
||
}
|
||
List<Object> taskClaims = evidence['task-claims'] instanceof List
|
||
? evidence['task-claims'] as List<Object>
|
||
: []
|
||
if (!(evidence['task-claims'] instanceof List)) {
|
||
violations << "${cardId}: evidence task-claims must be a list"
|
||
}
|
||
if (scenarios.isEmpty() && taskClaims.isEmpty()) {
|
||
violations << "${cardId}: evidence must contain a scenario or task claim"
|
||
}
|
||
|
||
scenarios.eachWithIndex { Object rawScenario, int index ->
|
||
if (!(rawScenario instanceof Map)) {
|
||
violations << "${cardId}: evidence scenario ${index} must be an object"
|
||
return
|
||
}
|
||
Map<String, Object> scenario = rawScenario as Map<String, Object>
|
||
Set<String> scenarioKeys =
|
||
scenario.keySet().collect { it as String }.toSet()
|
||
if (scenarioKeys != ['selector', 'covers'] as Set) {
|
||
violations << "${cardId}: evidence scenario ${index} has invalid keys ${scenarioKeys}"
|
||
}
|
||
String selector = scenario.selector as String
|
||
if (selector == null ||
|
||
!(selector ==~ /dev\.caskeleton\.[A-Za-z0-9_.]+\#[A-Za-z][A-Za-z0-9_]*/)) {
|
||
violations << "${cardId}: invalid evidence selector '${selector}'"
|
||
} else {
|
||
String previousOwner = evidenceSelectorOwners.putIfAbsent(selector, cardId)
|
||
if (previousOwner != null) {
|
||
violations << "duplicate evidence selector '${selector}' owned by " +
|
||
"${previousOwner} and ${cardId}"
|
||
}
|
||
}
|
||
List<String> covers = scenario.covers instanceof List
|
||
? (scenario.covers as List).collect { it as String }
|
||
: []
|
||
if (covers.isEmpty()) {
|
||
violations << "${cardId}: evidence scenario ${index} covers must be non-empty"
|
||
}
|
||
if (covers.toSet().size() != covers.size()) {
|
||
violations << "${cardId}: evidence scenario ${index} has duplicate covers ${covers}"
|
||
}
|
||
covers.each { String claim ->
|
||
if (!allowedEvidenceClaims.contains(claim)) {
|
||
violations << "${cardId}: evidence covers unknown requirement '${claim}'"
|
||
}
|
||
}
|
||
}
|
||
|
||
Set<String> ownedTasks = ([readinessTask] + supportTasks)
|
||
.findAll { it != null }
|
||
.toSet()
|
||
taskClaims.eachWithIndex { Object rawClaim, int index ->
|
||
if (!(rawClaim instanceof Map)) {
|
||
violations << "${cardId}: evidence task claim ${index} must be an object"
|
||
return
|
||
}
|
||
Map<String, Object> claim = rawClaim as Map<String, Object>
|
||
Set<String> claimKeys = claim.keySet().collect { it as String }.toSet()
|
||
if (claimKeys != ['task', 'covers'] as Set) {
|
||
violations << "${cardId}: evidence task claim ${index} has invalid keys ${claimKeys}"
|
||
}
|
||
String taskPath = claim.task as String
|
||
if (!ownedTasks.contains(taskPath)) {
|
||
violations << "${cardId}: evidence task claim is not owned by card '${taskPath}'"
|
||
}
|
||
List<String> covers = claim.covers instanceof List
|
||
? (claim.covers as List).collect { it as String }
|
||
: []
|
||
if (covers.isEmpty()) {
|
||
violations << "${cardId}: evidence task claim ${index} covers must be non-empty"
|
||
}
|
||
if (covers.toSet().size() != covers.size()) {
|
||
violations << "${cardId}: evidence task claim ${index} has duplicate covers ${covers}"
|
||
}
|
||
covers.each { String evidenceClaim ->
|
||
if (!allowedEvidenceClaims.contains(evidenceClaim)) {
|
||
violations << "${cardId}: evidence covers unknown requirement '${evidenceClaim}'"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (schemaStream == 'owned') {
|
||
actualOwnedMigrationCards << cardId
|
||
if (!(migrationNode instanceof Map)) {
|
||
violations << "${cardId}: owned schema-stream requires migration"
|
||
}
|
||
} else if (migrationNode != null) {
|
||
violations << "${cardId}: schema-stream ${schemaStream} forbids migration"
|
||
}
|
||
|
||
if (migrationNode instanceof Map) {
|
||
Map<String, Object> migration = migrationNode as Map<String, Object>
|
||
Set<String> expectedMigrationKeys = [
|
||
'location',
|
||
'history-table',
|
||
'required-core-epoch',
|
||
'feature-revision',
|
||
'lifecycle-evidence'
|
||
] as Set
|
||
Set<String> migrationKeys = migration.keySet().collect { it as String }.toSet()
|
||
if (migrationKeys != expectedMigrationKeys) {
|
||
violations << "${cardId}: migration keys must be exactly ${expectedMigrationKeys}"
|
||
}
|
||
|
||
String location = migration.location as String
|
||
String historyTable = migration['history-table'] as String
|
||
if (location == null || !(location ==~ /db\/migration\/jpa\/[a-z0-9-]+/)) {
|
||
violations << "${cardId}: invalid migration location '${location}'"
|
||
} else {
|
||
String previousOwner = migrationLocationOwners.putIfAbsent(location, cardId)
|
||
if (previousOwner != null) {
|
||
violations << "duplicate migration location '${location}' for ${previousOwner} and ${cardId}"
|
||
}
|
||
}
|
||
if (historyTable == null || !(historyTable ==~ /flyway_jpa_[a-z0-9_]+_history/)) {
|
||
violations << "${cardId}: invalid migration history-table '${historyTable}'"
|
||
} else {
|
||
String previousOwner = migrationHistoryOwners.putIfAbsent(historyTable, cardId)
|
||
if (previousOwner != null) {
|
||
violations << "duplicate migration history-table '${historyTable}' for ${previousOwner} and ${cardId}"
|
||
}
|
||
}
|
||
|
||
Object coreEpoch = migration['required-core-epoch']
|
||
Object featureRevision = migration['feature-revision']
|
||
if (!(coreEpoch instanceof Integer) || (coreEpoch as Integer) < 0) {
|
||
violations << "${cardId}: required-core-epoch must be a non-negative integer"
|
||
}
|
||
if (!(featureRevision instanceof Integer) || (featureRevision as Integer) <= 0) {
|
||
violations << "${cardId}: feature-revision must be a positive integer"
|
||
}
|
||
List<String> lifecycleEvidence = migration['lifecycle-evidence'] instanceof List
|
||
? (migration['lifecycle-evidence'] as List).collect { it as String }
|
||
: []
|
||
if (lifecycleEvidence.isEmpty()) {
|
||
violations << "${cardId}: lifecycle-evidence must be a non-empty list"
|
||
} else if (lifecycleEvidence.toSet().size() != lifecycleEvidence.size()) {
|
||
violations << "${cardId}: duplicate lifecycle-evidence ${lifecycleEvidence}"
|
||
}
|
||
}
|
||
|
||
if (card['external-prerequisites'] != null) {
|
||
if (!(card['external-prerequisites'] instanceof List)) {
|
||
violations << "${cardId}: external-prerequisites must be a list"
|
||
} else {
|
||
(card['external-prerequisites'] as List).eachWithIndex {
|
||
Object rawExternal, int index ->
|
||
if (!(rawExternal instanceof Map)) {
|
||
violations << "${cardId}: external prerequisite ${index} must be an object"
|
||
return
|
||
}
|
||
Map<String, Object> external = rawExternal as Map<String, Object>
|
||
Set<String> externalKeys = external.keySet()
|
||
.collect { it as String }
|
||
.toSet()
|
||
if (externalKeys != ['registry', 'card-id', 'minimum-readiness'] as Set) {
|
||
violations << "${cardId}: external prerequisite ${index} has invalid keys ${externalKeys}"
|
||
}
|
||
if (!((external.registry as String)?.startsWith('src/config/'))) {
|
||
violations << "${cardId}: external prerequisite ${index} has invalid registry"
|
||
}
|
||
if (!((external['card-id'] as String) ==~ /[a-z0-9.-]+/)) {
|
||
violations << "${cardId}: external prerequisite ${index} has invalid card-id"
|
||
}
|
||
if (!((external['minimum-readiness'] as String) ==~ /R[0-3]/)) {
|
||
violations << "${cardId}: external prerequisite ${index} has invalid minimum-readiness"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (actualOwnedMigrationCards != expectedJpaOwnedMigrationCardIds) {
|
||
violations << "owned migration cards must be exactly ${expectedJpaOwnedMigrationCardIds}; " +
|
||
"got ${actualOwnedMigrationCards}"
|
||
}
|
||
|
||
Map<String, Integer> visitState = [:].withDefault { 0 }
|
||
Closure<Void> visitCard
|
||
visitCard = { String cardId ->
|
||
if (visitState[cardId] == 1) {
|
||
violations << "readiness prerequisite cycle includes '${cardId}'"
|
||
return
|
||
}
|
||
if (visitState[cardId] == 2 || !cards.containsKey(cardId)) {
|
||
return
|
||
}
|
||
visitState[cardId] = 1
|
||
Map<String, Object> card = cards[cardId] as Map<String, Object>
|
||
if (card.prerequisites instanceof List) {
|
||
(card.prerequisites as List).each { Object prerequisite ->
|
||
visitCard(prerequisite as String)
|
||
}
|
||
}
|
||
visitState[cardId] = 2
|
||
}
|
||
cards.keySet().each { Object cardId -> visitCard(cardId as String) }
|
||
|
||
boolean pollingSelected =
|
||
((cards['jpa-outbox-polling-delivery-v2'] as Map)?.state as String) == 'selected'
|
||
boolean cdcSelected =
|
||
((cards['jpa-outbox-cdc-retention-v1'] as Map)?.state as String) == 'selected'
|
||
if (pollingSelected && cdcSelected) {
|
||
violations << 'polling and CDC outbox delivery cards cannot both be selected'
|
||
}
|
||
|
||
violations
|
||
}
|
||
|
||
Closure<Boolean> jpaTaskExists = { String absoluteTaskPath ->
|
||
int separator = absoluteTaskPath.lastIndexOf(':')
|
||
if (separator < 0 || separator == absoluteTaskPath.length() - 1) {
|
||
return false
|
||
}
|
||
String projectPath = separator == 0 ? ':' : absoluteTaskPath.substring(0, separator)
|
||
String taskName = absoluteTaskPath.substring(separator + 1)
|
||
Project targetProject = rootProject.findProject(projectPath)
|
||
targetProject != null && targetProject.tasks.findByName(taskName) != null
|
||
}
|
||
|
||
def verifyJpaReadinessRegistryContract = tasks.register('verifyJpaReadinessRegistryContract') {
|
||
group = 'verification'
|
||
description = 'Mutation-tests the fail-closed JPA readiness registry validator.'
|
||
|
||
File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml")
|
||
inputs.file(registryFile)
|
||
|
||
doLast {
|
||
String raw = registryFile.getText('UTF-8')
|
||
Map<String, Object> baseline = new JsonSlurper().parseText(raw) as Map<String, Object>
|
||
|
||
Closure<Map<String, Object>> copyRegistry = {
|
||
new JsonSlurper().parseText(JsonOutput.toJson(baseline)) as Map<String, Object>
|
||
}
|
||
Closure<Void> expectViolation = {
|
||
String scenario,
|
||
String expectedText,
|
||
Closure<Void> mutation,
|
||
Closure<Boolean> taskExists = { String ignored -> true } ->
|
||
Map<String, Object> candidate = copyRegistry()
|
||
mutation(candidate)
|
||
List<String> candidateViolations = validateJpaReadinessRegistry(
|
||
candidate,
|
||
JsonOutput.toJson(candidate),
|
||
taskExists)
|
||
if (!candidateViolations.any { String violation ->
|
||
violation.contains(expectedText)
|
||
}) {
|
||
throw new GradleException(
|
||
"verifyJpaReadinessRegistryContract: scenario '${scenario}' did not " +
|
||
"produce '${expectedText}'; got ${candidateViolations}")
|
||
}
|
||
}
|
||
|
||
expectViolation('unknown-card', 'unknown card ids', { Map<String, Object> candidate ->
|
||
(candidate.cards as Map)['jpa-primary-foundation-alias'] =
|
||
(candidate.cards as Map)['jpa-primary-foundation']
|
||
})
|
||
expectViolation('duplicate-task', 'duplicate task', { Map<String, Object> candidate ->
|
||
((candidate.cards as Map)['jpa-security-baseline'] as Map)['readiness-task'] =
|
||
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)['readiness-task']
|
||
})
|
||
expectViolation('missing-prerequisite', 'unknown prerequisite', {
|
||
Map<String, Object> candidate ->
|
||
((candidate.cards as Map)['jpa-security-baseline'] as Map).prerequisites =
|
||
['jpa-does-not-exist']
|
||
})
|
||
expectViolation('cycle', 'prerequisite cycle', { Map<String, Object> candidate ->
|
||
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).prerequisites =
|
||
['jpa-security-baseline']
|
||
})
|
||
expectViolation('duplicate-location', 'duplicate migration location', {
|
||
Map<String, Object> candidate ->
|
||
(((candidate.cards as Map)['jpa-idempotency-owner-safe-v2'] as Map).migration
|
||
as Map).location = 'db/migration/jpa/core'
|
||
})
|
||
expectViolation(
|
||
'missing-selected-task',
|
||
'selected task does not exist',
|
||
{ Map<String, Object> ignored -> },
|
||
{ String taskPath ->
|
||
taskPath !=
|
||
':adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest'
|
||
})
|
||
expectViolation('missing-active-evidence', 'active card requires evidence', {
|
||
Map<String, Object> candidate ->
|
||
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)
|
||
.remove('evidence')
|
||
})
|
||
expectViolation('unknown-evidence-requirement', 'evidence covers unknown requirement', {
|
||
Map<String, Object> candidate ->
|
||
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).evidence = [
|
||
scenarios: [[
|
||
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
|
||
covers: ['not-a-card-requirement']
|
||
]],
|
||
'task-claims': []
|
||
]
|
||
})
|
||
expectViolation('duplicate-evidence-selector', 'duplicate evidence selector', {
|
||
Map<String, Object> candidate ->
|
||
Map<String, Object> card =
|
||
(candidate.cards as Map)['jpa-observability-lifecycle'] as Map<String, Object>
|
||
card.evidence = [
|
||
scenarios: [
|
||
[
|
||
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
|
||
covers: ['real-postgresql']
|
||
],
|
||
[
|
||
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
|
||
covers: ['lifecycle']
|
||
]
|
||
],
|
||
'task-claims': []
|
||
]
|
||
})
|
||
expectViolation('unknown-evidence-task', 'evidence task claim is not owned by card', {
|
||
Map<String, Object> candidate ->
|
||
((candidate.cards as Map)['jpa-primary-foundation'] as Map).evidence = [
|
||
scenarios: [],
|
||
'task-claims': [[
|
||
task: ':test',
|
||
covers: ['architecture']
|
||
]]
|
||
]
|
||
})
|
||
|
||
logger.lifecycle(
|
||
'verifyJpaReadinessRegistryContract: OK — unknown card, duplicate task, ' +
|
||
'missing prerequisite, cycle, duplicate migration ownership, missing ' +
|
||
'selected task, and malformed evidence ownership all fail closed.')
|
||
}
|
||
}
|
||
|
||
def verifyJpaReadinessRegistry = tasks.register('verifyJpaReadinessRegistry') {
|
||
group = 'verification'
|
||
description = 'Validates the JPA readiness card, prerequisite, task, and migration registry.'
|
||
dependsOn verifyJpaReadinessRegistryContract
|
||
|
||
File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml")
|
||
inputs.file(registryFile)
|
||
|
||
doLast {
|
||
if (!registryFile.isFile()) {
|
||
throw new GradleException(
|
||
"verifyJpaReadinessRegistry: missing registry ${registryFile}")
|
||
}
|
||
String raw = registryFile.getText('UTF-8')
|
||
Map<String, Object> registry
|
||
try {
|
||
registry = new JsonSlurper().parseText(raw) as Map<String, Object>
|
||
} catch (RuntimeException ex) {
|
||
throw new GradleException(
|
||
"verifyJpaReadinessRegistry: registry is not valid JSON-compatible YAML",
|
||
ex)
|
||
}
|
||
|
||
List<String> violations =
|
||
validateJpaReadinessRegistry(registry, raw, jpaTaskExists)
|
||
if (!violations.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyJpaReadinessRegistry: ${violations.size()} violation(s):\n " +
|
||
violations.toSorted().join('\n '))
|
||
}
|
||
logger.lifecycle(
|
||
"verifyJpaReadinessRegistry: OK — ${expectedJpaReadinessCardIds.size()} exact " +
|
||
"cards, ${expectedJpaOwnedMigrationCardIds.size()} owned migration " +
|
||
'streams, acyclic prerequisites, unique tasks/locations/history tables, ' +
|
||
'and selected task existence verified.')
|
||
}
|
||
}
|
||
|
||
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
||
tasks.named('check') {
|
||
dependsOn verifyJpaReadinessRegistry
|
||
}
|
||
}
|
||
|
||
Project applicationCoreProject = project(':application-core')
|
||
def verifyApplicationCoreDependencyPurity = tasks.register('verifyApplicationCoreDependencyPurity') {
|
||
group = 'verification'
|
||
description = 'Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths.'
|
||
notCompatibleWithConfigurationCache('Inspects project configurations at execution time')
|
||
|
||
doLast {
|
||
Project application = applicationCoreProject
|
||
List<String> violations = []
|
||
|
||
['api', 'implementation', 'compileOnly', 'runtimeOnly'].each { configurationName ->
|
||
def configuration = application.configurations.findByName(configurationName)
|
||
if (configuration == null) {
|
||
return
|
||
}
|
||
configuration.dependencies.each { dependency ->
|
||
if (!(dependency instanceof ProjectDependency)) {
|
||
violations << "${configurationName}: non-project production dependency " +
|
||
"${dependency.group ?: '<no-group>'}:${dependency.name}"
|
||
}
|
||
}
|
||
}
|
||
|
||
Closure<Boolean> forbiddenGroup = { String groupName ->
|
||
groupName != null && (
|
||
groupName.startsWith('org.springframework') ||
|
||
groupName == 'org.slf4j' ||
|
||
groupName == 'ch.qos.logback' ||
|
||
groupName == 'org.apache.logging.log4j' ||
|
||
groupName == 'io.micrometer')
|
||
}
|
||
['compileClasspath', 'runtimeClasspath', 'testCompileClasspath', 'testRuntimeClasspath']
|
||
.each { configurationName ->
|
||
def configuration = application.configurations.getByName(configurationName)
|
||
configuration.incoming.resolutionResult.allComponents.each { component ->
|
||
if (component.id instanceof ModuleComponentIdentifier &&
|
||
forbiddenGroup(component.id.group)) {
|
||
violations << "${configurationName}: forbidden resolved dependency " +
|
||
"${component.id.group}:${component.id.module}:${component.id.version}"
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!violations.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyApplicationCoreDependencyPurity: ${violations.size()} violation(s):\n " +
|
||
violations.toSorted().join('\n '))
|
||
}
|
||
logger.lifecycle(
|
||
'verifyApplicationCoreDependencyPurity: OK — application-core production declarations are project-only and application classpaths contain no Spring/logging/metrics frameworks.')
|
||
}
|
||
}
|
||
|
||
applicationCoreProject.tasks.named('check') {
|
||
dependsOn verifyApplicationCoreDependencyPurity
|
||
}
|
||
|
||
def verifyConfigurationPropertiesProcessor = tasks.register('verifyConfigurationPropertiesProcessor') {
|
||
group = 'verification'
|
||
description = 'Verifies every registered leaf declares the Spring configuration processor exactly when its main source owns @ConfigurationProperties.'
|
||
|
||
File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json')
|
||
inputs.file(moduleRegistryFile)
|
||
|
||
doLast {
|
||
def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile)
|
||
List<String> violations = []
|
||
def processorDeclaration = ~/^\s*annotationProcessor\s+['"]org\.springframework\.boot:spring-boot-configuration-processor['"]\s*$/
|
||
|
||
moduleRegistry.modules.each { module ->
|
||
File leafDirectory = rootProject.projectDir.parentFile.toPath()
|
||
.resolve(module.source_path as String)
|
||
.normalize()
|
||
.toFile()
|
||
File mainSource = new File(leafDirectory, 'src/main')
|
||
File buildFile = new File(leafDirectory, 'build.gradle')
|
||
|
||
int propertyAnnotationCount = 0
|
||
if (mainSource.isDirectory()) {
|
||
mainSource.eachFileRecurse { File sourceFile ->
|
||
if (sourceFile.name.endsWith('.java')) {
|
||
propertyAnnotationCount += sourceFile.text.count('@ConfigurationProperties(')
|
||
}
|
||
}
|
||
}
|
||
int processorCount = buildFile.readLines().count { String line ->
|
||
processorDeclaration.matcher(line).matches()
|
||
}
|
||
|
||
boolean ownsConfigurationProperties = propertyAnnotationCount > 0
|
||
if (ownsConfigurationProperties && processorCount != 1) {
|
||
violations << "${module.id}: ${propertyAnnotationCount} @ConfigurationProperties occurrence(s), " +
|
||
"but ${processorCount} configuration-processor declaration(s)"
|
||
} else if (!ownsConfigurationProperties && processorCount != 0) {
|
||
violations << "${module.id}: no @ConfigurationProperties occurrence, but " +
|
||
"${processorCount} configuration-processor declaration(s)"
|
||
}
|
||
}
|
||
|
||
if (!violations.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyConfigurationPropertiesProcessor: ${violations.size()} parity violation(s):\n " +
|
||
violations.toSorted().join('\n '))
|
||
}
|
||
logger.lifecycle(
|
||
"verifyConfigurationPropertiesProcessor: OK — all ${moduleRegistry.modules.size()} registered leaves have exact configuration-processor parity.")
|
||
}
|
||
}
|
||
|
||
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
||
tasks.named('check') {
|
||
dependsOn verifyConfigurationPropertiesProcessor
|
||
}
|
||
}
|
||
|
||
// verifyOneTypePerFile — one public top-level type per file, file name == type name
|
||
// (code-conventions I6). Rationale in README.md.
|
||
tasks.register('verifyOneTypePerFile') {
|
||
group = 'verification'
|
||
description = 'code-conventions I6: one public top-level type per file; file name == type name.'
|
||
|
||
doLast {
|
||
def typeDecl = ~/^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(?:class|interface|record|enum|@interface)\s+([A-Za-z0-9_]+)/
|
||
List<String> violations = []
|
||
rootProject.subprojects.each { sub ->
|
||
File mainJava = sub.file('src/main/java')
|
||
if (!mainJava.exists()) {
|
||
return
|
||
}
|
||
mainJava.eachFileRecurse { File f ->
|
||
if (!f.name.endsWith('.java') || f.name == 'package-info.java' || f.name == 'module-info.java') {
|
||
return
|
||
}
|
||
List<String> names = []
|
||
f.eachLine { String line ->
|
||
def m = (line =~ typeDecl)
|
||
if (m.find()) {
|
||
names << m.group(1)
|
||
}
|
||
}
|
||
if (names.size() > 1) {
|
||
violations << "${f.path}: ${names.size()} public top-level types ${names}".toString()
|
||
} else if (names.size() == 1) {
|
||
String expected = f.name.replaceFirst(/\.java$/, '')
|
||
if (names[0] != expected) {
|
||
violations << "${f.path}: file name != public type name (type is '${names[0]}')".toString()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!violations.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyOneTypePerFile: ${violations.size()} violation(s) of code-conventions I6:\n " +
|
||
violations.join("\n "))
|
||
}
|
||
logger.lifecycle("verifyOneTypePerFile: OK — one public top-level type per file, names match.")
|
||
}
|
||
}
|
||
|
||
// verifyEnvKeys — keep env-keys.yaml <-> application.yml <-> src/.env in lock-step.
|
||
// Rationale in README.md.
|
||
tasks.register('verifyEnvKeys') {
|
||
group = 'verification'
|
||
description = 'Verifies application.yml APP_ references, src/.env, and env-keys.yaml stay registered.'
|
||
|
||
File envFile = file("${rootProject.projectDir}/.env")
|
||
File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml")
|
||
File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml")
|
||
// Check E reads the annotation processor's output, so the owning module has to have been
|
||
// compiled. Without this the check would quietly cover nothing on a clean checkout.
|
||
File redisSdkMetadata = file("${rootProject.projectDir}/adapter/outbound/cache-redis/build/" +
|
||
'classes/java/main/META-INF/spring-configuration-metadata.json')
|
||
dependsOn ':adapter:outbound:cache-redis:compileJava'
|
||
|
||
inputs.files(envFile, appYml, registryFile)
|
||
inputs.file(redisSdkMetadata).optional()
|
||
|
||
doLast {
|
||
if (!envFile.exists()) {
|
||
throw new GradleException("verifyEnvKeys: missing ${envFile}")
|
||
}
|
||
if (!appYml.exists()) {
|
||
throw new GradleException("verifyEnvKeys: missing ${appYml}")
|
||
}
|
||
if (!registryFile.exists()) {
|
||
throw new GradleException("verifyEnvKeys: missing ${registryFile}")
|
||
}
|
||
|
||
def keyPattern = ~/^([A-Z][A-Z0-9_]*)=.*/
|
||
Set<String> envKeys = envFile.readLines().findResults { String line ->
|
||
def m = keyPattern.matcher(line)
|
||
m.matches() ? m.group(1) : null
|
||
}.toSet()
|
||
|
||
// Parse application.yml placeholders: ${VAR} is required, ${VAR:default} is optional.
|
||
Set<String> requiredPlaceholders = new TreeSet<>()
|
||
Set<String> allPlaceholders = new TreeSet<>()
|
||
def pm = (appYml.text =~ /\$\{([A-Z][A-Z0-9_]*)(:[^}]*)?\}/)
|
||
while (pm.find()) {
|
||
allPlaceholders << pm.group(1)
|
||
if (pm.group(2) == null) {
|
||
requiredPlaceholders << pm.group(1)
|
||
}
|
||
}
|
||
Set<String> environmentSecretReferences = new TreeSet<>()
|
||
def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/)
|
||
while (sm.find()) {
|
||
environmentSecretReferences << sm.group(1)
|
||
}
|
||
Set<String> applicationAppReferences = new TreeSet<>(
|
||
allPlaceholders.findAll { it.startsWith('APP_') })
|
||
applicationAppReferences.addAll(environmentSecretReferences)
|
||
|
||
// A. Every required (no inline default) placeholder must exist in .env.
|
||
Set<String> missingKeys = new TreeSet<>(requiredPlaceholders - envKeys)
|
||
if (!missingKeys.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyEnvKeys: application.yml references required env absent from src/.env: ${missingKeys}")
|
||
}
|
||
|
||
// B. Every .env key must be referenced by some application.yml placeholder.
|
||
Set<String> knownApplicationReferences = new TreeSet<>(allPlaceholders)
|
||
knownApplicationReferences.addAll(environmentSecretReferences)
|
||
Set<String> orphanedKeys = new TreeSet<>(envKeys - knownApplicationReferences)
|
||
if (!orphanedKeys.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyEnvKeys: src/.env declares keys no application.yml \${...} placeholder uses: ${orphanedKeys}")
|
||
}
|
||
|
||
// C. Every APP_ key in src/.env must be registered in env-keys.yaml (APP_-scoped;
|
||
// SPRING_* native keys are intentionally not tracked — see README.md).
|
||
def registryNamePattern = ~/^\s*- name: (APP_[A-Z0-9_]+)/
|
||
Set<String> registryAppKeys = registryFile.readLines().findResults { String line ->
|
||
def m = registryNamePattern.matcher(line)
|
||
m.find() ? m.group(1) : null
|
||
}.toSet()
|
||
|
||
Set<String> envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet()
|
||
Set<String> unregisteredAppKeys = new TreeSet<>(envAppKeys - registryAppKeys)
|
||
if (!unregisteredAppKeys.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyEnvKeys: src/.env declares APP_ keys absent from docs/registries/env-keys.yaml " +
|
||
"(registry is the SSOT for APP_ keys): ${unregisteredAppKeys}")
|
||
}
|
||
|
||
// D. Every application-owned reference is registered, including optional placeholders
|
||
// with inline defaults and literal secret://environment/APP_* references.
|
||
Set<String> unregisteredApplicationReferences =
|
||
new TreeSet<>(applicationAppReferences - registryAppKeys)
|
||
if (!unregisteredApplicationReferences.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyEnvKeys: application.yml references APP_ keys absent from " +
|
||
"docs/registries/env-keys.yaml (optional defaults and environment " +
|
||
"secret references are included): ${unregisteredApplicationReferences}")
|
||
}
|
||
|
||
// E. Typed properties that are deliberately absent from application.yml and src/.env.
|
||
//
|
||
// Checks 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<String, String> metadataScopes = [
|
||
'app.redis.': 'adapter/outbound/cache-redis'
|
||
]
|
||
Set<String> typedProperties = new TreeSet<>()
|
||
Set<String> missingMetadata = new TreeSet<>()
|
||
metadataScopes.each { propertyPrefix, modulePath ->
|
||
File metadata = file(
|
||
"${rootProject.projectDir}/${modulePath}/build/classes/java/main/" +
|
||
'META-INF/spring-configuration-metadata.json')
|
||
if (!metadata.exists()) {
|
||
missingMetadata << "${propertyPrefix} (${metadata})".toString()
|
||
return
|
||
}
|
||
def parsed = new groovy.json.JsonSlurper().parse(metadata)
|
||
(parsed.properties ?: []).each { property ->
|
||
if (property.name?.startsWith(propertyPrefix)) {
|
||
typedProperties << property.name.toString()
|
||
}
|
||
}
|
||
}
|
||
if (!missingMetadata.isEmpty()) {
|
||
throw new GradleException(
|
||
'verifyEnvKeys: configuration metadata is missing for ' + missingMetadata +
|
||
' — run the owning module\'s compileJava first (the annotation ' +
|
||
'processor writes it), or the typed-property check silently covers ' +
|
||
'nothing.')
|
||
}
|
||
|
||
def registryPropertyPattern = ~/^\s*property:\s*(\S+)/
|
||
Set<String> registryProperties = registryFile.readLines().findResults { String line ->
|
||
def m = registryPropertyPattern.matcher(line)
|
||
m.find() ? m.group(1) : null
|
||
}.toSet()
|
||
|
||
Set<String> unregisteredTypedProperties = new TreeSet<>(typedProperties - registryProperties)
|
||
if (!unregisteredTypedProperties.isEmpty()) {
|
||
throw new GradleException(
|
||
'verifyEnvKeys: typed configuration properties absent from ' +
|
||
"docs/registries/env-keys.yaml: ${unregisteredTypedProperties} — every " +
|
||
'bindable property needs a registry row carrying its official env ' +
|
||
'name, type, default, secret classification and required_when.')
|
||
}
|
||
|
||
Set<String> scopedRegistryProperties = registryProperties.findAll { String property ->
|
||
metadataScopes.keySet().any { property.startsWith(it) }
|
||
}.toSet()
|
||
Set<String> orphanedRegistryProperties =
|
||
new TreeSet<>(scopedRegistryProperties - typedProperties)
|
||
if (!orphanedRegistryProperties.isEmpty()) {
|
||
throw new GradleException(
|
||
'verifyEnvKeys: docs/registries/env-keys.yaml declares properties that no ' +
|
||
"typed settings class binds any more: ${orphanedRegistryProperties} — " +
|
||
'remove the row or restore the property.')
|
||
}
|
||
|
||
// F. Every registered key has a consumer, or says out loud that it does not.
|
||
// Checks A-E each compare two SSOTs, and a row that appears in none of them falls
|
||
// through all of them: APP_CACHE_REDIS_TRUST_PEM and four namespace keys sat in the
|
||
// registry with no typed property, no application.yml reference and no .env entry,
|
||
// documented as if a deployment could still use them. A key nothing reads is worse
|
||
// than an undocumented one — an operator sets it, nothing happens, and the
|
||
// configuration looks correct.
|
||
Map<String, Map<String, String>> registryRows = [:]
|
||
String currentRow = null
|
||
registryFile.readLines().each { String line ->
|
||
def nameMatch = (line =~ /^\s*- name: (APP_[A-Z0-9_]+)/)
|
||
if (nameMatch.find()) {
|
||
currentRow = nameMatch.group(1)
|
||
registryRows[currentRow] = [:]
|
||
return
|
||
}
|
||
if (currentRow == null) {
|
||
return
|
||
}
|
||
def fieldMatch = (line =~ /^\s*([a-z_]+):\s*(\S.*)?$/)
|
||
if (fieldMatch.find()) {
|
||
registryRows[currentRow][fieldMatch.group(1)] = (fieldMatch.group(2) ?: '').trim()
|
||
}
|
||
}
|
||
Set<String> consumed = new TreeSet<>()
|
||
consumed.addAll(applicationAppReferences)
|
||
consumed.addAll(envAppKeys)
|
||
// A key can be read in ways checks A-D never look at: a module's own application.yml (the
|
||
// sample's, for one) and Java that names a secret directly, as SecretSourceValidator does.
|
||
// Counting only the composition root's yaml would report those as orphans, which is the
|
||
// opposite failure — a check that cries wolf gets an exclusion list and then gets ignored.
|
||
def appKeyPattern = ~/APP_[A-Z][A-Z0-9_]*/
|
||
rootProject.projectDir.eachFileRecurse { File candidate ->
|
||
if (!candidate.isFile()) {
|
||
return
|
||
}
|
||
boolean interesting =
|
||
(candidate.name == 'application.yml' && candidate.path.contains('/main/')) ||
|
||
(candidate.name.endsWith('.java') && candidate.path.contains('/src/main/'))
|
||
if (!interesting) {
|
||
return
|
||
}
|
||
def matcher = appKeyPattern.matcher(candidate.text)
|
||
while (matcher.find()) {
|
||
consumed << matcher.group()
|
||
}
|
||
}
|
||
Set<String> unconsumed = new TreeSet<>(registryRows.keySet().findAll { String name ->
|
||
Map<String, String> row = registryRows[name]
|
||
!consumed.contains(name) &&
|
||
!row.containsKey('property') &&
|
||
row['deprecated_orphaned'] != 'true'
|
||
})
|
||
// Enforced for the surfaces this branch owns; reported for the rest. A key nothing reads is
|
||
// a defect wherever it lives, but silently adopting another feature's backlog into a
|
||
// blocking gate is how a gate acquires an exclusion list. The rest are named on every run so
|
||
// they cannot be forgotten, and their owning branch turns them into failures here.
|
||
def enforcedPrefixes = ['APP_REDIS_', 'APP_CACHE_REDIS_', 'APP_RATE_LIMIT_REDIS_',
|
||
'APP_IDEMPOTENCY_REDIS_', 'APP_LEASE_REDIS_', 'APP_SESSION_REDIS_']
|
||
Set<String> unconsumedOwned =
|
||
new TreeSet<>(unconsumed.findAll { String name -> enforcedPrefixes.any { name.startsWith(it) } })
|
||
if (!unconsumedOwned.isEmpty()) {
|
||
throw new GradleException(
|
||
'verifyEnvKeys: registered Redis keys that nothing reads — no typed property, ' +
|
||
'no application.yml reference, no src/.env entry, no Java consumer, ' +
|
||
"and not marked deprecated_orphaned: ${unconsumedOwned}. Wire the key " +
|
||
'to a consumer, or mark the row deprecated_orphaned with a ' +
|
||
'removal_deadline so a deployment still setting it is told rather ' +
|
||
'than silently ignored.')
|
||
}
|
||
Set<String> unconsumedElsewhere = new TreeSet<>(unconsumed - unconsumedOwned)
|
||
if (!unconsumedElsewhere.isEmpty()) {
|
||
logger.warn('verifyEnvKeys: registered keys outside the Redis surface that nothing ' +
|
||
"reads yet: ${unconsumedElsewhere} — owned by the branch that registered them.")
|
||
}
|
||
|
||
logger.lifecycle("verifyEnvKeys: OK — ${envKeys.size()} env keys, " +
|
||
"${requiredPlaceholders.size()} required placeholders covered, " +
|
||
"${applicationAppReferences.size()} application APP_ references registered, " +
|
||
"${typedProperties.size()} typed properties registered, " +
|
||
"${registryRows.size() - unconsumed.size()} rows with a consumer or a deprecation.")
|
||
}
|
||
}
|
||
|
||
// verifyTrivyignore — feature-dependency-vulnerability-management-contract D5 (Suppression
|
||
// governance). Fails the build when a Trivy suppression entry in the repo-root .trivyignore.yaml
|
||
// lacks an expiry or a reason, is already expired, or exceeds the 90-day max window. Closes the
|
||
// 2026-05-25 ca-tmpl audit finding: a suppression could be added with no expiry/reason (a silent
|
||
// permanent bypass). This is the CI-side field check; CODEOWNERS adds the merge-time approval —
|
||
// the two controls are complementary (see .trivyignore.yaml header and src/README.md).
|
||
tasks.register('verifyTrivyignore') {
|
||
group = 'verification'
|
||
description = 'feature-dependency-vulnerability-management-contract D5: every Trivy suppression has a reason and a non-expired, bounded expiry.'
|
||
|
||
// UNSUPPORTED_IMPL_DECISION (team-policy): 90-day max suppression window. The Trivy docs only
|
||
// guarantee the `expired_at` field exists (raw/official-docs/trivy-filtering-suppression-policy
|
||
// C4); they recommend no specific bound. Trade-off: shorter forces frequent re-review; longer
|
||
// approaches a de-facto permanent ignore.
|
||
int maxWindowDays = 90
|
||
Set<String> governedSections = ['vulnerabilities', 'licenses', 'misconfigurations', 'secrets'] as Set
|
||
|
||
File suppressionFile = file("${rootProject.projectDir}/../.trivyignore.yaml")
|
||
inputs.file(suppressionFile)
|
||
|
||
doLast {
|
||
if (!suppressionFile.exists()) {
|
||
throw new GradleException("verifyTrivyignore: missing ${suppressionFile} (D5 requires the structured suppression file to exist, even if empty).")
|
||
}
|
||
|
||
java.time.LocalDate today = java.time.LocalDate.now()
|
||
java.time.LocalDate maxDate = today.plusDays(maxWindowDays)
|
||
List<String> violations = []
|
||
int entryCount = 0
|
||
|
||
String section = null
|
||
int entryIndent = -1
|
||
Map<String, String> entry = null
|
||
int entryLine = -1
|
||
|
||
Closure<Void> validate = { Map<String, String> e, String sec, int lineNo ->
|
||
entryCount++
|
||
String id = e['id']
|
||
String where = "${sec} entry at .trivyignore.yaml:${lineNo}" + (id ? " (id: ${id})" : "")
|
||
if (!id?.trim()) {
|
||
violations << "${where}: missing 'id'."
|
||
}
|
||
if (!e['statement']?.trim()) {
|
||
violations << "${where}: missing non-empty 'statement' (every suppression must record a reason)."
|
||
}
|
||
String exp = e['expired_at']?.trim()
|
||
if (!exp) {
|
||
violations << "${where}: missing 'expired_at' (a missing expiry never expires in Trivy — permanent suppression is forbidden)."
|
||
} else {
|
||
try {
|
||
java.time.LocalDate expDate = java.time.LocalDate.parse(exp)
|
||
if (!expDate.isAfter(today)) {
|
||
violations << "${where}: 'expired_at' ${exp} is not in the future (already expired — remove or renew the suppression)."
|
||
} else if (expDate.isAfter(maxDate)) {
|
||
violations << "${where}: 'expired_at' ${exp} exceeds the ${maxWindowDays}-day max window (must be on or before ${maxDate})."
|
||
}
|
||
} catch (java.time.format.DateTimeParseException ignored) {
|
||
violations << "${where}: 'expired_at' '${exp}' is not a valid ISO date (expected YYYY-MM-DD)."
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
suppressionFile.eachLine { String rawLine, int number ->
|
||
String noTab = rawLine.replace('\t', ' ')
|
||
String trimmed = noTab.trim()
|
||
if (trimmed.isEmpty() || trimmed.startsWith('#')) {
|
||
return
|
||
}
|
||
int indent = noTab.length() - noTab.replaceAll('^ +', '').length()
|
||
|
||
// Top-level section header (column 0), e.g. `vulnerabilities:` or `vulnerabilities: []`.
|
||
def sectionMatch = (trimmed =~ /^([a-z_]+):(\s*\[\s*\])?\s*$/)
|
||
if (indent == 0 && sectionMatch.find()) {
|
||
if (entry != null) { validate(entry, section, entryLine); entry = null }
|
||
section = sectionMatch.group(1)
|
||
entryIndent = -1
|
||
return
|
||
}
|
||
if (section == null || !governedSections.contains(section)) {
|
||
return
|
||
}
|
||
|
||
if (trimmed.startsWith('- ') || trimmed == '-') {
|
||
if (entryIndent == -1) {
|
||
entryIndent = indent
|
||
}
|
||
if (indent == entryIndent) {
|
||
if (entry != null) { validate(entry, section, entryLine) }
|
||
entry = [:]
|
||
entryLine = number
|
||
// A field may sit inline on the dash line, e.g. `- id: CVE-2024-0001`.
|
||
String inline = trimmed.replaceFirst(/^-\s*/, '')
|
||
int colon = inline.indexOf(':')
|
||
if (colon > 0) {
|
||
String k = inline.substring(0, colon).trim()
|
||
String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
|
||
if (!k.isEmpty()) { entry[k] = v }
|
||
}
|
||
}
|
||
// indent > entryIndent → nested list item (e.g. under `paths:`) → ignore.
|
||
return
|
||
}
|
||
|
||
// A `key: value` field of the current entry (one indent level deeper than the dash).
|
||
if (entry != null && indent == entryIndent + 2) {
|
||
int colon = trimmed.indexOf(':')
|
||
if (colon > 0) {
|
||
String k = trimmed.substring(0, colon).trim()
|
||
String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
|
||
entry[k] = v
|
||
}
|
||
}
|
||
}
|
||
if (entry != null) { validate(entry, section, entryLine) }
|
||
|
||
if (!violations.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyTrivyignore: ${violations.size()} Trivy suppression governance violation(s) (D5):\n " +
|
||
violations.join("\n ") +
|
||
"\nFix the .trivyignore.yaml entries, or remove them. Every suppression needs an 'id', a " +
|
||
"'statement' reason, and a future 'expired_at' within ${maxWindowDays} days.")
|
||
}
|
||
logger.lifecycle("verifyTrivyignore: OK — ${entryCount} suppression(s) validated (reason + bounded, non-expired expiry).")
|
||
}
|
||
}
|
||
|
||
// verifyQuarantineSunset — feature-ci-quality-gates-contract §4 (D7/D9, this branch is the flaky
|
||
// quarantine SSOT). The flaky-test quarantine bucket (@Tag("quarantine"), excluded from the release
|
||
// gate) is a TEMPORARY escape, not a parking lot: every quarantined test must be registered in the
|
||
// repo-root flaky-quarantine.yaml with a reason, a tracking issue, and a quarantined_since date, and
|
||
// it must leave quarantine within 14 days (the ca-tmpl compromise on the Spotify/Google/MS-vs-Fowler
|
||
// debate — company-case-study strength only, NOT an official best practice). This is the CI-side
|
||
// field + sunset check; like verifyTrivyignore it is the field-validation half and CODEOWNERS is the
|
||
// merge-approval half. Two enforcement directions:
|
||
// (a) sunset — fail when any registered quarantined_since is older than 14 days;
|
||
// (b) drift — fail when a test is @Tag("quarantine")-tagged in source but NOT registered
|
||
// (a flaky test must not escape sunset tracking by skipping the registry).
|
||
// Ships passing on the empty skeleton (zero tagged tests, `quarantined: []`).
|
||
tasks.register('verifyQuarantineSunset') {
|
||
group = 'verification'
|
||
description = 'feature-ci-quality-gates-contract §4 (D7): every @Tag("quarantine") test is registered and within its 14-day sunset.'
|
||
|
||
// UNSUPPORTED_IMPL_DECISION (company-case-study + team-policy): 14-day sunset. The Spotify/Google/MS
|
||
// case studies establish that a quarantine bucket is legitimate; the 14-day quantum and its
|
||
// automatic enforcement are ca-tmpl's own compromise (no external standard). A fork tunes
|
||
// sunsetDays here.
|
||
int sunsetDays = 14
|
||
|
||
File registryFile = file("${rootProject.projectDir}/../flaky-quarantine.yaml")
|
||
inputs.file(registryFile)
|
||
rootProject.subprojects.each { sub ->
|
||
File testJava = sub.file('src/test/java')
|
||
if (testJava.exists()) {
|
||
inputs.dir(testJava)
|
||
}
|
||
}
|
||
|
||
doLast {
|
||
if (!registryFile.exists()) {
|
||
throw new GradleException("verifyQuarantineSunset: missing ${registryFile} " +
|
||
"(D7 requires the quarantine registry to exist, even if empty: `quarantined: []`).")
|
||
}
|
||
|
||
// --- Parse the registry (line-based, same shape family as .trivyignore.yaml). -----------
|
||
// quarantined:
|
||
// - test: "fully.qualified.TestClass" | "...TestClass#method"
|
||
// quarantined_since: "YYYY-MM-DD"
|
||
// reason: "..."
|
||
// tracking_issue: "..."
|
||
List<Map<String, String>> entries = []
|
||
List<Integer> entryLines = []
|
||
boolean inSection = false
|
||
int entryIndent = -1
|
||
Map<String, String> entry = null
|
||
int entryLine = -1
|
||
|
||
Closure<Void> closeEntry = {
|
||
if (entry != null) { entries << entry; entryLines << entryLine; entry = null }
|
||
return null
|
||
}
|
||
|
||
registryFile.eachLine { String rawLine, int number ->
|
||
String noTab = rawLine.replace('\t', ' ')
|
||
String trimmed = noTab.trim()
|
||
if (trimmed.isEmpty() || trimmed.startsWith('#')) {
|
||
return
|
||
}
|
||
int indent = noTab.length() - noTab.replaceAll('^ +', '').length()
|
||
|
||
// Top-level `quarantined:` or `quarantined: []`.
|
||
def sectionMatch = (trimmed =~ /^quarantined:(\s*\[\s*\])?\s*$/)
|
||
if (indent == 0 && sectionMatch.find()) {
|
||
closeEntry()
|
||
inSection = true
|
||
entryIndent = -1
|
||
return
|
||
}
|
||
if (indent == 0) { // some other top-level key → leave the section
|
||
closeEntry()
|
||
inSection = false
|
||
return
|
||
}
|
||
if (!inSection) {
|
||
return
|
||
}
|
||
|
||
if (trimmed.startsWith('- ') || trimmed == '-') {
|
||
if (entryIndent == -1) { entryIndent = indent }
|
||
if (indent == entryIndent) {
|
||
closeEntry()
|
||
entry = [:]
|
||
entryLine = number
|
||
String inline = trimmed.replaceFirst(/^-\s*/, '')
|
||
int colon = inline.indexOf(':')
|
||
if (colon > 0) {
|
||
String k = inline.substring(0, colon).trim()
|
||
String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
|
||
if (!k.isEmpty()) { entry[k] = v }
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// A `key: value` field of the current entry (deeper than the dash).
|
||
if (entry != null && indent == entryIndent + 2) {
|
||
int colon = trimmed.indexOf(':')
|
||
if (colon > 0) {
|
||
String k = trimmed.substring(0, colon).trim()
|
||
String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
|
||
entry[k] = v
|
||
}
|
||
}
|
||
}
|
||
closeEntry()
|
||
|
||
// --- (a) Schema + sunset validation of every registered entry. -------------------------
|
||
java.time.LocalDate today = java.time.LocalDate.now()
|
||
List<String> violations = []
|
||
Set<String> registeredClasses = new HashSet<>()
|
||
|
||
entries.eachWithIndex { Map<String, String> e, int i ->
|
||
String test = e['test']?.trim()
|
||
String where = "flaky-quarantine.yaml:${entryLines[i]}" + (test ? " (test: ${test})" : "")
|
||
if (!test) {
|
||
violations << "${where}: missing 'test' (fully-qualified test class, optionally '#method')."
|
||
} else {
|
||
registeredClasses << test.replaceAll(/#.*$/, '')
|
||
}
|
||
if (!e['reason']?.trim()) {
|
||
violations << "${where}: missing non-empty 'reason' (why is it flaky / what is the suspected cause)."
|
||
}
|
||
if (!e['tracking_issue']?.trim()) {
|
||
violations << "${where}: missing 'tracking_issue' (the issue tracking the fix — quarantine is not a parking lot)."
|
||
}
|
||
String since = e['quarantined_since']?.trim()
|
||
if (!since) {
|
||
violations << "${where}: missing 'quarantined_since' (a missing date cannot be sunset — forbidden)."
|
||
} else {
|
||
try {
|
||
java.time.LocalDate sinceDate = java.time.LocalDate.parse(since)
|
||
if (sinceDate.isAfter(today)) {
|
||
violations << "${where}: 'quarantined_since' ${since} is in the future."
|
||
} else if (sinceDate.plusDays(sunsetDays).isBefore(today)) {
|
||
long age = java.time.temporal.ChronoUnit.DAYS.between(sinceDate, today)
|
||
violations << "${where}: quarantined ${age} days ago — past the ${sunsetDays}-day sunset. " +
|
||
"Fix and un-quarantine the test, or escalate; do not extend silently."
|
||
}
|
||
} catch (java.time.format.DateTimeParseException ignored) {
|
||
violations << "${where}: 'quarantined_since' '${since}' is not a valid ISO date (expected YYYY-MM-DD)."
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- (b) Drift: every @Tag("quarantine") test in source must be registered. -------------
|
||
def tagPattern = ~/@Tag\(\s*["']quarantine["']\s*\)/
|
||
def classPattern = ~/(?:class|interface|enum|record)\s+([A-Za-z0-9_]+)/
|
||
int taggedCount = 0
|
||
rootProject.subprojects.each { sub ->
|
||
File testJava = sub.file('src/test/java')
|
||
if (!testJava.exists()) {
|
||
return
|
||
}
|
||
testJava.eachFileRecurse { File f ->
|
||
if (!f.name.endsWith('.java')) {
|
||
return
|
||
}
|
||
String text = f.text
|
||
if (!(text =~ tagPattern)) {
|
||
return
|
||
}
|
||
taggedCount++
|
||
def cm = (text =~ classPattern)
|
||
String simpleName = cm ? cm[0][1] : f.name.replaceFirst(/\.java$/, '')
|
||
boolean registered = registeredClasses.any { it.endsWith('.' + simpleName) || it == simpleName }
|
||
if (!registered) {
|
||
violations << "${f.path}: test '${simpleName}' is @Tag(\"quarantine\") but is not registered " +
|
||
"in flaky-quarantine.yaml — a quarantined test must be tracked with a sunset date."
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!violations.isEmpty()) {
|
||
throw new GradleException(
|
||
"verifyQuarantineSunset: ${violations.size()} quarantine governance violation(s) (§4 / D7):\n " +
|
||
violations.join("\n ") +
|
||
"\nEach @Tag(\"quarantine\") test needs a flaky-quarantine.yaml entry with a 'reason', a " +
|
||
"'tracking_issue', and a 'quarantined_since' within ${sunsetDays} days.")
|
||
}
|
||
logger.lifecycle("verifyQuarantineSunset: OK — ${entries.size()} registered, ${taggedCount} tagged " +
|
||
"(${sunsetDays}-day sunset enforced).")
|
||
}
|
||
}
|