refactor: 빌드 최적화 및 ci 수정

This commit is contained in:
donghyeon-ka
2026-09-17 15:24:31 +09:00
parent 944a1e348b
commit ace8aaaef6
263 changed files with 1975 additions and 3130 deletions
@@ -17,9 +17,7 @@ import dev.caskeleton.buildlogic.ModuleRegistry
//
// What the count was supposed to protect is protected better elsewhere and without the copy: a
// registry entry must name an existing directory, `verifyCleanArchitectureDependencies` fails when a
// declared project has no entry or an entry no project, `verifyRuntimeModuleMembership` compares the
// resolved runtime closure against the declared memberships, and `verifyDocumentedLeafCount` fails
// any document that states a count the registry disagrees with. The registry is the SSOT for the
// declared project has no entry or an entry no project, `verifyRuntimeModuleRegistry` validates resolved project dependencies against the registry. The registry is the SSOT for the
// leaf list, so it is the SSOT for its length.
File repositoryRoot = settings.settingsDir.parentFile.canonicalFile
@@ -38,6 +36,3 @@ registry.modules.each { module ->
include module.gradlePath
project(module.gradlePath).projectDir = module.sourceDirectory
}
// Handed to the root build so it reads the same parsed registry rather than parsing it again.
gradle.ext.moduleRegistry = registry
@@ -1,3 +1,4 @@
import dev.caskeleton.buildlogic.ModuleRegistry
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
// The architecture rules. Applied to the root project, because their subject is the repository.
@@ -16,9 +17,10 @@ tasks.register('verifyCleanArchitectureDependencies') {
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
// Reuse the same parser/validation implementation as the settings plugin. Parsing the small
// registry twice is intentional: it avoids hidden Settings -> Gradle global state while keeping
// exactly one definition of a valid registry. Source paths are repository-root-relative.
def registry = ModuleRegistry.read(moduleRegistryFile, rootProject.projectDir.parentFile.canonicalFile)
// Registry-shape rules that used to run in settings, moved here.
//
@@ -26,25 +28,23 @@ tasks.register('verifyCleanArchitectureDependencies') {
// 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.
def productionModules = registry.modules.findAll { it.id != 'sample-portfolio' }
List<String> registryViolations = []
registry.modules.each { module ->
productionModules.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 ->
Map<String, Set<String>> allowedProjectDependencies = productionModules.collectEntries { module ->
String moduleName = module.gradlePath.replaceFirst('^:', '')
Set<String> allowed = module.allowedDependencies
.collect { registry.byId(it) }
.findAll { it != null }
.findAll { it != null && it.id != 'sample-portfolio' }
.collect { it.gradlePath.replaceFirst('^:', '') }
.toSet()
[(moduleName): allowed]
@@ -57,8 +57,9 @@ tasks.register('verifyCleanArchitectureDependencies') {
registryViolations.join('\n '))
}
Set<String> declaredModules = rootProject.subprojects.findAll { it.childProjects.isEmpty() }
.collect { it.path.replaceFirst('^:', '') }.toSet()
Set<String> declaredModules = rootProject.subprojects.findAll {
it.childProjects.isEmpty() && it.path != ':sample-portfolio'
}.collect { it.path.replaceFirst('^:', '') }.toSet()
Set<String> governedModules = allowedProjectDependencies.keySet()
Set<String> missingFromBuild = governedModules - declaredModules
Set<String> missingFromPolicy = declaredModules - governedModules
@@ -89,21 +90,12 @@ tasks.register('verifyCleanArchitectureDependencies') {
}
.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."
"Allowed dependencies are ${allowed}; all production project edges " +
"must be explicitly registered."
)
}
}
@@ -183,7 +175,7 @@ tasks.register('verifyNoIgnoredSourcePackages') {
List<String> violations = []
List<File> sourceFiles = []
rootProject.subprojects.each { sub ->
rootProject.subprojects.findAll { it.path != ':sample-portfolio' }.each { sub ->
['src/main/java', 'src/test/java'].each { String sourceRootPath ->
File sourceRoot = sub.file(sourceRootPath)
if (!sourceRoot.isDirectory()) {
@@ -0,0 +1,78 @@
import java.util.regex.Pattern
import org.gradle.api.tasks.bundling.Jar
Closure<Boolean> isTraceableArchiveFor = { Jar archiveTask, String fileName ->
String baseName = Pattern.quote(archiveTask.archiveBaseName.get())
String classifier = archiveTask.archiveClassifier.orNull
String classifierPart = classifier == null || classifier.isBlank()
? ''
: "-${Pattern.quote(classifier)}"
fileName ==~ /^${baseName}-\d+\.\d+\.\d+\+[0-9a-f]{7,40}${classifierPart}\.jar$/
}
Closure<List<File>> staleTraceableArchivesFor = { Jar archiveTask ->
File outputDirectory = archiveTask.destinationDirectory.get().asFile
if (!outputDirectory.isDirectory()) {
return []
}
String currentName = archiveTask.archiveFileName.get()
List<File> stale = outputDirectory.listFiles({ File ignored, String fileName ->
isTraceableArchiveFor(archiveTask, fileName) && fileName != currentName
} as FilenameFilter)?.toList() ?: []
stale.sort { it.name }
}
tasks.register('cleanStaleTraceableJars') {
group = 'build'
description = 'Explicitly deletes older git-revision JARs from leaf build/libs directories.'
notCompatibleWithConfigurationCache(
'Inspects subproject Jar task models at execution time')
doLast {
int deleted = 0
subprojects.each { subproject ->
subproject.tasks.withType(Jar).each { Jar archiveTask ->
staleTraceableArchivesFor(archiveTask).each { File stale ->
if (!stale.delete()) {
throw new GradleException("cleanStaleTraceableJars: failed to delete ${stale}")
}
deleted++
logger.lifecycle("cleanStaleTraceableJars: deleted ${stale}")
}
}
}
logger.lifecycle("cleanStaleTraceableJars: deleted ${deleted} stale archive(s).")
}
}
tasks.register('verifyNoStaleTraceableJars') {
group = 'verification'
description = 'Fails without mutation when leaf build/libs directories retain old traceable JARs.'
notCompatibleWithConfigurationCache(
'Inspects subproject Jar task models at execution time')
doLast {
List<String> violations = []
subprojects.each { subproject ->
subproject.tasks.withType(Jar).each { Jar archiveTask ->
List<String> staleJars =
staleTraceableArchivesFor(archiveTask).collect { File stale -> stale.name }
if (!staleJars.isEmpty()) {
violations <<
"${archiveTask.path}: stale JAR(s) ${staleJars}; " +
"current archive is ${archiveTask.archiveFileName.get()}"
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyNoStaleTraceableJars: ${violations.size()} archive task(s) retain old " +
"traceable JARs. Run cleanStaleTraceableJars explicitly if removal is " +
"intended.\n ${violations.join('\n ')}")
}
logger.lifecycle(
'verifyNoStaleTraceableJars: OK — no stale traceable JARs in build/libs.')
}
}
@@ -0,0 +1,284 @@
// The environment configuration contract, owned by the composition root.
//
// `verifyEnvKeys` compares docs/registries/env-keys.yaml, app-bootstrap's application.yml,
// src/.env.example and the annotation processor's configuration metadata. That is a question about
// what a deployment of THIS application must be given, so it belongs to the leaf that composes the
// application — not to the repository-wide `check` that `./gradlew :domain-core:check` reached.
//
// Applied from app-bootstrap/build.gradle. The task keeps its name because CI, the README and the
// runbooks call it; what changed is the project that owns it and the lifecycle it hangs off
// (`configContractCheck`, not `check`).
// verifyEnvKeys — keep env-keys.yaml <-> application.yml <-> src/.env.example in lock-step.
//
// The example, not the real file. Reading src/.env made this check false in both directions: it
// passed only where an operator's own environment file happened to be present, and it would have
// passed with no example at all — so the thing an adopter actually copies was never verified, while
// a file full of real credentials was a build input.
// Rationale in README.md.
tasks.register('verifyEnvKeys') {
group = 'verification'
description = 'Verifies application.yml APP_ references, src/.env.example, and env-keys.yaml stay registered.'
File envFile = file("${rootProject.projectDir}/.env.example")
File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml")
File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml")
// Check E reads the annotation processor's output, so the owning module has to have been
// compiled. Without this the check would quietly cover nothing on a clean checkout.
File redisSdkMetadata = file("${rootProject.projectDir}/adapter/outbound/cache-redis/build/" +
'classes/java/main/META-INF/spring-configuration-metadata.json')
dependsOn ':adapter:outbound:cache-redis:compileJava'
inputs.files(envFile, appYml, registryFile)
inputs.file(redisSdkMetadata).optional()
doLast {
if (!envFile.exists()) {
throw new GradleException(
"verifyEnvKeys: missing ${envFile}. The tracked example is the contract an " +
"adopter copies; a real .env is operator input and is never read here.")
}
if (!appYml.exists()) {
throw new GradleException("verifyEnvKeys: missing ${appYml}")
}
if (!registryFile.exists()) {
throw new GradleException("verifyEnvKeys: missing ${registryFile}")
}
def keyPattern = ~/^([A-Z][A-Z0-9_]*)=.*/
Set<String> envKeys = envFile.readLines().findResults { String line ->
def m = keyPattern.matcher(line)
m.matches() ? m.group(1) : null
}.toSet()
// Parse application.yml placeholders: ${VAR} is required, ${VAR:default} is optional.
Set<String> requiredPlaceholders = new TreeSet<>()
Set<String> allPlaceholders = new TreeSet<>()
def pm = (appYml.text =~ /\$\{([A-Z][A-Z0-9_]*)(:[^}]*)?\}/)
while (pm.find()) {
allPlaceholders << pm.group(1)
if (pm.group(2) == null) {
requiredPlaceholders << pm.group(1)
}
}
Set<String> environmentSecretReferences = new TreeSet<>()
def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/)
while (sm.find()) {
environmentSecretReferences << sm.group(1)
}
Set<String> applicationAppReferences = new TreeSet<>(
allPlaceholders.findAll { it.startsWith('APP_') })
applicationAppReferences.addAll(environmentSecretReferences)
// A. Every required (no inline default) placeholder must exist in the example.
Set<String> missingKeys = new TreeSet<>(requiredPlaceholders - envKeys)
if (!missingKeys.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: application.yml references required env absent from src/.env.example: ${missingKeys}")
}
// C. Every APP_ key in the example must be registered in env-keys.yaml (APP_-scoped;
// SPRING_* native keys are intentionally not tracked — see README.md).
def registryNamePattern = ~/^\s*- name: (APP_[A-Z0-9_]+)/
Set<String> registryAppKeys = registryFile.readLines().findResults { String line ->
def m = registryNamePattern.matcher(line)
m.find() ? m.group(1) : null
}.toSet()
// B. Every registered APP_ key appears in the example.
//
// This used to run the other way — every key in the file had to be an application.yml
// placeholder — which was true of a hand-maintained .env and is false of a catalogue: most
// of these are bound by typed settings inside a leaf, not by a placeholder in the
// composition root's YAML. Inverted, it has teeth the original did not: a key added to the
// registry that never reached the file an adopter copies is exactly the drift this is for.
Set<String> missingFromExample = new TreeSet<>(registryAppKeys - envKeys)
if (!missingFromExample.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: docs/registries/env-keys.yaml registers APP_ keys absent from " +
"src/.env.example, so an adopter copying the example never sees them: " +
"${missingFromExample}")
}
Set<String> envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet()
Set<String> unregisteredAppKeys = new TreeSet<>(envAppKeys - registryAppKeys)
if (!unregisteredAppKeys.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: src/.env.example declares APP_ keys absent from docs/registries/env-keys.yaml " +
"(registry is the SSOT for APP_ keys): ${unregisteredAppKeys}")
}
// D. Every application-owned reference is registered, including optional placeholders
// with inline defaults and literal secret://environment/APP_* references.
Set<String> unregisteredApplicationReferences =
new TreeSet<>(applicationAppReferences - registryAppKeys)
if (!unregisteredApplicationReferences.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: application.yml references APP_ keys absent from " +
"docs/registries/env-keys.yaml (optional defaults and environment " +
"secret references are included): ${unregisteredApplicationReferences}")
}
// E. Typed properties that are deliberately absent from application.yml and the example.
//
// Checks AD compare three text files, so a property that exists only as a typed
// @ConfigurationProperties field is invisible to them: the Redis SDK shipped 34 settings
// with no registered env name at all and verifyEnvKeys passed. Conditionally-composed
// adapters cannot be fixed by adding their settings to application.yml — that is what
// would make a Redis-free deployment carry Redis configuration — so the third SSOT for
// them is the annotation processor's own metadata, compared against the registry in both
// directions: a typed property with no row, and a row naming a property that no longer
// exists, are both failures.
// One prefix, deliberately, and the limit is worth stating because the summary line below
// ("N typed properties registered") reads like a repository-wide claim and is not one.
//
// Fourteen modules emit configuration metadata and it holds 311 distinct properties, of
// which 61 have `property:` rows in the registry. Those two sets are not meant to be equal:
// the registry's subject is the operator-facing environment surface, and most of the 250
// others are internal — map-valued trees, experimental toggles, properties with no env
// spelling at all. Comparing them wholesale would fail on the difference rather than on
// drift.
//
// So widening this map is a policy decision — which properties are supposed to have a
// registry row — rather than a mechanical fix, and until that is decided this check covers
// the one namespace that opted in.
Map<String, String> metadataScopes = [
'app.redis.': 'adapter/outbound/cache-redis'
]
Set<String> typedProperties = new TreeSet<>()
Set<String> missingMetadata = new TreeSet<>()
metadataScopes.each { propertyPrefix, modulePath ->
File metadata = file(
"${rootProject.projectDir}/${modulePath}/build/classes/java/main/" +
'META-INF/spring-configuration-metadata.json')
if (!metadata.exists()) {
missingMetadata << "${propertyPrefix} (${metadata})".toString()
return
}
def parsed = new groovy.json.JsonSlurper().parse(metadata)
(parsed.properties ?: []).each { property ->
if (property.name?.startsWith(propertyPrefix)) {
typedProperties << property.name.toString()
}
}
}
if (!missingMetadata.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: configuration metadata is missing for ' + missingMetadata +
' — run the owning module\'s compileJava first (the annotation ' +
'processor writes it), or the typed-property check silently covers ' +
'nothing.')
}
def registryPropertyPattern = ~/^\s*property:\s*(\S+)/
Set<String> registryProperties = registryFile.readLines().findResults { String line ->
def m = registryPropertyPattern.matcher(line)
m.find() ? m.group(1) : null
}.toSet()
Set<String> unregisteredTypedProperties = new TreeSet<>(typedProperties - registryProperties)
if (!unregisteredTypedProperties.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: typed configuration properties absent from ' +
"docs/registries/env-keys.yaml: ${unregisteredTypedProperties} every " +
'bindable property needs a registry row carrying its official env ' +
'name, type, default, secret classification and required_when.')
}
Set<String> scopedRegistryProperties = registryProperties.findAll { String property ->
metadataScopes.keySet().any { property.startsWith(it) }
}.toSet()
Set<String> orphanedRegistryProperties =
new TreeSet<>(scopedRegistryProperties - typedProperties)
if (!orphanedRegistryProperties.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: docs/registries/env-keys.yaml declares properties that no ' +
"typed settings class binds any more: ${orphanedRegistryProperties} " +
'remove the row or restore the property.')
}
// F. Every registered key has a consumer, or says out loud that it does not.
// Checks A-E each compare two SSOTs, and a row that appears in none of them falls
// through all of them: APP_CACHE_REDIS_TRUST_PEM and four namespace keys sat in the
// registry with no typed property, no application.yml reference and no .env entry,
// documented as if a deployment could still use them. A key nothing reads is worse
// than an undocumented one — an operator sets it, nothing happens, and the
// configuration looks correct.
Map<String, Map<String, String>> registryRows = [:]
String currentRow = null
registryFile.readLines().each { String line ->
def nameMatch = (line =~ /^\s*- name: (APP_[A-Z0-9_]+)/)
if (nameMatch.find()) {
currentRow = nameMatch.group(1)
registryRows[currentRow] = [:]
return
}
if (currentRow == null) {
return
}
def fieldMatch = (line =~ /^\s*([a-z_]+):\s*(\S.*)?$/)
if (fieldMatch.find()) {
registryRows[currentRow][fieldMatch.group(1)] = (fieldMatch.group(2) ?: '').trim()
}
}
Set<String> consumed = new TreeSet<>()
consumed.addAll(applicationAppReferences)
consumed.addAll(envAppKeys)
// A key can be read in ways checks A-D never look at: another production module's
// application.yml or Java that names a secret directly, as SecretSourceValidator does.
// Count those sources, but keep the removable preview sample outside the production config
// contract entirely. Source only: build outputs are excluded so stale processResources
// copies cannot make a deleted key look consumed.
def appKeyPattern = ~/APP_[A-Z][A-Z0-9_]*/
rootProject.projectDir.eachFileRecurse { File candidate ->
if (!candidate.isFile() || candidate.path.contains('/build/') ||
candidate.path.contains('/sample-portfolio/')) {
return
}
boolean interesting =
(candidate.name == 'application.yml' && candidate.path.contains('/main/')) ||
(candidate.name.endsWith('.java') && candidate.path.contains('/src/main/'))
if (!interesting) {
return
}
def matcher = appKeyPattern.matcher(candidate.text)
while (matcher.find()) {
consumed << matcher.group()
}
}
Set<String> unconsumed = new TreeSet<>(registryRows.keySet().findAll { String name ->
Map<String, String> row = registryRows[name]
!consumed.contains(name) &&
!row.containsKey('property') &&
row['deprecated_orphaned'] != 'true'
})
// Enforced for the surfaces this branch owns; reported for the rest. A key nothing reads is
// a defect wherever it lives, but silently adopting another feature's backlog into a
// blocking gate is how a gate acquires an exclusion list. The rest are named on every run so
// they cannot be forgotten, and their owning branch turns them into failures here.
def enforcedPrefixes = ['APP_REDIS_', 'APP_CACHE_REDIS_', 'APP_RATE_LIMIT_REDIS_',
'APP_IDEMPOTENCY_REDIS_', 'APP_LEASE_REDIS_', 'APP_SESSION_REDIS_']
Set<String> unconsumedOwned =
new TreeSet<>(unconsumed.findAll { String name -> enforcedPrefixes.any { name.startsWith(it) } })
if (!unconsumedOwned.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: registered Redis keys that nothing reads — no typed property, ' +
'no application.yml reference, no src/.env entry, no Java consumer, ' +
"and not marked deprecated_orphaned: ${unconsumedOwned}. Wire the key " +
'to a consumer, or mark the row deprecated_orphaned with a ' +
'removal_deadline so a deployment still setting it is told rather ' +
'than silently ignored.')
}
Set<String> unconsumedElsewhere = new TreeSet<>(unconsumed - unconsumedOwned)
if (!unconsumedElsewhere.isEmpty()) {
logger.warn('verifyEnvKeys: registered keys outside the Redis surface that nothing ' +
"reads yet: ${unconsumedElsewhere} owned by the branch that registered them.")
}
logger.lifecycle("verifyEnvKeys: OK ${envKeys.size()} env keys, " +
"${requiredPlaceholders.size()} required placeholders covered, " +
"${applicationAppReferences.size()} application APP_ references registered, " +
"${typedProperties.size()} typed properties registered, " +
"${registryRows.size() - unconsumed.size()} rows with a consumer or a deprecation.")
}
}
@@ -23,7 +23,8 @@ Closure<Map<String, Object>> readJUnitEvidence = { String evidenceName, File res
skipped : results.skipped,
failures : results.failures,
errors : results.errors,
executedClasses: results.executedClasses
executedClasses: results.executedClasses,
executedSelectors: results.executedSelectors
] as Map<String, Object>
}
@@ -0,0 +1,103 @@
// GraphQL API 실행 플랫폼 verification lanes.
//
// The GraphQL platform design package ships a 16-module Stable map and a 12-module Advanced map.
// Those maps are realised as bounded PACKAGES inside the single registered `adapter-inbound-graphql`
// leaf — the same mapping the httpclient capability already uses — because the GraphQL surface is one
// inbound transport boundary whose internal split does not have to reach the repository-wide leaf
// registry (`src/config/architecture/modules.json`).
//
// Note the counter-example: the sibling messaging platform made the opposite call and registered 24
// leaves of its own. The registry is extensible, so this is a deliberate trade-off, not a constraint.
// Whichever pattern the repository standardises on, the module identities, their allowed internal
// dependency edges and the Stable→Advanced isolation rule stay machine-checked through
// `moduleboundary/GraphQlStableModule`, `moduleboundary/GraphQlAdvancedModule` (declaration) and
// the test-source `moduleboundary/GraphQlBuildModel` + `GraphQlModuleBoundaryTest` (the scan).
//
// The package is `moduleboundary`, not `build`: `src/.gitignore` carries an unanchored `build/`
// rule for Gradle output, which once swallowed this entire model — production code imported types
// that no fresh checkout contained. `verifyNoIgnoredSourcePackages` now blocks that class of
// mistake repository-wide, and the required-class check below blocks the other half of it, where
// the boundary test quietly disappears and the lane still reports green.
//
// One lane survives here: `graphqlStableTest`, the Stable platform unit + boundary lane that CI
// runs (.github/workflows/ci-quality-gates.yml). It earns its place next to the default `test` task
// for exactly one reason — the required-class check below, which refuses a green lane that executed
// no case of the module-boundary test.
//
// Three further lanes were registered here and are gone. `graphqlContractTest` and
// `graphqlAdvancedTest` re-selected `@Tag("graphql-contract")` and `@Tag("graphql-advanced")` tests
// that the default `test` task already runs, so deleting them changes the set of executed tests by
// nothing, and no workflow, build file or `check` ever named either one. `graphqlPerformanceTest`
// demanded load, soak and fault scenarios that do not exist — no test in this repository carries
// `@Tag("graphql-performance")` — so the lane failed by construction on every invocation, which is
// why nothing ever invoked it. A lane that always fails and that nobody runs blocks nothing.
//
// The `graphql-performance` exclusion went with them, from this lane and from the default `test`
// task (`excludeTags 'quarantine'` already reaches every leaf's `test` from src/build.gradle).
// Keeping an exclusion after deleting the only lane that selected the tag would mean a future
// `@Tag("graphql-performance")` test runs nowhere and says so nowhere. When a real load environment
// exists, declare the lane through the `ca.strict-test-lane` convention plugin rather than
// re-deriving `failOnNoDiscoveredTests` and an empty-result check by hand.
ext.registerGraphQlPlatformTestLanes = { ->
String platformPackage = 'dev.caskeleton.adapter.inbound.graphql'
// Classes whose absence must fail the Stable lane instead of shrinking it. `failOnNoMatchingTests`
// only reacts to an empty lane, so deleting one boundary class out of four hundred tests is
// invisible to it — and losing exactly this class is how the platform shipped without an
// enforced module boundary in the first place.
List<String> requiredStableClasses = [
"${platformPackage}.moduleboundary.GraphQlModuleBoundaryTest".toString(),
]
// Resolved at configuration time: reaching for `rootProject` inside a task action is
// configuration-cache hostile and Gradle 10 removes it.
if (!rootProject.ext.has('readJUnitEvidence')) {
throw new GradleException(
'graphql-platform-conventions.gradle requires gradle/junit-evidence.gradle.')
}
Closure<Map<String, Object>> readJUnitEvidence = rootProject.ext.readJUnitEvidence
tasks.register('graphqlStableTest', Test) {
description = 'Runs the Stable GraphQL platform test lane (Stable plan Task 1-48).'
group = 'verification'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
jvmArgs '-Duser.timezone=UTC'
outputs.upToDateWhen { false }
useJUnitPlatform {
excludeTags 'quarantine', 'graphql-advanced'
}
filter {
includeTestsMatching "${platformPackage}.*"
failOnNoMatchingTests = true
}
failOnNoDiscoveredTests = true
reports.junitXml.required = true
reports.junitXml.outputLocation = layout.buildDirectory.dir('test-results/graphqlStableTest')
doFirst {
// Stale XML from a previous run would let a deleted class report as executed.
File staleResults = reports.junitXml.outputLocation.get().asFile
if (staleResults.exists() && !staleResults.deleteDir()) {
throw new GradleException(
"graphqlStableTest could not delete stale JUnit XML: ${staleResults}")
}
}
doLast {
Map<String, Object> evidence = readJUnitEvidence(
'graphqlStableTest', reports.junitXml.outputLocation.get().asFile)
Set<String> executed = evidence.executedClasses as Set<String>
List<String> missing = requiredStableClasses.findAll { String required ->
!executed.any { String actual ->
actual == required || actual.startsWith(required + '$')
}
}
if (!missing.isEmpty()) {
throw new GradleException(
'graphqlStableTest executed no test case for required boundary class(es): ' +
"${missing}. The lane is green only because the class is gone; restore it " +
'rather than removing it from requiredStableClasses.')
}
}
}
}
@@ -1,3 +1,5 @@
import org.gradle.api.artifacts.VersionCatalogsExtension
// A vendored platform leaf that compiles against io.grpc: `ca.platform-module` plus the grpc BOM.
//
// io.grpc is not managed by the Spring Boot BOM, so four `grpc:*` leaves each imported grpc-bom at
@@ -22,27 +24,13 @@ plugins {
id 'ca.platform-module'
}
// Read from the root's `ext.grpcVersion` SSOT, which is where the four leaves read it from. Resolved
// at apply time, which is the same moment their inline blocks resolved it: the root sets the property
// while evaluating its own build file, long before any leaf is evaluated.
Object declaredGrpcVersion = project.rootProject.findProperty('grpcVersion')
if (declaredGrpcVersion == null || declaredGrpcVersion.toString().isBlank()) {
throw new GradleException(
"${project.path} applies ca.grpc-platform-module, which imports io.grpc:grpc-bom, but " +
'the root project declares no `ext.grpcVersion`. Importing an unversioned BOM ' +
'would leave every io.grpc coordinate in this leaf unmanaged.')
// The gRPC version is owned by the shared version catalog. Optional builds import the same
// catalog file, so the platform no longer reaches into a parent/root ext property.
def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs')
def grpcVersionConstraint = versionCatalog.findVersion('grpc').orElseThrow {
new GradleException("Version catalog 'libs' must define version 'grpc' for ca.grpc-platform-module")
}
String grpcVersion = declaredGrpcVersion.toString()
// 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.
String grpcVersion = grpcVersionConstraint.requiredVersion
dependencyManagement {
imports {
@@ -24,6 +24,7 @@ plugins {
id 'ca.api-surface'
id 'ca.dependency-policy'
id 'ca.strict-qualification'
id 'ca.test-jvm-agents'
}
// The main build's catalog, read through the Gradle API rather than the `libs` accessor, which is
@@ -32,6 +33,9 @@ plugins {
def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs')
String springBootVersion = versionCatalog.findVersion('springBoot').get().requiredVersion
group = 'dev.caskeleton'
version = rootProject.ext.has('traceableVersion') ? rootProject.ext.traceableVersion : '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
@@ -0,0 +1,137 @@
// NTF-022 — the notification platform's public type surface, pinned.
//
// Nearly every top-level type in the platform is public, which means the boundary between "the API
// other code may build on" and "an implementation detail that happens to be reachable" is not
// written down anywhere. Enforcing internal-by-default across several hundred types is a design
// change; pinning the surface is not, and it converts surface growth from something that happens
// silently into something a reviewer sees. A new public type is then a line in a diff.
Closure<String> renderNotificationApiSurface = { List<File> sourceRoots ->
List<String> types = []
sourceRoots.each { File root ->
if (!root.isDirectory()) {
return
}
root.eachFileRecurse { File file ->
if (!file.isFile() || !file.name.endsWith('.java') || file.name == 'package-info.java') {
return
}
String text = file.getText('UTF-8')
def packageMatcher = (text =~ /(?m)^package\s+([\w.]+);/)
if (!packageMatcher.find()) {
return
}
String packageName = packageMatcher.group(1)
// Only top-level public declarations count. A nested public type is reachable only
// through its owner, so it is part of that owner's surface, not a separate one.
def declarationMatcher =
(text =~ /(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+|static\s+)*(class|interface|record|enum|@interface)\s+(\w+)/)
while (declarationMatcher.find()) {
types << "${packageName}.${declarationMatcher.group(2)}".toString()
}
}
}
String header =
"# NTF-022 — public type surface of the notification platform.\n" +
"# Every top-level public type under the platform packages. Growth is a reviewed\n" +
"# change: ./gradlew updateNotificationApiSurface -PapproveNotificationApiChange\n"
header + (types.isEmpty() ? '' : types.toSorted().unique().join('\n') + '\n')
}
List<File> notificationApiSourceRoots = [
rootProject.file('application-core/src/main/java/dev/caskeleton/application/notification'),
rootProject.file(
'adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification'),
]
File notificationApiSnapshotFile =
rootProject.file('../docs/notification/api-surface-snapshot.txt')
boolean notificationApiUpdateApproved = project.hasProperty('approveNotificationApiChange')
// Growing the surface is a second decision. Approving additions one at a time is how a leaf grows
// past the size that would have justified splitting it, with every step reviewed and the total
// never discussed. A change that removes more than it adds needs only the approval.
boolean notificationApiCeilingRaiseApproved =
project.hasProperty('raiseNotificationApiCeiling')
tasks.register('verifyNotificationApiSurface') {
group = 'verification'
description = 'Fails without mutation when the notification platform public type surface drifts.'
inputs.files(notificationApiSourceRoots.findAll { it.isDirectory() })
inputs.property('updateApprovalRequested', notificationApiUpdateApproved)
doLast {
if (notificationApiUpdateApproved) {
throw new GradleException(
'verifyNotificationApiSurface is read-only; use updateNotificationApiSurface ' +
'-PapproveNotificationApiChange for an intentional update.')
}
String canonical = renderNotificationApiSurface(notificationApiSourceRoots)
List<String> canonicalTypes = canonical.readLines().findAll { !it.startsWith('#') }
if (canonicalTypes.isEmpty()) {
// An empty rendering means the source roots moved and the check would pass vacuously —
// the exact failure this file exists to prevent, so it is an error rather than a pass.
throw new GradleException(
'verifyNotificationApiSurface: found no public types under ' +
notificationApiSourceRoots.join(', ') +
'. The source roots moved; fix the paths rather than accepting an empty surface.')
}
if (!notificationApiSnapshotFile.isFile()) {
throw new GradleException(
"verifyNotificationApiSurface: missing committed baseline ${notificationApiSnapshotFile}")
}
List<String> committed =
notificationApiSnapshotFile.readLines('UTF-8').findAll { !it.startsWith('#') }
List<String> added = (canonicalTypes - committed).toSorted()
List<String> removed = (committed - canonicalTypes).toSorted()
if (!added.isEmpty() || !removed.isEmpty()) {
throw new GradleException(
'verifyNotificationApiSurface: the notification public type surface changed.\n' +
(added.isEmpty() ? '' : " added (${added.size()}):\n " + added.join('\n ') + '\n') +
(removed.isEmpty() ? '' : " removed (${removed.size()}):\n " + removed.join('\n ') + '\n') +
'A type added here is a type other code may now depend on forever. If that is intended:\n' +
' ./gradlew updateNotificationApiSurface -PapproveNotificationApiChange')
}
logger.lifecycle(
"verifyNotificationApiSurface: OK — ${canonicalTypes.size()} public types, unchanged.")
}
}
tasks.register('updateNotificationApiSurface') {
group = 'build setup'
description = 'Explicitly updates the committed notification public type surface after review.'
inputs.files(notificationApiSourceRoots.findAll { it.isDirectory() })
inputs.property('approved', notificationApiUpdateApproved)
outputs.file(notificationApiSnapshotFile)
outputs.upToDateWhen { false }
doLast {
if (!notificationApiUpdateApproved) {
throw new GradleException(
'updateNotificationApiSurface requires -PapproveNotificationApiChange')
}
String canonical = renderNotificationApiSurface(notificationApiSourceRoots)
if (notificationApiSnapshotFile.isFile() && !notificationApiCeilingRaiseApproved) {
int committedCount = notificationApiSnapshotFile.readLines('UTF-8')
.count { !it.startsWith('#') && !it.trim().isEmpty() }
int renderedCount = canonical.readLines()
.count { !it.startsWith('#') && !it.trim().isEmpty() }
if (renderedCount > committedCount) {
throw new GradleException(
"updateNotificationApiSurface: the public surface would grow from " +
"${committedCount} to ${renderedCount} types.\n" +
'Either land the addition together with a removal that pays for it, ' +
'or raise the ceiling deliberately:\n' +
' ./gradlew updateNotificationApiSurface ' +
'-PapproveNotificationApiChange -PraiseNotificationApiCeiling')
}
}
if (!notificationApiSnapshotFile.parentFile.isDirectory()
&& !notificationApiSnapshotFile.parentFile.mkdirs()) {
throw new GradleException(
"updateNotificationApiSurface: failed to create ${notificationApiSnapshotFile.parentFile}")
}
notificationApiSnapshotFile.setText(canonical, 'UTF-8')
logger.lifecycle(
"updateNotificationApiSurface: wrote reviewed baseline ${notificationApiSnapshotFile}")
}
}
@@ -0,0 +1,93 @@
// NTF-025 — an env key the registry lists and nothing reads.
//
// This task used to check four relationships at once: application.yml against the configuration
// reference document in both directions, and application.yml against the env-key registry in both
// directions. Three of the four are now owned elsewhere or were never a build concern.
//
// * application.yml -> env-keys.yaml is `verifyEnvKeys` check D (src/build.gradle), which makes
// the same comparison over every `APP_*` reference, optional inline defaults included. The
// notification platform's keys all carry the `APP_` prefix, so they were being compared twice by
// two implementations that could disagree.
// * application.yml <-> docs/notification/configuration-reference.md was documentation drift. A
// reference that names a property the binding never had is a bad document, not a broken
// platform: nothing fails to start, no request is mishandled, no data moves. It was failing the
// `check` of every leaf in the repository over prose.
//
// What remains is the one direction nothing else covers: a key registered in env-keys.yaml that no
// binding reads. That one is worth a build failure because the registry is what an operator
// configures from — a key listed there that reaches no binding is an instruction to set an
// environment variable that does nothing, and it is indistinguishable from one that works.
//
// It reads the composition root's whole YAML set, not application.yml alone. Two of the deleted
// checks located the platform tree by slicing application.yml between the literals
// ` notification:\n platform:` and `\n persistence:` — an indent width and the NAME OF A
// SIBLING KEY. The tree has since moved into config/notification.yml, imported by
// application.yml's `spring.config.import`, so both literals stopped matching and this task failed
// on every `check` in the repository at its first assertion. Scanning application.yml plus every
// config/*.yml it imports means the same keys are found wherever the composition root chooses to
// keep them.
tasks.register('verifyNotificationConfiguration') {
group = 'verification'
description = 'Fails when docs/registries/env-keys.yaml registers a notification platform key no binding reads.'
File resourceRoot = rootProject.file('app-bootstrap/src/main/resources')
File applicationYaml = new File(resourceRoot, 'application.yml')
File configurationDirectory = new File(resourceRoot, 'config')
File environmentRegistry = rootProject.file('../docs/registries/env-keys.yaml')
inputs.files(applicationYaml, environmentRegistry)
inputs.dir(configurationDirectory)
doLast {
[applicationYaml, environmentRegistry].each { File required ->
if (!required.isFile()) {
throw new GradleException("verifyNotificationConfiguration: missing ${required}")
}
}
List<File> boundSources = [applicationYaml]
if (configurationDirectory.isDirectory()) {
boundSources.addAll(
configurationDirectory.listFiles()
.findAll { File file -> file.isFile() && file.name.endsWith('.yml') }
.toSorted { File file -> file.name })
}
Set<String> boundVariables = new TreeSet<>()
boundSources.each { File source ->
def placeholder =
(source.getText('UTF-8') =~ /\$\{(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)(:[^}]*)?\}/)
while (placeholder.find()) {
boundVariables << placeholder.group(1)
}
}
if (boundVariables.isEmpty()) {
// Fail closed. An empty set makes every registry entry look unread, but it far more
// likely means the platform tree moved again, and a check comparing nothing against
// nothing passes forever.
throw new GradleException(
'verifyNotificationConfiguration: no APP_NOTIFICATION_PLATFORM_* placeholder is ' +
"bound anywhere in ${rootProject.relativePath(resourceRoot)}, so there is " +
'nothing to compare the registry against.')
}
Set<String> registeredVariables = new TreeSet<>()
def registered = (environmentRegistry.getText('UTF-8')
=~ /(?m)^\s*- name:\s*(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]+)\s*$/)
while (registered.find()) {
registeredVariables << registered.group(1)
}
List<String> problems = (registeredVariables - boundVariables).collect {
"${it} is registered in env-keys.yaml and bound by nothing".toString()
}
if (!problems.isEmpty()) {
throw new GradleException(
'verifyNotificationConfiguration: the env-key registry promises settings the ' +
"binding does not have.\n " + problems.join('\n ') +
'\nThe binding is the fact; the registry describes it.')
}
logger.lifecycle(
"verifyNotificationConfiguration: OK — ${registeredVariables.size()} registered " +
"platform keys, all bound under ${rootProject.relativePath(resourceRoot)}.")
}
}
@@ -0,0 +1,155 @@
// NTF-024 — a support grade may not outrun the evidence that backs it.
//
// The grade column in docs/notification/support-matrix.md is the strongest claim the platform makes
// about itself, and nothing connected it to anything executable. This task connects them: the
// manifest declares which claims each grade requires and which artifact proves each claim, and the
// task refuses a grade whose claims are not all satisfied by files that actually exist.
//
// It fails on the manifest as well as on the document. An "evidence" entry naming a test that has
// been renamed or deleted is exactly how a gate goes quiet without anyone noticing.
import groovy.json.JsonSlurper
File notificationEvidenceManifest =
rootProject.file('../docs/notification/evidence-manifest.json')
tasks.register('verifyNotificationEvidence') {
group = 'verification'
description = 'Fails when a notification support grade claims more than the executable evidence proves.'
inputs.file(notificationEvidenceManifest)
doLast {
if (!notificationEvidenceManifest.isFile()) {
throw new GradleException(
"verifyNotificationEvidence: missing manifest ${notificationEvidenceManifest}")
}
def manifest = new JsonSlurper().parse(notificationEvidenceManifest)
File repositoryDirectory = rootProject.projectDir.parentFile
File matrixFile = new File(repositoryDirectory, manifest.matrixDocument as String)
if (!matrixFile.isFile()) {
throw new GradleException(
"verifyNotificationEvidence: missing support matrix ${matrixFile}")
}
List<String> problems = []
// 1. Every claim that says it is satisfied must name artifacts that exist.
Set<String> satisfied = [] as Set
manifest.claims.each { String claim, Object declaration ->
List<String> evidence = (declaration.evidence ?: []) as List<String>
if (declaration.status == 'satisfied') {
if (evidence.isEmpty()) {
problems << "claim '${claim}' is marked satisfied with no evidence at all"
return
}
List<String> missing = evidence.findAll { String path ->
!new File(rootProject.projectDir, path).isFile()
}
if (missing.isEmpty()) {
satisfied << claim
} else {
problems << "claim '${claim}' names evidence that does not exist: ${missing.join(', ')}"
}
} else if (!evidence.isEmpty()) {
problems << "claim '${claim}' is not satisfied but names evidence; " +
'either the status or the evidence list is wrong'
}
}
// 2. Every grade the matrix uses must be one the manifest defines, and every claim that
// grade requires must be satisfied.
//
// The grade column is located by its header, not by its position. The previous pattern took
// the third cell of every line with four or more pipes, which is the channel table's grade
// column by coincidence: the document's two other tables happen to have two columns, so
// they never matched. Adding a third column to either of them, or reordering the channel
// table, would have fed an unrelated cell to the "unknown grade" failure below.
//
// Only tables that ASSIGN a grade are read. A claim is "subject X is at grade G", so the
// grade column must be preceded by the column naming the subject; a table whose FIRST
// column is Grade is defining what the grades mean ("| Grade | Requires |"), not claiming
// one, and its left column is the manifest's own vocabulary rather than a promise about a
// channel.
Set<String> knownGrades = manifest.grades.keySet() as Set
Closure<List<String>> tableCells = { String line ->
String trimmed = line.trim()
if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) {
return null
}
trimmed.substring(1, trimmed.length() - 1).split(/\|/, -1).collect { it.trim() }
}
boolean insideTable = false
int gradeColumn = -1
int gradeAssigningTables = 0
matrixFile.readLines('UTF-8').eachWithIndex { String line, int index ->
List<String> cells = tableCells(line)
if (cells == null) {
insideTable = false
gradeColumn = -1
return
}
if (cells.every { it.isEmpty() || it ==~ /:?-{2,}:?/ }) {
return
}
if (!insideTable) {
// Header row: does this table assign a grade to something?
insideTable = true
gradeColumn = cells.indexOf('Grade')
if (gradeColumn > 0) {
gradeAssigningTables++
} else {
gradeColumn = -1
}
return
}
if (gradeColumn < 0) {
return
}
if (gradeColumn >= cells.size()) {
problems << ("${matrixFile.name}:${index + 1} has ${cells.size()} cell(s) but its " +
"table's grade column is ${gradeColumn + 1}").toString()
return
}
String grade = cells[gradeColumn]
if (grade.isEmpty()) {
return
}
if (!knownGrades.contains(grade)) {
problems << "${matrixFile.name}:${index + 1} uses grade '${grade}', " +
"which the evidence manifest does not define"
return
}
List<String> required = (manifest.grades[grade] ?: []) as List<String>
List<String> unmet = required.findAll { !satisfied.contains(it) }
if (!unmet.isEmpty()) {
problems << "${matrixFile.name}:${index + 1} claims '${grade}', which requires " +
"${unmet.join(', ')} — not proven by any artifact in the manifest"
}
}
if (knownGrades.isEmpty()) {
// A manifest with no grades would let every document line pass unexamined.
throw new GradleException(
'verifyNotificationEvidence: the manifest defines no grades, so the check ' +
'would pass whatever the support matrix claims.')
}
if (gradeAssigningTables == 0) {
// Renaming or dropping the grade column would otherwise leave this task green while it
// examined nothing at all — the quiet failure the header of this file warns about.
throw new GradleException(
"verifyNotificationEvidence: ${matrixFile.name} has no table that assigns a " +
"grade (a 'Grade' column that is not the first column), so no claim in " +
'it was checked.')
}
if (!problems.isEmpty()) {
throw new GradleException(
'verifyNotificationEvidence: the support matrix claims more than the evidence ' +
"proves.\n " + problems.join('\n ') +
'\nEither add the artifact and mark the claim satisfied, or lower the grade. ' +
'A grade is a promise about production behaviour; the manifest is where it is kept.')
}
logger.lifecycle(
"verifyNotificationEvidence: OK — ${satisfied.size()} claims proven, " +
"every grade in ${matrixFile.name} is backed.")
}
}
@@ -0,0 +1,150 @@
// The snapshot's input is the COMMITTED binding default in config/security.yml, not src/.env.
//
// It used to read rootProject.file('.env'). /.gitignore:7 excludes `src/.env*` (allowing only the
// two *.example files), so `git ls-files src/.env` is empty and the file does not exist in a CI
// checkout — a gate whose expected value comes from an untracked file is not reproducible, and the
// first line of this closure turned that into a hard failure on any clean machine. Locally it was
// worse than a failure: it passed against one developer's file. The committed snapshot recorded
// `/api/healthcheck`, taken from that local .env, while the shipped default in
// app-bootstrap/src/main/resources/config/security.yml binds
// public-paths: ${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck}
// = /v1/healthcheck. The reviewed snapshot therefore described a surface no deployment had.
//
// What the snapshot now pins is the permitAll surface a deployment gets when no operator override
// is set — the thing a reviewer must see change. An operator's own SECURITY_PUBLIC_PATHS at run
// time is outside the repository and outside any build gate; the default is the part this
// repository is accountable for.
Closure<String> renderPublicPathSnapshot = { File securityConfigFile ->
if (!securityConfigFile.isFile()) {
throw new GradleException(
"missing public-path security configuration ${securityConfigFile}")
}
def bindingPattern = ~/^\s*public-paths:\s*(\S.*?)\s*$/
List<String> bindings = securityConfigFile.readLines('UTF-8').findResults { String line ->
def matcher = bindingPattern.matcher(line)
matcher.matches() ? matcher.group(1) : null
}
if (bindings.size() != 1) {
throw new GradleException(
"expected exactly one 'public-paths:' binding in ${securityConfigFile}, " +
"found ${bindings.size()} — the snapshot cannot say which surface it pins")
}
// Resolve Spring placeholders to their defaults, innermost first:
// ${A:${B:/v1}/healthcheck} -> ${A:/v1/healthcheck} -> /v1/healthcheck.
// `[^{}]*` only ever matches the innermost placeholder, so one substitution per pass unwinds
// the nesting from the inside out without any replacement-string escaping.
def defaultedPlaceholder = ~/\$\{[A-Za-z0-9_.]+:([^{}]*)\}/
String raw = bindings.first()
for (int guard = 0; guard < 16; guard++) {
def matcher = defaultedPlaceholder.matcher(raw)
if (!matcher.find()) {
break
}
raw = raw.substring(0, matcher.start()) + matcher.group(1) + raw.substring(matcher.end())
}
if (raw.contains('${')) {
throw new GradleException(
"'public-paths' in ${securityConfigFile} resolves to '${raw}', which still holds a " +
'placeholder with no default — the deployed public path surface is not ' +
'determined by the repository and cannot be snapshotted')
}
List<String> publicPaths = raw.split(',')
.collect { String value -> value.trim() }
.findAll { String value -> !value.isEmpty() }
.toSorted()
String header =
"# feature-security-operational-baseline D5 — deny-by-default public path snapshot.\n" +
"# SSOT: ca-skeleton.security.public-paths default in " +
"app-bootstrap/src/main/resources/config/security.yml\n" +
"# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own " +
"SECURITY_PUBLIC_PATHS\n" +
"# overrides it at run time and is outside this snapshot.\n" +
"# Update only after review with: ./gradlew updatePublicPathSnapshot " +
"-PapprovePublicPathChange\n"
header + (publicPaths.isEmpty() ? '' : publicPaths.join('\n') + '\n')
}
File publicPathSourceFile =
rootProject.file('app-bootstrap/src/main/resources/config/security.yml')
File publicPathSnapshotFile =
rootProject.file('../docs/security/public-paths-snapshot.txt')
boolean publicPathUpdateApproved = project.hasProperty('approvePublicPathChange')
def existingPublicPathSource = providers.provider {
publicPathSourceFile.isFile() ? publicPathSourceFile : null
}
def existingPublicPathSnapshot = providers.provider {
publicPathSnapshotFile.isFile() ? publicPathSnapshotFile : null
}
tasks.register('verifyPublicPathSnapshot') {
group = 'verification'
description = 'Fails without mutation when the committed deny-by-default public path baseline drifts.'
inputs.file(existingPublicPathSource).optional()
inputs.file(existingPublicPathSnapshot).optional()
inputs.property('updateApprovalRequested', publicPathUpdateApproved)
doLast {
if (publicPathUpdateApproved) {
throw new GradleException(
'verifyPublicPathSnapshot is read-only; use updatePublicPathSnapshot ' +
'-PapprovePublicPathChange for an intentional update.')
}
String canonical
try {
canonical = renderPublicPathSnapshot(publicPathSourceFile)
} catch (GradleException exception) {
throw new GradleException(
"verifyPublicPathSnapshot: ${exception.message}", exception)
}
if (!publicPathSnapshotFile.isFile()) {
throw new GradleException(
"verifyPublicPathSnapshot: missing committed baseline ${publicPathSnapshotFile}")
}
String existing = publicPathSnapshotFile.getText('UTF-8')
if (existing != canonical) {
throw new GradleException(
"verifyPublicPathSnapshot: the deny-by-default public path surface changed.\n" +
" expected (snapshot):\n${existing}\n" +
" actual (security.yml public-paths default):\n${canonical}\n" +
'A protected endpoint may now be public. Review the change, then run:\n' +
' ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange')
}
logger.lifecycle(
'verifyPublicPathSnapshot: OK — committed public paths are unchanged.')
}
}
tasks.register('updatePublicPathSnapshot') {
group = 'build setup'
description = 'Explicitly updates the committed public path baseline after security review.'
inputs.file(existingPublicPathSource).optional()
inputs.property('approved', publicPathUpdateApproved)
outputs.file(publicPathSnapshotFile)
outputs.upToDateWhen { false }
doLast {
if (!publicPathUpdateApproved) {
throw new GradleException(
'updatePublicPathSnapshot requires -PapprovePublicPathChange')
}
String canonical
try {
canonical = renderPublicPathSnapshot(publicPathSourceFile)
} catch (GradleException exception) {
throw new GradleException(
"updatePublicPathSnapshot: ${exception.message}", exception)
}
if (!publicPathSnapshotFile.parentFile.isDirectory()
&& !publicPathSnapshotFile.parentFile.mkdirs()) {
throw new GradleException(
"updatePublicPathSnapshot: failed to create ${publicPathSnapshotFile.parentFile}")
}
publicPathSnapshotFile.setText(canonical, 'UTF-8')
logger.lifecycle(
"updatePublicPathSnapshot: wrote reviewed baseline ${publicPathSnapshotFile}")
}
}
@@ -1,6 +1,7 @@
import com.github.spotbugs.snom.Confidence
import groovy.xml.XmlSlurper
import com.github.spotbugs.snom.SpotBugsTask
import org.gradle.api.plugins.quality.Checkstyle
import org.gradle.api.artifacts.VersionCatalogsExtension
// feature-static-analysis-quality-contract — the static analysis baseline.
@@ -8,10 +9,10 @@ import org.gradle.api.artifacts.VersionCatalogsExtension
// 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`; the two slow, worker-forking production analysis moves 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)
// qualityCheck SpotBugs + FindSecBugs (production `main` bytecode only)
//
// 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.
@@ -27,6 +28,14 @@ plugins {
def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs')
Closure<String> versionOf = { String alias -> versionCatalog.findVersion(alias).get().requiredVersion }
// Main and optional-platform builds share one repository-level quality configuration. The optional
// build lives one directory below the main Gradle root, so resolve the shared config without
// assuming that every build root is the repository's src directory.
File repositoryConfigDirectory = rootProject.file('config')
if (!repositoryConfigDirectory.isDirectory()) {
repositoryConfigDirectory = new File(rootProject.projectDir.parentFile, 'config')
}
// D1 — google-java-format owns formatting + import order; spotlessApply auto-fixes, spotlessCheck
// (wired into check) verifies. CI must NEVER run spotlessApply.
spotless {
@@ -42,12 +51,16 @@ spotless {
// 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')
configFile = new File(repositoryConfigDirectory, 'checkstyle/checkstyle.xml')
configDirectory = new File(repositoryConfigDirectory, '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
// Keep the ordinary check lane limited to main and test. Auxiliary source sets belong to
// explicit verification lanes and are linted by auxiliaryStyleCheck instead.
sourceSets = [sourceSets.main, sourceSets.test]
}
// D3/D4 — bytecode bug finder; FindSecBugs plugin loaded via spotbugsPlugins below.
@@ -57,7 +70,7 @@ checkstyle {
spotbugs {
toolVersion = versionOf('spotbugs')
reportLevel = Confidence.valueOf('HIGH')
excludeFilter = rootProject.file('config/spotbugs/exclude.xml')
excludeFilter = new File(repositoryConfigDirectory, 'spotbugs/exclude.xml')
}
// An incomplete SpotBugs run is a failure, not a clean report.
@@ -106,18 +119,20 @@ Closure<List<String>> spotBugsAnalysisFailures = { File reportFile ->
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 '))
}
// Production bytecode is the blocking static-analysis surface. Auxiliary test/benchmark/
// qualification source sets are already compiled and executed by their lanes; running SpotBugs for
// every one of them multiplied analysis workers as source sets grew without protecting shipped code.
tasks.named('spotbugsMain', SpotBugsTask) {
def mainSourceSet = sourceSets.main
auxClassPaths.from(mainSourceSet.runtimeClasspath - mainSourceSet.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 '))
}
}
}
@@ -155,8 +170,16 @@ tasks.named('check') {
setDependsOn(dependsOn.findAll { !isSpotBugsDependency(it) })
}
tasks.register('auxiliaryStyleCheck') {
group = 'verification'
description = 'Runs Checkstyle for non-default source sets outside the fast local check lane.'
dependsOn tasks.withType(Checkstyle).matching {
it.name != 'checkstyleMain' && it.name != 'checkstyleTest'
}
}
tasks.register('qualityCheck') {
group = 'verification'
description = 'Runs this leaf\'s SpotBugs and FindSecBugs bytecode analysis.'
dependsOn tasks.withType(SpotBugsTask)
dependsOn tasks.named('spotbugsMain')
}
@@ -1,104 +1,64 @@
// Where the registry's repository-root-relative source paths are resolved from.
//
// Defaults to the parent of the Gradle root, which is this repository's layout: the build lives in
// src/ and source_path values read `src/...`. A consumer with a different layout — the functional
// fixture, whose projects sit beside its registry — says so rather than having the plugin guess.
import org.gradle.api.artifacts.component.ProjectComponentIdentifier
// Runtime topology is derived from Gradle, not copied into modules.json. The registry only names
// composition roots and the application projects that are allowed to exist in this build.
ext.moduleRegistryRepositoryRoot = rootProject.projectDir.parentFile
tasks.register('verifyRuntimeModuleMembership') {
File registryFile = rootProject.file('config/architecture/modules.json')
def registry
try {
registry = dev.caskeleton.buildlogic.ModuleRegistry.read(
registryFile, project.moduleRegistryRepositoryRoot as File)
} catch (IllegalStateException invalid) {
throw new GradleException(invalid.message, invalid)
}
def productionModules = registry.modules.findAll { it.id != 'sample-portfolio' }
Map<String, String> moduleIdByGradlePath =
productionModules.collectEntries { [(it.gradlePath): it.id] }
List<String> compositionIds = registry.compositionRoots.findAll { it != 'sample-portfolio' }
tasks.register('verifyRuntimeModuleRegistry') {
group = 'verification'
description = 'Verifies registry runtime membership against both shipped composition roots.'
File registryFile = rootProject.file('config/architecture/modules.json')
description = 'Verifies resolved composition runtime project dependencies are registered application modules.'
inputs.file(registryFile)
// Parsed through the shared reader, not re-implemented here.
//
// This task carried ~85 lines re-checking what settings already checked — duplicate ids,
// duplicate Gradle paths, membership validity, a composition including itself. Three
// implementations of one rule are three definitions of valid; what this task uniquely owns is
// the comparison below, between declared membership and the runtime closure Gradle resolves.
//
// It reads the file rather than taking the settings plugin's parsed result, because this plugin
// is also applied by a standalone fixture whose settings never ran that plugin. Same parser,
// same rules, one implementation.
def registry
try {
registry = dev.caskeleton.buildlogic.ModuleRegistry.read(
registryFile, project.moduleRegistryRepositoryRoot as File)
} catch (IllegalStateException invalid) {
throw new GradleException(invalid.message, invalid)
}
// From the registry file, not from a constant in the reader: the registry owns which runtime
// compositions exist, so a derived project adds or drops one by editing JSON.
List<String> compositionIds = registry.runtimeCompositions
Map<String, String> moduleIdByGradlePath =
registry.modules.collectEntries { [(it.gradlePath): it.id] }
// Resolved here rather than through `rootProject` inside the action: that is `Task.project` at
// execution time, which Gradle 9 deprecates and this repository's `--warning-mode=fail` gates
// reject outright.
def owningRootProject = project.rootProject
doLast {
compositionIds.each { String compositionId ->
def composition = registry.byId(compositionId)
String compositionGradlePath = composition.gradlePath
Project compositionProject = owningRootProject.findProject(compositionGradlePath)
Project compositionProject = rootProject.findProject(composition.gradlePath)
if (compositionProject == null) {
throw new GradleException(
"Runtime composition '${compositionId}' references missing Gradle project " +
"'${compositionGradlePath}'.")
"Runtime composition '${compositionId}' references missing Gradle project '${composition.gradlePath}'.")
}
Set<String> expected = registry.membersOf(compositionId)
.findAll { it.id != compositionId }
.collect { it.id }
.toSet()
// The resolved runtime closure, not the declared dependency list.
//
// A declared-dependency comparison cannot see a leaf that arrives transitively — a
// starter pulling in six internal modules puts all six in the bootJar while the registry
// records them as belonging to no runtime at all. The membership list then stays clean
// precisely because it is not looking at what ships. Resolving runtimeClasspath asks the
// question the jar answers.
def runtimeClasspath = compositionProject.configurations.findByName('runtimeClasspath')
if (runtimeClasspath == null) {
throw new GradleException(
"Runtime composition '${compositionId}' has no runtimeClasspath configuration.")
}
Set<String> actual = runtimeClasspath.incoming.resolutionResult.allComponents
.findAll { it.id instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier }
.collect { (it.id as org.gradle.api.artifacts.component.ProjectComponentIdentifier).projectPath }
Set<String> actualProjectPaths = runtimeClasspath.incoming.resolutionResult.allComponents
.findAll { it.id instanceof ProjectComponentIdentifier }
.collect { (it.id as ProjectComponentIdentifier).projectPath }
.findAll { String projectPath -> projectPath != compositionProject.path }
.collect { String projectPath ->
String dependencyId = moduleIdByGradlePath[projectPath]
if (dependencyId == null) {
throw new GradleException(
"Runtime composition '${compositionId}' resolves unregistered " +
"Gradle project '${projectPath}' onto its runtime classpath.")
}
dependencyId
}
.toSet()
Set<String> unregistered = actual - expected
Set<String> missing = expected - actual
if (!unregistered.isEmpty() || !missing.isEmpty()) {
List<String> violations = []
if (!unregistered.isEmpty()) {
violations << "unregistered runtime dependencies ${unregistered.toSorted()}"
}
if (!missing.isEmpty()) {
violations << "missing registered runtime dependencies ${missing.toSorted()}"
}
Set<String> unregistered = actualProjectPaths.findAll { !moduleIdByGradlePath.containsKey(it) }.toSet()
if (!unregistered.isEmpty()) {
throw new GradleException(
"Runtime composition '${compositionId}' membership drift: " +
violations.join('; ') + '.')
"Runtime composition '${compositionId}' resolves unregistered application projects " +
"${unregistered.toSorted()}.")
}
}
logger.lifecycle(
"verifyRuntimeModuleMembership: ${compositionIds.size()} runtime composition(s) " +
'match the registry')
"verifyRuntimeModuleRegistry: ${compositionIds.size()} composition root(s) resolve only registered application projects")
}
}
// Compatibility alias for scripts/docs that still use the old task name. It no longer compares an
// exact membership snapshot; it delegates to the invariant-based runtime registry check above.
tasks.register('verifyRuntimeModuleMembership') {
group = 'verification'
description = 'Compatibility alias for verifyRuntimeModuleRegistry.'
dependsOn tasks.named('verifyRuntimeModuleRegistry')
}
@@ -0,0 +1,47 @@
import org.gradle.api.GradleException
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Classpath
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.testing.Test
import org.gradle.process.CommandLineArgumentProvider
abstract class MockitoAgentArgumentProvider implements CommandLineArgumentProvider {
@Classpath
abstract ConfigurableFileCollection getMockitoCoreClasspath()
@Input
abstract Property<String> getOwner()
@Override
Iterable<String> asArguments() {
List<File> candidates = mockitoCoreClasspath.files.findAll { File file ->
file.isFile() && file.name ==~ 'mockito-core-[^/]+\\.jar'
}.sort { File left, File right -> left.absolutePath <=> right.absolutePath }
if (candidates.size() != 1) {
throw new GradleException(
"${owner.get()}: expected exactly one mockito-core JAR for the test JVM, " +
"found ${candidates.size()}: " + candidates.collect { it.absolutePath })
}
["-javaagent:${candidates[0].absolutePath}", '-Xshare:off']
}
}
// Leaf-owned configuration: no rootProject.subprojects traversal and no cross-project mutation.
pluginManager.withPlugin('java') {
pluginManager.withPlugin('io.spring.dependency-management') {
def mockitoAgentDependencies = configurations.dependencyScope('mockitoAgentDependencies')
def mockitoAgent = configurations.resolvable('mockitoAgent') {
description = 'Mockito core JAR used only as a Test JVM startup agent.'
extendsFrom(mockitoAgentDependencies.get())
transitive = false
}
dependencies.add(mockitoAgentDependencies.get().name, 'org.mockito:mockito-core')
tasks.withType(Test).configureEach {
def provider = objects.newInstance(MockitoAgentArgumentProvider)
provider.mockitoCoreClasspath.from(mockitoAgent)
provider.owner.set("${project.path}:${name}")
jvmArgumentProviders.add(provider)
}
}
}
@@ -3,89 +3,46 @@ package dev.caskeleton.buildlogic
import groovy.json.JsonSlurper
/**
* The module registry, parsed and validated once.
* Parses the application build's module registry.
*
* <p>Seven build files parsed {@code config/architecture/modules.json} independently — settings, the
* root build in nine places, the runtime-membership script and three leaves — each with its own
* assumptions about the shape. Two of those readers were the authority on the same rule: settings
* validated the field set and the edges before including projects, and the root re-implemented the
* edge check for {@code verifyCleanArchitectureDependencies}. A registry with two validators has two
* definitions of valid, and the disagreement is only visible when one of them is wrong.
*
* <p>Deliberately a plain class rather than a script plugin. Settings and projects both need it, and
* they load plugins through different mechanisms; a class in the included build's jar is reachable
* from either.
*
* <p>Validation is here, not at the call sites. A caller that only wants the leaf list still gets the
* duplicate-id check, because a registry that is malformed for one reader is malformed for all.
* The registry owns project identity/path plus architecture-edge policy. It intentionally does not
* mirror Gradle's runtime graph: runtime membership is derived from the resolved runtimeClasspath,
* so adding a dependency never requires a second edit to this JSON just to restate what Gradle
* already knows.
*/
final class ModuleRegistry {
/**
* 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
['id', 'gradle_path', 'source_path', 'allowed_dependencies'] as Set
private static final Set<String> REQUIRED_ROOT_FIELDS = ['composition_roots', '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
/**
* The runtime compositions this registry declares, in registry order.
*
* <p>Read from {@code runtime_compositions}, not from a constant. The list used to exist twice —
* once here as {@code RUNTIME_COMPOSITIONS} and once in the JSON — and {@code read} only checked
* that the two copies agreed, so the JSON field looked like configuration while deciding nothing.
* The cost of that was not cosmetic: a derived project that drops or renames a composition root
* fails in <em>settings</em>, before any project exists, with no way to recover short of editing
* this file. The JSON is the registry, so the JSON is where the list lives.
*/
final List<String> runtimeCompositions
/** The file this was read from, for failure messages that name it. */
final List<String> compositionRoots
final File source
private ModuleRegistry(List<Module> modules, List<String> runtimeCompositions, File source) {
private ModuleRegistry(List<Module> modules, List<String> compositionRoots, File source) {
this.modules = Collections.unmodifiableList(modules)
this.runtimeCompositions = Collections.unmodifiableList(runtimeCompositions)
this.compositionRoots = Collections.unmodifiableList(compositionRoots)
this.source = source
}
/** One registered leaf. */
static final class Module {
final String id
final String gradlePath
final String sourcePath
final File sourceDirectory
final List<String> allowedDependencies
final List<String> runtimeMemberships
private Module(String id, String gradlePath, String sourcePath, File sourceDirectory,
List<String> allowedDependencies, List<String> runtimeMemberships) {
List<String> allowedDependencies) {
this.id = id
this.gradlePath = gradlePath
this.sourcePath = sourcePath
this.sourceDirectory = sourceDirectory
this.allowedDependencies = Collections.unmodifiableList(allowedDependencies)
this.runtimeMemberships = Collections.unmodifiableList(runtimeMemberships)
}
}
/**
* Reads and validates the registry.
*
* @param registryFile the registry JSON
* @param repositoryRoot the root every source path is resolved against and must stay inside
*/
static ModuleRegistry read(File registryFile, File repositoryRoot) {
if (!registryFile.isFile()) {
throw new IllegalStateException("Missing module registry: ${registryFile}")
@@ -103,24 +60,18 @@ final class ModuleRegistry {
if (!(parsed.modules instanceof List) || parsed.modules.isEmpty()) {
throw new IllegalStateException("Module registry has no modules: ${registryFile}")
}
if (!(parsed.runtime_compositions instanceof List) || parsed.runtime_compositions.isEmpty()) {
List<String> compositionRoots = requireStringList(
parsed.composition_roots, '<root>', 'composition_roots')
if (compositionRoots.isEmpty()) {
throw new IllegalStateException(
"Module registry needs a nonempty 'runtime_compositions' list: ${registryFile}")
"Module registry needs a nonempty 'composition_roots' list: ${registryFile}")
}
List<String> runtimeCompositions = parsed.runtime_compositions.withIndex().collect {
value, index ->
if (!(value instanceof String) || (value as String).isBlank()) {
throw new IllegalStateException(
"Module registry has a non-string or blank runtime_compositions entry at " +
"index ${index}: ${registryFile}")
}
value as String
}
if (runtimeCompositions.toSet().size() != runtimeCompositions.size()) {
if (compositionRoots.toSet().size() != compositionRoots.size()) {
throw new IllegalStateException(
"Module registry contains duplicate runtime_compositions: ${registryFile}")
"Module registry contains duplicate composition_roots: ${registryFile}")
}
Set<String> declaredCompositions = runtimeCompositions.toSet()
File canonicalRoot = repositoryRoot.canonicalFile
String rootPrefix = canonicalRoot.path + File.separator
Set<String> ids = new LinkedHashSet<>()
@@ -135,9 +86,6 @@ final class ModuleRegistry {
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.
Object rawId = module['id']
String named = (rawId instanceof String && !(rawId as String).isBlank())
? "'${rawId}'"
@@ -151,20 +99,13 @@ final class ModuleRegistry {
"Module registry entry ${index} needs a nonblank string '${field}'.")
}
}
String id = module.id as String
List<String> allowedDependencies =
requireStringList(module.allowed_dependencies, id, 'allowed_dependencies')
List<String> runtimeMemberships =
requireStringList(module.runtime_memberships, id, 'runtime_memberships')
if (runtimeMemberships.toSet().size() != runtimeMemberships.size()) {
if (allowedDependencies.toSet().size() != allowedDependencies.size()) {
throw new IllegalStateException(
"Module registry entry '${id}' contains duplicate runtime memberships.")
}
Set<String> unknown = runtimeMemberships.toSet() - declaredCompositions
if (!unknown.isEmpty()) {
throw new IllegalStateException(
"Module registry entry '${id}' references unknown runtime memberships ${unknown.toSorted()}.")
"Module registry entry '${id}' contains duplicate allowed dependencies.")
}
if (!ids.add(id)) {
throw new IllegalStateException("Module registry contains duplicate module id '${id}'.")
@@ -194,57 +135,34 @@ final class ModuleRegistry {
throw new IllegalStateException(
"Module registry entry '${id}' source path is not an existing directory: ${sourceDirectory}")
}
// Canonicalised first, so two entries that differ only by a symlink or a `..` segment are
// caught rather than silently mapped onto one project directory.
if (!sourceDirectories.add(sourceDirectory.path)) {
throw new IllegalStateException(
"Module registry entry '${id}' resolves to duplicate or aliased canonical source " +
"directory: ${sourceDirectory}")
}
return new Module(id, gradlePath, sourcePath, sourceDirectory,
allowedDependencies, runtimeMemberships)
new Module(id, gradlePath, sourcePath, sourceDirectory, allowedDependencies)
}
runtimeCompositions.each { compositionId ->
Module composition = modules.find { it.id == compositionId }
if (composition == null || !composition.runtimeMemberships.contains(compositionId)) {
throw new IllegalStateException(
"Runtime composition '${compositionId}' must be registered and include itself in " +
'runtime_memberships.')
}
Set<String> moduleIds = modules.collect { it.id }.toSet()
Set<String> unknownRoots = compositionRoots.toSet() - moduleIds
if (!unknownRoots.isEmpty()) {
throw new IllegalStateException(
"Module registry composition_roots reference unknown module ids ${unknownRoots.toSorted()}.")
}
// 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)
new ModuleRegistry(modules, compositionRoots, registryFile)
}
/** Modules whose runtime_memberships name the given composition. */
List<Module> membersOf(String composition) {
return modules.findAll { it.runtimeMemberships.contains(composition) }
}
/** A module by id, or null. */
Module byId(String id) {
return modules.find { it.id == id }
modules.find { it.id == id }
}
private static List<String> requireStringList(Object raw, String id, String field) {
if (!(raw instanceof List)) {
throw new IllegalStateException("Module registry entry '${id}' needs a '${field}' list.")
}
return raw.withIndex().collect { value, index ->
raw.withIndex().collect { value, index ->
if (!(value instanceof String) || (value as String).isBlank()) {
throw new IllegalStateException(
"Module registry entry '${id}' has a non-string or blank ${field} entry at index ${index}.")
@@ -9,13 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals
import static org.junit.jupiter.api.Assertions.assertThrows
import static org.junit.jupiter.api.Assertions.assertTrue
/**
* The registry rules still fail closed after being moved out of settings.gradle.
*
* <p>Moving validation is the risk the design names: build logic that relocates can lose a rule and
* still look green, because the build that no longer checks a thing does not report that it stopped.
* Each case below is one rule settings.gradle enforced before the move.
*/
/** Contract tests for the small amount of state modules.json still owns. */
class ModuleRegistryTest {
Path root
@@ -30,192 +24,129 @@ class ModuleRegistryTest {
private File write(String json) {
Path file = root.resolve('modules.json')
Files.writeString(file, json)
return file.toFile()
file.toFile()
}
private static String entry(String id, String path, String source, String deps = '[]',
String memberships = '[]') {
return """{"id":"${id}","gradle_path":"${path}","source_path":"${source}",
"allowed_dependencies":${deps},"runtime_memberships":${memberships}}"""
private static String entry(String id, String path, String source, String deps = '[]') {
"""{"id":"${id}","gradle_path":"${path}","source_path":"${source}","allowed_dependencies":${deps}}"""
}
private String registry(String... entries) {
return """{"runtime_compositions":["app-bootstrap","sample-portfolio"],
"modules":[${entries.join(',')}]}"""
private String registry(String roots = '["app-bootstrap","sample-portfolio"]', String... entries) {
"""{"composition_roots":${roots},"modules":[${entries.join(',')}]}"""
}
private ModuleRegistry read(String json) {
return ModuleRegistry.read(write(json), root.toFile())
ModuleRegistry.read(write(json), root.toFile())
}
private String valid() {
return registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]', '["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
registry('["app-bootstrap","sample-portfolio"]',
entry('app-bootstrap', ':app-bootstrap', 'src/alpha'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta'))
}
@Test
@DisplayName("a well-formed registry parses into its modules")
@DisplayName('a well-formed registry parses project identity and composition roots')
void wellFormedRegistryParses() {
def parsed = read(valid())
assertEquals(2, parsed.modules.size())
assertEquals(['app-bootstrap'], parsed.membersOf('app-bootstrap').collect { it.id })
assertEquals(['app-bootstrap', 'sample-portfolio'], parsed.compositionRoots)
assertTrue(parsed.byId('app-bootstrap').sourceDirectory.isDirectory())
}
@Test
@DisplayName("a duplicate module id is refused")
void duplicateIdIsRefused() {
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]', '["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'),
entry('app-bootstrap', ':other', 'src/alpha'))
def failure = assertThrows(IllegalStateException) { read(json) }
def failure = assertThrows(IllegalStateException) {
read(registry('["app-bootstrap","sample-portfolio"]',
entry('app-bootstrap', ':app-bootstrap', 'src/alpha'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta'),
entry('app-bootstrap', ':other', 'src/alpha')))
}
assertTrue(failure.message.contains('duplicate module id'), failure.message)
}
@Test
@DisplayName("two entries resolving to one canonical directory are refused")
void aliasedSourceDirectoryIsRefused() {
// The `..` segment makes two different source_path strings name one directory. Without
// canonicalisation both would be included and the second would silently take the first's
// project directory.
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]', '["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'),
entry('aliased', ':aliased', 'src/beta/../alpha'))
def failure = assertThrows(IllegalStateException) { read(json) }
def failure = assertThrows(IllegalStateException) {
read(registry('["app-bootstrap","sample-portfolio"]',
entry('app-bootstrap', ':app-bootstrap', 'src/alpha'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta'),
entry('aliased', ':aliased', 'src/beta/../alpha')))
}
assertTrue(failure.message.contains('duplicate or aliased'), failure.message)
}
@Test
@DisplayName("a source path escaping the repository root is refused")
void escapingSourcePathIsRefused() {
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]', '["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'),
entry('escaping', ':escaping', '../outside'))
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('escapes the repository root')
|| failure.message.contains('not an existing directory'), failure.message)
def failure = assertThrows(IllegalStateException) {
read(registry('["app-bootstrap","sample-portfolio"]',
entry('app-bootstrap', ':app-bootstrap', 'src/alpha'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta'),
entry('escaping', ':escaping', '../outside')))
}
assertTrue(failure.message.contains('escapes the repository root') ||
failure.message.contains('not an existing directory'), failure.message)
}
@Test
@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(
@DisplayName('architecture edges are data here; architectureCheck decides whether they are legal')
void edgeRulesDoNotFailProjectDiscovery() {
def parsed = read(registry('["app-bootstrap","sample-portfolio"]',
entry('app-bootstrap', ':app-bootstrap', 'src/alpha',
'["sample-portfolio","nope","app-bootstrap"]', '["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
def registry = read(json)
assertEquals(2, registry.modules.size())
assertEquals(['sample-portfolio', 'nope', 'app-bootstrap'],
registry.byId('app-bootstrap').allowedDependencies)
'["sample-portfolio","unknown","app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta')))
assertEquals(['sample-portfolio', 'unknown', 'app-bootstrap'],
parsed.byId('app-bootstrap').allowedDependencies)
}
@Test
@DisplayName("an unknown runtime membership is refused")
void unknownMembershipIsRefused() {
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]',
'["app-bootstrap","not-a-composition"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('unknown runtime memberships'), failure.message)
}
@Test
@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":[
String json = """{"composition_roots":["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)
"allowed_dependencies":[],"owner":"platform"},
${entry('sample-portfolio', ':sample-portfolio', 'src/beta')}]}"""
assertEquals(2, read(json).modules.size())
}
@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"]')}]}"""
String json = """{"composition_roots":["app-bootstrap","sample-portfolio"],"modules":[
{"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha"},
${entry('sample-portfolio', ':sample-portfolio', 'src/beta')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('is missing [runtime_memberships]'), failure.message)
assertTrue(failure.message.contains('allowed_dependencies'), failure.message)
}
@Test
@DisplayName("the registry decides which runtime compositions exist, so a derived project may drop one")
void theRegistryOwnsItsCompositionList() {
// The list used to be a constant here as well as a field in the JSON, and read() only checked
// that the two agreed. A derived project that drops the sample fixture then failed in
// *settings* — before any project exists — with no recovery short of editing this class.
String json = """{"runtime_compositions":["app-bootstrap"],
"modules":[${entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]',
'["app-bootstrap"]')}]}"""
def parsed = read(json)
assertEquals(['app-bootstrap'], parsed.runtimeCompositions)
assertEquals(['app-bootstrap'], parsed.membersOf('app-bootstrap').collect { it.id })
void aDerivedBuildMayOwnOneCompositionRoot() {
def parsed = read(registry('["app-bootstrap"]',
entry('app-bootstrap', ':app-bootstrap', 'src/alpha')))
assertEquals(['app-bootstrap'], parsed.compositionRoots)
}
@Test
@DisplayName("a composition the registry names is still checked, whatever it is called")
void aRenamedCompositionIsStillChecked() {
// Dropping the constant must not drop the rule. A membership naming something the registry
// does not declare is still refused, against the declared list rather than a fixed one.
String json = """{"runtime_compositions":["service-bootstrap"],
"modules":[${entry('service-bootstrap', ':service-bootstrap', 'src/alpha', '[]',
'["service-bootstrap"]')},
${entry('beta', ':beta', 'src/beta', '[]', '["app-bootstrap"]')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('unknown runtime memberships'), failure.message)
void unknownCompositionRootIsRefused() {
def failure = assertThrows(IllegalStateException) {
read(registry('["service-bootstrap"]',
entry('app-bootstrap', ':app-bootstrap', 'src/alpha')))
}
assertTrue(failure.message.contains('unknown module ids'), failure.message)
}
@Test
@DisplayName("an empty runtime_compositions list is refused")
void anEmptyCompositionListIsRefused() {
String json = """{"runtime_compositions":[],
"modules":[${entry('alpha', ':alpha', 'src/alpha')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains("nonempty 'runtime_compositions'"), failure.message)
void emptyCompositionRootsAreRefused() {
def failure = assertThrows(IllegalStateException) {
read(registry('[]', entry('app-bootstrap', ':app-bootstrap', 'src/alpha')))
}
assertTrue(failure.message.contains("nonempty 'composition_roots'"), failure.message)
}
@Test
@DisplayName("a duplicated runtime composition is refused")
void aDuplicatedCompositionIsRefused() {
String json = """{"runtime_compositions":["app-bootstrap","app-bootstrap"],
"modules":[${entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]',
'["app-bootstrap"]')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('duplicate runtime_compositions'), failure.message)
}
@Test
@DisplayName("a runtime composition that does not include itself is refused")
void compositionMustIncludeItself() {
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]', '[]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('must be registered and include itself'), failure.message)
void duplicateCompositionRootsAreRefused() {
def failure = assertThrows(IllegalStateException) {
read(registry('["app-bootstrap","app-bootstrap"]',
entry('app-bootstrap', ':app-bootstrap', 'src/alpha')))
}
assertTrue(failure.message.contains('duplicate composition_roots'), failure.message)
}
}
@@ -11,12 +11,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue
* The vendored-platform conventions give a leaf what its forty-three build files each wrote by hand,
* and refuse to give it half of that silently.
*
* <p>The grpc BOM is the part worth testing rather than reading. Its two preconditions — a root that
* declares {@code ext.grpcVersion}, and Spring's dependency-management plugin to import into — are
* both satisfied today by the order in which the root build applies things, and both are invisible
* at the call site. A convention that skipped the import when either was missing would not fail
* here; it would surface much later as an io.grpc coordinate with no version, in whichever leaf
* asked for one first.
* <p>The grpc BOM is the part worth testing rather than reading. Its two preconditions are a shared
* {@code libs.versions.grpc} entry and Spring's dependency-management plugin to import into. The
* convention must fail at configuration time when the shared catalog contract is incomplete.
*/
class PlatformModuleConventionTest {
@@ -37,6 +34,7 @@ class PlatformModuleConventionTest {
spotbugs = "4.10.2"
findsecbugs = "1.14.0"
errorprone = "2.49.0"
grpc = "1.68.1"
'''.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.
@@ -96,8 +94,6 @@ class PlatformModuleConventionTest {
doLast { logger.lifecycle('managed-grpc-api=' + managed) }
}
''')
Files.writeString(projectDir.resolve('gradle.properties'), "grpcVersion=1.68.1\n")
def result = runner('reportManagedVersion').build()
assertTrue(result.output.contains('managed-grpc-api=1.68.1'),
@@ -105,8 +101,17 @@ class PlatformModuleConventionTest {
}
@Test
@DisplayName("the grpc convention refuses a root that declares no grpcVersion")
@DisplayName("the grpc convention refuses a catalog that declares no grpc version")
void grpcConventionRefusesAMissingVersion() {
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())
buildFile('''
plugins {
id 'ca.grpc-platform-module'
@@ -115,8 +120,8 @@ class PlatformModuleConventionTest {
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains('ext.grpcVersion'),
"the refusal should name the property that is missing:\n${result.output}")
assertTrue(result.output.contains("Version catalog 'libs' must define version 'grpc'"),
"the refusal should name the missing catalog entry:\n${result.output}")
}
@Test
@@ -135,8 +140,6 @@ class PlatformModuleConventionTest {
doLast { logger.lifecycle('dependency-management-present=' + present) }
}
''')
Files.writeString(projectDir.resolve('gradle.properties'), "grpcVersion=1.68.1\n")
def result = runner('reportDependencyManagement').build()
assertTrue(result.output.contains('dependency-management-present=true'),