Files
clean-architecture-backend-…/docs/superpowers/plans/2026-08-15-wave5-gradle-build-logic.md
T

12 KiB
Raw Blame History

Wave 5 — Gradle Build Logic Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (- [ ]) syntax. Read 2026-08-15-five-adapter-runtime-remediation-index.md first. Entry criterion: Wave 4 complete — a fully green, warning-zero baseline whose task graph, dependency graph, test selection, and evidence output have been captured as a comparison artifact.

Goal: Extract the duplicated source-set, test-lane, testkit, API-surface, evidence, registry, and dependency machinery into eight TestKit-tested convention plugins in a build-logic included build, so that root, settings, and leaf build files each hold only their own responsibility — with the task graph, dependency graph, test selection, and evidence semantics provably unchanged.

Architecture: A build-logic included build holds precompiled convention plugins. Settings and root stop re-implementing registry parsing against each other by sharing one typed parser/validator. Every extraction is one step, and every step is verified by diffing the captured baseline artifacts — because the failure mode this wave uniquely risks is a task quietly ceasing to exist and the build reporting green for work it no longer does.

Tech Stack: Gradle 9 included builds, precompiled script plugins (build-logic/src/main/groovy), Gradle TestKit.

Spec: 2026-08-15-five-adapter-runtime-remediation-review-design.md (§10 in full, §11 Wave 5, §14 closing paragraph)


Global Constraints

Inherited from the index. Wave 5 adds:

  • Never share a diff with a runtime change. Spec §14 is explicit: build refactoring on top of a red or unverified baseline produces false evidence, because a task that stops existing looks the same as a task that passes.
  • LOC is not the goal. Do not hide logic to move a number. Completion is judged by whether the same source-set/Test/API-surface machinery is still copied into two or more leaves, and whether root, settings, and leaf hold only their stated responsibilities.
  • Each extraction step is verified by artifact diff, not by "the build still works".
  • Provider semantics stay per-module. Image, scenario, security requirement, and promotion meaning differ per provider and stay in each module's registry. Do not merge them into one over-general DSL — spec §8.2 names that as a pattern to avoid.
  • Do not remove a dependency a provider actually uses. The exclusion convention exists to make declared exclusions real, not to strip dependencies centrally.

Measured baseline

File Lines
src/build.gradle 2,767
src/settings.gradle 185
src/app-bootstrap/build.gradle 273
src/adapter/outbound/persistence-jpa/build.gradle 352
src/adapter/outbound/persistence-mongo/build.gradle 346
src/adapter/outbound/messaging/build.gradle 105
src/adapter/outbound/notification/build.gradle 39
src/adapter/inbound/graphql/build.gradle 241
src/gradle/jpa-evidence.gradle 930

The duplication that matters, not the size:

  • custom source set + extendsFrom + Test task + failOnNoDiscoveredTests, repeated per lane;
  • strict qualification lane registration and result-directory wiring, repeated;
  • testkit artifact/source-set wiring, repeated;
  • API-surface snapshot render/update/verify machinery, repeated;
  • registry parsing and validation implemented twice, once in settings and once in root verification;
  • release-evidence manifest and output handling, repeated;
  • dependency exclusion intent that drifts from the resolved graph — e.g. the notification build claims a YAML exclusion (adapter/outbound/notification/build.gradle:21-33) while SnakeYAML remains in its lockfile (gradle.lockfile:165).

And one policy violation: settings.gradle:37-44 hardcodes expectedModuleCount = 44, with a comment arguing the count should be a deliberate decision — but the project policy (AGENTS.md:52-55) makes the registry the count's SSOT and verifyDocumentedLeafCount enforces it against documents. A number in the build file is the same drift the policy forbids in prose. Meanwhile verifyDocumentedLeafCount inspects only some root documents, so a stale count in a module CLAUDE.md — for example src/app-bootstrap/CLAUDE.md's "19-leaf dependency list" — is not caught.


Task 0: Capture the comparison baseline

Files:

  • Create: scripts/capture-build-baseline.sh
  • Create: docs/superpowers/plans/evidence/2026-08-15-wave5-baseline/

Context: This is the instrument the whole wave is judged by. Capture, from the green Wave 4 state:

  1. ./gradlew tasks --all — the full task list;
  2. per-module ./gradlew <path>:dependencies --configuration runtimeClasspath;
  3. every lane's JUnit XML class list (not timings);
  4. every evidence directory's file list and manifest schema;
  5. ./gradlew <every architecture-wide verify task> output.

Normalize timestamps, durations, and absolute paths, so a diff shows semantic change only.

  • Steps 14: write the script, run it, commit the baseline, and prove the script is deterministic by running it twice and diffing (must be identical).

Task 1: ca.architecture-registry — one parser for settings and root

Files:

  • Create: build-logic/settings.gradle, build-logic/build.gradle
  • Create: build-logic/src/main/groovy/ca.architecture-registry.gradle
  • Create: build-logic/src/main/java/dev/caskeleton/buildlogic/registry/ModuleRegistry.java
  • Create: build-logic/src/test/java/dev/caskeleton/buildlogic/registry/ModuleRegistryTest.java
  • Modify: src/settings.gradle, src/build.gradle

Context: First extraction because everything else reads the registry. One typed parser/validator, fail-closed on: a project directory with a build.gradle that the registry does not list; a source_path that does not exist; a duplicate ID or path; and drift between the resolved runtime project closure and declared memberships (Wave 1 Task 13 already made the closure the comparison input).

Remove expectedModuleCount. The registry is the count.

Extend verifyDocumentedLeafCount to cover tracked root AGENTS.md, root CLAUDE.md, and all src/**/CLAUDE.md, and wire it into check and CI. Prefer removing the duplicated number from each document over asserting it — a count that is not written cannot drift. Where a document genuinely needs the number, it must be generated.

  • Steps 18: TestKit tests first (malformed registry, missing path, duplicate ID, membership drift), then the plugin, then delete both re-implementations, then diff against the baseline.

Task 2: ca.strict-test-lane

Files: build-logic/src/main/groovy/ca.strict-test-lane.gradle + TestKit tests; then apply to the JPA, Mongo, GraphQL, messaging, and app-bootstrap leaves one at a time.

Context: The single largest duplication. The convention owns source set creation, configuration extendsFrom, the Test task, failOnNoDiscoveredTests, stale-XML deletion, required-class enforcement, and the results directory — the shape src/gradle/graphql-platform-conventions.gradle:55-100 implements by hand today.

Semantics that must survive verbatim, because each was written against a real failure: stale JUnit XML is deleted before the lane runs (a deleted class would otherwise report as executed), and a lane that executes no test case for a required class fails with a message saying the lane is green only because the class is gone.

TestKit cases: empty lane fails; duplicate task registration fails; a required class with no executed test case fails; stale XML is removed.

  • Steps 19: one leaf per step, diffing lane task names and JUnit XML class lists against the baseline after each.

Task 3: ca.api-surface

Read-only verify plus an explicitly-named approved update task. The two must not be the same task with a flag — an update that runs by default silently blesses a surface change.

  • Steps 16: TestKit tests, extraction, per-leaf application, diff.

Task 4: ca.testkit-publisher

Testkit source set and consumable artifact, currently repeated. app-bootstrap consumes project(path: ':adapter:outbound:persistence-jpa', configuration: 'jpaTestkit'); the convention must keep that consumer contract byte-identical.

  • Steps 16.

Task 5: ca.evidence

Manifest and result schema, deterministic output ordering, and a no-empty-evidence rule. Applies to src/gradle/jpa-evidence.gradle (930 lines) and the Mongo/GraphQL equivalents.

Provider-specific promotion meaning stays in each module registry.

  • Steps 17.

Task 6: ca.dependency-policy

Common exclusions and constraints, plus verification that each configuration's resolved graph and lockfile match the declared intent. The notification/SnakeYAML case is the acceptance test: a build that declares an exclusion while the lockfile still carries the dependency must fail.

Guard rail: a dependency a provider genuinely uses directly is never centrally removed.

  • Steps 17.

Task 7: ca.java-leaf

Java 21 toolchain, encoding, compiler flags (-Werror -Xlint:deprecation -Xlint:unchecked, currently src/build.gradle:353), Error Prone, and the baseline test task.

  • Steps 16.

Task 8: ca.optional-adapter

Activation metadata plus disabled/on composition-contract wiring for the five adapters. This is the convention that makes Wave 1's off-invariant testing a build-level default rather than something each leaf remembers.

  • Steps 16.

Task 9: Reduce the three build files to their responsibilities

Files: src/settings.gradle, src/build.gradle, each leaf build.gradle

Target responsibilities:

  • settings.gradle: plugin management, root project name, and applying the registry settings plugin. Nothing else.

  • root build.gradle: shared plugin and version declarations plus architecture-wide lifecycle tasks.

  • leaf build.gradle: plugins, project and external dependencies, and that leaf's own semantic lane/matrix.

  • Steps 15: reduce, run the full verification set, diff against the baseline, commit.


Wave 5 Exit Criteria

  • ./scripts/capture-build-baseline.sh output diffs clean against the Wave 4 baseline for the task list, dependency graphs, JUnit XML class lists, and evidence manifests. A task that disappeared is a failure even if everything green stayed green.
  • cd src && ./gradlew clean check --warning-mode=fail --no-daemon --console=plain — green.
  • ./gradlew verifyCleanArchitectureDependencies verifyEnvKeys verifyRuntimeModuleMembership verifyPublicPathSnapshot verifyDocumentedLeafCount --console=plain — green.
  • build-logic's own TestKit suite is green and covers malformed registry, empty lane, duplicate task, and membership drift.
  • expectedModuleCount is gone from src/settings.gradle.
  • verifyDocumentedLeafCount covers root AGENTS.md, root CLAUDE.md, and every src/**/CLAUDE.md, and is wired into check and CI.
  • No source-set/Test/API-surface machinery is copied into two or more leaves.
  • ./scripts/run-compose-runtime-smoke.sh --matrix ... — still green, proving the refactor did not change what actually runs.

What Wave 5 explicitly does not do

  • No runtime, configuration, or test-behaviour change in the same diff.
  • No LOC-driven relocation that hides logic.
  • No merging of provider-specific release matrices into one generic DSL.
  • No central removal of a dependency a provider uses directly.