refactor(build,ci): 현재 상태 검증을 걷어내고 불변조건만 남기는 검증 표면 축소

외부 리뷰("현재 상태를 유지하기 위한 검증이 너무 많고, 그 검증 자체를
다시 검증하는 구조까지 생겼다")를 설계 문서로 정리하고 코드로 반영한다.
설계·판단 근거는 docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md.

삭제
- .github/ci-gate-matrix.yml(1,025줄) + verify-gate-matrix.sh(568줄):
  Gradle task graph와 workflow graph에 이미 있는 정보의 3중 복제
- verify-gradle-wrapper.sh(799줄): workflow 바이트 해시 잠금.
  wrapper 검증은 gradle/actions/wrapper-validation(full SHA 핀)에 위임
- DeveloperExperienceContractTest 등의 CI YAML mutation 테스트:
  애플리케이션 test suite가 GitHub Actions YAML 파서를 검증하던 계층 역전
- 문서 drift 파서: verifyReadmeCommands, verifyRunbookReferences,
  verifyDocumentedLeafCount, verifyTestSourceSetRegistry
- 빈 레지스트리를 지키던 커스텀 YAML 파서: verifyTrivyignore,
  verifyQuarantineSunset, flaky-quarantine.yaml
- verifyConfigurationPropertiesProcessor, verifyOneTypePerFile:
  각각 ca.spring-config convention과 Checkstyle OneTopLevelClass가 대체
- 정상 입력으로도 성공할 수 없던 messaging always-fail task
- ModuleRegistry의 JSON 필드 집합 정확 일치, sample-portfolio negative guard

이동
- java/quality/spring 공통 설정을 configure(subprojects) 블록에서
  ca.java-conventions / ca.quality-conventions / ca.java-library /
  ca.spring-library convention plugin으로
- 아키텍처 검증을 ca.architecture로, JPA·messaging qualification을
  gradle/qualification/ 아래로, verifyEnvKeys를 :app-bootstrap 소유로

완화
- Git revision은 releaseCheck·아카이브 생성에서만 요구. 일반 빌드는 SNAPSHOT
- SpotBugs/FindSecBugs는 로컬 check에서 빼고 qualityCheck 레인으로

task 계층
- leaf check는 그 leaf만. architectureCheck / qualityCheck /
  configContractCheck / integrationCheck / ci / releaseCheck로 이름 분리

CI
- _reusable-gradle.yml 신규. checkout + wrapper validation + JDK/캐시 공통화
- fileserver-release.yml -> fileserver-certification.yml (CD가 아니라 certification)
- GitHub Actions = CI + artifact, Argo CD = CD 경계를 docs/ci-cd/boundary.md로 고정

순증감 +3,274 / -7,483.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 20:33:19 +09:00
co-authored by Claude Opus 5
parent d00c76241c
commit ef947e5bb0
95 changed files with 3284 additions and 7493 deletions
+12
View File
@@ -5,6 +5,18 @@ plugins {
}
dependencies {
// The third-party Gradle plugins the convention plugins apply.
//
// The root build used to apply these to every leaf from `configure(subprojects)`, and the
// recorded reason for keeping them there (D8) was that build-logic would have to re-declare
// their versions, giving each one a second home that could drift. That objection no longer
// holds: build-logic/settings.gradle reads the main build's `gradle/libs.versions.toml`, so the
// versions below and the ones the root `plugins {}` block declares are the same table entries.
implementation "com.diffplug.spotless:spotless-plugin-gradle:${libs.versions.spotless.get()}"
implementation "com.github.spotbugs.snom:spotbugs-gradle-plugin:${libs.versions.spotbugsPlugin.get()}"
implementation "net.ltgt.gradle:gradle-errorprone-plugin:${libs.versions.errorpronePlugin.get()}"
implementation "io.spring.gradle:dependency-management-plugin:${libs.versions.springDependencyManagement.get()}"
// TestKit needs the Gradle API of the running distribution, which `groovy-gradle-plugin` already
// puts on the main source set; the test source set asks for it explicitly.
testImplementation gradleTestKit()
@@ -0,0 +1,270 @@
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
// The architecture rules. Applied to the root project, because their subject is the repository.
//
// These are the invariants the review kept: a dependency direction is what a Clean Architecture
// skeleton *is*, so it is worth automating, and it is worth having exactly one implementation of.
// They used to sit in the middle of a 3,200-line root build file next to a README command parser and
// a JPA certification registry, which is why they are here instead.
//
// One `architectureCheck`, not a dependency on every leaf's `check`.
tasks.register('verifyCleanArchitectureDependencies') {
group = 'verification'
description = 'Verifies Clean Architecture project dependency direction.'
File moduleRegistryFile = rootProject.file('config/architecture/modules.json')
inputs.file(moduleRegistryFile)
// The registry the settings plugin already parsed. Reading it again here would be a second
// definition of a valid registry.
def registry = gradle.moduleRegistry
// Registry-shape rules that used to run in settings, moved here.
//
// An unknown or self-referential `allowed_dependencies` entry is a real defect, but failing on
// it in settings meant failing before any project existed — no task could run, `--dry-run`
// could not run, and a derived project that mistyped an id had no way to reach a diagnostic
// other than editing the registry blind. Here the same mistake is a named task failure.
List<String> registryViolations = []
registry.modules.each { module ->
module.allowedDependencies.each { String dependencyId ->
if (dependencyId == module.id) {
registryViolations << "'${module.id}' declares itself as an allowed dependency"
} else if (registry.byId(dependencyId) == null) {
registryViolations << "'${module.id}' allows unknown dependency id '${dependencyId}'"
} else if (module.id != 'sample-portfolio' && dependencyId == 'sample-portfolio') {
registryViolations <<
"'${module.id}' allows a production dependency on the removable sample fixture"
}
}
}
Map<String, Set<String>> allowedProjectDependencies = registry.modules.collectEntries { module ->
String moduleName = module.gradlePath.replaceFirst('^:', '')
Set<String> allowed = module.allowedDependencies
.collect { registry.byId(it) }
.findAll { it != null }
.collect { it.gradlePath.replaceFirst('^:', '') }
.toSet()
[(moduleName): allowed]
}
doLast {
if (!registryViolations.isEmpty()) {
throw new GradleException(
"config/architecture/modules.json declares impossible edges:\n " +
registryViolations.join('\n '))
}
Set<String> declaredModules = rootProject.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."
)
}
}
}
}
Project applicationCoreProject = rootProject.project(':application-core')
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.')
}
}
// verifyNoIgnoredSourcePackages — a Java package must never be invisible to Git.
//
// `src/.gitignore` carries an unanchored `build/` rule so every leaf's Gradle output directory is
// ignored at any depth. That rule cannot tell a build directory from a Java package, so a package
// named `build` is silently dropped from every commit. The GraphQL leaf lost its entire module
// boundary model that way: production code still imported the types, the author's working copy still
// compiled, and a fresh checkout failed with seven "package does not exist" errors.
//
// Kept where most of this file's neighbours were deleted, because it is an invariant rather than a
// snapshot: no source file may be one a fresh checkout would not carry. Nothing else can answer it —
// it is a question about the ignore rules, not about the code.
tasks.register('verifyNoIgnoredSourcePackages') {
group = 'verification'
description = 'Fails when a Java source file lives in a package that Git ignores or would ignore.'
doLast {
Set<String> outputDirectoryNames = ['build', 'out', 'target', 'bin', 'classes'] as Set
List<String> violations = []
List<File> sourceFiles = []
rootProject.subprojects.each { sub ->
['src/main/java', 'src/test/java'].each { String sourceRootPath ->
File sourceRoot = sub.file(sourceRootPath)
if (!sourceRoot.isDirectory()) {
return
}
sourceRoot.eachFileRecurse { File candidate ->
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
return
}
sourceFiles << candidate
String relative = sourceRoot.toPath().relativize(candidate.toPath()).toString()
List<String> packageSegments = relative.split('/').toList().dropRight(1)
packageSegments.findAll { outputDirectoryNames.contains(it) }.each { String segment ->
violations << ("${candidate.path}: package segment '${segment}' collides with a " +
'build output directory name').toString()
}
}
}
}
if (sourceFiles.isEmpty()) {
throw new GradleException(
'verifyNoIgnoredSourcePackages: found no Java sources at all; the gate would pass vacuously.')
}
Closure<String> runGit = { List<String> command, String stdin ->
try {
Process process = new ProcessBuilder(command)
.directory(rootProject.projectDir)
.redirectErrorStream(false)
.start()
if (stdin != null) {
process.outputStream.withWriter('UTF-8') { it.write(stdin) }
} else {
process.outputStream.close()
}
String output = process.inputStream.getText('UTF-8')
process.errorStream.getText('UTF-8')
process.waitFor()
return output
} catch (IOException unavailable) {
logger.info("verifyNoIgnoredSourcePackages: git unavailable (${unavailable.message})")
return null
}
}
String repositoryRoot = runGit(['git', 'rev-parse', '--show-toplevel'], null)?.trim()
if (repositoryRoot == null || repositoryRoot.isEmpty()) {
logger.lifecycle('verifyNoIgnoredSourcePackages: not a Git checkout; naming rule only.')
} else {
// --no-index asks "would the rules drop this path", which is the question that matters.
// Without it, a file rescued by `git add -f` reports clean while still depending on every
// future contributor remembering to force-add it.
String ignoredOutput = runGit(
['git', '-C', repositoryRoot, 'check-ignore', '--no-index', '-v', '--stdin'],
sourceFiles.collect { it.path }.join('\n'))
(ignoredOutput ?: '').readLines().findAll { !it.isBlank() }.each { String line ->
List<String> parts = line.split('\t').toList()
String rule = parts.size() > 1 ? parts[0] : '(unknown rule)'
String path = parts.size() > 1 ? parts[1..-1].join('\t') : line
violations << "${path}: ignored by ${rule}; it will not survive a fresh checkout".toString()
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyNoIgnoredSourcePackages: ${violations.size()} source file(s) Git cannot carry:\n " +
violations.join('\n '))
}
logger.lifecycle(
"verifyNoIgnoredSourcePackages: OK — ${sourceFiles.size()} Java sources are all committable.")
}
}
tasks.register('architectureCheck') {
group = 'verification'
description = 'Runs the repository-wide architecture invariants.'
dependsOn tasks.named('verifyCleanArchitectureDependencies')
dependsOn tasks.named('verifyApplicationCoreDependencyPurity')
dependsOn tasks.named('verifyNoIgnoredSourcePackages')
dependsOn tasks.named('verifyRuntimeModuleMembership')
}
@@ -34,16 +34,15 @@ if (declaredGrpcVersion == null || declaredGrpcVersion.toString().isBlank()) {
}
String grpcVersion = declaredGrpcVersion.toString()
// Fail-closed rather than silently skipped. `dependencyManagement` is Spring's extension, so without
// that plugin there is nothing to import into — and a BOM that was never imported does not announce
// itself: it surfaces later as an io.grpc coordinate with no version, in whichever leaf asks first.
if (!project.pluginManager.hasPlugin('io.spring.dependency-management')) {
throw new GradleException(
"${project.path} applies ca.grpc-platform-module before " +
"'io.spring.dependency-management'. The grpc BOM is imported through that " +
'plugin, so applying it afterwards would leave io.grpc versions unmanaged ' +
'without failing anything here.')
}
// No runtime guard for dependency-management any more, because there is nothing left to guard.
//
// This used to throw when `io.spring.dependency-management` was absent, since without it there is no
// `dependencyManagement` block to import the BOM into, and a BOM that was never imported does not
// announce itself: it surfaces later as an io.grpc coordinate with no version, in whichever leaf
// asks first. That check answered a question a leaf could get wrong while the root applied the
// plugin from `configure(subprojects)`. `ca.platform-module` -> `ca.java-library` ->
// `ca.java-conventions` applies it now, so the plugin graph makes the precondition true instead of
// checking it afterwards.
dependencyManagement {
imports {
@@ -0,0 +1,173 @@
import org.gradle.api.artifacts.dsl.LockMode
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.tasks.bundling.AbstractArchiveTask
import org.gradle.api.tasks.bundling.Jar
// What every registered leaf is, before it is anything else: a Java 21 module with locked
// dependencies, reproducible archives, a traceable jar manifest and the Spring BOM available for
// version management.
//
// This was `configure(subprojects.findAll { it.childProjects.isEmpty() })` in the root build. The
// recorded reason for leaving it there (D8) was that a leaf's build file should have one place to
// look for the plugins it acquires. It had the opposite effect: `domain-core/build.gradle` is three
// lines and nothing in it says that Java, dependency locking, a BOM, four analysis tools and a
// strict test-lane container are applied to it. A leaf now names what it is —
// `ca.java-library`, `ca.spring-library`, `ca.platform-module` — and this file says what that means.
plugins {
id 'java'
id 'io.spring.dependency-management'
// Lane, API-surface, dependency-policy and strict-qualification containers. Each is inert for a
// leaf that never configures it: an empty lane container registers no task, an unnamed
// apiSurface registers none, an empty dependency policy adds no check.
id 'ca.strict-test-lane'
id 'ca.api-surface'
id 'ca.dependency-policy'
id 'ca.strict-qualification'
}
// The main build's catalog, read through the Gradle API rather than the `libs` accessor, which is
// not generated for a precompiled script plugin. Same table, same entries as the root build's
// `plugins {}` block reads.
def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs')
String springBootVersion = versionCatalog.findVersion('springBoot').get().requiredVersion
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.
//
// `unknown` when the root declares no revision, which is a source archive with no `.git` and no
// `-PgitRevision`. That used to fail the build during configuration, so `./gradlew test` on an
// unpacked tarball could not run at all; release traceability is enforced by `releaseCheck`, which
// is where a missing revision actually matters.
String buildRevision =
rootProject.ext.has('sourceRevision') ? rootProject.ext.sourceRevision : 'unknown'
tasks.withType(Jar).configureEach {
manifest {
attributes(
'Implementation-Version': project.version.toString(),
'Build-Revision': buildRevision
)
}
}
// Keep method parameter names in bytecode for Spring MVC @PathVariable/@RequestParam binding
// (rationale in README.md).
//
// Pinned encoding, 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".
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
['-parameters', '-Werror', '-Xlint:deprecation', '-Xlint:unchecked'].each { String compilerArg ->
if (!options.compilerArgs.contains(compilerArg)) {
options.compilerArgs.add(compilerArg)
}
}
}
// 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'
dependencyManagement {
imports {
// The literal coordinate `SpringBootPlugin.BOM_COORDINATES` expands to, with the version
// read from the catalog. Spelling it out keeps spring-boot-gradle-plugin off build-logic's
// compile classpath: build-logic applies dependency-management, not Boot.
mavenBom "org.springframework.boot:spring-boot-dependencies:${springBootVersion}"
}
}
dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
// 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() }
}
}
// feature-ci-quality-gates-contract §4 (D7) — the main gate EXCLUDES the flaky quarantine bucket so
// a quarantined test can never block merge. Quarantined tests carry JUnit's built-in
// @Tag("quarantine") and run separately through `quarantineTest`, which never blocks.
//
// The 14-day sunset registry that used to enforce a fixed lifetime on those tags is gone: it was a
// 250-line YAML-and-Java parser guarding a registry with zero entries. The bucket itself is three
// lines and stays.
tasks.named('test') {
useJUnitPlatform {
excludeTags 'quarantine'
}
}
tasks.register('quarantineTest', Test) {
group = 'verification'
description = 'Flaky-test quarantine bucket: runs only @Tag("quarantine") tests, non-blocking.'
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'
}
@@ -0,0 +1,13 @@
// A leaf whose tests need no Spring context: `domain-core`, `application-core`, `shared-contract`.
//
// Keeping their test classpath on plain JUnit + AssertJ is what makes "application-core has no
// Spring dependency" verifiable rather than aspirational. A leaf that genuinely needs a Spring test
// context declares it in its own build file — or, more likely, is a `ca.spring-library`.
plugins {
id 'ca.quality-conventions'
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.assertj:assertj-core'
}
@@ -0,0 +1,56 @@
// A leaf that carries JMH benchmarks: `messaging-kafka`, `messaging-rabbit`, `messaging-testkit`.
//
// A source set rather than the JMH 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.
//
// This was an `if (project.path in [three paths])` branch inside the root build's
// `configure(subprojects)` block. The three leaves it names now name it.
import org.gradle.api.artifacts.VersionCatalogsExtension
plugins {
id 'ca.platform-module'
}
def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs')
Closure<String> versionOf = { String alias -> versionCatalog.findVersion(alias).get().requiredVersion }
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:${versionOf('jmh')}"
jmhAnnotationProcessor "org.openjdk.jmh:jmh-generator-annprocess:${versionOf('jmh')}"
// Error Prone's -Werror would reject JMH's generated sources, which the platform does not own
// and cannot fix.
jmhAnnotationProcessor "com.google.errorprone:error_prone_core:${versionOf('errorprone')}"
}
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'
}
@@ -6,18 +6,15 @@
// does not is `java-library`: a consumer compiles against their types, so they have an `api`
// configuration and the distinction between `api` and `implementation` is load-bearing for them.
//
// Forty-three build files said that by each writing `apply plugin: 'java-library'` at line 1. That is
// not merely repetition. The root build applies every other plugin a leaf gets, centrally, and states
// why: "leaves in this repository have no plugins {} block — the root is where a leaf acquires its
// plugins, and splitting that would mean two places to look" (src/build.gradle). These forty-three
// files were the exception, so there were two places to look, and the one with forty-three copies is
// the one that drifts — a platform leaf added without the line compiles until the first consumer
// writes `api`, and then fails somewhere else.
// Forty-three build files said that by each writing `apply plugin: 'java-library'` at line 1, which
// is how a platform leaf could be added without the line and compile until the first consumer wrote
// `api`.
//
// Deliberately thin. Everything else these leaves share — the toolchain, Spotless, Checkstyle,
// SpotBugs, Error Prone, dependency locking, the strict lane conventions — the root already applies
// to every leaf, and duplicating any of it here would be the second place to look this plugin exists
// to remove. What belongs here is what is true of the vendored platform and false of the rest.
// `ca.java-library` is applied here rather than left to the root build's `configure(subprojects)`
// block, which is where the toolchain, locking, analysis tools and lane containers used to come
// from invisibly. A vendored platform leaf's tests run on plain JUnit + AssertJ, which is what makes
// "messaging-core-api has no Spring dependency" — and the same claim for grpc-core-api — checkable.
plugins {
id 'ca.java-library'
id 'java-library'
}
@@ -0,0 +1,162 @@
import com.github.spotbugs.snom.Confidence
import groovy.xml.XmlSlurper
import com.github.spotbugs.snom.SpotBugsTask
import org.gradle.api.artifacts.VersionCatalogsExtension
// feature-static-analysis-quality-contract — the static analysis baseline.
//
// Tiered, which is the change. Every tool used to hang off every leaf's `check`, so
// `./gradlew :domain-core:check` ran a bytecode bug finder and a security scanner before it would
// tell a developer whether their unit test passed. The two fast, deterministic tools stay on
// `check`; the two slow, worker-forking ones move to `qualityCheck`, which `ci` runs.
//
// check Spotless (formatting), Checkstyle (style), Error Prone (compile-time)
// qualityCheck SpotBugs + FindSecBugs (bytecode analysis, forks an analysis worker per source set)
//
// Nothing is disabled and no finding is downgraded: `./gradlew qualityCheck` runs the same tasks
// with the same configuration, and CI runs it on every pull request.
plugins {
id 'ca.java-conventions'
id 'com.diffplug.spotless' // D1 formatter
id 'checkstyle' // D2 style linter (Gradle built-in — no plugins{} id)
id 'com.github.spotbugs' // D3 bytecode bug finder (+ D4 FindSecBugs)
id 'net.ltgt.errorprone' // D5 compile-time checker
}
def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs')
Closure<String> versionOf = { String alias -> versionCatalog.findVersion(alias).get().requiredVersion }
// D1 — google-java-format owns formatting + import order; spotlessApply auto-fixes, spotlessCheck
// (wired into check) verifies. CI must NEVER run spotlessApply.
spotless {
java {
googleJavaFormat(versionOf('googleJavaFormat'))
importOrder()
removeUnusedImports()
}
}
// D2 — naming + logical ruleset; formatter-owned modules suppressed in the XML. Checkstyle also
// owns code-conventions I6 (one top-level type per file) through OneTopLevelClass and
// OuterTypeFilename, which is why no hand-written Java scanner enforces it any more.
checkstyle {
toolVersion = versionOf('checkstyle')
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
}
// 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). Confirmed false positives go in config/spotbugs/exclude.xml.
spotbugs {
toolVersion = versionOf('spotbugs')
reportLevel = Confidence.valueOf('HIGH')
excludeFilter = rootProject.file('config/spotbugs/exclude.xml')
}
// An incomplete SpotBugs run is a failure, not a clean report.
//
// SpotBugs writes missing classes and analysis errors into the XML report's <Errors> element and
// still exits zero, so a run that could not load half the classpath looks exactly like a run that
// found nothing. This reads that element and fails on it. It stays as a hand-written reader because
// no SpotBugs option expresses "fail when the analysis did not complete"; what does NOT stay is the
// task that mutated this reader with four XML fixtures to prove it fails — a validator's validator.
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
}
sourceSets.configureEach { sourceSet ->
tasks.named("spotbugs${sourceSet.name.capitalize()}", 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 '))
}
}
}
}
tasks.withType(JavaCompile).configureEach {
options.errorprone {
disableWarningsInGeneratedCode = true // D5 — MapStruct/Lombok generated code
}
}
dependencies {
spotbugsPlugins "com.h3xstream.findsecbugs:findsecbugs-plugin:${versionOf('findsecbugs')}"
errorprone "com.google.errorprone:error_prone_core:${versionOf('errorprone')}"
}
// SpotBugs off the local `check`, on to `qualityCheck`.
//
// The SpotBugs plugin wires its analysis into `check` with `check.dependsOn(tasks.withType(
// SpotBugsTask))` — a live TaskCollection, not a named TaskProvider. A filter that matched on task
// NAME therefore removed nothing and left `:domain-core:check` running a bytecode analyser, while
// reading in review as if it had worked. Matching on element type is what actually identifies it.
Closure<Boolean> isSpotBugsDependency = { Object dependency ->
if (dependency instanceof TaskCollection) {
// An empty collection would vacuously satisfy `every`, and dropping some other plugin's
// empty collection is exactly the kind of silent removal this file is correcting.
return !dependency.isEmpty() && dependency.every { it instanceof SpotBugsTask }
}
String name = dependency instanceof TaskProvider ? ((TaskProvider) dependency).name
: dependency instanceof Task ? ((Task) dependency).name
: null
name != null && name.startsWith('spotbugs')
}
tasks.named('check') {
setDependsOn(dependsOn.findAll { !isSpotBugsDependency(it) })
}
tasks.register('qualityCheck') {
group = 'verification'
description = 'Runs this leaf\'s SpotBugs and FindSecBugs bytecode analysis.'
dependsOn tasks.withType(SpotBugsTask)
}
@@ -0,0 +1,23 @@
// A leaf that owns @ConfigurationProperties types, and therefore needs Spring's configuration
// metadata processor.
//
// This replaces `verifyConfigurationPropertiesProcessor`, which read every leaf's Java source
// looking for the string `@ConfigurationProperties` (after blanking comments and string literals
// with a 95-line hand-written Java lexer, because `{@code @ConfigurationProperties}` appears in
// twenty Javadoc comments), then read the same leaf's build.gradle with a regular expression looking
// for an `annotationProcessor` line, and failed when the two counts disagreed. Two custom parsers to
// enforce something a plugin can simply do, and writing the declaration in any equivalent form broke
// the checker rather than the build.
//
// Applies nothing else on purpose. The leaves that need the processor are not one family — four
// inbound adapters, seven outbound adapters, two platform starters, the composition root and the
// sample — so making it imply `ca.spring-library` would have changed the test classpath of the two
// platform starters, whose tests run on plain JUnit by design.
//
// Opt-in rather than automatic, because every configuration in this build is dependency-locked in
// STRICT mode: adding an annotation processor to a leaf that does not declare one today would
// invalidate its lock state for no change in what it compiles.
dependencies {
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
@@ -0,0 +1,15 @@
// A leaf that runs inside a Spring context: the inbound and outbound adapters, the composition root
// and the sample.
//
// The split between this and `ca.java-library` used to be a path test inside the root build's
// `configure(subprojects)` block — `project.path in [':domain-core', ...] || path.startsWith(':messaging:')`
// — so which test framework a leaf got was decided by a string comparison in a file the leaf's
// author never opened, and adding an adapter under a new path silently changed its test classpath.
plugins {
id 'ca.quality-conventions'
}
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}
@@ -369,3 +369,18 @@ project.afterEvaluate {
}
}
}
// One aggregate per leaf, so the root can offer `integrationCheck` without a hand-kept list.
//
// A lane is declared, not discovered by naming convention, so the container that holds the
// declarations is the only honest source for "every lane in this repository". Registered
// unconditionally — a leaf with no lanes gets a task that depends on nothing, which is what makes
// the root aggregate a plain `collect` rather than a `findAll` over task existence.
//
// Deliberately NOT wired into `check`. Several of these lanes need a container runtime, and a leaf
// check that needs Docker is a leaf check that people learn to skip.
tasks.register('strictTestLaneCheck') {
group = 'verification'
description = 'Runs every strict test lane this leaf declares.'
dependsOn provider { strictTestLanes.lanes.collect { tasks.named(it.name) } }
}
@@ -21,11 +21,20 @@ import groovy.json.JsonSlurper
*/
final class ModuleRegistry {
/** Exactly the fields a module entry carries — extra or missing is a failure, not a default. */
private static final Set<String> MODULE_FIELDS =
/**
* The fields a module entry must carry. A missing one is a failure; an extra one is not.
*
* <p>This used to be an exact-set comparison in both directions, and the second direction was a
* current-state check rather than an invariant: adding a {@code description} or a {@code type}
* to an entry — a normal thing to want from a registry — failed the build in <em>settings</em>,
* before any project existed. Nothing reads a field this class does not know about, so an extra
* one cannot change what the build does; refusing it only stopped the registry being extended.
*/
private static final Set<String> REQUIRED_MODULE_FIELDS =
['id', 'gradle_path', 'source_path', 'allowed_dependencies', 'runtime_memberships'] as Set
private static final Set<String> ROOT_FIELDS = ['runtime_compositions', 'modules'] as Set
/** Same rule at the root: these two must be present, and others are allowed. */
private static final Set<String> REQUIRED_ROOT_FIELDS = ['runtime_compositions', 'modules'] as Set
/** Every registered module, in registry order. */
final List<Module> modules
@@ -85,9 +94,11 @@ final class ModuleRegistry {
if (!(parsed instanceof Map)) {
throw new IllegalStateException("Module registry root must be a JSON object: ${registryFile}")
}
if (parsed.keySet().collect { it as String }.toSet() != ROOT_FIELDS) {
Set<String> missingRootFields =
REQUIRED_ROOT_FIELDS - parsed.keySet().collect { it as String }.toSet()
if (!missingRootFields.isEmpty()) {
throw new IllegalStateException(
"Module registry root fields must be exactly ${ROOT_FIELDS}: ${registryFile}")
"Module registry root is missing ${missingRootFields.toSorted()}: ${registryFile}")
}
if (!(parsed.modules instanceof List) || parsed.modules.isEmpty()) {
throw new IllegalStateException("Module registry has no modules: ${registryFile}")
@@ -121,7 +132,9 @@ final class ModuleRegistry {
throw new IllegalStateException("Module registry entry ${index} must be a JSON object.")
}
Map<String, Object> module = rawModule as Map<String, Object>
if (module.keySet().collect { it as String }.toSet() != MODULE_FIELDS) {
Set<String> missingFields =
REQUIRED_MODULE_FIELDS - module.keySet().collect { it as String }.toSet()
if (!missingFields.isEmpty()) {
// Named by id when the entry still carries one. "entry 2 has the wrong fields" sends
// a reader counting array elements; naming the module and the fields that differ
// says which entry and what about it.
@@ -129,12 +142,8 @@ final class ModuleRegistry {
String named = (rawId instanceof String && !(rawId as String).isBlank())
? "'${rawId}'"
: "at index ${index}"
Set<String> missing = MODULE_FIELDS - module.keySet().collect { it as String }.toSet()
Set<String> unexpected = module.keySet().collect { it as String }.toSet() - MODULE_FIELDS
throw new IllegalStateException(
"Module registry entry ${named} fields must be exactly ${MODULE_FIELDS}" +
(missing.isEmpty() ? '' : "; missing ${missing.toSorted()}") +
(unexpected.isEmpty() ? '' : "; unexpected ${unexpected.toSorted()}"))
"Module registry entry ${named} is missing ${missingFields.toSorted()}")
}
['id', 'gradle_path', 'source_path'].each { field ->
if (!(module[field] instanceof String) || (module[field] as String).isBlank()) {
@@ -206,24 +215,17 @@ final class ModuleRegistry {
}
}
modules.each { module ->
module.allowedDependencies.each { dependencyId ->
if (dependencyId == module.id) {
throw new IllegalStateException(
"Module registry entry '${module.id}' must not depend on itself.")
}
if (module.id != 'sample-portfolio' && dependencyId == 'sample-portfolio') {
throw new IllegalStateException(
"Production module registry entry '${module.id}' must not allow a dependency on " +
"'sample-portfolio'.")
}
if (!ids.contains(dependencyId)) {
throw new IllegalStateException(
"Module registry entry '${module.id}' references unknown allowed dependency id " +
"'${dependencyId}'.")
}
}
}
// Edge rules — self-dependency, an unknown id, a production edge onto the removable sample
// fixture — are NOT checked here any more. They are real defects, and
// `verifyCleanArchitectureDependencies` fails on every one of them by name.
//
// What moved is where they fail. Settings runs before any project exists, so a mistyped
// dependency id took the whole build down: no task could be listed, no `--dry-run` could
// run, and the only diagnostic was this exception. That is the right severity for "this
// registry cannot be turned into a project list" — a duplicate id, a path outside the
// repository, a directory that is not there — and the wrong severity for "this edge is not
// allowed", which is a question about the architecture and belongs to the task that answers
// the rest of them.
return new ModuleRegistry(modules, runtimeCompositions, registryFile)
}
@@ -102,25 +102,20 @@ class ModuleRegistryTest {
}
@Test
@DisplayName("a production module may not depend on sample-portfolio")
void productionDependencyOnSampleIsRefused() {
@DisplayName("edge rules are not settings-time failures; the registry still parses")
void edgeRulesDoNotFailTheProjectList() {
// A self-edge, an unknown id and a production edge onto the sample fixture are all real
// defects, and verifyCleanArchitectureDependencies fails on each by name. None of them
// stops this registry describing a project list, so none of them fails here: settings runs
// before any project exists, and a failure here leaves no task able to report anything.
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '["sample-portfolio"]',
'["app-bootstrap"]'),
entry('app-bootstrap', ':app-bootstrap', 'src/alpha',
'["sample-portfolio","nope","app-bootstrap"]', '["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains("must not allow a dependency on 'sample-portfolio'"),
failure.message)
}
@Test
@DisplayName("an unknown allowed-dependency id is refused")
void unknownDependencyIsRefused() {
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '["nope"]', '["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('unknown allowed dependency id'), failure.message)
def registry = read(json)
assertEquals(2, registry.modules.size())
assertEquals(['sample-portfolio', 'nope', 'app-bootstrap'],
registry.byId('app-bootstrap').allowedDependencies)
}
@Test
@@ -135,14 +130,29 @@ class ModuleRegistryTest {
}
@Test
@DisplayName("an extra field on a module entry is refused rather than ignored")
void extraFieldIsRefused() {
@DisplayName("an extra field on a module entry is carried, not refused")
void extraFieldIsAccepted() {
// The registry is meant to be extended — a `description`, a `type`, an owner. Nothing reads
// a field this class does not know about, so an extra one cannot change what the build does,
// and refusing it only stopped derived projects adding one.
String json = """{"runtime_compositions":["app-bootstrap","sample-portfolio"],"modules":[
{"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha",
"allowed_dependencies":[],"runtime_memberships":["app-bootstrap"],"extra":true},
${entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]')}]}"""
def registry = read(json)
assertEquals(2, registry.modules.size())
assertEquals(':app-bootstrap', registry.byId('app-bootstrap').gradlePath)
}
@Test
@DisplayName("a module entry missing a required field is still refused")
void missingRequiredFieldIsRefused() {
String json = """{"runtime_compositions":["app-bootstrap","sample-portfolio"],"modules":[
{"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha",
"allowed_dependencies":[]},
${entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('fields must be exactly'), failure.message)
assertTrue(failure.message.contains('is missing [runtime_memberships]'), failure.message)
}
@Test
@@ -25,7 +25,27 @@ class PlatformModuleConventionTest {
@BeforeEach
void setUp() {
projectDir = Files.createTempDirectory('platform-module')
Files.writeString(projectDir.resolve('settings.gradle'), "rootProject.name = 'fixture'\n")
// The conventions read their tool versions from the consuming build's `libs` catalog rather
// than from constants of their own, so a fixture has to bring one. Only the entries
// ca.java-conventions and ca.quality-conventions look up are needed.
Files.createDirectories(projectDir.resolve('gradle'))
Files.writeString(projectDir.resolve('gradle/libs.versions.toml'), '''
[versions]
springBoot = "4.0.8"
googleJavaFormat = "1.35.0"
checkstyle = "13.5.0"
spotbugs = "4.10.2"
findsecbugs = "1.14.0"
errorprone = "2.49.0"
'''.stripIndent())
// No explicit `versionCatalogs` block: Gradle imports gradle/libs.versions.toml as `libs`
// by convention, and declaring it again is rejected as a second `from` call.
Files.writeString(projectDir.resolve('settings.gradle'), '''
dependencyResolutionManagement {
repositories { mavenCentral() }
}
rootProject.name = 'fixture'
'''.stripIndent())
}
private void buildFile(String body) {
@@ -68,10 +88,9 @@ class PlatformModuleConventionTest {
// BOM's POM rather than downloading any jar.
buildFile('''
plugins {
id 'io.spring.dependency-management' version '1.1.7'
id 'ca.platform-module'
id 'ca.grpc-platform-module'
}
repositories { mavenCentral() }
tasks.register('reportManagedVersion') {
String managed = dependencyManagement.managedVersions['io.grpc:grpc-api']
doLast { logger.lifecycle('managed-grpc-api=' + managed) }
@@ -101,20 +120,26 @@ class PlatformModuleConventionTest {
}
@Test
@DisplayName("the grpc convention refuses to be applied before dependency-management")
void grpcConventionRefusesAMissingDependencyManagement() {
// Without Spring's plugin there is no `dependencyManagement` block to import the BOM into.
// Skipping the import quietly is the failure mode this refuses.
@DisplayName("the grpc convention brings dependency-management itself")
void grpcConventionBringsDependencyManagement() {
// The BOM import needs Spring's plugin, and this convention used to throw when a leaf had
// not applied it. It cannot be missing now: ca.platform-module -> ca.java-library ->
// ca.java-conventions applies it. Asserting the extension exists asserts that the chain
// still does, which is what the throw used to protect.
buildFile('''
plugins {
id 'ca.grpc-platform-module'
}
tasks.register('reportDependencyManagement') {
boolean present = project.extensions.findByName('dependencyManagement') != null
doLast { logger.lifecycle('dependency-management-present=' + present) }
}
''')
Files.writeString(projectDir.resolve('gradle.properties'), "grpcVersion=1.68.1\n")
def result = runner('tasks').buildAndFail()
def result = runner('reportDependencyManagement').build()
assertTrue(result.output.contains('io.spring.dependency-management'),
"the refusal should name the plugin the import needs:\n${result.output}")
assertTrue(result.output.contains('dependency-management-present=true'),
"the platform chain should apply Spring's dependency-management:\n${result.output}")
}
}