refactor: 각 어댑터터별 리펙토링 진행

This commit is contained in:
DongHyeonka
2026-08-24 18:26:40 +09:00
parent e98b56eb03
commit 0137263441
439 changed files with 31935 additions and 4719 deletions
+71
View File
@@ -0,0 +1,71 @@
# build-logic
The included build that holds this repository's Gradle conventions. A convention lives here when the
same machine code was otherwise copied into more than one place, and the copies could drift apart
without any check noticing.
## What is here
| Plugin | Owns |
| --- | --- |
| `ca.architecture-registry` (settings) | project inclusion, directory mapping, and the parsed registry every other reader shares |
| `ca.strict-test-lane` | tagged / named / own-source-set Test lanes that cannot pass without executing something |
| `ca.strict-qualification` | qualification lanes that cannot pass without executing every named class, re-checked against the JUnit XML |
| `ca.evidence` | the JUnit XML reader and the no-skip / required-class rules built on it |
| `ca.api-surface` | read-only API surface verification with an explicit, separate update task |
| `ca.testkit-publisher` | a leaf's testkit source set consumers and its optional consumable artifact |
| `ca.dependency-policy` | declared absences, checked against the resolved graph rather than against a comment |
| `ca.runtime-membership` | the resolved runtime project closure against the registry's memberships |
`dev.caskeleton.buildlogic.ModuleRegistry` and `JUnitEvidence` are plain classes rather than plugins,
because settings and projects load plugins through different mechanisms and both need them.
## What the design named and this build does not have
The remediation design's §10.2 listed eight conventions. Two of them were attempted or assessed and
deliberately not built, and the reasons belong next to the code rather than in a review thread.
### `ca.java-leaf` — reverted
Written, measured, reverted. The full reasoning is in `src/build.gradle` beside the static-analysis
block it would have moved. In short: a recorded decision (`feature-static-analysis-quality-contract`
D8) already put that baseline in the root `subprojects {}` block; the block is applied once and
copied nowhere, so extracting it removes no duplication; and build-logic would have to re-declare the
spotless / spotbugs / errorprone / dependency-management coordinates *and their versions*, which
creates a drift surface where there was none.
A content-level baseline of the resolved analysis configuration — compiler args, encoding, release,
Checkstyle tool version and config, SpotBugs effort and report level, for every source set of every
project — was captured before the attempt and compared after the revert, because the task graph
cannot see a weakened Error Prone flag: the task names are identical either way. The two dumps are
identical.
### `ca.optional-adapter` — not warranted
Its stated responsibility was "activation metadata and disabled/on composition contract wiring".
Neither half is build machine code in this repository:
- Activation metadata is one registry, `docs/registries/env-keys.yaml`, verified by one root task,
`verifyEnvKeys`. There is no per-leaf copy for a convention to deduplicate.
- The off invariant and the on fail-closed contract are ordinary tests over the shared `test` source
set, gated by the master switch in `@ConditionalOnProperty` at runtime. They need no source set,
no configuration, and no task of their own.
A plugin here would have to invent state to hold — an `optionalAdapter { switch = '...' }` block that
no build step reads — and a declaration nothing checks is worse than no declaration, because it reads
like a guarantee.
## Testing a convention
```bash
cd src
./gradlew -p build-logic test --console=plain
```
The lane and registry conventions are covered by Gradle TestKit against real builds rather than by
reading the plugin source, because the properties that matter — a lane that discovers nothing fails,
a lane never reports up-to-date, a malformed registry is refused before any project is included — are
runtime behaviour rather than text in a script. Two of those tests exist because the obvious reading
of the Gradle documentation was wrong: `failOnNoDiscoveredTests` does not fire when a tag filter
matches nothing, and `failOnNoMatchingTests` does not fire when only *some* of the named tests are
missing.
+18
View File
@@ -0,0 +1,18 @@
// Precompiled script plugins, written in Groovy because the main build is Groovy DSL and a reader
// moving logic out of a leaf should not also be translating it.
plugins {
id 'groovy-gradle-plugin'
}
dependencies {
// TestKit needs the Gradle API of the running distribution, which `groovy-gradle-plugin` already
// puts on the main source set; the test source set asks for it explicitly.
testImplementation gradleTestKit()
testImplementation 'org.spockframework:spock-core:2.3-groovy-4.0'
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.3'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
+13
View File
@@ -0,0 +1,13 @@
// The included build that holds this repository's convention plugins.
//
// Its own settings are deliberately tiny: an included build for build logic should not need the
// registry validation, toolchain resolution or plugin management the main build does, and giving it
// any of those would make the thing that validates the build depend on the build it validates.
dependencyResolutionManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = 'build-logic'
@@ -0,0 +1,187 @@
// A committed public API surface: what a leaf exposes is a reviewed decision, not a discovery.
//
// Two leaves carried ~65 lines of identical machine code for this — render, verify, update, the
// approval flag, the diff message — differing only in a name and a path. The copy had already
// drifted: the Mongo leaf's verify task described itself as checking "the committed GraphQL public
// API surface", and its update task said the same. Nothing was wrong with the behaviour; the text a
// reader relies on to know which surface failed was simply from the other leaf.
//
// So the names are derived from one label rather than written five times:
//
// apiSurface {
// label = 'Mongo' // verifyMongoApiSurface, ...
// baseline = rootProject.file('../docs/architecture/mongo-api-surface.txt')
// description = 'MongoDB leaf public API surface'
// rationale = ['A public type in a single-jar leaf is reachable from ...']
// }
class ApiSurfaceExtension {
/** Capitalised label the task and property names are derived from — 'Mongo', 'GraphQl'. */
String label
/** The committed baseline file. */
File baseline
/** First header line: what this surface is. */
String description
/** Further header lines explaining why the surface is reviewed rather than discovered. */
List<String> rationale = []
/** Source root scanned for public top-level types. */
String sourceRoot = 'src/main/java'
}
def apiSurface = extensions.create('apiSurface', ApiSurfaceExtension)
// Deriving every name from the label is the point: a leaf cannot end up verifying one surface while
// telling the reader about another.
// Captured at configuration time. The render helper below runs inside a task action, and reading
// `project.path` there is `Task.project` at execution time — deprecated in Gradle 9, an error in
// Gradle 10, and a failure today because this repository runs its gates with `--warning-mode=fail`.
String owningProjectPath = project.path
def verifyName = { "verify${apiSurface.label}ApiSurface" }
def updateName = { "update${apiSurface.label}ApiSurface" }
def approvalProperty = { "approve${apiSurface.label}ApiSurfaceChange" }
def ceilingProperty = { "raise${apiSurface.label}ApiSurfaceCeiling" }
def renderSurface = { ->
File sourceRoot = project.file(apiSurface.sourceRoot)
def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/
def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/
List<String> types = []
if (sourceRoot.isDirectory()) {
sourceRoot.eachFileRecurse { candidate ->
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
return
}
String text = candidate.getText('UTF-8')
def packageMatcher = packagePattern.matcher(text)
if (!packageMatcher.find()) {
return
}
String packageName = packageMatcher.group(1)
def typeMatcher = typePattern.matcher(text)
while (typeMatcher.find()) {
types << "${packageName}.${typeMatcher.group(2)}".toString()
}
}
}
types = types.unique().toSorted()
StringBuilder header = new StringBuilder()
header.append("# ").append(apiSurface.description).append('\n')
apiSurface.rationale.each { header.append('# ').append(it).append('\n') }
header.append("# Update only after review with:\n")
header.append("# ./gradlew ${owningProjectPath}:${updateName()} -P${approvalProperty()}\n")
header.append("# types: ${types.size()}\n")
return header.toString() + (types.isEmpty() ? '' : types.join('\n') + '\n')
}
def countTypes = { String surface ->
surface.readLines().count { !it.startsWith('#') && !it.trim().isEmpty() }
}
project.afterEvaluate {
// Applied to every leaf, configured by few. A leaf that never opens an `apiSurface { }` block
// has not opted in and gets no tasks — the convention is available, not imposed.
if (!apiSurface.label?.trim() && apiSurface.baseline == null) {
return
}
if (!apiSurface.label?.trim()) {
throw new GradleException("${project.path} declares an apiSurface baseline without a label")
}
if (apiSurface.baseline == null) {
throw new GradleException("${project.path} declares an apiSurface label without a baseline")
}
// Read here rather than at the top of the script. The approval property is named after
// `apiSurface.label`, and the label is set by the leaf's own `apiSurface { }` block, which has
// not run when the script body does — so the top-level read asked for
// `approvenullApiSurfaceChange`, a name no caller would ever pass. The documented flag silently
// never applied, which made the update task impossible to approve and the verify task's
// read-only guard impossible to trip.
//
// `afterEvaluate` is configuration time, so this is not the deprecated `Task.project` access at
// execution time; the value is captured into the task actions below.
boolean updateApproved = project.hasProperty(approvalProperty())
// Growing the surface is a second decision, and it needs a second flag.
//
// Approving each addition one at a time is how a surface goes from 373 types to 398 with every
// step reviewed and the total never discussed: no single diff is the one that made the leaf too
// big to split, so no single review refuses. The count is the thing the split argument is made
// from, so the count is what gets a ceiling. A change that removes more than it adds needs only
// the approval; a change that raises the total says so out loud.
boolean ceilingRaiseApproved = project.hasProperty(ceilingProperty())
tasks.register(verifyName()) {
group = 'verification'
description = "Fails without mutation when the committed ${apiSurface.label} public API " +
"surface drifts."
doLast {
if (updateApproved) {
throw new GradleException(
"${verifyName()} is read-only; use ${updateName()} to record an approved " +
"change.")
}
String rendered = renderSurface()
if (!apiSurface.baseline.isFile()) {
throw new GradleException(
"${verifyName()}: missing committed baseline ${apiSurface.baseline}")
}
String committed = apiSurface.baseline.getText('UTF-8')
if (committed != rendered) {
List<String> committedTypes = committed.readLines().findAll { !it.startsWith('#') }
List<String> renderedTypes = rendered.readLines().findAll { !it.startsWith('#') }
List<String> added = (renderedTypes - committedTypes).toSorted()
List<String> removed = (committedTypes - renderedTypes).toSorted()
throw new GradleException(
"${verifyName()}: the public API surface changed.\n" +
(added.isEmpty() ? '' : " added:\n " + added.join('\n ') + '\n') +
(removed.isEmpty() ? '' : " removed:\n " + removed.join('\n ') + '\n') +
"Review the change, then record it with:\n" +
" ./gradlew ${owningProjectPath}:${updateName()} -P${approvalProperty()}")
}
logger.lifecycle(
"${verifyName()}: OK — the committed public API surface is unchanged.")
}
}
tasks.register(updateName()) {
group = 'verification'
description = "Rewrites the committed ${apiSurface.label} public API surface baseline " +
"after review."
doLast {
if (!updateApproved) {
throw new GradleException(
"${updateName()} requires -P${approvalProperty()}: growing the public " +
"surface is a review decision, not a build step.")
}
String rendered = renderSurface()
if (apiSurface.baseline.isFile() && !ceilingRaiseApproved) {
int committedCount = countTypes(apiSurface.baseline.getText('UTF-8'))
int renderedCount = countTypes(rendered)
if (renderedCount > committedCount) {
throw new GradleException(
"${updateName()}: the public surface would grow from ${committedCount} " +
"to ${renderedCount} types.\n" +
"Approving additions one at a time is how this leaf got too big " +
"to split without anybody deciding to make it so.\n" +
"Either land the addition together with a removal that pays for " +
"it, or raise the ceiling deliberately:\n" +
" ./gradlew ${owningProjectPath}:${updateName()} " +
"-P${approvalProperty()} -P${ceilingProperty()}")
}
}
apiSurface.baseline.parentFile.mkdirs()
apiSurface.baseline.setText(rendered, 'UTF-8')
logger.lifecycle("${updateName()}: wrote ${apiSurface.baseline}")
}
}
tasks.named('check') {
dependsOn tasks.named(verifyName())
}
}
@@ -0,0 +1,43 @@
import dev.caskeleton.buildlogic.ModuleRegistry
// The registry decides which projects exist, and it is read here once.
//
// settings.gradle carried ~150 lines validating the JSON — field sets, duplicate ids, duplicate
// canonical directories, unknown dependency ids, self-dependencies, runtime memberships — and the
// root build re-implemented parts of the same rules for its verification tasks. Two validators mean
// two definitions of valid, and the difference only shows when one of them is wrong.
//
// A settings plugin rather than a project one: including projects and mapping their directories is
// a settings-time decision, and it must happen before any project exists to make it.
// No expected module count. Settings used to assert one — the registry listed the leaves and this
// file separately asserted how many there were — and a count written beside the list it is derived
// from is a second place to edit that carries no information the list does not already carry. Its
// only effect was that adding a leaf failed the build until somebody bumped a number.
//
// 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
// leaf list, so it is the SSOT for its length.
File repositoryRoot = settings.settingsDir.parentFile.canonicalFile
File registryFile = new File(settings.settingsDir, 'config/architecture/modules.json')
def registry
try {
registry = ModuleRegistry.read(registryFile, repositoryRoot)
} catch (IllegalStateException invalid) {
// Rethrown as a Gradle failure so the message reads as a build problem rather than as an
// internal error from a helper class the reader has never heard of.
throw new GradleException(invalid.message, invalid)
}
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
@@ -0,0 +1,119 @@
// A dependency this leaf states must not be on a configuration, checked against what resolved.
//
// Exclusions are declared in a build file and their intent is written in a comment beside them, and
// the two drift: the notification leaf excluded Jackson's YAML dataformat and the comment claimed the
// result was "no YAML parser on the runtime classpath", while org.yaml:snakeyaml sat on that exact
// configuration the whole time, arriving from spring-boot-starter. The exclusion was right; the
// sentence describing what it achieved was not, and nothing could tell them apart.
//
// So intent becomes a declaration the build checks:
//
// dependencyPolicy {
// absent 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml',
// because: 'schemas arrive as JSON strings; a second parser is surface for a format this leaf never reads'
// }
//
// It verifies, it does not remove. §10.2 is explicit that a shared plugin must not strip a
// dependency a provider genuinely uses — the exclusion stays where the leaf declares it, and this
// only refuses to let the claim outlive the fact.
class DependencyPolicyExtension {
/** Coordinates that must not appear, by configuration name. */
final Map<String, List<Map<String, String>>> absentByConfiguration = [:]
/**
* States that a coordinate must not resolve onto a configuration.
*
* <p>The options map comes first because Groovy collects named arguments into a leading Map,
* so {@code absent 'g:m', because: 'why'} calls {@code absent(Map, String)}.
*
* @param options {@code because} — why the leaf wants it gone; {@code configuration} — which
* configuration to check, defaulting to runtimeClasspath
* @param coordinate {@code group:module}, version-independent
*/
void absent(Map<String, String> options, String coordinate) {
String configuration = options.configuration ?: 'runtimeClasspath'
String because = options.because
if (!because?.trim()) {
throw new GradleException(
"dependencyPolicy.absent('${coordinate}') needs a `because`: an unexplained " +
"exclusion is the comment drift this check exists to prevent")
}
if (coordinate.count(':') != 1) {
throw new GradleException(
"dependencyPolicy.absent('${coordinate}') must be group:module without a version")
}
absentByConfiguration.computeIfAbsent(configuration) { [] } <<
[coordinate: coordinate, because: because]
}
}
def dependencyPolicy = extensions.create('dependencyPolicy', DependencyPolicyExtension)
def verifyDependencyPolicy = tasks.register('verifyDependencyPolicy') {
group = 'verification'
description = 'Fails when a coordinate this leaf declares absent is on the resolved graph.'
// Never up-to-date: the answer depends on a resolution result, not on an input file this task
// declares, and a stale pass is exactly the shape of the drift being checked.
outputs.upToDateWhen { false }
}
// Wired in afterEvaluate, because the leaf's declarations do not exist until its build file has run.
//
// The action captures the project path and the Configuration objects here rather than reaching for
// `project` inside `doLast`. `Task.project` at execution time is deprecated in Gradle 9 and fails in
// Gradle 10, and this build runs `check` with `--warning-mode=fail` — so a convention that reached
// for it would fail the gate for every leaf that declares a policy.
project.afterEvaluate {
if (dependencyPolicy.absentByConfiguration.isEmpty()) {
// Only leaves that declare something pay for the check.
return
}
String projectPath = project.path
List<Map<String, Object>> checks =
dependencyPolicy.absentByConfiguration.collect { configurationName, entries ->
def configuration = project.configurations.findByName(configurationName)
if (configuration == null) {
throw new GradleException(
"${projectPath} declares a dependency policy for configuration " +
"'${configurationName}', which does not exist")
}
if (!configuration.canBeResolved) {
throw new GradleException(
"${projectPath} declares a dependency policy for '${configurationName}', " +
"which cannot be resolved")
}
[name: configurationName, configuration: configuration, entries: entries]
}
verifyDependencyPolicy.configure {
doLast {
List<String> violations = []
checks.each { check ->
Set<String> resolved = check.configuration.incoming.resolutionResult.allComponents
.collect { it.moduleVersion }
.findAll { it != null }
.collect { "${it.group}:${it.name}".toString() }
.toSet()
check.entries.each { entry ->
if (resolved.contains(entry.coordinate)) {
violations << " ${entry.coordinate} is on ${check.name} — the leaf states: " +
"${entry.because}"
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"${projectPath}: the dependency graph contradicts what this leaf declares.\n" +
violations.join('\n') + "\n" +
"Either the exclusion is incomplete, or the declaration describes an " +
"outcome it never achieved.")
}
}
}
// Named rather than passed as the provider, so `.github/scripts/verify-gate-matrix.sh` can see
// the wiring. The lint proves the CI gate matrix's claims are true by finding the dependsOn that
// backs each row; a row it cannot verify is a row that gets deleted rather than trusted.
tasks.named('check') { dependsOn tasks.named('verifyDependencyPolicy') }
}
@@ -0,0 +1,69 @@
import dev.caskeleton.buildlogic.JUnitEvidence
// JUnit evidence: what a lane actually executed, read one way.
//
// Was `gradle/junit-evidence.gradle`, applied with `apply from:`. It became a plugin when its reader
// moved into build-logic: an applied script gets no classpath of its own, so a standalone fixture
// that applied it by path could no longer compile it. A plugin carries its own classpath, which
// means the fixture exercises the same mechanism the real build uses rather than a copy of it.
//
// The three closures stay on rootProject.ext because that is how every consumer reaches them today;
// converting those call sites is a separate change from moving the implementation.
Closure<Map<String, Object>> readJUnitEvidence = { String evidenceName, File resultDirectory ->
def results
try {
results = JUnitEvidence.read(evidenceName, resultDirectory)
} catch (IllegalStateException unreadable) {
throw new GradleException(unreadable.message, unreadable)
}
[
tests : results.tests,
skipped : results.skipped,
failures : results.failures,
errors : results.errors,
executedClasses: results.executedClasses
] as Map<String, Object>
}
Closure<Map<String, Object>> verifyNoSkipJUnitXml = {
String evidenceName, File resultDirectory ->
Map<String, Object> evidence = readJUnitEvidence(evidenceName, resultDirectory)
if (evidence.tests <= 0) {
throw new GradleException(
"${evidenceName}: requires a positive executed test count")
}
if (evidence.skipped > 0) {
throw new GradleException(
"${evidenceName}: forbids skipped tests: ${evidence.skipped}")
}
if (evidence.failures > 0 || evidence.errors > 0) {
throw new GradleException(
"${evidenceName}: failures=${evidence.failures}, errors=${evidence.errors}")
}
logger.lifecycle("${evidenceName}: ${evidence.tests} tests, ${evidence.skipped} skipped")
evidence
}
Closure<Map<String, Object>> verifyRequiredJUnitClasses = {
String evidenceName, File resultDirectory, List<String> requiredClasses ->
Map<String, Object> evidence = verifyNoSkipJUnitXml(evidenceName, resultDirectory)
Set<String> executedClasses = evidence.executedClasses as Set<String>
// A nested class counts for its outer class: a required class whose cases all live in
// @Nested inner classes is executed, and matching on exact names alone would call it missing.
List<String> missingClasses = requiredClasses.findAll { String requiredClass ->
!executedClasses.any { String executedClass ->
executedClass == requiredClass || executedClass.startsWith(requiredClass + '$')
}
}
if (!missingClasses.isEmpty()) {
throw new GradleException(
"${evidenceName}: no executed test cases for required classes: ${missingClasses}")
}
evidence
}
rootProject.ext.readJUnitEvidence = readJUnitEvidence
rootProject.ext.verifyNoSkipJUnitXml = verifyNoSkipJUnitXml
rootProject.ext.verifyRequiredJUnitClasses = verifyRequiredJUnitClasses
@@ -0,0 +1,106 @@
import groovy.json.JsonSlurper
// 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.
ext.moduleRegistryRepositoryRoot = rootProject.projectDir.parentFile
def verifyRuntimeModuleMembership = tasks.register('verifyRuntimeModuleMembership') {
group = 'verification'
description = 'Verifies registry runtime membership against both shipped composition roots.'
File registryFile = rootProject.file('config/architecture/modules.json')
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)
}
List<String> compositionIds = registry.RUNTIME_COMPOSITIONS.toList()
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)
if (compositionProject == null) {
throw new GradleException(
"Runtime composition '${compositionId}' references missing Gradle project " +
"'${compositionGradlePath}'.")
}
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 }
.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()}"
}
throw new GradleException(
"Runtime composition '${compositionId}' membership drift: " +
violations.join('; ') + '.')
}
}
logger.lifecycle(
"verifyRuntimeModuleMembership: ${compositionIds.size()} runtime composition(s) " +
'match the registry')
}
}
rootProject.ext.verifyRuntimeModuleMembership = verifyRuntimeModuleMembership
@@ -0,0 +1,150 @@
// A strict qualification lane: a Test task that cannot pass without executing every named class.
//
// Nine leaves reached this through `apply from: gradle/strict-qualification-test.gradle`. The logic
// was already in one place, so this move removes no duplication — it removes nine lines that each
// hardcode a path into the root project's directory, and it puts the lane under TestKit like the
// other conventions. A script applied by path is also a script no leaf can be tested without, which
// is why the fixtures that exercise qualification behaviour had to copy the file.
//
// What the lane guarantees, and why each part is not optional:
//
// - the required classes must have *compiled*, checked before the tests run, so a renamed or
// deleted qualification test fails as a missing class rather than as a lane that quietly has
// less to run than it did yesterday;
// - the filter names those classes and `failOnNoMatchingTests` is on, so a lane whose classes
// exist but whose names drifted fails instead of executing nothing;
// - a skipped test at any level is a failure, because a qualification lane's output is evidence
// and "skipped" is not a result;
// - the JUnit XML is re-read afterwards and checked against the same required list, so the claim
// rests on what the run recorded rather than on the task's exit code.
//
// The evidence reader comes from `ca.evidence` on the root project.
ext.registerStrictQualificationTest = { Map<String, ?> specification ->
String taskName = specification.name as String
def qualificationSourceSet = specification.sourceSet
List<String> requiredClasses = (specification.requiredClasses ?: []) as List<String>
if (taskName == null || taskName.isBlank()) {
throw new GradleException('A strict qualification task name is required.')
}
if (qualificationSourceSet == null) {
throw new GradleException("${taskName} requires an owner source set.")
}
if (!project.sourceSets.findByName(qualificationSourceSet.name).is(qualificationSourceSet)) {
throw new GradleException(
"${taskName} source set '${qualificationSourceSet.name}' does not belong to owner project ${project.path}.")
}
if (requiredClasses.isEmpty() || requiredClasses.any { it == null || it.isBlank() }) {
throw new GradleException("${taskName} must name at least one required test FQCN.")
}
if (requiredClasses.toSet().size() != requiredClasses.size()) {
throw new GradleException("${taskName} contains duplicate required test FQCNs.")
}
def junitXmlOutput = specification.junitXmlOutput ?:
project.layout.buildDirectory.dir("test-results/${taskName}")
def binaryResultsOutput = specification.binaryResultsOutput ?:
project.layout.buildDirectory.dir("test-results/${taskName}/binary")
// Captured here, not read from inside a task action. `Task.project` at execution time is
// deprecated in Gradle 9 and fails in Gradle 10, and this repository runs its gates with
// `--warning-mode=fail` — so a convention that reached for it would break the gate for every
// leaf that registers a qualification lane. It did: the poster-image lane, which is an explicit
// lane outside `check`, was the first place it surfaced.
String owningProjectPath = project.path
def evidenceOwner = project.rootProject
def requiredClassesCheck = project.tasks.register("${taskName}RequiredClasses") {
group = 'verification'
description = "Fails when ${taskName} did not compile every required test class."
dependsOn qualificationSourceSet.classesTaskName
inputs.files(qualificationSourceSet.output.classesDirs)
outputs.upToDateWhen { false }
doLast {
Set<File> classDirectories = qualificationSourceSet.output.classesDirs.files
// Walked directly rather than through `project.fileTree`, which would need the Project
// at execution time for a question a plain directory walk answers.
boolean hasAnyClass = classDirectories.any { File directory ->
if (!directory.isDirectory()) {
return false
}
boolean found = false
directory.eachFileRecurse(groovy.io.FileType.FILES) { File candidate ->
if (candidate.name.endsWith('.class')) {
found = true
}
}
return found
}
if (!hasAnyClass) {
throw new GradleException(
"${taskName} source set produced no test class files.")
}
List<String> missingClasses = requiredClasses.findAll { String requiredClass ->
String relativeClassFile = requiredClass.replace('.', '/') + '.class'
!classDirectories.any { File directory ->
new File(directory, relativeClassFile).isFile()
}
}
if (!missingClasses.isEmpty()) {
throw new GradleException(
"${taskName} is missing required test class files: ${missingClasses}")
}
File staleEvidence = junitXmlOutput.get().asFile
if (staleEvidence.exists() && !staleEvidence.deleteDir()) {
throw new GradleException(
"${taskName} could not delete stale JUnit XML: ${staleEvidence}")
}
}
}
def qualificationTest = project.tasks.register(taskName, Test) {
group = 'verification'
description = specification.description ?:
"Runs exact no-skip qualification evidence for ${owningProjectPath}."
dependsOn requiredClassesCheck
testClassesDirs = qualificationSourceSet.output.classesDirs
classpath = qualificationSourceSet.runtimeClasspath
useJUnitPlatform()
filter {
requiredClasses.each { String requiredClass ->
includeTestsMatching(requiredClass)
}
failOnNoMatchingTests = true
}
failOnNoDiscoveredTests = true
reports.junitXml.required = true
reports.junitXml.outputLocation = junitXmlOutput
reports.html.required = false
binaryResultsDirectory = binaryResultsOutput
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
afterSuite { descriptor, result ->
if (descriptor.parent == null && result.skippedTestCount > 0) {
throw new GradleException(
"${taskName} forbids skipped tests: ${result.skippedTestCount}")
}
}
}
def evidenceCheck = project.tasks.register("${taskName}Evidence") {
group = 'verification'
description = "Fails unless ${taskName} executed every required test class without skips."
mustRunAfter qualificationTest
outputs.upToDateWhen { false }
doLast {
if (!evidenceOwner.ext.has('verifyRequiredJUnitClasses')) {
throw new GradleException(
"${taskName} requires the ca.evidence convention on the root project.")
}
evidenceOwner.ext.verifyRequiredJUnitClasses(
taskName, junitXmlOutput.get().asFile, requiredClasses)
}
}
qualificationTest.configure {
finalizedBy evidenceCheck
}
qualificationTest
}
@@ -0,0 +1,351 @@
// A strict test lane: a tagged, fail-closed Test task.
//
// Every lane in this repository repeated the same five lines — testClassesDirs, classpath,
// useJUnitPlatform { includeTags }, failOnNoDiscoveredTests, outputs.upToDateWhen { false } — once per
// lane, across five leaves. Copied machine code is not just noise: the two that mattered are
// `failOnNoDiscoveredTests` and `upToDateWhen { false }`, and a lane that is added by copy-paste is a
// lane that can silently lose either. A selected lane which discovers nothing then reports success
// for a thing nobody tested, and an up-to-date lane reports a result it did not produce.
//
// So the mechanics live here and a leaf declares intent:
//
// strictTestLanes {
// lane('mongoReplicaSetTest') {
// tag = 'mongodb-replicaset'
// description = 'Single-node replica set contract lane.'
// customize = { test -> applyMongoImageSelection(test) }
// }
// }
//
// A lane selects in exactly one of three ways — a tag, a source set of its own, or the exact tests
// it names — and a lane over the shared `test` source set that selects in none of them is refused.
//
// What a leaf may still not do is opt out of failing closed. There is no `failOnNoDiscoveredTests`
// knob on the DSL, deliberately.
class StrictTestLaneSpec {
/** The lane's task name. */
final String name
/**
* JUnit tag that selects this lane's tests.
*
* <p>Required when the lane runs over the shared `test` source set, where an unfiltered lane
* would run the entire suite under a name claiming it ran one thing. Optional for a lane with a
* source set of its own, where the source set is already the selection.
*/
String tag
/** What the lane proves. Required — a lane nobody can describe is a lane nobody can interpret. */
String description
/**
* Exact test selectors this lane runs — fully qualified class or class-plus-method names.
*
* <p>The third way a lane may select, alongside a tag and a source set of its own. A lane that
* exists to run four named contracts out of a shared suite cannot express that as a tag without
* tagging the tests, and tagging them would let any future test join the lane by annotation.
*
* <p>Selecting this way turns on {@code failOnNoMatchingTests}, so a renamed test fails the lane
* instead of quietly leaving it with less to run.
*/
final List<String> requiredTests = []
/**
* Source set the lane's classes come from. Defaults to `test`, which is what every current lane
* uses; a leaf with a dedicated source set names it.
*/
String sourceSet = 'test'
/** Leaf-specific wiring — container image selection, system properties, environment. */
Closure customize
StrictTestLaneSpec(String name) {
this.name = name
}
/** Names the exact tests this lane runs. */
void requires(String... selectors) {
requiredTests.addAll(selectors as List)
}
}
/**
* A source set a lane (or a testkit) owns, declared rather than spelled out.
*
* <p>Seven leaves wrote the same four things by hand for roughly twenty source sets: the srcDirs
* Gradle already assigns by convention, `runtimeClasspath += output + compileClasspath`, and two
* `extendsFrom` lines pointing at the `test` configurations. Only two facts actually differ between
* them — which source sets' output the code compiles against, and which `test` configurations it
* inherits — so those two are what a leaf declares and the rest is here.
*
* <p>The inherited configuration list is not normalised to "all four". Every configuration in this
* build is dependency-locked in STRICT mode, so adding an `extendsFrom` a leaf never had changes its
* resolved graph and invalidates its lock state. A convention that quietly rewrote lockfiles while
* claiming to move machine code would be exactly the kind of refactor this wave forbids.
*/
class AuxiliarySourceSetSpec {
/** The source set's name; its sources live under src/<name>/. */
final String name
/** Source sets whose output this one compiles against. */
final List<String> visibleOutputs = ['main']
/** `test` configurations this source set's own configurations extend. */
final List<String> inheritedTestConfigurations = ['implementation', 'runtimeOnly']
AuxiliarySourceSetSpec(String name) {
this.name = name
}
/** Names the source sets whose output this one compiles against. */
void compilesAgainst(String... names) {
visibleOutputs.clear()
visibleOutputs.addAll(names as List)
}
/**
* Names the `test` configurations this source set inherits — `implementation`, `compileOnly`,
* `runtimeOnly`, `annotationProcessor`. Declared as a whole rather than added to, so a leaf's
* build file states the complete set rather than a delta from a default the reader cannot see.
*/
void inherits(String... names) {
inheritedTestConfigurations.clear()
inheritedTestConfigurations.addAll(names as List)
}
}
class StrictTestLaneExtension {
private final org.gradle.api.Project project
private final org.gradle.api.NamedDomainObjectContainer<StrictTestLaneSpec> lanes
private final org.gradle.api.NamedDomainObjectContainer<AuxiliarySourceSetSpec> sourceSets
StrictTestLaneExtension(org.gradle.api.Project project) {
this.project = project
this.lanes = project.container(StrictTestLaneSpec) { String name ->
new StrictTestLaneSpec(name)
}
this.sourceSets = project.container(AuxiliarySourceSetSpec) { String name ->
new AuxiliarySourceSetSpec(name)
}
}
org.gradle.api.NamedDomainObjectContainer<StrictTestLaneSpec> getLanes() {
return lanes
}
org.gradle.api.NamedDomainObjectContainer<AuxiliarySourceSetSpec> getAuxiliarySourceSets() {
return sourceSets
}
/** Declares one lane. */
void lane(String name, Closure configuration) {
lanes.create(name, configuration)
}
/**
* Declares one auxiliary source set.
*
* <p>Also used for a source set with no lane of its own — a testkit, a JMH harness — because the
* wiring is the same and splitting it by whether a Test task happens to exist would give two
* spellings of one thing.
*/
void sourceSet(String name, Closure configuration) {
// Wired here rather than from a container `all` hook, and that is not a style choice: a
// NamedDomainObjectContainer fires `all` *before* it applies the configuration closure, so a
// hook would read a spec whose `compilesAgainst` and `inherits` are still the defaults and
// would silently wire every source set the same way. `create` returns the configured spec,
// so the wiring happens after the leaf has spoken. A TestKit fixture caught this — the build
// succeeded where it should have failed, which is the only symptom the mistake has.
realize(sourceSets.create(name, configuration))
}
private void realize(AuxiliarySourceSetSpec spec) {
// Read into a local before the closures below. Inside a closure, a bare `project` resolves
// through the extension's metaclass rather than as a field access, and Gradle's decorated
// extension answers that with "unknown property 'project'" — a failure whose message points
// nowhere near the cause.
def owningProject = project
def created = owningProject.sourceSets.create(spec.name)
// No srcDir calls. The java plugin already assigns src/<name>/java and src/<name>/resources
// to a created source set; the leaves that spelled them out were re-adding directories that
// were already there.
spec.visibleOutputs.each { String visible ->
def source = owningProject.sourceSets.findByName(visible)
if (source == null) {
throw new org.gradle.api.GradleException(
"source set '${spec.name}' in ${owningProject.path} compiles against '${visible}', " +
"which does not exist. Declare it first — source sets are created in " +
"declaration order.")
}
created.compileClasspath += source.output
}
created.runtimeClasspath += created.output + created.compileClasspath
spec.inheritedTestConfigurations.each { String suffix ->
String inherited = "test${suffix.capitalize()}"
String own = "${spec.name}${suffix.capitalize()}"
def target = owningProject.configurations.findByName(own)
def source = owningProject.configurations.findByName(inherited)
if (target == null || source == null) {
throw new org.gradle.api.GradleException(
"source set '${spec.name}' in ${owningProject.path} cannot inherit '${suffix}': " +
"expected configurations '${own}' and '${inherited}'.")
}
target.extendsFrom source
}
}
}
def strictTestLanes = extensions.create('strictTestLanes', StrictTestLaneExtension, project)
// Registration is lazy and validation is deferred, for a reason worth stating: a
// NamedDomainObjectContainer adds the object — firing `all` — *before* it applies the configuration
// closure. Validating inside `all` therefore inspects a spec whose every field is still null, and
// the first version of this plugin rejected six perfectly well-formed lanes on that basis.
// Captured once, outside the task actions below. Reading `project.path` inside a `doLast` is
// `Task.project` at execution time — deprecated in Gradle 9, an error in Gradle 10, and rejected
// today by the `--warning-mode=fail` gates. A failure message is exactly where it would hide, since
// that branch only runs on the day the lane is already broken.
String owningProjectPath = project.path
strictTestLanes.lanes.all { StrictTestLaneSpec lane ->
tasks.register(lane.name, Test) {
group = 'verification'
description = lane.description
testClassesDirs = project.sourceSets.getByName(lane.sourceSet).output.classesDirs
classpath = project.sourceSets.getByName(lane.sourceSet).runtimeClasspath
if (lane.tag?.trim()) {
useJUnitPlatform { includeTags lane.tag }
} else {
// A dedicated source set is itself the selection; there is nothing left to filter.
useJUnitPlatform()
}
if (!lane.requiredTests.isEmpty()) {
filter {
lane.requiredTests.each { String selector -> includeTestsMatching(selector) }
// Necessary and not sufficient — see the executed-selector check below.
failOnNoMatchingTests = true
}
}
// Not configurable. A selected lane that discovers no test is an error, never a skip: it
// reports success for a datastore, a broker or a protocol nobody exercised.
failOnNoDiscoveredTests = true
// And it is not sufficient, which a TestKit fixture established. `failOnNoDiscoveredTests`
// applies to discovery; a tag filter excludes tests *after* discovery, so a lane whose tag
// matches nothing runs, executes zero tests and passes. That is the likeliest way a lane
// goes hollow — a tag renamed on the tests but not on the lane — and it is exactly what the
// flag reads as if it prevented.
//
// So the lane counts what it actually executed and refuses zero.
def executedTests = new java.util.concurrent.atomic.AtomicLong(0L)
def executedSelectors =
java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
afterTest { descriptor, result ->
executedTests.incrementAndGet()
if (!lane.requiredTests.isEmpty()) {
executedSelectors.add(descriptor.className as String)
executedSelectors.add("${descriptor.className}.${descriptor.name}" as String)
}
}
doLast {
if (executedTests.get() == 0L) {
throw new GradleException(
"strict test lane '${lane.name}' in ${owningProjectPath} executed no test" +
(lane.tag?.trim()
? "; its tag '${lane.tag}' matches nothing in source set '${lane.sourceSet}'"
: (lane.requiredTests.isEmpty()
? " in source set '${lane.sourceSet}'"
: "; its required tests ${lane.requiredTests} matched no executable test")) +
" — a lane that runs nothing reports success for whatever it was " +
"meant to prove.")
}
// Every named test, not merely one of them. `failOnNoMatchingTests` fails only when the
// whole filter matches nothing, so a lane naming five contracts of which four still
// exist passes and reports on four — which a TestKit fixture demonstrated rather than a
// reading of the docs. Coverage that silently shrank is a gate that silently weakened,
// and this is the shape that produces it: a test renamed, the lane not updated.
if (!lane.requiredTests.isEmpty()) {
List<String> absent = lane.requiredTests.findAll { String required ->
!executedSelectors.any { String executed ->
// A parameterized test executes as `method(String)[1]`, so an exact-equality
// check would report a test that ran as absent.
executed == required ||
executed.startsWith(required + '(') ||
executed.startsWith(required + '[')
}
}
if (!absent.isEmpty()) {
throw new GradleException(
"strict test lane '${lane.name}' in ${owningProjectPath} required " +
"${absent} and executed neither. A lane that named a test it no " +
"longer runs proves less than it claims.")
}
}
}
// Nor this. A lane whose result is served from an earlier run is evidence about that run.
outputs.upToDateWhen { false }
if (lane.customize != null) {
lane.customize.call(it)
}
}
// failOnNoDiscoveredTests is not enough on its own, which a TestKit fixture established rather
// than a reading of the docs. It only applies once the task runs, and Gradle skips a Test task
// whose class directories are empty as NO-SOURCE — so a lane over an empty or misconfigured
// source set is reported as a success, which is the precise failure the flag exists to prevent,
// one level earlier.
//
// Checked when the graph is ready rather than in a doFirst, because a NO-SOURCE task has no
// actions to run. Only when the lane is actually selected: a lane nobody asked for should not
// fail a build for having no classes yet.
project.gradle.taskGraph.whenReady { graph ->
// Compared by task identity, not by a constructed path: the root project's path is ":", so
// "${project.path}:${lane.name}" yields "::name" and matches nothing — which is how the
// first version of this guard silently never fired.
if (!graph.allTasks.any { it.name == lane.name && it.project == project }) {
return
}
// Sources, not compiled output. whenReady fires before any task executes, so classesDirs
// is empty at this point even for a leaf full of tests — checking it failed every lane on a
// clean build, which the TestKit fixture caught before any leaf did.
def sourceSet = project.sourceSets.getByName(lane.sourceSet)
if (sourceSet.allSource.files.isEmpty()) {
throw new GradleException(
"strict test lane '${lane.name}' in ${project.path} has no sources in source " +
"set '${lane.sourceSet}'. Gradle would skip it as NO-SOURCE and report " +
"success for a lane that ran nothing.")
}
}
}
// Checked once the leaf has finished declaring, so a malformed lane fails the build even when its
// task is never selected — the alternative is a lane that is wrong until the day somebody runs it.
project.afterEvaluate {
strictTestLanes.lanes.each { StrictTestLaneSpec lane ->
if (!lane.tag?.trim() && lane.requiredTests.isEmpty() && lane.sourceSet == 'test') {
throw new GradleException(
"strict test lane '${lane.name}' in ${project.path} selects nothing while " +
"running over the shared 'test' source set; it would run the entire " +
"suite under a name that claims it ran one thing. Give it a tag, a set " +
"of required tests, or a source set of its own.")
}
if (lane.tag?.trim() && !lane.requiredTests.isEmpty()) {
// Both would intersect, and an intersection of two selections is a lane whose contents
// nobody can predict from either declaration.
throw new GradleException(
"strict test lane '${lane.name}' in ${project.path} declares both a tag and " +
"required tests; pick one selection.")
}
if (!lane.description?.trim()) {
throw new GradleException(
"strict test lane '${lane.name}' in ${project.path} declares no description")
}
}
}
@@ -0,0 +1,77 @@
// A leaf's testkit: its own source set, and optionally a consumable artifact.
//
// Four leaves declare a testkit and each spells out the same wiring — a source set with main on its
// compile classpath, two configurations extending the test ones, and the testkit output added to
// every lane that consumes it. The copies already differ in a way worth noticing: the JPA leaf
// publishes its testkit as a consumable artifact and the Mongo leaf does not, which is a real
// difference in what each leaf offers rather than an oversight to normalise away.
//
// So publishing is opt-in. A leaf that only wants the source set declares only that, and gains no
// task it did not have:
//
// testkitPublisher {
// consumedBy 'test', 'postgresqlIntegrationTest'
// publishAs 'jpaTestkit' // omit and nothing is published
// }
class TestkitPublisherExtension {
/** Source set name. Its sources live in src/<name>/java. */
String sourceSetName = 'testkit'
/** Source sets that compile and run against the testkit. */
final List<String> consumers = []
/** Consumable configuration name, or null when this leaf publishes nothing. */
String consumableConfiguration
/** Names the source sets that compile against the testkit. */
void consumedBy(String... names) {
consumers.addAll(names as List)
}
/** Publishes the testkit as a consumable artifact under this configuration name. */
void publishAs(String configurationName) {
this.consumableConfiguration = configurationName
}
}
def testkitPublisher = extensions.create('testkitPublisher', TestkitPublisherExtension)
project.afterEvaluate {
def testkit = project.sourceSets.findByName(testkitPublisher.sourceSetName)
if (testkit == null) {
// Not a leaf with a testkit. The convention is applied everywhere and configured by few.
return
}
// Every consumer compiles and runs against the testkit output. Declared per leaf because which
// lanes consume a testkit is a leaf's decision, not a convention's.
testkitPublisher.consumers.each { String consumerName ->
def consumer = project.sourceSets.findByName(consumerName)
if (consumer == null) {
throw new GradleException(
"${project.path} testkitPublisher names consumer source set '${consumerName}', " +
"which does not exist")
}
consumer.compileClasspath += testkit.output
consumer.runtimeClasspath += testkit.output
}
if (testkitPublisher.consumableConfiguration == null) {
return
}
// Publishing is what makes a testkit usable from the composition root, which is the only place
// that can see every runtime leaf at once — and therefore the only place an architecture rule
// pack can be applied to the production graph rather than to its own fixtures.
def testkitJar = tasks.register('testkitJar', Jar) {
archiveClassifier = 'testkit'
from testkit.output
}
configurations.create(testkitPublisher.consumableConfiguration) {
canBeConsumed = true
canBeResolved = false
}
artifacts.add(testkitPublisher.consumableConfiguration, testkitJar)
}
@@ -0,0 +1,147 @@
package dev.caskeleton.buildlogic
import groovy.xml.XmlSlurper
/**
* JUnit XML result files, read once and the same way everywhere.
*
* <p>Two evidence scripts read the same file format independently, and the copies had already
* diverged on something that matters: {@code junit-evidence.gradle} disables DOCTYPE processing on
* its parser and {@code jpa-evidence.gradle} does not. Both read build output rather than untrusted
* input, so nothing was exploited — but one of the two is wrong about the same question, and the
* hardened one shows which answer the author meant. A single reader cannot hold two answers.
*
* <p>The counts come from the suite attributes rather than from counting {@code testcase} elements,
* because a suite that failed to initialise reports its failure in the attributes and carries no
* testcase at all — counting elements would call that suite empty and therefore fine.
*/
final class JUnitEvidence {
/** Totals across every result file, plus what actually ran. */
static final class Results {
/** Tests the runner reported, from the suite attributes. */
final int tests
final int skipped
final int failures
final int errors
/**
* Fully-qualified class names that actually ran.
*
* <p>A skipped test case does not put its class here. One of the two readers this replaces
* made that distinction and the other did not, and the distinction is the point: a lane that
* proves a required class ran must not be satisfied by that class having been skipped.
*/
final Set<String> executedClasses
/** {@code Class#method} selectors, method parameters stripped. */
final Set<String> executedSelectors
/** The files these totals came from, in stable order. */
final List<File> resultFiles
private Results(int tests, int skipped, int failures, int errors,
Set<String> executedClasses, Set<String> executedSelectors,
List<File> resultFiles) {
this.tests = tests
this.skipped = skipped
this.failures = failures
this.errors = errors
this.executedClasses = Collections.unmodifiableSet(executedClasses)
this.executedSelectors = Collections.unmodifiableSet(executedSelectors)
this.resultFiles = Collections.unmodifiableList(resultFiles)
}
/** Nothing was skipped, nothing failed, and something ran. */
boolean isClean() {
return tests > 0 && skipped == 0 && failures == 0 && errors == 0
}
}
private JUnitEvidence() {}
/**
* Reads every {@code TEST-*.xml} under the directory.
*
* @param evidenceName the lane this evidence belongs to, used in failure messages
* @param resultDirectory the directory Gradle wrote JUnit XML into
* @throws IllegalStateException when the directory holds no result files — evidence that does
* not exist must not be summarised as evidence of nothing having gone wrong
*/
static Results read(String evidenceName, File resultDirectory) {
List<File> resultFiles = []
if (resultDirectory != null && resultDirectory.isDirectory()) {
resultDirectory.eachFileRecurse { File candidate ->
if (candidate.isFile() && candidate.name.startsWith('TEST-')
&& candidate.name.endsWith('.xml')) {
resultFiles << candidate
}
}
}
resultFiles.sort { left, right -> left.path <=> right.path }
if (resultFiles.isEmpty()) {
throw new IllegalStateException(
"${evidenceName}: no JUnit XML result files in ${resultDirectory}")
}
int tests = 0
int skipped = 0
int failures = 0
int errors = 0
Set<String> classes = new LinkedHashSet<>()
Set<String> selectors = new TreeSet<>()
resultFiles.each { File resultFile ->
def suite
try {
suite = parser().parse(resultFile)
} catch (Exception unreadable) {
throw new IllegalStateException(
"${evidenceName}: ${resultFile} is not readable JUnit XML", unreadable)
}
if (suite.name() != 'testsuite') {
throw new IllegalStateException(
"${evidenceName}: ${resultFile.name} root must be testsuite")
}
tests += attribute(suite, 'tests', evidenceName, resultFile)
skipped += attribute(suite, 'skipped', evidenceName, resultFile)
failures += attribute(suite, 'failures', evidenceName, resultFile)
errors += attribute(suite, 'errors', evidenceName, resultFile)
suite.testcase.each { testcase ->
String className = testcase.@classname.text()
boolean wasSkipped = !testcase.skipped.isEmpty()
if (className && !className.isBlank() && !wasSkipped) {
classes << className
}
String methodName = testcase.@name.text().replaceFirst(/\([^)]*\)$/, '')
selectors << "${className}#${methodName}".toString()
}
}
return new Results(tests, skipped, failures, errors, classes, selectors, resultFiles)
}
/**
* The one parser configuration.
*
* <p>DOCTYPE processing off. It is off in one of the two readers this replaces and on in the
* other, and "the input is our own build output" is an argument for why it never mattered, not
* for which setting is correct.
*/
private static XmlSlurper parser() {
XmlSlurper parser = new XmlSlurper(false, false)
parser.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true)
return parser
}
/**
* One suite attribute, required and numeric.
*
* <p>No defaulting. An absent or unparseable count is a file this reader does not understand,
* and treating it as zero turns "I could not read the result" into "nothing went wrong".
*/
private static int attribute(Object suite, String name, String evidenceName, File resultFile) {
String raw = suite.attributes()[name]?.toString()
if (!(raw ==~ /\d+/)) {
throw new IllegalStateException(
"${evidenceName}: ${resultFile.name} has invalid ${name}='${raw}'")
}
return Integer.parseInt(raw)
}
}
@@ -0,0 +1,231 @@
package dev.caskeleton.buildlogic
import groovy.json.JsonSlurper
/**
* The module registry, parsed and validated once.
*
* <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.
*/
final class ModuleRegistry {
/** The runtime compositions this repository recognises. */
static final Set<String> RUNTIME_COMPOSITIONS = ['app-bootstrap', 'sample-portfolio'] as Set
/** Exactly the fields a module entry carries — extra or missing is a failure, not a default. */
private static final Set<String> MODULE_FIELDS =
['id', 'gradle_path', 'source_path', 'allowed_dependencies', 'runtime_memberships'] as Set
private static final Set<String> ROOT_FIELDS = ['runtime_compositions', 'modules'] as Set
/** Every registered module, in registry order. */
final List<Module> modules
/** The file this was read from, for failure messages that name it. */
final File source
private ModuleRegistry(List<Module> modules, File source) {
this.modules = Collections.unmodifiableList(modules)
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) {
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}")
}
def parsed = new JsonSlurper().parse(registryFile)
if (!(parsed instanceof Map)) {
throw new IllegalStateException("Module registry root must be a JSON object: ${registryFile}")
}
if (parsed.keySet().collect { it as String }.toSet() != ROOT_FIELDS) {
throw new IllegalStateException(
"Module registry root fields must be exactly ${ROOT_FIELDS}: ${registryFile}")
}
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.collect { it as String }.toSet() != RUNTIME_COMPOSITIONS ||
parsed.runtime_compositions.size() != RUNTIME_COMPOSITIONS.size()) {
throw new IllegalStateException(
"Module registry runtime_compositions must be exactly ${RUNTIME_COMPOSITIONS}: ${registryFile}")
}
File canonicalRoot = repositoryRoot.canonicalFile
String rootPrefix = canonicalRoot.path + File.separator
Set<String> ids = new LinkedHashSet<>()
Set<String> gradlePaths = new LinkedHashSet<>()
Set<String> sourceDirectories = new LinkedHashSet<>()
List<Module> modules = parsed.modules.withIndex().collect { rawModule, index ->
if (!(rawModule instanceof Map)) {
throw new IllegalStateException("Module registry entry ${index} must be a JSON object.")
}
Map<String, Object> module = rawModule as Map<String, Object>
if (module.keySet().collect { it as String }.toSet() != MODULE_FIELDS) {
// 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}'"
: "at index ${index}"
Set<String> missing = MODULE_FIELDS - module.keySet().collect { it as String }.toSet()
Set<String> unexpected = module.keySet().collect { it as String }.toSet() - MODULE_FIELDS
throw new IllegalStateException(
"Module registry entry ${named} fields must be exactly ${MODULE_FIELDS}" +
(missing.isEmpty() ? '' : "; missing ${missing.toSorted()}") +
(unexpected.isEmpty() ? '' : "; unexpected ${unexpected.toSorted()}"))
}
['id', 'gradle_path', 'source_path'].each { field ->
if (!(module[field] instanceof String) || (module[field] as String).isBlank()) {
throw new IllegalStateException(
"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()) {
throw new IllegalStateException(
"Module registry entry '${id}' contains duplicate runtime memberships.")
}
Set<String> unknown = runtimeMemberships.toSet() - RUNTIME_COMPOSITIONS
if (!unknown.isEmpty()) {
throw new IllegalStateException(
"Module registry entry '${id}' references unknown runtime memberships ${unknown.toSorted()}.")
}
if (!ids.add(id)) {
throw new IllegalStateException("Module registry contains duplicate module id '${id}'.")
}
String gradlePath = module.gradle_path as String
if (!gradlePath.startsWith(':')) {
throw new IllegalStateException(
"Module registry entry '${id}' has Gradle path '${gradlePath}' that does not start with ':'.")
}
if (!gradlePaths.add(gradlePath)) {
throw new IllegalStateException(
"Module registry contains duplicate Gradle path '${gradlePath}'.")
}
String sourcePath = module.source_path as String
if (new File(sourcePath).isAbsolute()) {
throw new IllegalStateException(
"Module registry entry '${id}' source path must be repository-root-relative: '${sourcePath}'.")
}
File sourceDirectory = new File(canonicalRoot, sourcePath).canonicalFile
if (!sourceDirectory.path.startsWith(rootPrefix)) {
throw new IllegalStateException(
"Module registry entry '${id}' source path escapes the repository root: '${sourcePath}'.")
}
if (!sourceDirectory.isDirectory()) {
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)
}
RUNTIME_COMPOSITIONS.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.')
}
}
modules.each { module ->
module.allowedDependencies.each { dependencyId ->
if (dependencyId == module.id) {
throw new IllegalStateException(
"Module registry entry '${module.id}' must not depend on itself.")
}
if (module.id != 'sample-portfolio' && dependencyId == 'sample-portfolio') {
throw new IllegalStateException(
"Production module registry entry '${module.id}' must not allow a dependency on " +
"'sample-portfolio'.")
}
if (!ids.contains(dependencyId)) {
throw new IllegalStateException(
"Module registry entry '${module.id}' references unknown allowed dependency id " +
"'${dependencyId}'.")
}
}
}
return new ModuleRegistry(modules, 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 }
}
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 ->
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}.")
}
value as String
}
}
}
@@ -0,0 +1,125 @@
import java.nio.file.Files
import java.nio.file.Path
import org.gradle.testkit.runner.GradleRunner
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import static org.junit.jupiter.api.Assertions.assertEquals
import static org.junit.jupiter.api.Assertions.assertTrue
/**
* The approval flag the convention documents is the one it honours.
*
* <p>The flag is named after the leaf's own label, and the label is set by a block that runs after
* the convention's script body. Reading the property in the body therefore asked for
* {@code approvenullApiSurfaceChange} — a name no caller would ever pass — so the documented flag
* silently never applied: the update task could not be approved at all, and the verify task's
* read-only guard could not be tripped. Nothing failed; the gate simply had no on switch.
*
* <p>TestKit against a real build rather than reading the script, because the defect was entirely
* about <em>when</em> a value is read, which is invisible in the text.
*/
class ApiSurfaceConventionTest {
Path projectDir
@BeforeEach
void setUp() {
projectDir = Files.createTempDirectory('api-surface')
Files.writeString(projectDir.resolve('settings.gradle'), "rootProject.name = 'fixture'\n")
Path source = projectDir.resolve('src/main/java/app')
Files.createDirectories(source)
Files.writeString(source.resolve('Visible.java'),
"package app;\npublic final class Visible {}\n")
Files.writeString(projectDir.resolve('build.gradle'), """
plugins {
id 'java'
id 'ca.api-surface'
}
apiSurface {
label = 'Fixture'
sourceRoot = 'src/main/java'
baseline = file('surface.txt')
description = 'The fixture leaf public surface.'
}
""".stripIndent())
}
private GradleRunner runner(String... args) {
return GradleRunner.create()
.withProjectDir(projectDir.toFile())
.withPluginClasspath()
.withArguments(args)
}
@Test
@DisplayName("the documented flag is what approves an update")
void theDocumentedFlagApprovesAnUpdate() {
def result = runner('updateFixtureApiSurface', '-PapproveFixtureApiSurfaceChange').build()
assertTrue(result.output.contains('wrote'), "the baseline should be written:\n${result.output}")
assertTrue(Files.readString(projectDir.resolve('surface.txt')).contains('app.Visible'),
'the rendered surface should name the public type')
}
@Test
@DisplayName("an update without the flag is refused, and the message names the flag that works")
void anUnapprovedUpdateIsRefused() {
def result = runner('updateFixtureApiSurface').buildAndFail()
assertTrue(result.output.contains('requires -PapproveFixtureApiSurfaceChange'),
"the refusal should name the flag a caller can actually pass:\n${result.output}")
}
@Test
@DisplayName("the label-shaped flag is the only one that counts")
void theLabelShapedFlagIsTheOnlyOneThatCounts() {
// The name the defect produced. Honouring it would mean the property is being read before
// the label exists, which is the whole failure.
def result = runner('updateFixtureApiSurface', '-PapprovenullApiSurfaceChange').buildAndFail()
assertTrue(result.output.contains('requires -PapproveFixtureApiSurfaceChange'),
"a mis-named flag must not approve anything:\n${result.output}")
}
@Test
@DisplayName("verify refuses to run under the approval flag, rather than reporting success")
void verifyIsReadOnly() {
runner('updateFixtureApiSurface', '-PapproveFixtureApiSurfaceChange').build()
def result = runner('verifyFixtureApiSurface', '-PapproveFixtureApiSurfaceChange').buildAndFail()
assertTrue(result.output.contains('read-only'),
"verify must not silently pass while an approval is in flight:\n${result.output}")
}
@Test
@DisplayName("a surface that grew since the baseline fails verification, naming what was added")
void aGrownSurfaceFailsVerification() {
runner('updateFixtureApiSurface', '-PapproveFixtureApiSurfaceChange').build()
Files.writeString(projectDir.resolve('src/main/java/app/Added.java'),
"package app;\npublic interface Added {}\n")
def result = runner('verifyFixtureApiSurface').buildAndFail()
assertTrue(result.output.contains('app.Added'),
"the failure should name the added type:\n${result.output}")
}
@Test
@DisplayName("a leaf that declares no surface gets no tasks")
void aLeafWithoutASurfaceGetsNoTasks() {
Files.writeString(projectDir.resolve('build.gradle'), """
plugins {
id 'java'
id 'ca.api-surface'
}
""".stripIndent())
def result = runner('tasks', '--group=verification').build()
assertEquals(false, result.output.contains('ApiSurface'),
"the convention is available, not imposed:\n${result.output}")
}
}
@@ -0,0 +1,122 @@
import dev.caskeleton.buildlogic.JUnitEvidence
import java.nio.file.Files
import java.nio.file.Path
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import static org.junit.jupiter.api.Assertions.assertEquals
import static org.junit.jupiter.api.Assertions.assertFalse
import static org.junit.jupiter.api.Assertions.assertThrows
import static org.junit.jupiter.api.Assertions.assertTrue
/**
* The evidence reader keeps the stricter answer from each of the two readers it replaces.
*
* <p>Both read JUnit XML and had drifted: one disabled DOCTYPE processing, the other did not; one
* excluded skipped cases from the executed-class set, the other counted every case. Each case below
* pins one of those answers so a later simplification cannot quietly take the looser one.
*/
class JUnitEvidenceTest {
Path results
@BeforeEach
void setUp() {
results = Files.createTempDirectory('junit-evidence')
}
private void suite(String name, String body) {
Files.writeString(results.resolve("TEST-${name}.xml"), body)
}
@Test
@DisplayName("totals come from the suite attributes, not from counting elements")
void totalsComeFromAttributes() {
// A suite that failed to initialise reports its failure in the attributes and carries no
// testcase element. Counting elements would call that suite empty and therefore fine.
suite('Broken', '<testsuite name="Broken" tests="1" skipped="0" failures="0" errors="1"/>')
def read = JUnitEvidence.read('lane', results.toFile())
assertEquals(1, read.tests)
assertEquals(1, read.errors)
assertFalse(read.clean, 'a suite reporting an error is not clean')
}
@Test
@DisplayName("a skipped case does not make its class an executed class")
void skippedCaseIsNotExecuted() {
suite('Mixed', '''<testsuite name="Mixed" tests="2" skipped="1" failures="0" errors="0">
<testcase classname="a.Ran" name="ran"/>
<testcase classname="a.Skipped" name="skipped"><skipped/></testcase>
</testsuite>''')
def read = JUnitEvidence.read('lane', results.toFile())
assertTrue(read.executedClasses.contains('a.Ran'))
assertFalse(read.executedClasses.contains('a.Skipped'),
'a lane proving a required class ran must not be satisfied by it being skipped')
}
@Test
@DisplayName("every case reaches the selector set, skipped included")
void selectorsIncludeEveryCase() {
// Selectors answer "what did this lane address", which is a different question from "what
// ran"; the manifest uses them to detect two lanes covering the same test.
suite('Mixed', '''<testsuite name="Mixed" tests="2" skipped="1" failures="0" errors="0">
<testcase classname="a.Ran" name="ran"/>
<testcase classname="a.Skipped" name="skipped"><skipped/></testcase>
</testsuite>''')
def read = JUnitEvidence.read('lane', results.toFile())
assertTrue(read.executedSelectors.contains('a.Ran#ran'))
assertTrue(read.executedSelectors.contains('a.Skipped#skipped'))
}
@Test
@DisplayName("an empty result directory is an error, not evidence of nothing going wrong")
void emptyDirectoryIsRefused() {
def failure = assertThrows(IllegalStateException) {
JUnitEvidence.read('lane', results.toFile())
}
assertTrue(failure.message.contains('no JUnit XML result files'), failure.message)
}
@Test
@DisplayName("a missing or unparseable count is refused rather than defaulted to zero")
void malformedCountIsRefused() {
suite('Odd', '<testsuite name="Odd" tests="" skipped="0" failures="0" errors="0"/>')
def failure = assertThrows(IllegalStateException) {
JUnitEvidence.read('lane', results.toFile())
}
assertTrue(failure.message.contains("invalid tests="), failure.message)
}
@Test
@DisplayName("a root element that is not testsuite is refused")
void wrongRootIsRefused() {
suite('Wrong', '<testsuites><testsuite name="x" tests="1" skipped="0" failures="0" errors="0"/></testsuites>')
def failure = assertThrows(IllegalStateException) {
JUnitEvidence.read('lane', results.toFile())
}
assertTrue(failure.message.contains('root must be testsuite'), failure.message)
}
@Test
@DisplayName("a DOCTYPE declaration is refused rather than processed")
void doctypeIsRefused() {
// The setting the two readers disagreed on. The input is build output, so nothing was
// exploited — but "it never mattered" is not an answer to which setting is right.
suite('Doctype', '''<!DOCTYPE testsuite [<!ENTITY x "y">]>
<testsuite name="Doctype" tests="1" skipped="0" failures="0" errors="0"/>''')
def failure = assertThrows(IllegalStateException) {
JUnitEvidence.read('lane', results.toFile())
}
assertTrue(failure.message.contains('not readable JUnit XML'), failure.message)
}
}
@@ -0,0 +1,157 @@
import dev.caskeleton.buildlogic.ModuleRegistry
import java.nio.file.Files
import java.nio.file.Path
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
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.
*/
class ModuleRegistryTest {
Path root
@BeforeEach
void setUp() {
root = Files.createTempDirectory('registry')
Files.createDirectories(root.resolve('src/alpha'))
Files.createDirectories(root.resolve('src/beta'))
}
private File write(String json) {
Path file = root.resolve('modules.json')
Files.writeString(file, json)
return 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 String registry(String... entries) {
return """{"runtime_compositions":["app-bootstrap","sample-portfolio"],
"modules":[${entries.join(',')}]}"""
}
private ModuleRegistry read(String json) {
return 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"]'))
}
@Test
@DisplayName("a well-formed registry parses into its modules")
void wellFormedRegistryParses() {
def parsed = read(valid())
assertEquals(2, parsed.modules.size())
assertEquals(['app-bootstrap'], parsed.membersOf('app-bootstrap').collect { it.id })
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) }
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) }
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)
}
@Test
@DisplayName("a production module may not depend on sample-portfolio")
void productionDependencyOnSampleIsRefused() {
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '["sample-portfolio"]',
'["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains("must not allow a dependency on 'sample-portfolio'"),
failure.message)
}
@Test
@DisplayName("an unknown allowed-dependency id is refused")
void unknownDependencyIsRefused() {
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '["nope"]', '["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('unknown allowed dependency id'), failure.message)
}
@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 refused rather than ignored")
void extraFieldIsRefused() {
String json = """{"runtime_compositions":["app-bootstrap","sample-portfolio"],"modules":[
{"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha",
"allowed_dependencies":[],"runtime_memberships":["app-bootstrap"],"extra":true},
${entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('fields must be exactly'), 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)
}
}
@@ -0,0 +1,347 @@
import java.nio.file.Files
import java.nio.file.Path
import org.gradle.testkit.runner.GradleRunner
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import static org.junit.jupiter.api.Assertions.assertTrue
/**
* The lane convention fails closed, and does so for the reasons the lanes exist.
*
* <p>Tested with TestKit against a real Gradle build rather than by reading the plugin's source,
* because the properties that matter — a lane that discovers nothing is an error, a lane never
* reports up-to-date — are runtime behaviour of a Test task, not text in a script.
*/
class StrictTestLaneConventionTest {
Path projectDir
@BeforeEach
void setUp() {
projectDir = Files.createTempDirectory('strict-lane')
Files.writeString(projectDir.resolve('settings.gradle'), "rootProject.name = 'fixture'\n")
}
private void buildFile(String laneBlock) {
Files.writeString(projectDir.resolve('build.gradle'), """
plugins {
id 'java'
id 'ca.strict-test-lane'
}
repositories { mavenCentral() }
${laneBlock}
""".stripIndent())
}
private GradleRunner runner(String... args) {
return GradleRunner.create()
.withProjectDir(projectDir.toFile())
.withPluginClasspath()
.withArguments(args)
}
@Test
@DisplayName("a declared lane becomes a verification task carrying its description")
void aDeclaredLaneBecomesATask() {
buildFile("""
strictTestLanes {
lane('contractLane') {
tag = 'contract'
description = 'What this lane proves.'
}
}
""")
def result = runner('tasks', '--group=verification').build()
assertTrue(result.output.contains('contractLane'),
"the lane should be registered:\\n${result.output}")
assertTrue(result.output.contains('What this lane proves.'),
"the description should reach the task:\\n${result.output}")
}
@Test
@DisplayName("a lane with no tag fails the build, even though its task is never run")
void aLaneWithNoTagFailsTheBuild() {
// `tasks` never realizes the lane. The point is that a malformed lane is refused at
// configuration time rather than on the day somebody selects it.
buildFile("""
strictTestLanes {
lane('untagged') {
description = 'Selects nothing.'
}
}
""")
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains("lane 'untagged'") && result.output.contains('selects nothing'),
"the failure should name the lane and the missing selection:\\n${result.output}")
}
@Test
@DisplayName("a lane with no description fails the build")
void aLaneWithNoDescriptionFailsTheBuild() {
buildFile("""
strictTestLanes {
lane('undescribed') {
tag = 'contract'
}
}
""")
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains('declares no description'),
"a lane nobody can describe is a lane nobody can interpret:\\n${result.output}")
}
@Test
@DisplayName("a lane over an empty source set fails instead of being skipped as NO-SOURCE")
void anEmptySourceSetFails() {
// Gradle skips a Test task with no class directories as NO-SOURCE, before
// failOnNoDiscoveredTests can apply — so the flag alone reports success for a lane that ran
// nothing. This case is why the convention carries a second guard.
buildFile("""
strictTestLanes {
lane('emptyLane') {
tag = 'nothing-carries-this-tag'
description = 'Selects a tag no test declares.'
}
}
""")
def result = runner('emptyLane').buildAndFail()
assertTrue(result.output.contains('has no sources'),
"an empty lane must fail rather than skip:" + System.lineSeparator() + result.output)
}
@Test
@DisplayName("a lane whose tag matches nothing fails, though the source set has tests")
void aTagThatMatchesNothingFails() {
// The other half: classes exist, so the task runs, and failOnNoDiscoveredTests is what
// refuses. This is the case a renamed tag or a moved test produces.
buildFile("""
dependencies {
testImplementation platform('org.junit:junit-bom:5.11.3')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
strictTestLanes {
lane('mismatchedLane') {
tag = 'no-test-carries-this'
description = 'A tag nothing declares.'
}
}
""")
Path testSource = projectDir.resolve('src/test/java')
Files.createDirectories(testSource)
Files.writeString(testSource.resolve('PresentTest.java'), """
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
class PresentTest {
@Test
@Tag("carried")
void present() {}
}
""".stripIndent())
def result = runner('mismatchedLane').buildAndFail()
assertTrue(result.output.toLowerCase().contains('no test'),
"a tag matching nothing must fail:" + System.lineSeparator() + result.output)
}
@Test
@DisplayName("a lane over the shared test source set must name a tag")
void aSharedSourceSetLaneMustNameATag() {
buildFile("""
strictTestLanes {
lane('unfiltered') {
description = 'Would run the entire suite.'
}
}
""")
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains('runs over the shared') || result.output.contains("shared 'test' source set"),
"an unfiltered lane over `test` runs everything under a name that says otherwise:"
+ System.lineSeparator() + result.output)
}
@Test
@DisplayName("a lane may select exact tests instead of a tag")
void aLaneMaySelectExactTests() {
// The third selection. A lane that must stay exactly these tests cannot say so with a tag:
// a tag is an open set, and any test added later joins the lane by annotation alone.
buildFile("""
dependencies {
testImplementation platform('org.junit:junit-bom:5.11.3')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
strictTestLanes {
lane('namedLane') {
description = 'Runs exactly one named contract.'
requires 'SelectedTest.selected'
}
}
""")
Path testSource = projectDir.resolve('src/test/java')
Files.createDirectories(testSource)
Files.writeString(testSource.resolve('SelectedTest.java'), """
import org.junit.jupiter.api.Test;
class SelectedTest {
@Test
void selected() {}
@Test
void notSelected() { throw new AssertionError("this test is not in the lane"); }
}
""".stripIndent())
def result = runner('namedLane').build()
assertTrue(result.output.contains('BUILD SUCCESSFUL'),
"the lane must run only what it named:" + System.lineSeparator() + result.output)
}
@Test
@DisplayName("a required test that no longer exists fails the lane rather than shrinking it")
void aMissingRequiredTestFails() {
buildFile("""
dependencies {
testImplementation platform('org.junit:junit-bom:5.11.3')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
strictTestLanes {
lane('renamedLane') {
description = 'Names a test that was renamed away.'
requires 'SelectedTest.selected', 'SelectedTest.renamedAway'
}
}
""")
Path testSource = projectDir.resolve('src/test/java')
Files.createDirectories(testSource)
Files.writeString(testSource.resolve('SelectedTest.java'), """
import org.junit.jupiter.api.Test;
class SelectedTest {
@Test
void selected() {}
}
""".stripIndent())
def result = runner('renamedLane').buildAndFail()
assertTrue(result.output.contains('renamedAway') || result.output.toLowerCase().contains('no tests found'),
"a lane that silently lost a test is a gate that silently weakened:"
+ System.lineSeparator() + result.output)
}
@Test
@DisplayName("a lane may not declare both a tag and required tests")
void aLaneMayNotDeclareBothSelections() {
buildFile("""
strictTestLanes {
lane('doubleSelection') {
tag = 'contract'
description = 'Two selections at once.'
requires 'SomeTest.some'
}
}
""")
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains('pick one selection'),
"an intersection of two selections has contents neither declaration predicts:"
+ System.lineSeparator() + result.output)
}
@Test
@DisplayName("a declared source set gets its configurations and its lane needs no tag")
void aDeclaredSourceSetIsWiredAndSelects() {
buildFile("""
strictTestLanes {
sourceSet('contractLane') { compilesAgainst 'main' }
lane('contractLane') {
sourceSet = 'contractLane'
description = 'Its own source set is the selection.'
}
}
tasks.register('showWiring') {
def extended = configurations.contractLaneImplementation.extendsFrom.collect { it.name }
doLast { println "extends=" + extended }
}
""")
def result = runner('showWiring').build()
assertTrue(result.output.contains('testImplementation'),
"the source set must inherit the test configurations:"
+ System.lineSeparator() + result.output)
}
@Test
@DisplayName("a source set compiling against one that does not exist fails, naming both")
void anUnknownVisibleSourceSetFails() {
// Declaration order matters — the container creates them as it reads them — and the failure
// has to say so, because "cannot get property output on null" does not.
buildFile("""
strictTestLanes {
sourceSet('performanceLane') { compilesAgainst 'main', 'testkit' }
}
""")
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains("'performanceLane'") && result.output.contains("'testkit'"),
"the failure should name the source set and the missing one:"
+ System.lineSeparator() + result.output)
}
@Test
@DisplayName("a lane whose name is already a task fails rather than silently replacing it")
void aDuplicateLaneNameFails() {
buildFile("""
tasks.register('contractLane') { }
strictTestLanes {
lane('contractLane') {
tag = 'contract'
description = 'Collides with an existing task.'
}
}
""")
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains('contractLane'),
"a name collision must fail, not overwrite:" + System.lineSeparator() + result.output)
}
@Test
@DisplayName("a lane with its own source set needs no tag, because the source set is the selection")
void aDedicatedSourceSetLaneNeedsNoTag() {
buildFile("""
sourceSets { performance }
strictTestLanes {
lane('performanceLane') {
sourceSet = 'performance'
description = 'Its own source set is the selection.'
}
}
""")
def result = runner('tasks', '--group=verification').build()
assertTrue(result.output.contains('performanceLane'),
"a dedicated source set is itself the filter:" + System.lineSeparator() + result.output)
}
}