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:
co-authored by
Claude Opus 5
parent
d00c76241c
commit
ef947e5bb0
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user