refactor: 빌드 최적화 및 ci 수정
This commit is contained in:
@@ -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 A–D compare three text files, so a property that exists only as a typed
|
||||
// @ConfigurationProperties field is invisible to them: the Redis SDK shipped 34 settings
|
||||
// with no registered env name at all and verifyEnvKeys passed. Conditionally-composed
|
||||
// adapters cannot be fixed by adding their settings to application.yml — that is what
|
||||
// would make a Redis-free deployment carry Redis configuration — so the third SSOT for
|
||||
// them is the annotation processor's own metadata, compared against the registry in both
|
||||
// directions: a typed property with no row, and a row naming a property that no longer
|
||||
// exists, are both failures.
|
||||
// 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'),
|
||||
|
||||
Reference in New Issue
Block a user