refactor: 각 어댑터터별 리펙토링 진행
This commit is contained in:
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user