diff --git a/.github/workflows/ci-quality-gates.yml b/.github/workflows/ci-quality-gates.yml index 40a90da5..ff49de59 100644 --- a/.github/workflows/ci-quality-gates.yml +++ b/.github/workflows/ci-quality-gates.yml @@ -51,11 +51,6 @@ jobs: - name: Run the pull-request gate working-directory: src run: ./gradlew :ci :verifyPublicPathSnapshot :verifyDependencyLocks --warning-mode=fail --stacktrace - # build-logic is an included build: its own suite is not reachable from the root project's - # `check`, so the convention plugins every leaf applies would otherwise ship untested. - - name: Test the build-logic convention plugins - working-directory: src - run: ./gradlew -p build-logic test --stacktrace # Named as its own step because nothing else runs it: `check` does not depend on # graphqlStableTest, so the lane's required-class guard — the check that its module-boundary # suite has not silently stopped being discovered — would protect nothing in CI. @@ -66,6 +61,17 @@ jobs: working-directory: src run: ./gradlew :conditionalTransportQualification --stacktrace + build-logic: + # Included-build tests are independent of the main project task graph. Running them as a + # separate blocking job keeps plugin TestKit work off the quality-gates critical path. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: ./.github/actions/setup-gradle-java + - name: Test the build-logic convention plugins + working-directory: src + run: ./gradlew -p build-logic test --stacktrace + redis-sdk: # Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity, permit # provenance, connection isolation, and the executor guard. There is no real-server lane yet. @@ -149,6 +155,7 @@ jobs: release-gate: needs: - quality-gates + - build-logic - redis-sdk - jpa-candidate-evidence - optional-platforms @@ -161,6 +168,7 @@ jobs: - name: Require every current blocking job to succeed env: QUALITY_RESULT: ${{ needs.quality-gates.result }} + BUILD_LOGIC_RESULT: ${{ needs.build-logic.result }} REDIS_RESULT: ${{ needs.redis-sdk.result }} JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }} OPTIONAL_PLATFORMS_RESULT: ${{ needs.optional-platforms.result }} @@ -169,6 +177,7 @@ jobs: set -euo pipefail for result in \ "${QUALITY_RESULT}" \ + "${BUILD_LOGIC_RESULT}" \ "${REDIS_RESULT}" \ "${JPA_CANDIDATE_RESULT}" \ "${OPTIONAL_PLATFORMS_RESULT}" \ diff --git a/.github/workflows/jpa-release.yml b/.github/workflows/jpa-release.yml index c781b117..fe23717b 100644 --- a/.github/workflows/jpa-release.yml +++ b/.github/workflows/jpa-release.yml @@ -1,10 +1,9 @@ name: jpa-release -# The release gate. src/config/jpa/release-registry.json is the source: every gate it declares has a -# job or an assertion here, JpaReleaseRenderingTest holds this file's matrix and promotion lists to -# the registry's Stable majors, and verifyJpaReleaseGateTasks resolves each gate's task against the -# real Gradle graph. So a gate removed from the registry, or a major demoted in it, fails the build -# rather than quietly ceasing to be checked. +# The release registry is the gate-task source: jpaReleaseQualification reads its blocking gates, +# while JpaReleaseRenderingTest holds this file's matrix and promotion lists to the registry's Stable +# majors and verifyJpaReleaseGateTasks resolves each declared task against the real Gradle graph. +# CI therefore owns release scheduling, not a second JPA gate-task inventory. # # The matrix below is therefore not free to drift: editing it without editing the registry fails the # unit lane. @@ -44,13 +43,12 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - - name: Run the full JPA release gate on PostgreSQL ${{ matrix.postgresql }} + - name: Run the JPA database qualification set on PostgreSQL ${{ matrix.postgresql }} working-directory: src run: >- ./gradlew - jpaReleaseGate + jpaReleaseQualification -Pjpa.matrix.versions=${{ matrix.postgresql }} - --stacktrace - name: Record which major this evidence covers if: always() @@ -61,7 +59,7 @@ jobs: echo "sha=${{ github.sha }}" echo "ref=${{ github.ref }}" echo "postgresql-major=${{ matrix.postgresql }}" - echo "task=jpaReleaseGate" + echo "task-set=jpa-database-qualification" } > "build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties" - name: Upload the release evidence if: always() @@ -120,9 +118,11 @@ jobs: working-directory: src run: >- ./gradlew + :verifyJpaReleaseGateTasks + :verifyJpaReadinessRegistry :verifyCleanArchitectureDependencies checkstyleMain - :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' + :app-bootstrap:architectureTest :adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest' --stacktrace diff --git a/docs/jpa/repository-adaptation.md b/docs/jpa/repository-adaptation.md index bcf9e00f..9225777c 100644 --- a/docs/jpa/repository-adaptation.md +++ b/docs/jpa/repository-adaptation.md @@ -101,7 +101,7 @@ Docker-dependent lanes fail closed rather than skipping, matching the existing | `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. | | `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. | | `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. | -| `build.gradle.kts` release aggregate `jpaReleaseGate` | Root is `src/build.gradle` | Registered there against the repository lane names in §3. | +| release blocking aggregate | `.github/workflows/jpa-release.yml` | CI names the blocking JPA lanes directly; Gradle only defines how each lane runs. | | Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. | | Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. | | Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". | diff --git a/docs/messaging/configuration-reference.md b/docs/messaging/configuration-reference.md index 31afe67a..6fa2a812 100644 --- a/docs/messaging/configuration-reference.md +++ b/docs/messaging/configuration-reference.md @@ -13,6 +13,29 @@ > 어떤 binder도 그것을 읽지 않았다 — 문서대로 설정한 배포는 아무것도 바뀌지 않았고 아무 말도 듣지 > 못했다 (MSG-008). +## Application publish bridge identity + +Application의 canonical integration event를 platform publish pipeline으로 보낼 때는 +`app.messaging.producer-id`를 명시한다. 같은 값의 환경변수 이름은 +`APP_MESSAGING_PRODUCER_ID`다. 이 값은 host/pod 이름이 아니라 배포와 무관하게 유지되는 논리적 +producing-service identity다. + +값이 없으면 `IntegrationEventPublishPort` bridge 자체를 만들지 않는다. `spring.application.name`이나 +현재 process 이름으로 추론하지 않는다. 기존 legacy `OutboxEvent`/realtime 경로는 별도 cutover가 +끝날 때까지 `app.messaging.broker` 경로를 유지한다. + +## Outbox canonical transport-only cutover + +`APP_OUTBOX_CANONICAL_TRANSPORT_ENABLED` / `ca-skeleton.outbox.canonical-transport-enabled`은 +기존 `outbox_event` writer/claim/status authority를 유지한 채 canonical row의 **transport만** platform +publish path로 보내는 compatibility gate다. 기본값은 `false`이며 `POLLING_V2`를 활성화하지 않는다. + +`true`일 때는 `OutboxAppendPort`가 `ValidatedIntegrationEvent`의 exact envelope bytes와 canonical +metadata를 기존 outbox row에 저장하고, claim된 canonical row는 `IntegrationEventPublishPort`로 간다. +legacy row는 계속 `MessageBroker`를 사용한다. 따라서 mixed-row compatibility 기간에는 relay가 켜져 +있다면 `app.messaging.broker`도 계속 필요하며, canonical path를 위해 `IntegrationEventPublishPort`도 +추가로 필요하다. legacy backlog가 0이라는 별도 증거 없이 broker 요구를 제거하지 않는다. + ## Destination profile ```yaml diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index c9b4610d..791c232a 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -3325,6 +3325,21 @@ env_keys: compatibility_impact: behavior-change required_test: adapter-contract:messaging-broker-selection + - name: APP_MESSAGING_PRODUCER_ID + # source: canonical messaging platform bridge 2026-09-18 + # Explicit logical producing-service identity for IntegrationEventPublishPort. + # Blank/absent = canonical platform bridge is not exposed; identity is never inferred. + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-integration-adapter-templates + validation: none + compatibility_impact: additive + required_test: adapter-contract:messaging-platform-producer-id + - name: APP_MESSAGING_KAFKA_BROKERS # source: feature-domain-event-outbox-contract — "Kafka는 optional integration adapter" # (broker 활성화 시 endpoint 필요) @@ -4358,6 +4373,22 @@ env_keys: validation: boolean compatibility_impact: behavior-change required_test: adapter-contract:outbox-capability-disabled-safe + - name: APP_OUTBOX_CANONICAL_TRANSPORT_ENABLED + # source: MSG-015 transport-only cutover 2026-09-18 + # Enables canonical outbox rows/platform transport without switching publication authority. + type: boolean + default: false + allowed_values: + - "true" + - "false" + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-integration-adapter-templates + validation: boolean + compatibility_impact: additive + required_test: app-bootstrap:outbox-canonical-transport-gate + - name: APP_OUTBOX_RELAY_ENABLED # source: five-adapter-runtime-remediation §6.3 MSG-INT-001 — starts the relay scheduler. # Requires APP_OUTBOX_ENABLED, APP_PERSISTENCE_JPA_ENABLED and APP_MESSAGING_ENABLED with a diff --git a/docs/superpowers/plans/2026-09-17-jpa-evidence-gradle-model-decoupling.md b/docs/superpowers/plans/2026-09-17-jpa-evidence-gradle-model-decoupling.md new file mode 100644 index 00000000..76a13192 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-jpa-evidence-gradle-model-decoupling.md @@ -0,0 +1,102 @@ +# JPA Evidence Gradle Model Decoupling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove execution-time Gradle `Project`/`Task`/`TaskState` access from JPA evidence generation without changing evidence semantics. + +**Architecture:** A shared `JpaEvidenceExecutionService` consumes Gradle task-completion events for non-Test task claims. The JPA evidence plugin snapshots/configures JUnit result directories, provenance, environment/profile values, and dependency versions as typed task inputs. `GenerateJpaEvidenceManifestsTask` becomes a pure evidence assembler over those inputs plus filesystem/exec services. + +**Tech Stack:** Java 21, Gradle 9 BuildService + Tooling Events, JUnit 6/TestKit, Jackson 3. + +**Spec:** `docs/superpowers/specs/2026-09-17-jpa-evidence-gradle-model-decoupling-design.md` + +## Global Constraints + +- Preserve readiness-card schema and task names. +- Preserve JUnit XML as test evidence. +- Preserve task-claim meaning: only a successful producer task covers a task claim. +- Preserve evidence grade, blocker, hashing, prerequisite, output, candidate/R2 semantics. +- No execution-time `Project`, `Task`, or `TaskState` access in `GenerateJpaEvidenceManifestsTask`. +- Do not stage, commit, amend, reset, or push existing worktree changes. + +--- + +### Task 1: Task completion evidence service + +**Files:** +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceExecutionService.java` +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskOutcome.java` +- Test: `src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceExecutionServiceTest.java` + +**Interfaces:** +- Produces: `JpaEvidenceExecutionService.outcome(String taskPath)` and `completedSuccessfully(String taskPath)`. +- Consumes: Gradle `TaskFinishEvent` via `OperationCompletionListener`. + +- [ ] Write tests for success, failure, skipped, and unknown task paths. +- [ ] Verify tests fail because the service/model do not exist. +- [ ] Implement the typed outcome model and thread-safe service. +- [ ] Verify focused tests pass. + +### Task 2: Typed JUnit evidence inputs + +**Files:** +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTestResultLocator.java` +- Modify: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskSupport.java` +- Test: `src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTestResultLocatorTest.java` + +**Interfaces:** +- Consumes: `Map` task-path to repository-relative/absolute JUnit XML directory. +- Produces: `JpaGeneratedTestResult read(String taskPath)` without `Project` or `Test`. + +- [ ] Write a failing test that creates JUnit XML under a temporary directory and resolves it by task path. +- [ ] Implement file-based result lookup using `JUnitEvidenceReader`. +- [ ] Remove the `readJUnitResult(Project, Test)` helper once no caller remains. +- [ ] Verify focused tests pass. + +### Task 3: Generator typed input surface + +**Files:** +- Modify: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/GenerateJpaEvidenceManifestsTask.java` +- Test: extend `src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidencePluginTypeTest.java` + +**Interfaces:** +- Add typed properties for profile/CI/artifact/topology/provenance/dependency versions/JUnit result directories. +- Add an internal/service reference to `JpaEvidenceExecutionService`. + +- [ ] Add reflection/type tests asserting the new task properties exist and no generator source contains `getProject()` or `Task.getState()` usage. +- [ ] Verify the test fails against the current generator. +- [ ] Add the typed properties and service reference. +- [ ] Replace Project/Task/TaskState/configuration/extra-property reads with typed inputs/service lookups. +- [ ] Verify focused tests pass. + +### Task 4: Plugin wiring + +**Files:** +- Modify: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidencePlugin.java` +- Modify: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskSupport.java` +- Test: add `src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidencePluginFunctionalTest.java` + +**Interfaces:** +- Register shared execution service and task-completion listener. +- Configure generator typed inputs. +- Configure JUnit result-directory mapping for active readiness/support Test tasks. +- Preserve existing `dependsOn` producer graph. + +- [ ] Write TestKit fixture asserting typed generator inputs and task wiring. +- [ ] Verify RED. +- [ ] Register/wire the service and all generator properties. +- [ ] Resolve dependency versions and release provenance during configuration/plugin wiring rather than task action. +- [ ] Verify TestKit GREEN. + +### Task 5: Regression and Gradle 10-preparation verification + +**Files:** +- Modify only if verification exposes a regression. + +- [ ] Run `src/build-tools` full `check --warning-mode=fail`. +- [ ] Run root `verifyJpaReadinessRegistry verifyJpaReleaseGateTasks --warning-mode=fail`. +- [ ] Run `:adapter:outbound:persistence-jpa:check --warning-mode=fail`. +- [ ] Run candidate evidence generation with `--warning-mode=all`; verify there is no `Task.project`/execution-time project deprecation from JPA evidence tooling. +- [ ] Run adapter procedural-Groovy scan and confirm no regression. +- [ ] Run `git diff --check`. +- [ ] Record any environment-only Docker/Testcontainers limitation separately from code correctness. diff --git a/docs/superpowers/plans/2026-09-17-jpa-leaf-verification-java.md b/docs/superpowers/plans/2026-09-17-jpa-leaf-verification-java.md new file mode 100644 index 00000000..443ecb1a --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-jpa-leaf-verification-java.md @@ -0,0 +1,46 @@ +# JPA Leaf Verification Java Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the remaining procedural JPA leaf verification logic from Groovy while deleting a redundant verifier-of-verifier task. + +**Architecture:** Keep the security scenario as a declarative `strictTestLanes.requires(...)` contract and remove `verifyJpaSecurityFixtures` from both the leaf and readiness registry. Move the real PostgreSQL `set_config` source-safety rule into a typed task/verifier owned by the already-applied `ca.jpa-evidence` Java plugin. + +**Tech Stack:** Java 21, Gradle 9 binary plugins/tasks, JUnit 6. + +**Spec:** `docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md` + +## Global Constraints + +- Preserve the `verifyJpaSqlConstructionSafety` task name because readiness registry/evidence tooling references it. +- Preserve the PostgreSQL security method selector on `postgresqlSecurityBaselineIntegrationTest`. +- Remove `verifyJpaSecurityFixtures` only together with its readiness-card support-task reference. +- Do not add production dependencies or change JPA runtime behavior. +- Do not stage, commit, amend, or push. + +--- + +### Task 1: Typed SQL construction safety verifier + +**Files:** +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyVerifier.java` +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyResult.java` +- Test: `src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyVerifierTest.java` + +- [ ] **Step 1:** Write RED tests for parameterized `set_config`, non-parameterized `set_config`, comments, and nested source paths. +- [ ] **Step 2:** Implement the minimal typed verifier preserving the current line-based rule. +- [ ] **Step 3:** Run the focused verifier tests to GREEN. + +### Task 2: Java task ownership and redundant task removal + +**Files:** +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaSqlConstructionSafetyTask.java` +- Modify: `src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidencePlugin.java` +- Modify: `src/adapter/outbound/persistence-jpa/build.gradle` +- Modify: `src/config/jpa/readiness-cards.yaml` + +- [ ] **Step 1:** Register `verifyJpaSqlConstructionSafety` as a typed task from `JpaEvidencePlugin`. +- [ ] **Step 2:** Delete the Groovy implementation of `verifyJpaSqlConstructionSafety`. +- [ ] **Step 3:** Delete `verifyJpaSecurityFixtures` and remove it from the security card support tasks while keeping the `requires(...)` selector. +- [ ] **Step 4:** Verify readiness registry and SQL-safety tasks. +- [ ] **Step 5:** Run `:adapter:outbound:persistence-jpa:check`, build-tools tests, and `git diff --check`. diff --git a/docs/superpowers/plans/2026-09-17-jpa-test-lanes-java.md b/docs/superpowers/plans/2026-09-17-jpa-test-lanes-java.md new file mode 100644 index 00000000..503cddc1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-jpa-test-lanes-java.md @@ -0,0 +1,19 @@ +# JPA Test Lanes Java Convention Plan + +**Goal:** Remove the remaining Map-driven JPA lane factories from Groovy and make lane metadata compile-time checked Java records. + +**Architecture:** `ca.jpa-test-lanes` lives in build-logic and configures `ca.strict-test-lane`. Typed record lists own the PostgreSQL readiness and tagged platform lane metadata. The JPA leaf keeps source-set/dependency declarations plus the explicit cross-project task edge. + +## Constraints +- Preserve all 14 PostgreSQL readiness task names and selectors. +- Preserve the security method selector in addition to its class selector. +- Preserve five tagged platform lanes and the pool contract lane. +- Preserve UTC JVM args, PostgreSQL evidence image property, and `jpa.matrix.versions` default `16`. +- Keep release orchestration outside the leaf. +- Do not stage, commit, amend, or push. + +## Steps +- [ ] RED TestKit contract for registered lanes, typed selector metadata, and property defaults. +- [ ] Implement Java record-backed `JpaTestLanesPlugin` and register `ca.jpa-test-lanes`. +- [ ] Apply plugin in persistence-jpa and remove both Groovy `Map` factories plus pool-lane Groovy configuration. +- [ ] Run focused build-logic tests, JPA check, adapter dynamic-model scan, and broad verification. diff --git a/docs/superpowers/plans/2026-09-17-mongo-gradle-verification-java.md b/docs/superpowers/plans/2026-09-17-mongo-gradle-verification-java.md new file mode 100644 index 00000000..a6aa6758 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-mongo-gradle-verification-java.md @@ -0,0 +1,61 @@ +# Mongo Gradle Verification Java Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove Mongo JUnit/XML and release-manifest verification algorithms from the Groovy leaf build script and move them into typed Java build tooling without changing task names or verification semantics. + +**Architecture:** `ca.mongo-verification` lives in `src/build-tools` because the checks are repository certification tooling, not reusable compilation conventions. The plugin registers the two existing verification task names; normal Java verifier/parser classes own XML/JSON parsing and return typed records, while `persistence-mongo/build.gradle` keeps only plugin/lane/dependency declarations and `check` wiring. + +**Tech Stack:** Java 21, Gradle 9 binary plugins/tasks, Jackson 3, JUnit 6, JUnit XML. + +**Spec:** `docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md` + +## Global Constraints + +- Preserve `verifyMongoTestLaneDisjointness` and `verifyMongoReleaseContractLanes` task names and report paths. +- Preserve the existing `test` + `mongoStableContractTest` dependency graph. +- Do not add or resolve new production dependencies in the Mongo leaf. +- Keep Groovy only as declarative build DSL; no JSON/XML parsing or `doLast` verification algorithm remains in the leaf. +- Do not stage, commit, amend, or push; repository policy is human-only commits. + +--- + +### Task 1: Typed Mongo verification core + +**Files:** +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessVerifier.java` +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessResult.java` +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContract.java` +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractManifestParser.java` +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneVerifier.java` +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneResult.java` +- Test: `src/build-tools/src/test/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessVerifierTest.java` +- Test: `src/build-tools/src/test/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneVerifierTest.java` + +**Interfaces:** +- Consumes: Gradle JUnit XML result directories and `config/mongodb/release-contracts.json`. +- Produces: typed result records used by Gradle task classes. + +- [ ] **Step 1: Write failing verifier tests** covering disjoint lanes, overlap failure data, manifest filtering to hermetic lanes, missing result XML, and minimum-executed checks. +- [ ] **Step 2: Run** `cd src/build-tools && ../gradlew test --tests 'dev.caskeleton.buildtools.mongo.*' --console=plain` and confirm RED from missing production types. +- [ ] **Step 3: Implement minimal typed records/parsers/verifiers** using fail-closed XML parsing and Jackson 3 JSON tree parsing. +- [ ] **Step 4: Run the focused tests again** and confirm PASS. + +### Task 2: Binary plugin/task ownership and leaf cleanup + +**Files:** +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoVerificationPlugin.java` +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/VerifyMongoTestLaneDisjointnessTask.java` +- Create: `src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/VerifyMongoReleaseContractLanesTask.java` +- Modify: `src/build-tools/build.gradle` +- Modify: `src/adapter/outbound/persistence-mongo/build.gradle` + +**Interfaces:** +- Consumes: typed verifiers from Task 1. +- Produces: existing task names `verifyMongoTestLaneDisjointness`, `verifyMongoReleaseContractLanes` with unchanged report paths. + +- [ ] **Step 1: Register `ca.mongo-verification`** and the two typed tasks in Java. +- [ ] **Step 2: Apply the plugin in the Mongo leaf and delete both Groovy `tasks.register { doLast { ... } }` implementations.** +- [ ] **Step 3: Run** `cd src && ./gradlew :adapter:outbound:persistence-mongo:verifyMongoTestLaneDisjointness :adapter:outbound:persistence-mongo:verifyMongoReleaseContractLanes --console=plain`. +- [ ] **Step 4: Run** `cd src && ./gradlew :adapter:outbound:persistence-mongo:check --console=plain`. +- [ ] **Step 5: Run** `cd src/build-tools && ../gradlew test --console=plain` and `git diff --check`. diff --git a/docs/superpowers/plans/2026-09-17-redis-topology-lane-java.md b/docs/superpowers/plans/2026-09-17-redis-topology-lane-java.md new file mode 100644 index 00000000..c04763e0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-redis-topology-lane-java.md @@ -0,0 +1,29 @@ +# Redis Topology Lane Java Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Remove the last adapter-level procedural Groovy test lifecycle logic by moving Redis topology qualification into a typed Java convention plugin. + +**Architecture:** `ca.redis-topology-lane` belongs in `build-logic`: it configures the default test exclusion, declares the strict `redisTopologyTest` lane, validates mode/properties, forwards topology system properties, and verifies executed required classes/no skips. The Redis leaf keeps only plugin and dependency declarations. + +**Tech Stack:** Java 21, Gradle 9, JUnit Platform test tasks. + +**Spec:** `docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md` + +## Constraints +- Preserve supported modes: standalone, sentinel, cluster, tls. +- Preserve TLS deployment-mode mapping to standalone. +- Preserve required property and required executed-class semantics. +- Preserve fail-closed behavior for unknown mode and skipped topology tests. +- Keep `redisTopologyTest` opt-in; do not add it to normal `check`. +- Do not stage, commit, amend, or push. + +### Task 1: Typed topology contract +- [ ] Write failing Java tests for mode validation, required properties, class coverage, and skipped-test rejection. +- [ ] Implement typed contract/result records and get focused tests GREEN. + +### Task 2: Java convention plugin +- [ ] Implement `RedisTopologyLanePlugin` using `StrictTestLaneExtension` and a Java `TestListener` tracker. +- [ ] Register `ca.redis-topology-lane` in build-logic. +- [ ] Apply it in cache-redis and remove the Groovy topology lifecycle/configuration block. +- [ ] Verify build-logic tests, Redis unit `check`, task configuration, invalid-mode fail-closed behavior, and `git diff --check`. diff --git a/docs/superpowers/plans/2026-09-18-messaging-platform-bridge.md b/docs/superpowers/plans/2026-09-18-messaging-platform-bridge.md new file mode 100644 index 00000000..7a562665 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-messaging-platform-bridge.md @@ -0,0 +1,93 @@ +# Messaging Platform Bridge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Connect the canonical application integration-event publish boundary to the messaging platform without re-encoding bytes or introducing broker-specific ownership in app-bootstrap. + +**Architecture:** Add an application-owned publish port, implement it in `adapter/outbound/messaging/platformbridge`, and publish canonical pre-encoded envelopes through `EncodedMessagePublisher`. The bridge preserves identity/routing/evidence and maps platform publish evidence explicitly into application outcomes. + +**Tech Stack:** Java 21, Gradle 9, Spring Boot auto-configuration, JUnit 5, AssertJ. + +**Spec:** `docs/superpowers/specs/2026-09-18-messaging-platform-bridge-design.md` + +## Global Constraints + +- Preserve existing uncommitted changes; do not reset, stage, commit, amend, or push. +- Never invent missing canonical identity, timestamp, trace, tenant, schema, or routing values. +- Never bypass `DefaultMessagePublisher` through transport SPI or native Kafka clients. +- Preserve exact `ValidatedIntegrationEvent.envelopeBytes()`. +- Fail closed before send when event or causation identity is not UUIDv7. +- Legacy outbox storage/relay migration is outside this plan. + +--- + +### Task 1: Application-owned canonical publish port + +**Files:** +- Create: `application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventPublishPort.java` +- Test: existing bridge test compile contract + +**Interfaces:** +- Produces: `CompletionStage publish(ValidatedIntegrationEvent event)` + +- [x] Create the minimal application-owned interface. +- [x] Run the focused bridge test and verify remaining failures are platform dependencies/adapter implementation, not the port. + +### Task 2: Outbound messaging platform API dependency + +**Files:** +- Modify: `adapter/outbound/messaging/build.gradle` +- Modify: `adapter/outbound/messaging/gradle.lockfile` through Gradle lock writing + +**Interfaces:** +- Consumes: `:messaging:messaging-core-api`, `:messaging:messaging-schema-api`. + +- [x] Add only the platform API dependencies required by the bridge. +- [x] Refresh this module's locks. +- [x] Re-run focused bridge test and verify the missing type set is reduced to bridge production code. + +### Task 3: Canonical platform bridge + +**Files:** +- Create: `adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/platformbridge/PlatformIntegrationEventPublishAdapter.java` +- Test: `adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/platformbridge/PlatformIntegrationEventPublishAdapterTest.java` + +**Interfaces:** +- Consumes: `IntegrationEventPublishPort`, `EncodedMessagePublisher`. +- Produces: canonical application-to-platform anti-corruption bridge. + +- [x] Implement UUIDv7 parsing that rejects incompatible identity before publisher invocation. +- [x] Map canonical metadata and exact bytes into `MessageEnvelope`. +- [x] Preserve non-first-class evidence in bounded `x-ca-*` headers. +- [x] Map `PublishResult` using transmission evidence. +- [x] Run all three focused bridge tests to GREEN. + +### Task 4: Spring ownership while preserving the legacy seam + +**Files:** +- Modify: `adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java` +- Modify: `adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java` +- Test: focused auto-configuration ownership test + +**Interfaces:** +- Consumes: Spring-provided `EncodedMessagePublisher`, explicit `app.messaging.producer-id`. +- Produces: `IntegrationEventPublishPort` bean for canonical events. + +- [x] Add explicit `producerId` to the existing `app.messaging` adapter settings. +- [x] Register the canonical bridge only when `app.messaging.producer-id` is explicitly present. +- [x] Keep `KafkaSender` / `KafkaMessageBroker` as a documented transitional dependency of legacy outbox/realtime only. +- [x] Add a Spring test proving producer-id present => one canonical bridge bean, absent => no canonical bridge bean. +- [x] Run outbound messaging tests. + +### Task 5: Platform and composition regression verification + +**Files:** no new production files unless a test exposes a real defect. + +- [x] Run `:messaging:messaging-runtime-core:test`. +- [x] Run `:messaging:messaging-spring-boot-starter:test`. +- [x] Run `:adapter:outbound:messaging:check --warning-mode=fail`. +- [x] Run `:app-bootstrap:architectureTest :app-bootstrap:systemTest --warning-mode=fail`. +- [x] Search for direct app-bootstrap Kafka producer ownership. +- [x] Run `git diff --check`. +- [x] Report any remaining legacy outbox cutover blocker explicitly rather than inventing a migration. + diff --git a/docs/superpowers/plans/2026-09-18-outbox-transport-only-cutover.md b/docs/superpowers/plans/2026-09-18-outbox-transport-only-cutover.md new file mode 100644 index 00000000..1859ed6b --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-outbox-transport-only-cutover.md @@ -0,0 +1,105 @@ +# Outbox Transport-Only Cutover Implementation Plan + +**Goal:** Preserve canonical integration-event bytes and metadata inside the existing legacy `outbox_event` authority, then route canonical claimed rows through the messaging platform while legacy rows keep the current broker path. + +**Spec:** `docs/superpowers/specs/2026-09-18-outbox-transport-only-cutover-design.md` + +## Constraints + +- Preserve all existing uncommitted work. No reset/checkout/stage/commit/amend/push. +- Do not activate or switch to `POLLING_V2`. +- Do not synthesize missing canonical metadata for legacy rows. +- Do not re-encode a persisted canonical envelope. +- One claimed row goes through exactly one publish branch. +- Default configuration remains legacy-compatible and canonical transport is off. + +### Task 1 — Split canonical and legacy append ports + +- [x] Create `LegacyOutboxAppendPort` with the current `NewOutboxEvent` signature. +- [x] Change `OutboxAppendPort` to accept `ValidatedIntegrationEvent`. +- [x] Move all current raw production consumers and their tests to `LegacyOutboxAppendPort`. +- [x] Make `OutboxStoreAdapter` implement `LegacyOutboxAppendPort` only. +- [x] Run `:application-core:test` and focused sample/outbox compile tests. + +### Task 2 — Add additive canonical columns to `outbox_event` + +- [x] Add the next PostgreSQL migration after V12. +- [x] Widen event/correlation identifiers as required. +- [x] Add canonical metadata, exact `BYTEA`, hashes/revisions, and all-or-none check constraints. +- [x] Extend `OutboxEventEntity` mappings. +- [x] Update migration history expectations. +- [x] Add real PostgreSQL integration assertions for legacy rows and canonical shape constraints. + +### Task 3 — Implement canonical append adapter + +- [x] Add `CanonicalOutboxAppendAdapter`. +- [x] Strictly validate UTF-8 compatibility projection. +- [x] Persist every canonical field and exact `envelopeBytes`. +- [x] Preserve old required columns for legacy relay/storage compatibility. +- [x] Add unit tests for byte equality, field mapping and invalid UTF-8. +- [x] Gate bean exposure on `ca-skeleton.outbox.canonical-transport-enabled=true`. + +### Task 4 — Split the claimed row model + +- [x] Add sealed `ClaimedOutboxEvent`. +- [x] Keep `OutboxEvent` as legacy subtype. +- [x] Add `CanonicalClaimedOutboxEvent` carrying reconstructed `ValidatedIntegrationEvent`. +- [x] Change `OutboxStorePort.claimBatch` to return the sealed type. +- [x] Map all-canonical rows to canonical subtype and all-null rows to legacy subtype. +- [x] Reject partial canonical rows. +- [x] Update legacy relay tests without changing its state-machine semantics. + +### Task 5 — Route canonical claims through the platform + +- [x] Update `OutboxMessagePublishPort` to accept `ClaimedOutboxEvent`. +- [x] Extend `OutboxMessagePublishAdapter` with canonical `IntegrationEventPublishPort`. +- [x] Legacy subtype uses only `MessageBroker`. +- [x] Canonical subtype uses only the application canonical publish port and exact stored bytes. +- [x] Add focused branch-isolation and outcome tests. + +### Task 6 — Add explicit activation and composition validation + +- [x] Add `canonicalTransportEnabled` to `OutboxSettings` and `config/outbox.yml`, default false. +- [x] Startup fails when canonical transport is enabled but no `IntegrationEventPublishPort` exists. +- [x] Relay-enabled compatibility deployment still requires the legacy broker until a later zero-legacy-backlog proof. +- [x] Default-off composition keeps the existing legacy path. +- [x] Enabled composition exposes the canonical append/publish path without a second scheduler. +- [x] Update configuration docs/SSOT. + +### Task 7 — Regression and architecture verification + +- [x] `:application-core:check` +- [x] `:adapter:outbound:persistence-jpa:check` +- [x] focused PostgreSQL migration/outbox integration lane +- [x] `:adapter:outbound:messaging:check` +- [ ] `:sample-portfolio:check` — blocked by pre-existing `JpaLiveEventReplayAdapter` missing `Duration` wiring; the same 3 `SampleApplicationContextTest` failures reproduce on clean HEAD. +- [x] focused sample outbox regression tests (`PosterEventPublisherTest`, `CreateWorkLogOutboxTest`, `WorkLogUseCasesTest`, `WorkLogAuthorizationContractTest`) +- [x] `:app-bootstrap:architectureTest :app-bootstrap:systemTest` +- [x] `verifyCleanArchitectureDependencies` +- [x] `:app-bootstrap:verifyEnvKeys` +- [x] static scans: no platform runtime/Kafka import in canonical bridge +- [x] `git diff --check` +- [x] no separate LLM Wiki branch-note warranted; spec, plan, module README/CLAUDE and configuration reference carry the implementation decision. + +## Verification result + +Transport-only cutover implementation is complete for this slice. + +Passed: +- `:application-core:check` +- `:adapter:outbound:persistence-jpa:check` +- `:adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest` +- `:adapter:outbound:messaging:check` +- focused sample outbox regression tests +- `:app-bootstrap:test` +- focused `:app-bootstrap:integrationTest` outbox append + row-lifecycle contracts +- `:app-bootstrap:architectureTest` +- `:app-bootstrap:systemTest` +- `verifyCleanArchitectureDependencies` +- `:app-bootstrap:verifyEnvKeys` +- `git diff --check` + +Known unrelated blocker: +- full `:sample-portfolio:check` still fails only the 3 previously documented `SampleApplicationContextTest` cases because `JpaLiveEventReplayAdapter` requires an unbound `Duration` bean. This reproduces on clean HEAD and was not introduced by this cutover. + +Publication authority remains `LEGACY_POLLING`; no code path in this slice activates `POLLING_V2`. diff --git a/docs/superpowers/specs/2026-09-17-jpa-evidence-gradle-model-decoupling-design.md b/docs/superpowers/specs/2026-09-17-jpa-evidence-gradle-model-decoupling-design.md new file mode 100644 index 00000000..90a7ebe4 --- /dev/null +++ b/docs/superpowers/specs/2026-09-17-jpa-evidence-gradle-model-decoupling-design.md @@ -0,0 +1,150 @@ +# JPA Evidence Gradle Model Decoupling Design + +## Context + +`GenerateJpaEvidenceManifestsTask` currently performs evidence generation after its producer tasks run. Its semantic contract is useful, but the task action reaches back into the live Gradle model through `getProject()`, resolves configurations, locates `Task` instances, reads `Test` report locations, inspects `TaskState`, and reads root extra properties. + +Gradle 9 deprecates `Task.project` access at execution time and Gradle 10 will reject it. More importantly, the current task mixes two responsibilities: + +1. Gradle configuration/model discovery. +2. Pure evidence assembly from producer results. + +The refactor must separate those concerns without weakening evidence claims. + +## Goals + +- Preserve the current readiness-card and evidence-manifest semantics. +- Remove execution-time `Project`, `Task`, and `TaskState` access from `GenerateJpaEvidenceManifestsTask`. +- Preserve JUnit XML as the source of truth for test execution evidence. +- Preserve successful non-Test task execution as the source of truth for `task-claims` such as architecture/configuration claims. +- Represent generator inputs with typed Gradle properties rather than hidden project lookups. +- Keep producer task names and readiness-card schema unchanged. +- Remain compatible with `--warning-mode=fail` on Gradle 9 and prepare the evidence lane for Gradle 10. + +## Non-goals + +- Do not redesign the readiness-card schema. +- Do not change evidence grades, prerequisite semantics, content hashing, R1/R2 rules, or output layout. +- Do not introduce marker files into every producer task. +- Do not move release orchestration into the persistence-JPA leaf. +- Do not add new runtime dependencies to application modules. + +## Architecture + +### 1. Build service owns task completion outcomes + +Introduce `JpaEvidenceExecutionService`, a Gradle shared build service implementing `OperationCompletionListener`. + +The plugin registers it through `BuildEventsListenerRegistry.onTaskCompletion(...)` so the service receives `TaskFinishEvent` events without the generator querying `TaskState`. + +The service stores a thread-safe typed outcome for each task path: + +```text +Task path + -> SUCCESS + -> FAILED + -> SKIPPED +``` + +Only `SUCCESS` satisfies an evidence `task-claim`. Failed or skipped producers do not cover the claim. + +The service is build-scoped and contains no `Project` reference. + +### 2. Test evidence remains file-based + +JUnit evidence already has a durable output: Gradle's JUnit XML result directory. The plugin resolves every readiness/support `Test` task during configuration and supplies a typed mapping: + +```text +absolute task path -> JUnit XML result directory +``` + +The generator reads those directories directly with `JUnitEvidenceReader`; it never locates a `Test` object. + +Non-Test support tasks continue to participate in the task graph but do not produce JUnit evidence. + +### 3. Configuration-derived values become task inputs + +The plugin supplies these inputs before execution: + +- evidence profile +- CI job +- artifact location +- topology +- PostgreSQL image +- source revision +- traceable version +- resolved PostgreSQL JDBC version +- resolved Hibernate ORM version +- resolved Flyway version +- repository-relative evidence output location used by the candidate default +- JUnit result-directory mapping + +The generator reads only its properties/files plus the execution service. + +`releaseProvenance` is the preferred source for revision/version. The existing extra-property compatibility bridge is no longer read by the generator. + +### 4. Dependency-version discovery stays in plugin configuration + +The JPA evidence plugin owns the Gradle `Configuration` object. It derives the three relevant resolved module versions and writes them into typed task properties before the generator executes. + +This keeps dependency-graph access out of the task action. The existing coordinates remain unchanged: + +- `org.postgresql:postgresql` +- `org.hibernate.orm:hibernate-core` +- `org.flywaydb:flyway-core` + +### 5. Generator becomes an evidence assembler + +The generator task action may use: + +- its declared Gradle properties/files +- `ExecOperations` for git/docker commands already owned by the task +- `FileSystemOperations` +- `JpaEvidenceExecutionService` +- pure parser/verifier/helper classes + +It must not call: + +```java +getProject() +Project.findProject(...) +Task.getState() +TaskContainer.findByName(...) +ConfigurationContainer.getByName(...) +ExtraPropertiesExtension.get(...) +``` + +### 6. Evidence semantics + +For a readiness card: + +- `evidence.scenarios` are covered only by selectors found in JUnit XML. +- `evidence.task-claims` are covered only when the build service reports the named task completed successfully in the current build. +- `no-skip` remains based on JUnit result counts. +- prerequisite manifest ordering and hashing remain unchanged. +- candidate/R2 blockers remain unchanged. + +The primary foundation card still obtains architecture/configuration coverage from successful execution of its declared producer tasks; the mechanism changes from `TaskState` lookup to task-finish events, not the meaning. + +## Error handling + +- A readiness task expected to produce JUnit evidence but missing from the configured result mapping is a hard failure. +- A configured JUnit result directory that contains no usable result remains subject to the existing JUnit evidence validation. +- A task claim with no successful completion event is simply uncovered and therefore becomes missing required evidence when that claim is required. +- Unsupported evidence profile remains a hard failure. +- Missing immutable image digest/dependency versions retain the existing blocker behavior. + +## Testing + +1. Unit-test task-event classification in `JpaEvidenceExecutionService`. +2. Unit-test pure JUnit result lookup from configured task-path/directory inputs. +3. TestKit: apply `ca.jpa-evidence` in a fixture and verify the generator task exposes typed inputs without execution-time project lookup. +4. Existing JPA evidence verifier tests must remain green. +5. Run `build-tools:check --warning-mode=fail`. +6. Run `verifyJpaReadinessRegistry verifyJpaReleaseGateTasks --warning-mode=fail`. +7. Run the affected JPA leaf `check`. +8. Run a candidate evidence lane far enough to confirm no `Task.project` deprecation is emitted; environment-dependent Docker/Testcontainers failure may be reported separately from Gradle-model warnings. + +## Migration boundary + +This change only decouples evidence generation from the live Gradle model. It does not alter the readiness registry, producer tasks, JUnit test suites, manifest schema, release workflow, or evidence verification policy. diff --git a/docs/superpowers/specs/2026-09-18-messaging-platform-bridge-design.md b/docs/superpowers/specs/2026-09-18-messaging-platform-bridge-design.md new file mode 100644 index 00000000..7db72d14 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-messaging-platform-bridge-design.md @@ -0,0 +1,94 @@ +# Messaging Platform Bridge Design + +## Goal + +Replace the application-specific broker seam with one canonical anti-corruption bridge: + +``` +application-core IntegrationEventPublishPort + -> adapter/outbound/messaging/platformbridge + -> messaging-schema-api EncodedMessagePublisher + -> messaging-runtime-core DefaultMessagePublisher + -> messaging transport/runtime +``` + +The bridge must preserve canonical event identity and exact encoded bytes while reusing the platform's destination resolution, authorization, admission, runtime leasing, transport normalization, and observation pipeline. + +## Scope + +This phase introduces and verifies the canonical bridge. It does **not** migrate the legacy outbox storage/relay rows, because `OutboxEvent` does not retain the schema/order/tenant metadata required to reconstruct `ValidatedIntegrationEvent` without invention. + +## Application boundary + +Create `IntegrationEventPublishPort` in `application-core`. + +Signature: + +```java +CompletionStage publish(ValidatedIntegrationEvent event); +``` + +The application package depends only on its own canonical event model and application outcome vocabulary. + +## Adapter bridge + +`PlatformIntegrationEventPublishAdapter` lives under: + +``` +adapter/outbound/messaging/platformbridge +``` + +It depends on `EncodedMessagePublisher`, never on a concrete broker client, runtime-core implementation, or transport SPI. + +Mapping rules: + +- `logicalDestinationId` -> platform `DestinationName`. +- `contractId` -> platform `MessageType`. +- `payloadVersion` -> `SchemaVersion`. +- event and causation identities must parse as UUIDv7; values are preserved exactly. Incompatible identities fail closed before the platform publisher is called. +- `occurredAt` is used for both `producedAt` and `occurredAt` until the application canonical model carries a separate production timestamp. The bridge never invents a new timestamp. +- producer is an explicit constructor/configuration value. +- correlation, partition key, tenant, aggregate order and exact envelope bytes are preserved. +- trace context is explicitly absent (`TraceContext.none()`) until the application model owns canonical trace context. +- exact `envelopeBytes` become `EncodedMessage` bytes; no re-encoding occurs. +- schema/catalog/binding/envelope evidence that has no first-class platform field is preserved as bounded `x-ca-*` headers. +- the schema reference subject is the canonical contract id and version is the canonical payload version. + +## Outcome mapping + +Mapping is based on completion **and transmission evidence**, not enum name similarity: + +- CONFIRMED -> `OutboxPublishOutcome.CONFIRMED`. +- AMBIGUOUS -> `OutboxPublishOutcome.AMBIGUOUS`. +- REJECTED + NOT_TRANSMITTED -> `REJECTED_BEFORE_SEND`. +- REJECTED + any evidence that bytes may have left the process -> `REJECTED_AFTER_BROKER`. + +Bridge preparation failures are definite pre-send rejection. + +## Platform boundary + +`EncodedMessagePublisher` is owned by `messaging-schema-api`, because `EncodedMessage` is owned there and the dependency direction remains acyclic. + +`DefaultMessagePublisher` implements both `MessagePublisher` and `EncodedMessagePublisher`. The encoded path skips only codec lookup/encoding; destination resolution, access policy, admission, runtime lease, transport send, deadline handling, result normalization and observation are shared with the normal publish path. + +The starter exposes one `DefaultMessagePublisher` singleton, which therefore satisfies both public interfaces. + +## Spring ownership + +`MessagingBridgeRootAutoConfiguration` owns the bridge bean when an `EncodedMessagePublisher` is present **and** `app.messaging.producer-id` is explicitly configured. Producer identity is never inferred from `spring.application.name` or invented. Application bootstrap must not construct Kafka producer clients or implement broker-specific send behavior. + +The existing `KafkaSender` / `KafkaMessageBroker` path remains temporarily for the legacy `OutboxEvent` and realtime publishers, which do not yet carry enough canonical metadata to enter the new bridge without invention. It is explicitly transitional and is removed only with the legacy outbox/realtime cutover. The new canonical bridge never calls it. + +## Verification + +Required checks: + +1. `DefaultMessagePublisherTest`: pre-encoded publish preserves bytes and skips codec while still exercising central pipeline. +2. `PlatformIntegrationEventPublishAdapterTest`: golden mapping, outcome mapping, fail-closed identity behavior. +3. outbound messaging module tests/check. +4. messaging runtime/starter tests. +5. app-bootstrap system test and architecture test after adding the canonical bridge while retaining the documented legacy seam. +6. search proving app-bootstrap has no direct native Kafka sender configuration. +7. dependency/build lock refresh only where dependency ownership changed. +8. `git diff --check`. + diff --git a/docs/superpowers/specs/2026-09-18-outbox-transport-only-cutover-design.md b/docs/superpowers/specs/2026-09-18-outbox-transport-only-cutover-design.md new file mode 100644 index 00000000..6094caae --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-outbox-transport-only-cutover-design.md @@ -0,0 +1,194 @@ +# Outbox Transport-Only Cutover Design + +## Status + +Approved implementation slice for MSG-015 transport-only cutover. + +This design deliberately does **not** activate `POLLING_V2` and does not migrate the publication authority to the v2 delivery tables. The existing `outbox_event` writer/store/claim/status authority remains the only active authority. The change makes that legacy authority capable of carrying a canonical integration event without losing the exact platform envelope. + +## Goal + +Support both row generations under one legacy relay authority: + +```text +business transaction + -> legacy NewOutboxEvent -> legacy row + -> canonical ValidatedIntegrationEvent -> canonical-compatible row + +one OutboxStorePort claim authority + -> legacy claimed row -> MessageBroker compatibility path + -> canonical claimed row -> IntegrationEventPublishPort -> messaging platform +``` + +A row is published through exactly one branch. There is no dual write and no second relay scheduler. + +## Application boundaries + +### Canonical append + +`OutboxAppendPort` becomes the canonical durable append boundary: + +```java +void append(ValidatedIntegrationEvent event); +``` + +### Legacy append + +Raw R0 payload append moves to an explicitly named compatibility port: + +```java +LegacyOutboxAppendPort + void append(NewOutboxEvent event); +``` + +Existing sample/durable-operation code that still emits raw `NewOutboxEvent` uses only the legacy port. New canonical code must not call the legacy port. + +### Claimed row model + +The relay-facing row is a sealed application model: + +```text +ClaimedOutboxEvent + |- OutboxEvent // legacy R0 claim model retained for compatibility + `- CanonicalClaimedOutboxEvent // reconstructs one ValidatedIntegrationEvent +``` + +`OutboxStorePort.claimBatch` returns `List`. + +Common relay state is exposed by the sealed interface: event id, event type, aggregate id, occurred-at, status and attempt count. The canonical subtype also exposes the exact `ValidatedIntegrationEvent`. + +A persisted row with a **partial** canonical metadata set is corrupt and fails closed during mapping. It is never downgraded to the legacy path. + +## Storage compatibility projection + +The existing PostgreSQL `outbox_event` remains authoritative. Add a forward migration after current legacy V12 that: + +- widens `event_id` to `varchar(96)`; +- widens `correlation_id` to `varchar(128)`; +- adds nullable canonical columns to preserve existing rows; +- adds an all-or-none canonical-shape check; +- stores exact canonical envelope bytes in `bytea`; +- keeps the legacy required columns for the rollback window. + +Canonical required columns: + +```text +contract_id +envelope_version +payload_version +logical_destination +tenant_scope +aggregate_type +aggregate_sequence +event_index +partition_key +envelope_bytes +content_type +schema_set_hash +envelope_sha256 +envelope_schema_hash +payload_schema_hash +contract_catalog_revision +destination_binding_revision +``` + +`causation_id` is optional by the application contract. + +Existing legacy columns remain populated for canonical rows with this compatibility projection: + +```text +event_id = canonical event id +aggregate_id = canonical aggregate id +event_type = contract id +payload = exact envelope bytes decoded as strict UTF-8 +occurred_at = canonical occurred-at +status = PENDING +attempt_count = 0 +next_attempt_at = occurred-at +correlation_id = canonical correlation id +idempotency_key = event id +``` + +The canonical encoder currently emits a UTF-8 JSON envelope. The append adapter verifies strict UTF-8 round-trip before storing the compatibility text. Invalid UTF-8 fails the business transaction; replacement characters are forbidden. + +`partitionKeyBytes` is not stored separately because the canonical model already requires it to be exactly the US-ASCII bytes of `partitionKeyText`. The claimed model reconstructs those bytes from the stored canonical text. + +## Persistence adapters + +`OutboxStoreAdapter` remains the legacy claim/status store and implements `LegacyOutboxAppendPort`, not `OutboxAppendPort`. + +A separate `CanonicalOutboxAppendAdapter` implements `OutboxAppendPort`. It participates in the caller's existing write transaction exactly like the legacy adapter and never opens a local transaction. + +Both write the same `outbox_event` table; they are alternative semantic inputs, not dual writers for one business fact. + +## Activation + +Introduce: + +```text +ca-skeleton.outbox.canonical-transport-enabled=false +``` + +Default remains false. + +When false: +- existing legacy append/relay behavior is unchanged; +- canonical append bean is not exposed; +- canonical relay routing is not considered an active deployment capability. + +When true: +- canonical append bean is exposed; +- startup requires an `IntegrationEventPublishPort`; +- the relay publisher can route canonical claimed rows to that port; +- legacy rows continue through `MessageBroker`; +- while mixed legacy rows may still exist, a relay-enabled deployment still requires the legacy broker. Canonical transport is an additional route, not permission to strand legacy backlog. + +The gate is a compatibility/cutover gate only. It does not change DB publication authority and does not activate `POLLING_V2`. + +## Publish routing + +`OutboxMessagePublishPort` remains the one relay publish port and accepts `ClaimedOutboxEvent`. + +Implementation behavior: +- `OutboxEvent` -> existing `OutboxEnvelopeJson` + `MessageBroker`. +- `CanonicalClaimedOutboxEvent` -> exact stored `ValidatedIntegrationEvent` -> `IntegrationEventPublishPort`. + +The canonical branch blocks on the returned `CompletionStage` only at this legacy compatibility boundary, because the current legacy relay port is synchronous. The platform result is mapped unchanged into `OutboxPublishOutcome`. + +The bridge does not re-encode canonical bytes. + +If canonical transport is disabled or the canonical publisher is absent, canonical publication fails closed before broker/platform transmission. Startup validation prevents the normal configured case from reaching that state. + +## Outcome policy + +The existing legacy relay state machine remains authoritative in this slice: +- CONFIRMED -> mark PUBLISHED. +- AMBIGUOUS -> retryable legacy FAILED flow. +- REJECTED_BEFORE_SEND / REJECTED_AFTER_BROKER -> existing definite-refusal DEAD behavior. + +This is intentionally the existing compatibility semantics. The richer v2 per-attempt state machine is a later storage-authority cutover. + +## Non-goals + +This slice does not: +- switch `OutboxPublicationAuthority` to `POLLING_V2`; +- mutate/reconcile `outbox_event_log_v2` or `outbox_delivery_v2`; +- implement CDC; +- remove `MessageBroker`, `KafkaSender`, `NewOutboxEvent`, `OutboxEvent`, or the legacy scheduler; +- migrate old rows into canonical rows; +- invent tenant, trace, schema or routing metadata for old rows. + +## Verification + +Required: +1. application port split compiles and old raw producers use `LegacyOutboxAppendPort`; +2. migration integration proves additive columns, exact BYTEA, constraints and legacy compatibility; +3. canonical append adapter round-trips every canonical field and exact bytes; +4. partial canonical row mapping fails closed; +5. legacy row mapping remains unchanged; +6. relay unit test proves canonical row invokes only `IntegrationEventPublishPort`; +7. legacy row invokes only `MessageBroker`; +8. canonical bytes reaching `PlatformIntegrationEventPublishAdapter` are byte-identical; +9. startup rejects canonical transport enabled without `IntegrationEventPublishPort`; +10. default-off composition preserves current behavior; +11. architecture/dependency checks and `git diff --check` pass. diff --git a/src/.env.example b/src/.env.example index c2cf88b5..cc0028df 100644 --- a/src/.env.example +++ b/src/.env.example @@ -227,6 +227,7 @@ APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY=1024 APP_CACHE_DEFAULT_TTL=300s APP_CACHE_NEGATIVE_TTL=60s APP_MESSAGING_BROKER= +APP_MESSAGING_PRODUCER_ID= APP_MESSAGING_KAFKA_BROKERS= APP_NOTIFICATION_SLACK_PROVIDER= APP_NOTIFICATION_EMAIL_PROVIDER= @@ -301,6 +302,7 @@ APP_GRAPHQL_ENABLED=false APP_GRAPHQL_DEPLOYMENT_MODE= APP_OUTBOX_ENABLED=false APP_OUTBOX_RELAY_ENABLED=false +APP_OUTBOX_CANONICAL_TRANSPORT_ENABLED=false APP_NOTIFICATION_PLATFORM_ENABLED=false APP_NOTIFICATION_PLATFORM_MODE=SERVING # OpenAPI exposure. application-prod.yml pins both false regardless of these. diff --git a/src/adapter/inbound/graphql/build.gradle b/src/adapter/inbound/graphql/build.gradle index 86c6ca90..b8f6e6ed 100644 --- a/src/adapter/inbound/graphql/build.gradle +++ b/src/adapter/inbound/graphql/build.gradle @@ -87,70 +87,15 @@ dependencies { testImplementation 'io.micrometer:micrometer-core' } -registerGraphQlPlatformTestLanes() -registerStrictQualificationTest( - name: 'graphqlTransportQualificationTest', - sourceSet: sourceSets.test, - requiredClasses: [ +extensions.getByName('strictQualification').register( + 'graphqlTransportQualificationTest', + sourceSets.test, + [ 'dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest' ], - description: 'Runs exact no-skip GraphQL conditional transport wire evidence.') - -// verifyGraphQlProductionJar — the production artifact must carry nothing a test wrote. -// -// Moving the testkit into test fixtures is a source-tree decision, and source-tree decisions drift. -// One `implementation` where a `testFixturesImplementation` belonged, one file created in the wrong -// directory, and the contract suites are back inside the jar an adopter deploys — where an -// in-memory persisted-operation registry looks like a working bean until a second instance starts, -// and where `testContext(String)` hands out an authenticated actor to anyone who calls it. -// -// So the claim is checked against the jar rather than against the layout that is supposed to -// produce it. Entry names and class names only: this reads the archive index, never the bytecode. -tasks.register('verifyGraphQlProductionJar') { - group = 'verification' - description = 'Fails when the GraphQL production jar contains testkit, fixture or in-memory-only types.' - - dependsOn tasks.named('jar') - def jarFile = tasks.named('jar').flatMap { it.archiveFile } - inputs.file(jarFile) - outputs.upToDateWhen { true } - - doLast { - Map forbidden = [ - '/testkit/' : 'contract suites and integration fixtures belong to test fixtures', - 'InMemory' : 'an in-memory implementation is a development stand-in, not a shipped default', - 'ForTests' : 'a for-tests factory in the production jar is reachable from production code', - 'TestContext' : 'a credential-free authenticated context must not ship', - 'Fixture' : 'fixtures belong to test fixtures', - ] - List violations = [] - new java.util.zip.ZipFile(jarFile.get().asFile).withCloseable { archive -> - archive.entries().each { entry -> - if (entry.directory || !entry.name.endsWith('.class')) { - return - } - forbidden.each { marker, reason -> - if (entry.name.contains(marker)) { - violations << "${entry.name}: ${reason}" - } - } - } - } - if (!violations.isEmpty()) { - throw new GradleException( - "The GraphQL production jar contains non-production types:\n " + - violations.sort().join('\n ') + - "\nMove them to src/testFixtures/java, or declare them with " + - "testFixturesImplementation." - ) - } - } -} - -tasks.named('check') { - dependsOn tasks.named('verifyGraphQlProductionJar') -} + 'Runs exact no-skip GraphQL conditional transport wire evidence.' +) // verifyGraphQlApiSurface — every public type this leaf exposes is a committed decision. // diff --git a/src/adapter/inbound/grpc/build.gradle b/src/adapter/inbound/grpc/build.gradle index b33c63ef..671eed8d 100644 --- a/src/adapter/inbound/grpc/build.gradle +++ b/src/adapter/inbound/grpc/build.gradle @@ -52,11 +52,12 @@ dependencies { testImplementation "io.grpc:grpc-stub:${grpcVersion}" } -registerStrictQualificationTest( - name: 'grpcTransportQualificationTest', - sourceSet: sourceSets.test, - requiredClasses: [ +extensions.getByName('strictQualification').register( + 'grpcTransportQualificationTest', + sourceSets.test, + [ 'dev.caskeleton.adapter.inbound.grpc.GrpcSafeActivationTest', 'dev.caskeleton.adapter.inbound.grpc.GrpcP1BoundaryWireTest' ], - description: 'Runs exact no-skip gRPC conditional transport wire evidence.') + 'Runs exact no-skip gRPC conditional transport wire evidence.' +) diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index aab96498..abbb5f1c 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -2,6 +2,7 @@ plugins { id 'ca.spring-library' id 'ca.spring-config' id 'java-test-fixtures' + id 'ca.auxiliary-source-set' } // Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001. @@ -70,7 +71,7 @@ tasks.named('test') { // The web platform's reusable ArchUnit rules ship in their own source set, consumed by this leaf's // tests and by the composition root. A rule pack that only its own fixture tests import is verified // as library code and applied to nothing — the shape the JPA testkit had to be corrected out of. -strictTestLanes { +auxiliarySourceSets { // The Jetty compatibility lane is its own source set because it needs a different embedded // server on the classpath. Two servers in one source set means Spring Boot picks one and the // "Jetty" lane silently runs on Tomcat — a compatibility matrix that certifies the same @@ -183,22 +184,23 @@ strictTestLanes { lane('webFluxContractTest') { sourceSet = 'webfluxContractTest' description = 'Runs the Stable HTTP contract against a real Reactor Netty.' - customize = { test -> test.jvmArgs '-Duser.timezone=UTC' } + jvmArgs '-Duser.timezone=UTC' } // Docker-gated, and it says so rather than skipping. A lane that quietly passes when the // container runtime is missing is a lane that has been certifying nothing since whenever Docker // last broke. lane('webNginxProxyTest') { + integration() sourceSet = 'nginxProxyTest' description = 'Runs the proxy, prefix and spoofing contract behind a real Nginx.' - customize = { test -> test.jvmArgs '-Duser.timezone=UTC' } + jvmArgs '-Duser.timezone=UTC' } lane('webJettyCompatTest') { sourceSet = 'jettyCompatTest' description = 'Runs the Stable HTTP contract against a real Jetty instead of Tomcat.' - customize = { test -> test.jvmArgs '-Duser.timezone=UTC' } + jvmArgs '-Duser.timezone=UTC' } // The cross-stack gate. It depends on every recording lane rather than tolerating a missing one: @@ -207,10 +209,8 @@ strictTestLanes { lane('webCrossStackParityTest') { tag = 'web-parity' description = 'Compares the wire contract recorded by Tomcat, Jetty and Reactor Netty.' - customize = { test -> - test.jvmArgs '-Duser.timezone=UTC' - test.dependsOn 'test', 'webJettyCompatTest', 'webFluxContractTest' - } + jvmArgs '-Duser.timezone=UTC' + dependsOn 'test', 'webJettyCompatTest', 'webFluxContractTest' } // The Advanced lane. Every capability is off unless a deployment names it, so none of them is @@ -223,7 +223,7 @@ strictTestLanes { lane('webAdvancedTest') { tag = 'web-advanced' description = 'Runs every web Advanced capability contract.' - customize = { test -> test.jvmArgs '-Duser.timezone=UTC' } + jvmArgs '-Duser.timezone=UTC' } } @@ -241,16 +241,9 @@ strictTestLanes { lane('webSecurityBoundaryTest') { tag = 'security-boundary' description = 'Runs hermetic JWT/JWKS and CORS filter-boundary contracts with no skips.' - customize = { test -> - test.shouldRunAfter test.project.tasks.named('test') - test.jvmArgs '-Duser.timezone=UTC' - test.afterSuite { descriptor, result -> - if (descriptor.parent == null && result.skippedTestCount > 0) { - throw new GradleException( - "webSecurityBoundaryTest forbids skipped tests: ${result.skippedTestCount}") - } - } - } + shouldRunAfter 'test' + jvmArgs '-Duser.timezone=UTC' + rejectSkipped = true } } diff --git a/src/adapter/inbound/websocket/build.gradle b/src/adapter/inbound/websocket/build.gradle index 58d1ee72..5ac207ee 100644 --- a/src/adapter/inbound/websocket/build.gradle +++ b/src/adapter/inbound/websocket/build.gradle @@ -2,6 +2,7 @@ plugins { id 'ca.spring-library' id 'ca.spring-config' id 'java-test-fixtures' + id 'ca.auxiliary-source-set' } // Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001. @@ -57,7 +58,7 @@ dependencies { // The platform's reusable ArchUnit rules ship in their own source set, consumed by this leaf's // tests and available to the composition root. A rule pack that only its own fixture tests import // is verified as library code and applied to nothing. -strictTestLanes { +auxiliarySourceSets { // Jetty replaces Tomcat for this lane only. With both on one classpath Boot starts Tomcat and // the lane certifies the same container twice — which for a WebSocket matters more than for // HTTP, because upgrade handling, close-frame timing and idle handling are all container code. @@ -111,9 +112,10 @@ strictTestLanes { // Docker-gated, and it fails rather than skipping. A proxy contract that quietly passes without // a proxy has been certifying nothing since whenever the container runtime last broke. lane('websocketNginxTest') { + integration() sourceSet = 'nginxWebSocketTest' description = 'Runs the upgrade and forwarded-header contract behind a real Nginx.' - customize = { test -> test.jvmArgs '-Duser.timezone=UTC' } + jvmArgs '-Duser.timezone=UTC' } // The real-container lane. Upgrade negotiation, close-frame handling and idle behaviour are @@ -121,7 +123,7 @@ strictTestLanes { lane('websocketJettyTest') { sourceSet = 'jettyWebSocketTest' description = 'Runs the WebSocket runtime contract against a real Jetty instead of Tomcat.' - customize = { test -> test.jvmArgs '-Duser.timezone=UTC' } + jvmArgs '-Duser.timezone=UTC' } // The Advanced lane. Every capability is off unless a deployment names it, so nothing a @@ -131,14 +133,15 @@ strictTestLanes { lane('websocketAdvancedTest') { tag = 'websocket-advanced' description = 'Runs every WebSocket Advanced capability contract.' - customize = { test -> test.jvmArgs '-Duser.timezone=UTC' } + jvmArgs '-Duser.timezone=UTC' } } -registerStrictQualificationTest( - name: 'websocketTransportQualificationTest', - sourceSet: sourceSets.test, - requiredClasses: [ +extensions.getByName('strictQualification').register( + 'websocketTransportQualificationTest', + sourceSets.test, + [ 'dev.caskeleton.adapter.inbound.websocket.stomp.WebSocketBoundaryQualificationTest' ], - description: 'Runs exact no-skip WebSocket conditional transport wire evidence.') + 'Runs exact no-skip WebSocket conditional transport wire evidence.' +) diff --git a/src/adapter/outbound/cache-redis/build.gradle b/src/adapter/outbound/cache-redis/build.gradle index ad78c314..7e4f12fb 100644 --- a/src/adapter/outbound/cache-redis/build.gradle +++ b/src/adapter/outbound/cache-redis/build.gradle @@ -1,6 +1,7 @@ plugins { id 'ca.spring-library' id 'ca.spring-config' + id 'ca.redis-topology-lane' } // Redis SDK leaf — see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md. @@ -50,148 +51,6 @@ dependencies { // Zero imports. } -// The topology lane is opt-in and fail-closed. The default unit task excludes it, and selecting it -// without an endpoint is an error rather than a skip: a topology test that silently passes because -// it never connected is worse than not having one. -tasks.named('test') { - useJUnitPlatform { - excludeTags 'redis-topology' - } -} - -// Lane selection is derived from the declared mode rather than chosen by hand. A promotion test is -// meaningless without sentinels and a cross-slot test is meaningless without a cluster, but writing -// that as a runtime assumption would turn "the lane was never started" into a green skip. Selecting -// by tag keeps the lane fail-closed: what a mode cannot prove is not selected, and what is selected -// must pass. -// -// The mode is an allowlist, not free text. Deriving the tag from an arbitrary property produced the -// worst possible result for a qualification lane: `-Predis.topology.mode=TYPO` built the tag -// `lane-typo`, matched nothing, ran zero tests and exited 0. A release gate that reports success -// for a lane it never ran is worse than no gate, so an unknown mode is an error and a run that -// executed no test is a failure. -// `tls` is a lane, not a deployment mode. Its shape is standalone; what it qualifies is the -// transport, which no other lane carries a single command over. It was reachable only by hand — -// point LiveRedisCompositionTest at the TLS compose with an ad-hoc init script — which is another -// way of saying the release gate did not cover TLS at all. -def REDIS_TOPOLOGY_MODES = ['standalone', 'sentinel', 'cluster', 'tls'] as Set -def REDIS_TOPOLOGY_DEPLOYMENT_MODE = ['standalone': 'standalone', 'sentinel': 'sentinel', - 'cluster': 'cluster', 'tls': 'standalone'] -// The classes each lane exists to run. A declaration, not an observation: a lane that lost a class -// to a rename otherwise still reports success on whatever remains. -// -// This list stays hand-written and that is a deliberate refusal, not an oversight. The repository's -// own mechanism for "a named test must actually have run" is `strictTestLanes { lane { requires(…) } }`, -// and ca.strict-test-lane refuses a lane that declares both a tag and required tests — "pick one -// selection". This lane's selection is a tag expression computed from the mode, so `requires` is not -// available to it. Deriving the list instead would mean reading @Tag off the compiled test classes, -// which is a bytecode dependency a leaf build file should not grow. -// -// What did go: REDIS_TOPOLOGY_MINIMUM_TESTS, a per-mode floor of 20/20/24/4 that had to be edited -// whenever a case was added or removed, and whose failure sentence was "coverage shrank" — not a -// runtime, deployment, data, security or compile failure. The class list below covers the case that -// mattered (a lane class silently leaving the lane); the number did not add a second one. -def REDIS_TOPOLOGY_REQUIRED_CLASSES = [ - 'standalone': ['LiveRedisCompositionTest', 'LiveRedisSemanticPortsTest', - 'RedisTopologyContractTest', 'LiveRedisGuardrailTest'], - 'sentinel' : ['LiveRedisCompositionTest', 'LiveRedisSentinelPromotionTest', - 'RedisTopologyContractTest'], - 'cluster' : ['LiveRedisCompositionTest', 'LiveRedisClusterTest', - 'LiveRedisClusterTransactionTest', 'LiveRedisSemanticPortsTest'], - 'tls' : ['LiveRedisTlsTest'], -] - -String declaredMode = (project.findProperty('redis.topology.mode') ?: 'unset').toString().toLowerCase() - -// Declared through the convention rather than hand-rolled. `ca.strict-test-lane` owns -// testClassesDirs, classpath, the tag filter, failOnNoDiscoveredTests, the refusal to serve an -// up-to-date result, and the "executed nothing" check — the same six things this task spelled out. -// What stays here is what is true of this lane only: the mode allowlist, the endpoint properties and -// the class-coverage check. -// -// The @Tag source-text scan that used to sit in `doFirst` is gone. It read every .java file in the -// test source set looking for the literal strings `@Tag("redis-topology")` and `@Tag("lane-")`, -// which a comment satisfied and a tag held in a constant defeated — and by its own comment it only -// improved the message for a failure `failOnNoDiscoveredTests` already produces. -strictTestLanes { - lane('redisTopologyTest') { - tag = "redis-topology & lane-${declaredMode}".toString() - description = 'Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk.' - customize = { test -> - ['redis.topology.host', 'redis.topology.port', - 'redis.topology.master', 'redis.topology.username', 'redis.topology.password', - 'redis.topology.trust-material'] - .each { String key -> - if (project.hasProperty(key)) { - test.systemProperty key, project.property(key) - } - } - // The lane name and the deployment mode are different things, and only the TLS lane makes - // that visible: its shape is standalone, so the tests must see `standalone` while the tag - // filter and the required properties come from the lane. Passing the lane name through as - // the mode would fail RedisDeploymentMode.valueOf on a value that is not a topology. - test.systemProperty 'redis.topology.mode', - REDIS_TOPOLOGY_DEPLOYMENT_MODE.getOrDefault(declaredMode, declaredMode) - test.systemProperty 'redis.topology.tls', (declaredMode == 'tls').toString() - - // Executed, not merely reported. `afterTest` fires for a skipped test too, so counting - // every callback would let a lane whose tests all skipped satisfy the checks below. - def skipped = new java.util.concurrent.atomic.AtomicInteger() - def classes = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet()) - test.afterTest { descriptor, result -> - if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) { - skipped.incrementAndGet() - } else { - classes.add(descriptor.className.tokenize('.').last()) - } - } - - test.doFirst { - if (!REDIS_TOPOLOGY_MODES.contains(declaredMode)) { - throw new GradleException( - "redisTopologyTest was selected with redis.topology.mode='${declaredMode}'; " + - 'the supported modes are ' + REDIS_TOPOLOGY_MODES.sort().join(', ') + - '. An unrecognised mode selects no test and would otherwise report success.') - } - def required = ['redis.topology.host', 'redis.topology.port'] - if (declaredMode == 'sentinel') { - required += 'redis.topology.master' - } - if (declaredMode == 'tls') { - // Without the trust material the client would have to disable verification to - // connect, and a TLS lane that trusts anything qualifies nothing. - required += 'redis.topology.trust-material' - } - def missing = required.findAll { !project.hasProperty(it) } - if (!missing.isEmpty()) { - throw new GradleException( - 'redisTopologyTest was selected without ' + missing.join(', ') + - '; start a lane from infra/redis-sdk and pass -P=.') - } - } - - test.doLast { - // What a lane must cover, named rather than counted by accident. A tag filter matching - // one trivial class satisfied "ran something" while the class the lane exists for had - // been renamed out of the filter, and nothing said so. - def absent = REDIS_TOPOLOGY_REQUIRED_CLASSES[declaredMode].findAll { - !classes.contains(it) - } - if (!absent.isEmpty()) { - throw new GradleException( - "redisTopologyTest ran the ${declaredMode} lane without ${absent.join(', ')}. " + - 'These classes are what the lane qualifies; a run that skipped them proves ' + - 'less than the lane claims.') - } - if (skipped.get() > 0) { - throw new GradleException( - "redisTopologyTest skipped ${skipped.get()} test(s) on the ${declaredMode} " + - 'lane. A qualification lane has no conditional coverage: what it cannot prove ' + - 'must not be selected, and what is selected must run.') - } - test.logger.lifecycle( - "redisTopologyTest: ${declaredMode} lane covered ${classes.size()} class(es).") - } - } - } -} +// Redis topology qualification is opt-in and owned by `ca.redis-topology-lane`. The Java +// convention owns mode/property validation, tag selection, TLS deployment mapping, required-class +// coverage and no-skip enforcement; this leaf keeps only its dependency declaration surface. diff --git a/src/adapter/outbound/httpclient/build.gradle b/src/adapter/outbound/httpclient/build.gradle index 497d5f74..fd162e1a 100644 --- a/src/adapter/outbound/httpclient/build.gradle +++ b/src/adapter/outbound/httpclient/build.gradle @@ -1,6 +1,8 @@ plugins { id 'ca.spring-library' id 'java-test-fixtures' + id 'ca.auxiliary-source-set' + id 'ca.jmh-benchmarks' } // Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001. @@ -88,10 +90,9 @@ dependencies { // Performance certification and JMH benchmarks are separate source sets for their own reason: they // are slow, they assert on resource bounds rather than behaviour, and they must never be part of // the default unit lane. -strictTestLanes { +auxiliarySourceSets { // The testkit compiles against exactly what a test does: `implementation` inheritance runs sourceSet('httpClientPerformanceTest') { compilesAgainst 'main', 'testFixtures' } - sourceSet('jmh') { compilesAgainst 'main', 'testFixtures' } } dependencies { @@ -119,44 +120,23 @@ dependencies { testFixturesImplementation libs.archunit.junit5 testFixturesImplementation libs.blockhound - jmhImplementation libs.jmh.core - jmhAnnotationProcessor libs.jmh.generator.annprocess } // UTF-8 is pinned for every JavaCompile task in the root build; this leaf no longer repeats it. -// JMH generates its harness classes at compile time. They are not our source, so the -// compile-time checker and -Werror are switched off for that source set only; applying them -// would fail the build on generated code we cannot edit. -tasks.named('compileJmhJava', JavaCompile) { - options.errorprone.enabled = false - options.compilerArgs.removeIf { it == '-Werror' } -} - -// The bytecode analyser is disabled for the same generated harness, for the same reason. -tasks.named('spotbugsJmh') { - enabled = false -} - -// Takes a Test task. The parameter is left untyped because the IDE's Gradle parser has no Gradle -// API on its classpath and reports the annotation as an unresolved type; Groovy dispatches the -// calls below dynamically either way. -Closure applyContractSelection = { task -> - // Cross-transport contract lane. The same semantic contract runs against every Stable transport; - // the transport under test is selected explicitly so a missing transport is an error, not a skip. - task.systemProperty 'httpclient.contract.transports', - (project.findProperty('httpclient.contract.transports') ?: 'apache,jdk,reactor').toString() - // Netty's strictest leak detector is on for every lane. It is only meaningful if it is actually - // live, so NettyLeakDetectionExtension asserts the level rather than trusting the flag reached - // the forked JVM. - task.systemProperty 'io.netty.leakDetection.level', 'paranoid' - // HTTP/3 is Experimental: it is never part of the default lane and never silently skipped. - task.systemProperty 'httpclient.http3.tests.enabled', - (project.findProperty('http3.tests.enabled') ?: 'false').toString() +// JMH is owned by ca.jmh-benchmarks. This leaf's benchmarks deliberately see test fixtures, +// not the ordinary test output, so benchmark support remains a reusable main-like surface. +jmhBenchmarks { + compilesAgainst 'main', 'testFixtures' + jsonReport 'reports/jmh/result.json' } tasks.named('test', Test) { - applyContractSelection(it) + systemProperty 'httpclient.contract.transports', + providers.gradleProperty('httpclient.contract.transports').orElse('apache,jdk,reactor').get() + systemProperty 'io.netty.leakDetection.level', 'paranoid' + systemProperty 'httpclient.http3.tests.enabled', + providers.gradleProperty('http3.tests.enabled').orElse('false').get() // Two lanes are excluded from the default run for opposite reasons: the fault lane needs Docker // and fails closed without it, and the BlockHound lane rewrites core JDK bytecode, which must // not be imposed on every unit run. @@ -176,16 +156,22 @@ strictTestLanes { lane('httpClientBlockHoundTest') { tag = 'httpclient-blockhound' description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).' - customize = { test -> - applyContractSelection(test) - // BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them. - test.jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods' - } + systemProperty 'httpclient.contract.transports', + providers.gradleProperty('httpclient.contract.transports').orElse('apache,jdk,reactor').get() + systemProperty 'io.netty.leakDetection.level', 'paranoid' + systemProperty 'httpclient.http3.tests.enabled', + providers.gradleProperty('http3.tests.enabled').orElse('false').get() + // BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them. + jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods' } lane('httpClientStableContractTest') { tag = 'httpclient-contract' description = 'Runs the cross-transport stable contract suite (design §28.2, §33).' - customize = { test -> applyContractSelection(test) } + systemProperty 'httpclient.contract.transports', + providers.gradleProperty('httpclient.contract.transports').orElse('apache,jdk,reactor').get() + systemProperty 'io.netty.leakDetection.level', 'paranoid' + systemProperty 'httpclient.http3.tests.enabled', + providers.gradleProperty('http3.tests.enabled').orElse('false').get() } // The same contract suite, run under the name the release gate and the support matrix quote for // the repository's Spring 7 baseline. It used to be a hand-written `tasks.register(..., Test)` @@ -196,12 +182,20 @@ strictTestLanes { lane('spring70CompatibilityTest') { tag = 'httpclient-contract' description = 'Runs the contract suite on the repository Spring 7 baseline (design §29).' - customize = { test -> applyContractSelection(test) } + systemProperty 'httpclient.contract.transports', + providers.gradleProperty('httpclient.contract.transports').orElse('apache,jdk,reactor').get() + systemProperty 'io.netty.leakDetection.level', 'paranoid' + systemProperty 'httpclient.http3.tests.enabled', + providers.gradleProperty('http3.tests.enabled').orElse('false').get() } lane('httpClientSecurityTest') { tag = 'httpclient-security' description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).' - customize = { test -> applyContractSelection(test) } + systemProperty 'httpclient.contract.transports', + providers.gradleProperty('httpclient.contract.transports').orElse('apache,jdk,reactor').get() + systemProperty 'io.netty.leakDetection.level', 'paranoid' + systemProperty 'httpclient.http3.tests.enabled', + providers.gradleProperty('http3.tests.enabled').orElse('false').get() } // Spring 6.2 / 7.0 compatibility lanes. This repository's Spring Boot 4.0 baseline pins Spring // Framework 7, so the 6.2 lane verifies the *API surface* the common packages compile against @@ -213,42 +207,37 @@ strictTestLanes { } // Its own source set rather than a tag, so the source set is the selection. lane('httpClientPerformanceTest') { + performance() sourceSet = 'httpClientPerformanceTest' description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).' - customize = { test -> - applyContractSelection(test) - test.systemProperty 'performance.assertions.enabled', - (project.findProperty('performance.assertions.enabled') ?: 'false').toString() - } + systemProperty 'httpclient.contract.transports', + providers.gradleProperty('httpclient.contract.transports').orElse('apache,jdk,reactor').get() + systemProperty 'io.netty.leakDetection.level', 'paranoid' + systemProperty 'httpclient.http3.tests.enabled', + providers.gradleProperty('http3.tests.enabled').orElse('false').get() + systemProperty 'performance.assertions.enabled', + providers.gradleProperty('performance.assertions.enabled').orElse('false').get() } lane('httpClientFailureInjectionTest') { + integration() tag = 'httpclient-fault' description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker ' + '(design §28.3).' - customize = { test -> - applyContractSelection(test) - // The upstream image is mutable by default. A digest here is what makes a red fault run - // attributable to this repository rather than to someone else's image push — and the - // default said `:latest`, which is the exact thing this comment forbade. The digest is - // the registry manifest digest of the image the lane has been running. - test.systemProperty 'httpclient.fault.httpbin.image', - (project.findProperty('httpclient.fault.httpbin.image') - ?: 'kennethreitz/httpbin@sha256:' + - '599fe5e5073102dbb0ee3dbb65f049dab44fa9fc251f6835c9990f8fb196a72b') - .toString() - } + systemProperty 'httpclient.contract.transports', + providers.gradleProperty('httpclient.contract.transports').orElse('apache,jdk,reactor').get() + systemProperty 'io.netty.leakDetection.level', 'paranoid' + systemProperty 'httpclient.http3.tests.enabled', + providers.gradleProperty('http3.tests.enabled').orElse('false').get() + // The upstream image is mutable by default. A digest here is what makes a red fault run + // attributable to this repository rather than to someone else's image push. + systemProperty 'httpclient.fault.httpbin.image', + providers.gradleProperty('httpclient.fault.httpbin.image').orElse( + 'kennethreitz/httpbin@sha256:' + + '599fe5e5073102dbb0ee3dbb65f049dab44fa9fc251f6835c9990f8fb196a72b').get() } } -tasks.register('jmh', JavaExec) { - group = 'verification' - description = 'Runs the JMH benchmarks for the blocking and reactive clients (design §28.8).' - mainClass = 'org.openjdk.jmh.Main' - classpath = sourceSets.jmh.runtimeClasspath - args '-rf', 'json', '-rff', layout.buildDirectory.file('reports/jmh/result.json').get().asFile.absolutePath -} - // Which lanes gate an ordinary build, and which do not. // // The specialised lanes existed but hung off nothing: `check` ran only `test`, so the SSRF suite, diff --git a/src/adapter/outbound/identifier/build.gradle b/src/adapter/outbound/identifier/build.gradle index e78608c3..42d0ef5f 100644 --- a/src/adapter/outbound/identifier/build.gradle +++ b/src/adapter/outbound/identifier/build.gradle @@ -1,17 +1,7 @@ plugins { id 'ca.spring-library' - // groovy: compiles the UuidCodec Spock specs under src/test/groovy. See README. - id 'groovy' } dependencies { implementation project(':application-core') - - testImplementation libs.spock.core.groovy5 -} - -// Pin UTF-8 so non-ASCII (Korean) Spock spec names build on any host. See README. -tasks.withType(GroovyCompile).configureEach { - groovyOptions.encoding = 'UTF-8' - options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/identifier/gradle.lockfile b/src/adapter/outbound/identifier/gradle.lockfile index 183d0558..58357ca2 100644 --- a/src/adapter/outbound/identifier/gradle.lockfile +++ b/src/adapter/outbound/identifier/gradle.lockfile @@ -40,7 +40,6 @@ commons-logging:commons-logging:1.3.6=testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath io.micrometer:micrometer-commons:1.16.7=testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.7=testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath @@ -58,8 +57,6 @@ org.apache.bcel:bcel:6.12.0=spotbugs org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle -org.apache.groovy:groovy-bom:5.0.8=testCompileClasspath,testRuntimeClasspath -org.apache.groovy:groovy:5.0.8=testCompileClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle org.apache.logging.log4j:log4j-api:2.25.5=spotbugs,testCompileClasspath,testRuntimeClasspath @@ -89,7 +86,7 @@ org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs @@ -114,8 +111,6 @@ org.slf4j:jul-to-slf4j:2.0.18=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j org.slf4j:slf4j-simple:2.0.18=checkstyle -org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath -org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/identifier/src/test/groovy/dev/caskeleton/adapter/outbound/identifier/UuidCodecSpec.groovy b/src/adapter/outbound/identifier/src/test/groovy/dev/caskeleton/adapter/outbound/identifier/UuidCodecSpec.groovy deleted file mode 100644 index e0483b4b..00000000 --- a/src/adapter/outbound/identifier/src/test/groovy/dev/caskeleton/adapter/outbound/identifier/UuidCodecSpec.groovy +++ /dev/null @@ -1,39 +0,0 @@ -package dev.caskeleton.adapter.outbound.identifier - -import spock.lang.Specification - -class UuidCodecSpec extends Specification { - - static final String CANONICAL = "0190bd6e-7c3e-7abc-8def-0123456789ab" - - def "normalize 는 #label 을 36자 소문자 canonical 형태로 변환한다"() { - expect: - UuidCodec.normalize(input) == CANONICAL - - where: - label | input - "이미 canonical 인 입력" | CANONICAL - "대문자 입력" | CANONICAL.toUpperCase() - } - - def "normalize 는 null 입력에 대해 null 을 반환한다"() { - expect: - UuidCodec.normalize(null) == null - } - - def "normalize 는 형식이 잘못된 UUID 를 거부한다"() { - when: - UuidCodec.normalize("not-a-uuid") - - then: - thrown(IllegalArgumentException) - } - - def "UUID -> UUID -> UUID 왕복 변환은 무손실이다"() { - given: - def uuid = UuidCodec.toUuid(CANONICAL) - - expect: - UuidCodec.fromUuid(uuid) == CANONICAL - } -} diff --git a/src/adapter/outbound/identifier/src/test/java/dev/caskeleton/adapter/outbound/identifier/UuidCodecTest.java b/src/adapter/outbound/identifier/src/test/java/dev/caskeleton/adapter/outbound/identifier/UuidCodecTest.java new file mode 100644 index 00000000..a5c7aa60 --- /dev/null +++ b/src/adapter/outbound/identifier/src/test/java/dev/caskeleton/adapter/outbound/identifier/UuidCodecTest.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.outbound.identifier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Locale; +import org.junit.jupiter.api.Test; + +class UuidCodecTest { + private static final String CANONICAL = "0190bd6e-7c3e-7abc-8def-0123456789ab"; + + @Test + void normalizeProducesLowercaseCanonicalUuid() { + assertEquals(CANONICAL, UuidCodec.normalize(CANONICAL)); + assertEquals(CANONICAL, UuidCodec.normalize(CANONICAL.toUpperCase(Locale.ROOT))); + } + + @Test + void normalizeReturnsNullForNullInput() { + assertNull(UuidCodec.normalize(null)); + } + + @Test + void normalizeRejectsMalformedUuid() { + assertThrows(IllegalArgumentException.class, () -> UuidCodec.normalize("not-a-uuid")); + } + + @Test + void uuidRoundTripIsLossless() { + var uuid = UuidCodec.toUuid(CANONICAL); + assertEquals(CANONICAL, UuidCodec.fromUuid(uuid)); + } +} diff --git a/src/adapter/outbound/messaging/CLAUDE.md b/src/adapter/outbound/messaging/CLAUDE.md index a9a02746..739da5ce 100644 --- a/src/adapter/outbound/messaging/CLAUDE.md +++ b/src/adapter/outbound/messaging/CLAUDE.md @@ -29,6 +29,13 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`. - Allowed dependency edges come only from the module's `src/config/architecture/modules.json` entry. - No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency. +- The canonical platform bridge may depend only on `messaging-core-api` and + `messaging-schema-api`; do not import `messaging-runtime-core`, transport SPI implementations, or + broker SDK types under `platformbridge/`. +- `KafkaSender` / `MessageBroker` are transitional legacy R0 seams for `OutboxEvent` and realtime + compatibility only. Do not route new canonical integration-event publication through them. +- Canonical producer identity is explicit `app.messaging.producer-id`; never derive it from + `spring.application.name`, host, pod, or process identity. - Contract compilation accepts only explicitly supplied application SPI contributions; do not scan, import sample payload classes, use `Class.forName`, or discover contracts from raw JSON/tree data. - Snapshot each contribution accessor exactly once. Compiled contracts/publication bindings have @@ -52,7 +59,7 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`. mutation/concurrency, and enforce output bytes while the generator writes. - Keep the nine exact checked-in Draft 2020-12 schema/meta bytes and digests synchronized. Startup compares those bytes, IDs, and NetworkNT runtime schema trees without assuming the validator's - `CodeSource` is a regular JAR. Strict dependency locks and `verifyJsonSchemaRuntimeGraph` own the + `CodeSource` is a regular JAR. Strict dependency locks and `verifyDependencyPolicy` own the NetworkNT 3.0.2 artifact provenance. - Permit only lowercase exact `urn` root `$id` and absolute `$ref` schemes. Reject every root-external nested `$id` key regardless of value type, and reject `$dynamicRef`, diff --git a/src/adapter/outbound/messaging/README.md b/src/adapter/outbound/messaging/README.md index 941306ce..7f486fb7 100644 --- a/src/adapter/outbound/messaging/README.md +++ b/src/adapter/outbound/messaging/README.md @@ -11,15 +11,32 @@ correlation / fail-open 의존성 로깅을 일반 publisher에서 재사용한 ## 모듈 개요 -application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에 두는 **선택형** -연동 어댑터다. `@ConditionalOnProperty` 로 게이팅되고 기본 비활성이며, 비활성 바인딩은 -`Disabled*` 구현으로 fail-fast 한다(Layer 3). 무거운 broker SDK 는 의도적으로 classpath 에 -최소화하고, 실제 broker client(`KafkaSender`)는 포킹 프로젝트가 채우는 seam 이다. +application-core의 legacy 포트(`MessagePublisher` / `OutboxMessagePublishPort`)와 canonical +`IntegrationEventPublishPort` 뒤에 두는 **선택형** 연동 어댑터다. `@ConditionalOnProperty` 로 +게이팅되고 기본 비활성이며, legacy 비활성 바인딩은 `Disabled*` 구현으로 fail-fast 한다(Layer 3). -## 두 포트를 하나의 활성 broker 에 조립 +canonical path는 `platformbridge/PlatformIntegrationEventPublishAdapter`가 +`EncodedMessagePublisher`에 위임한다. 이 path는 broker SDK나 transport SPI를 직접 보지 않는다. +`KafkaSender`/`MessageBroker`는 metadata가 부족한 legacy `OutboxEvent`/realtime 경로의 transitional +R0 seam으로만 남아 있고, legacy storage/relay cutover와 함께 제거한다. -`MessagingConfig` 는 두 messaging 포트를 단일 활성 `MessageBroker` 위에 조립한다 — broker -추가는 새 broker 구현 파일 추가만으로 끝나고 이 config 는 바뀌지 않는다. +## Legacy 두 포트와 canonical platform bridge + +`MessagingConfig`는 legacy 두 messaging 포트를 단일 활성 `MessageBroker` 위에 조립한다. 이 경로는 +기존 `OutboxEvent`/realtime compatibility를 위해 남아 있다. + +canonical `IntegrationEventPublishPort`는 별도 경로다. + +`IntegrationEventPublishPort → PlatformIntegrationEventPublishAdapter → EncodedMessagePublisher` + +`app.messaging.producer-id`가 명시되고 platform publisher bean이 있을 때만 생성된다. exact +`ValidatedIntegrationEvent.envelopeBytes()`를 보존하고 platform의 중앙 publish pipeline을 탄다. + +MSG-015 transport-only 단계에서는 legacy relay authority도 이 bridge를 사용할 수 있다. +`OutboxMessagePublishAdapter`가 claim subtype으로 경로를 분리해서 legacy `OutboxEvent`는 기존 +`MessageBroker`, `CanonicalClaimedOutboxEvent`는 `IntegrationEventPublishPort`만 호출한다. 한 row가 두 +transport로 fallback/dual-publish되지 않는다. mixed-row 기간에는 old backlog를 보존하기 위해 legacy +broker requirement도 유지한다. ## broker 선택 검증 @@ -136,7 +153,7 @@ Draft 2020-12 authority는 `draft/2020-12/schema` 1개와 `meta/*` 8개의 exact digest로 pin한다. registry startup은 9개 digest, 각 `$id`, NetworkNT runtime schema tree를 대조하고 하나라도 다르면 fail-closed 한다. Spring Boot executable/fat/nested JAR 배치를 깨뜨리는 `CodeSource` regular-file/JAR 가정은 하지 않는다. NetworkNT 3.0.2 artifact provenance는 strict -Gradle dependency lock과 `verifyJsonSchemaRuntimeGraph`가 담당한다. 이 검증은 business schema +Gradle dependency lock과 `verifyDependencyPolicy`가 담당한다. 이 검증은 business schema registry나 runtime remote resolution 경로를 넓히지 않는다. 공통 build evidence manifest는 수동 구조 검사만으로 PASS하지 않는다. test/build 전용 @@ -153,7 +170,8 @@ Suite/Bowtie 전체 호환이나 hostile regex 시간 상한 증명이 아니며 `regex-engine-timeout`, `consumer-compatibility-full-suite` unsupported claim으로 남는다. 테스트 runtime은 remote corpus를 내려받지 않는다. -이 Task의 acceptance는 **deterministic local wire contract candidate**다. encoder/catalog는 기존 -`MessagingConfig`, runtime append, `KafkaSender` 또는 legacy broker selection에 연결하지 않았다. -Kafka ACK, durable outbox R2, external topic attestation은 아직 구현되지 않았고 R0 runtime authority와 -동작은 그대로다. +deterministic local wire contract는 이제 canonical platform bridge의 입력으로 사용할 수 있다. +다만 production legacy relay가 아직 `OutboxEvent`를 읽기 때문에 R2 durable outbox storage/relay +cutover는 남아 있다. `KafkaSender`/legacy broker selection은 그 R0 compatibility path에서만 +authority를 유지한다. canonical path는 platform `PublishResult`의 confirmation/transmission evidence를 +application `OutboxPublishOutcome`으로 명시적으로 변환한다. diff --git a/src/adapter/outbound/messaging/build.gradle b/src/adapter/outbound/messaging/build.gradle index 5b0ace37..77a1a644 100644 --- a/src/adapter/outbound/messaging/build.gradle +++ b/src/adapter/outbound/messaging/build.gradle @@ -8,9 +8,12 @@ dependencies { implementation project(':application-core') implementation project(':shared-contract') implementation project(':adapter:outbound:support') + implementation project(':messaging:messaging-core-api') + implementation project(':messaging:messaging-schema-api') implementation 'org.springframework.boot:spring-boot-autoconfigure' implementation 'org.springframework.boot:spring-boot-starter-json' + implementation 'org.apache.kafka:kafka-clients' implementation(libs.json.schema.validator) { exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml' } @@ -28,106 +31,56 @@ configurations.configureEach { exclude group: 'org.snakeyaml', module: 'snakeyaml-engine' } -import org.gradle.api.artifacts.MinimalExternalModuleDependency -import org.gradle.api.artifacts.ModuleIdentifier +// The runtime graph policy is declarative; ca.dependency-policy owns resolution and verification. +// Module presence is version-agnostic because dependency locking/BOMs own version selection. +dependencyPolicy { + absentMatching '(?i).*yaml.*', + 'Messaging JSON runtime is intentionally YAML-free.' + absentMatching 'com\\.fasterxml\\.jackson\\.core:jackson-(?:core|databind)', + 'Jackson 2 core/databind must not enter the Jackson 3 messaging runtime.' + required libs.json.schema.validator, + 'NetworkNT is the runtime JSON Schema engine.' + required libs.jackson3.core, + 'Jackson 3 core is part of the closed JSON runtime graph.' + required libs.jackson3.databind, + 'Jackson 3 databind is part of the closed JSON runtime graph.' +} -tasks.register('verifyJsonSchemaRuntimeGraph') { - group = 'verification' - description = 'Verifies the closed Jackson 3 / NetworkNT graph contains no YAML or Jackson 2 runtime.' - doLast { - Set modules = configurations.runtimeClasspath.incoming.resolutionResult - .allComponents - .findAll { it.moduleVersion != null } - .collect { - "${it.moduleVersion.group}:${it.moduleVersion.name}:${it.moduleVersion.version}" - .toString() - } as Set - List forbidden = modules.findAll { String coordinate -> - String lowered = coordinate.toLowerCase(Locale.ROOT) - lowered.contains('yaml') || - lowered.startsWith('org.yaml:') || - lowered.startsWith('org.snakeyaml:') || - lowered ==~ /com\.fasterxml\.jackson\.core:jackson-(core|databind):.*/ - }.sort() - if (!forbidden.isEmpty()) { - throw new GradleException( - "Messaging JSON runtime contains forbidden Jackson 2/YAML modules: ${forbidden}") - } - // The catalog accessors are Providers of a dependency, not coordinate strings. - // - // This block read `.each { String required -> ... }` over them, so Groovy tried to call the - // closure with a TransformBackedProvider and the task threw - // `No signature of method: doCall() ... (TransformBackedProvider)` before comparing - // anything. It had never passed: the forbidden-module half above ran first and found - // nothing, and then this half failed on its own argument types. `check` reached it, but - // only ever after some earlier failure had already stopped the build. +def messagingCompiledContractsQualification = extensions.getByName('strictQualification').register( + 'messagingCompiledContractsQualificationTest', + sourceSets.test, [ - libs.json.schema.validator, - libs.jackson3.core, - libs.jackson3.databind - ].collect { Provider accessor -> - ModuleIdentifier module = accessor.get().module - "${module.group}:${module.name}".toString() - }.each { String requiredModule -> - if (!modules.any { it.startsWith(requiredModule + ':') }) { - throw new GradleException( - "Messaging JSON runtime is missing required module ${requiredModule}; " + - "resolved runtime modules are ${modules.toSorted()}") - } - } - // Module, not module-and-version. - // - // The first working version of this compared the full `group:name:version` string from the - // catalog against the resolved graph, and the gate failed on its first real run: the - // catalog pins tools.jackson.core:jackson-core 3.0.2 while the Jackson 3 BOM resolves - // 3.1.5. That is not drift — it is dependency management doing its job, and this task is - // not the place that decides versions (gradle.lockfile is). What this task owns is the - // shape of the runtime graph: the Jackson 3 + NetworkNT engine present, no YAML engine, no - // Jackson 2 databind. Pinning the version here would have made a BOM patch bump a build - // failure in a leaf that never asked for the version. - - // Jackson 3 intentionally retains the 2.x-namespace annotations artifact. It is not a - // Jackson 2 databind/runtime engine and is part of the official Jackson 3 BOM graph. - } -} - -tasks.named('check') { - dependsOn tasks.named('verifyJsonSchemaRuntimeGraph') -} - -def messagingCompiledContractsQualification = registerStrictQualificationTest( - name: 'messagingCompiledContractsQualificationTest', - sourceSet: sourceSets.test, - requiredClasses: [ 'dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistryTest', 'dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompilerTest', 'dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigestTest', 'dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingCompilerTest', 'dev.caskeleton.adapter.outbound.messaging.destination.PartitionKeyV1Test' ], - junitXmlOutput: rootProject.layout.buildDirectory.dir( + rootProject.layout.buildDirectory.dir( 'test-results/messaging-evidence/compiled'), - binaryResultsOutput: rootProject.layout.buildDirectory.dir( + rootProject.layout.buildDirectory.dir( 'test-results/messaging-evidence-binary/compiled'), - description: 'Runs exact Messaging compiled-contract qualification tests.') + 'Runs exact Messaging compiled-contract qualification tests.' +) messagingCompiledContractsQualification.configure { dependsOn ':prepareMessagingContractEvidence' } -def messagingJsonSchemaV1Qualification = registerStrictQualificationTest( - name: 'messagingJsonSchemaV1QualificationTest', - sourceSet: sourceSets.test, - requiredClasses: [ +def messagingJsonSchemaV1Qualification = extensions.getByName('strictQualification').register( + 'messagingJsonSchemaV1QualificationTest', + sourceSets.test, + [ 'dev.caskeleton.adapter.outbound.messaging.envelope.LocalJsonSchemaRegistryTest', 'dev.caskeleton.adapter.outbound.messaging.envelope.JsonSchemaIntegrationEventEncoderTest', 'dev.caskeleton.adapter.outbound.messaging.envelope.EnvelopeAdversarialCorpusTest', 'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidatorTest' ], - junitXmlOutput: rootProject.layout.buildDirectory.dir( + rootProject.layout.buildDirectory.dir( 'test-results/messaging-evidence/json-schema'), - binaryResultsOutput: rootProject.layout.buildDirectory.dir( + rootProject.layout.buildDirectory.dir( 'test-results/messaging-evidence-binary/json-schema'), - description: 'Runs exact Messaging JSON Schema v1 qualification tests.') + 'Runs exact Messaging JSON Schema v1 qualification tests.' +) messagingJsonSchemaV1Qualification.configure { dependsOn ':prepareMessagingContractEvidence' } diff --git a/src/adapter/outbound/messaging/gradle.lockfile b/src/adapter/outbound/messaging/gradle.lockfile index 1126ebf2..c173eadb 100644 --- a/src/adapter/outbound/messaging/gradle.lockfile +++ b/src/adapter/outbound/messaging/gradle.lockfile @@ -1,6 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. +at.yawk.lz4:lz4-java:1.10.1=testRuntimeClasspath biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -8,6 +9,7 @@ com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.luben:zstd-jni:1.5.6-10=testRuntimeClasspath com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs @@ -61,6 +63,7 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.kafka:kafka-clients:4.1.2=compileClasspath,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -144,6 +147,7 @@ org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,te org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath +org.xerial.snappy:snappy-java:1.1.10.7=testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java index 905739a9..700d1dd3 100644 --- a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java @@ -8,9 +8,11 @@ import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePub import dev.caskeleton.adapter.outbound.messaging.outbox.OutboxMessagePublishAdapter; import dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter; import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -39,11 +41,20 @@ public class MessagingConfig { @Bean public OutboxMessagePublishPort outboxMessagePublishPort( - ObjectProvider brokerProvider, MessagingSettings settings) { + ObjectProvider brokerProvider, + ObjectProvider canonicalPublisherProvider, + MessagingSettings settings, + @Value("${ca-skeleton.outbox.canonical-transport-enabled:false}") + boolean canonicalTransportEnabled) { MessageBroker active = resolveBroker(brokerProvider, settings); - return (active == null) - ? new DisabledOutboxMessagePublisher() - : new OutboxMessagePublishAdapter(active); + IntegrationEventPublishPort canonicalPublisher = canonicalPublisherProvider.getIfAvailable(); + if (active == null && canonicalPublisher == null) { + return new DisabledOutboxMessagePublisher(); + } + return new OutboxMessagePublishAdapter( + java.util.Optional.ofNullable(active), + java.util.Optional.ofNullable(canonicalPublisher), + canonicalTransportEnabled); } /** Always available, including when broker publication is disabled. */ diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java index a80a60cf..bab5e7c4 100644 --- a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java @@ -8,13 +8,16 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * {@link MessageBroker} (e.g. {@code kafka}); unset/blank = no broker = fail-fast on use (the * disabled sentinels {@code DisabledMessagePublisher} / {@code DisabledOutboxMessagePublisher}). * - * @param broker the active broker id, matched against {@link MessageBroker#brokerId()}; blank means - * the messaging template is disabled (the default) + * @param broker the active legacy broker id, matched against {@link MessageBroker#brokerId()}; + * blank means the legacy broker template is disabled + * @param producerId the explicit logical producing-service identity used by the canonical platform + * bridge; blank means that bridge is not exposed */ @ConfigurationProperties(prefix = "app.messaging") -public record MessagingSettings(String broker) { +public record MessagingSettings(String broker, String producerId) { public MessagingSettings { broker = (broker == null) ? "" : broker.trim(); + producerId = (producerId == null) ? "" : producerId.trim(); } } diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java index 8dd9090e..247b7c63 100644 --- a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java @@ -3,9 +3,16 @@ package dev.caskeleton.adapter.outbound.messaging.autoconfigure; import dev.caskeleton.adapter.outbound.messaging.MessagingConfig; import dev.caskeleton.adapter.outbound.messaging.MessagingSettings; import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig; +import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaSenderAutoConfiguration; +import dev.caskeleton.adapter.outbound.messaging.platformbridge.PlatformIntegrationEventPublishAdapter; +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; +import dev.caskeleton.messaging.schema.EncodedMessagePublisher; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; /** @@ -20,5 +27,22 @@ import org.springframework.context.annotation.Import; @AutoConfiguration @ConditionalOnProperty(prefix = "app.messaging", name = "enabled", havingValue = "true") @EnableConfigurationProperties(MessagingSettings.class) -@Import({MessagingConfig.class, KafkaAdapterConfig.class}) -public class MessagingBridgeRootAutoConfiguration {} +@Import({MessagingConfig.class, KafkaSenderAutoConfiguration.class, KafkaAdapterConfig.class}) +public class MessagingBridgeRootAutoConfiguration { + + /** + * Connects canonical application integration events to the central messaging platform. + * + *

Producer identity is explicit configuration. The bridge is not created when that identity is + * absent, because guessing from a process name or deployment name would make the wire producer + * identity environment-dependent. + */ + @Bean + @ConditionalOnBean(EncodedMessagePublisher.class) + @ConditionalOnMissingBean(IntegrationEventPublishPort.class) + @ConditionalOnProperty(prefix = "app.messaging", name = "producer-id") + public IntegrationEventPublishPort integrationEventPublishPort( + EncodedMessagePublisher publisher, MessagingSettings settings) { + return new PlatformIntegrationEventPublishAdapter(publisher, settings.producerId()); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/KafkaSenderConfig.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSenderAutoConfiguration.java similarity index 90% rename from src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/KafkaSenderConfig.java rename to src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSenderAutoConfiguration.java index d2b5ee5c..5ed9f6b1 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/KafkaSenderConfig.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSenderAutoConfiguration.java @@ -1,7 +1,5 @@ -package dev.caskeleton.bootstrap.messaging; +package dev.caskeleton.adapter.outbound.messaging.kafka; -import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterSettings; -import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaSender; import java.time.Duration; import java.util.HashMap; import java.util.Map; @@ -18,7 +16,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** - * The real broker bridge behind {@link KafkaSender} (MSG-INT-003). + * The adapter-owned broker bridge behind {@link KafkaSender} (MSG-INT-003). * *

{@code KafkaSender} is documented as an integration seam for a forking project, and the * skeleton supplied no implementation of it — only a test fake. So selecting {@code @@ -26,9 +24,10 @@ import org.springframework.context.annotation.Configuration; * messaging capability had no way to reach a broker in any deployment. A seam with no production * implementation anywhere is indistinguishable from an unimplemented feature. * - *

It stays a seam. The bean backs off entirely to anything a forking project defines, and it is - * registered only when Kafka is the selected broker — so a deployment with messaging off, or on a - * different broker, carries no producer, no connection and no background sender thread. + *

It stays a seam and is owned by the outbound messaging adapter. The bean backs off entirely to + * anything a forking project defines, and it is registered only when Kafka is the selected broker — + * so a deployment with messaging off, or on a different broker, carries no producer, no connection + * and no background sender thread. * *

{@code kafka-clients} rather than {@code spring-kafka}: the seam takes one already-serialized * message and returns nothing, so a listener container, a converter stack and a template would all @@ -36,7 +35,7 @@ import org.springframework.context.annotation.Configuration; */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(name = "app.messaging.broker", havingValue = "kafka") -public class KafkaSenderConfig { +public class KafkaSenderAutoConfiguration { /** * The producer, configured so a send that returns has actually been accepted. diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java index af521a89..222c647a 100644 --- a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java @@ -1,20 +1,14 @@ package dev.caskeleton.adapter.outbound.messaging.outbox; -import dev.caskeleton.application.outbox.OutboxEvent; +import dev.caskeleton.application.outbox.ClaimedOutboxEvent; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; import dev.caskeleton.shared.error.AdapterDisabledException; -/** - * Fail-fast {@link OutboxMessagePublishPort} binding when no broker is active ({@code - * app.messaging.broker} unset — the default). Any publish throws {@link AdapterDisabledException} — - * never a silent no-op. Kept separate from {@code DisabledMessagePublisher} so each disabled bean - * implements exactly one port (a single class implementing both makes {@code - * getBean(MessagePublisher.class)} ambiguous). - */ +/** Fail-fast outbox binding when neither legacy broker nor canonical platform transport exists. */ public class DisabledOutboxMessagePublisher implements OutboxMessagePublishPort { @Override - public void publish(OutboxEvent event) { + public void publish(ClaimedOutboxEvent event) { throw new AdapterDisabledException("messaging"); } } diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java index 19ee6b5c..887e2e15 100644 --- a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java @@ -2,40 +2,100 @@ package dev.caskeleton.adapter.outbound.messaging.outbox; import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage; +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; +import dev.caskeleton.application.outbox.CanonicalClaimedOutboxEvent; +import dev.caskeleton.application.outbox.ClaimedOutboxEvent; import dev.caskeleton.application.outbox.OutboxEvent; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import dev.caskeleton.application.outbox.OutboxPublishAmbiguousException; +import dev.caskeleton.application.outbox.OutboxPublishOutcome; +import dev.caskeleton.application.outbox.OutboxPublishRefusedException; +import dev.caskeleton.shared.error.AdapterDisabledException; import java.util.Objects; +import java.util.Optional; /** - * Outbox {@link OutboxMessagePublishPort} binding (fail-closed). Maps the claimed {@link - * OutboxEvent} to an {@link OutboundMessage} and delegates to the active {@link MessageBroker}. - * Runtime failures propagate unchanged and checked failures are wrapped with their cause so the - * relay can drive the FAILED/DEAD transition. This adapter emits no dependency log; the confirmed - * transition has one canonical ERROR reporter. + * Transport-only cutover router for the legacy outbox relay authority. * - *

Broker-agnostic: the same decorator serves any {@link MessageBroker}, so adding a broker never - * touches this class. + *

Legacy rows are rendered through the existing {@link MessageBroker} path. Canonical rows carry + * an already validated event and are delegated unchanged to {@link IntegrationEventPublishPort}. A + * row never falls back across branches when its required transport is absent. */ -public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort { +public final class OutboxMessagePublishAdapter implements OutboxMessagePublishPort { - private final MessageBroker broker; + private final Optional broker; + private final Optional canonicalPublisher; + private final boolean canonicalTransportEnabled; public OutboxMessagePublishAdapter(MessageBroker broker) { - this.broker = Objects.requireNonNull(broker, "broker must not be null"); + this( + Optional.of(Objects.requireNonNull(broker, "broker must not be null")), + Optional.empty(), + false); + } + + public OutboxMessagePublishAdapter( + Optional broker, Optional canonicalPublisher) { + this(broker, canonicalPublisher, true); + } + + public OutboxMessagePublishAdapter( + Optional broker, + Optional canonicalPublisher, + boolean canonicalTransportEnabled) { + this.broker = Objects.requireNonNull(broker, "broker Optional must not be null"); + this.canonicalPublisher = + Objects.requireNonNull(canonicalPublisher, "canonicalPublisher Optional must not be null"); + this.canonicalTransportEnabled = canonicalTransportEnabled; + if (broker.isEmpty() && canonicalPublisher.isEmpty()) { + throw new IllegalArgumentException( + "at least one outbox publication transport must be present"); + } } @Override - public void publish(OutboxEvent event) { + public void publish(ClaimedOutboxEvent event) { + OutboxPublishOutcome outcome = publishForOutcome(event); + if (outcome == OutboxPublishOutcome.CONFIRMED) { + return; + } + if (outcome == OutboxPublishOutcome.AMBIGUOUS) { + throw new OutboxPublishAmbiguousException(); + } + throw new OutboxPublishRefusedException(outcome); + } + + @Override + public OutboxPublishOutcome publishForOutcome(ClaimedOutboxEvent event) { + Objects.requireNonNull(event, "event must not be null"); + if (event instanceof CanonicalClaimedOutboxEvent canonical) { + if (!canonicalTransportEnabled) { + throw new AdapterDisabledException("messaging canonical platform transport"); + } + IntegrationEventPublishPort publisher = + canonicalPublisher.orElseThrow( + () -> new AdapterDisabledException("messaging canonical platform")); + return publisher.publish(canonical.event()).toCompletableFuture().join(); + } + if (event instanceof OutboxEvent legacy) { + publishLegacy(legacy); + return OutboxPublishOutcome.CONFIRMED; + } + throw new IllegalStateException("unsupported claimed outbox event type: " + event.getClass()); + } + + private void publishLegacy(OutboxEvent event) { + MessageBroker activeBroker = + broker.orElseThrow(() -> new AdapterDisabledException("messaging legacy broker")); String envelope = OutboxEnvelopeJson.toJson(event); OutboundMessage message = new OutboundMessage(event.eventType(), event.aggregateId(), envelope); try { - broker.send(message); - } catch (RuntimeException ex) { - throw ex; - } catch (Exception ex) { - // Wrap checked exceptions; preserve cause so the relay can inspect it. + activeBroker.send(message); + } catch (RuntimeException exception) { + throw exception; + } catch (Exception exception) { throw new RuntimeException( - "outbox publish failed for broker '" + broker.brokerId() + "'", ex); + "outbox publish failed for broker '" + activeBroker.brokerId() + "'", exception); } } } diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/platformbridge/PlatformIntegrationEventPublishAdapter.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/platformbridge/PlatformIntegrationEventPublishAdapter.java new file mode 100644 index 00000000..a93d080c --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/platformbridge/PlatformIntegrationEventPublishAdapter.java @@ -0,0 +1,151 @@ +package dev.caskeleton.adapter.outbound.messaging.platformbridge; + +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; +import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent; +import dev.caskeleton.application.outbox.OutboxPublishOutcome; +import dev.caskeleton.messaging.api.CausationId; +import dev.caskeleton.messaging.api.ContentType; +import dev.caskeleton.messaging.api.CorrelationId; +import dev.caskeleton.messaging.api.MessageEnvelope; +import dev.caskeleton.messaging.api.MessageId; +import dev.caskeleton.messaging.api.MessageType; +import dev.caskeleton.messaging.api.ProducerId; +import dev.caskeleton.messaging.api.SchemaVersion; +import dev.caskeleton.messaging.api.TenantContext; +import dev.caskeleton.messaging.api.TraceContext; +import dev.caskeleton.messaging.api.destination.DestinationName; +import dev.caskeleton.messaging.api.destination.MessageDestination; +import dev.caskeleton.messaging.api.header.HeaderName; +import dev.caskeleton.messaging.api.header.HeaderValue; +import dev.caskeleton.messaging.api.header.MessageHeaders; +import dev.caskeleton.messaging.api.publish.PublishCompletion; +import dev.caskeleton.messaging.api.publish.PublishOptions; +import dev.caskeleton.messaging.api.publish.PublishResult; +import dev.caskeleton.messaging.api.publish.TransmissionEvidence; +import dev.caskeleton.messaging.schema.EncodedMessage; +import dev.caskeleton.messaging.schema.EncodedMessagePublisher; +import dev.caskeleton.messaging.schema.SchemaReference; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Anti-corruption bridge from the application's canonical integration event to the messaging + * platform's central pre-encoded publish path. + * + *

The bridge does not reconstruct or re-encode the event. The exact canonical envelope bytes + * produced by the application contract boundary are carried as the platform payload. Facts the + * platform models directly are mapped to first-class fields; provider-neutral evidence without a + * first-class field is retained in bounded {@code x-ca-*} headers. + */ +public final class PlatformIntegrationEventPublishAdapter implements IntegrationEventPublishPort { + + private final EncodedMessagePublisher publisher; + private final ProducerId producer; + + public PlatformIntegrationEventPublishAdapter( + EncodedMessagePublisher publisher, String producerId) { + this.publisher = Objects.requireNonNull(publisher, "publisher must not be null"); + this.producer = new ProducerId(producerId); + } + + @Override + public CompletionStage publish(ValidatedIntegrationEvent event) { + final PreparedPublish prepared; + try { + prepared = prepare(Objects.requireNonNull(event, "event must not be null")); + } catch (RuntimeException incompatibleCanonicalEvent) { + return CompletableFuture.completedFuture(OutboxPublishOutcome.REJECTED_BEFORE_SEND); + } + + return publisher + .publishEncoded(prepared.destination(), prepared.envelope(), PublishOptions.defaults()) + .thenApply(PlatformIntegrationEventPublishAdapter::mapOutcome); + } + + private PreparedPublish prepare(ValidatedIntegrationEvent event) { + MessageId messageId = canonicalMessageId(event.eventId().value()); + Optional causationId = + event.causationId().map(PlatformIntegrationEventPublishAdapter::canonicalCausationId); + MessageType messageType = new MessageType(event.contractId().value()); + SchemaVersion schemaVersion = new SchemaVersion(event.payloadVersion()); + DestinationName destinationName = new DestinationName(event.logicalDestinationId().value()); + ContentType contentType = new ContentType(event.contentType()); + + EncodedMessage encoded = + new EncodedMessage( + event.envelopeBytes(), + contentType, + Optional.of(SchemaReference.of(event.contractId().value(), schemaVersion))); + + MessageEnvelope envelope = + new MessageEnvelope<>( + messageId, + messageType, + schemaVersion, + event.occurredAt(), + Optional.of(event.occurredAt()), + producer, + Optional.of(new CorrelationId(event.correlationId())), + causationId, + contentType, + Optional.of(event.partitionKeyText()), + Optional.of(event.order().sequence() + ":" + event.order().eventIndex()), + Optional.of(new TenantContext(event.aggregate().tenantScope())), + TraceContext.none(), + evidenceHeaders(event), + encoded); + + MessageDestination destination = + new MessageDestination<>(destinationName, messageType, EncodedMessage.class); + return new PreparedPublish(destination, envelope); + } + + private static MessageHeaders evidenceHeaders(ValidatedIntegrationEvent event) { + Map values = new LinkedHashMap<>(); + put(values, "x-ca-envelope-version", Integer.toString(event.envelopeVersion())); + put(values, "x-ca-envelope-sha256", event.envelopeSha256().toString()); + put(values, "x-ca-schema-set-sha256", event.schemaSetHash().toString()); + put(values, "x-ca-envelope-schema-sha256", event.envelopeSchemaHash().toString()); + put(values, "x-ca-payload-schema-sha256", event.payloadSchemaHash().toString()); + put(values, "x-ca-contract-catalog-revision", event.contractCatalogRevision()); + put(values, "x-ca-destination-binding-revision", event.destinationBindingRevision()); + put(values, "x-ca-aggregate-type", event.aggregate().aggregateType()); + put(values, "x-ca-aggregate-id", event.aggregate().aggregateId()); + put(values, "x-ca-aggregate-sequence", Long.toString(event.order().sequence())); + put(values, "x-ca-event-index", Integer.toString(event.order().eventIndex())); + return MessageHeaders.platform(values); + } + + private static void put(Map target, String name, String value) { + target.put(new HeaderName(name), new HeaderValue(value)); + } + + private static MessageId canonicalMessageId(String value) { + return new MessageId(UUID.fromString(value)); + } + + private static CausationId canonicalCausationId(String value) { + return new CausationId(canonicalMessageId(value)); + } + + private static OutboxPublishOutcome mapOutcome(PublishResult result) { + Objects.requireNonNull(result, "publish result must not be null"); + if (result.completion() == PublishCompletion.CONFIRMED) { + return OutboxPublishOutcome.CONFIRMED; + } + if (result.completion() == PublishCompletion.AMBIGUOUS) { + return OutboxPublishOutcome.AMBIGUOUS; + } + return result.evidence().transmission() == TransmissionEvidence.NOT_TRANSMITTED + ? OutboxPublishOutcome.REJECTED_BEFORE_SEND + : OutboxPublishOutcome.REJECTED_AFTER_BROKER; + } + + private record PreparedPublish( + MessageDestination destination, MessageEnvelope envelope) {} +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingPlatformBridgeAutoConfigurationTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingPlatformBridgeAutoConfigurationTest.java new file mode 100644 index 00000000..dab5bd6c --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingPlatformBridgeAutoConfigurationTest.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.messaging.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; +import dev.caskeleton.messaging.schema.EncodedMessagePublisher; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class MessagingPlatformBridgeAutoConfigurationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MessagingBridgeRootAutoConfiguration.class)) + .withBean(FailOpenDependencyLogger.class, FailOpenDependencyLogger::new) + .withBean( + EncodedMessagePublisher.class, + () -> + (destination, message, options) -> { + throw new AssertionError("publisher is not invoked while wiring the bridge"); + }) + .withPropertyValues("app.messaging.enabled=true"); + + @Test + void explicitProducerIdCreatesOneCanonicalPublishBridge() { + runner + .withPropertyValues("app.messaging.producer-id=portfolio-service") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(IntegrationEventPublishPort.class); + }); + } + + @Test + void missingProducerIdDoesNotInventCanonicalPublisherIdentity() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(IntegrationEventPublishPort.class); + }); + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSenderAutoConfigurationOwnershipTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSenderAutoConfigurationOwnershipTest.java new file mode 100644 index 00000000..a20250e0 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSenderAutoConfigurationOwnershipTest.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.messaging.kafka; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.messaging.autoconfigure.MessagingBridgeRootAutoConfiguration; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +class KafkaSenderAutoConfigurationOwnershipTest { + @Test + void messagingBridgeRootOwnsTheProductionKafkaSenderInsideTheAdapter() { + Import imports = MessagingBridgeRootAutoConfiguration.class.getAnnotation(Import.class); + + assertThat(imports).isNotNull(); + assertThat(Arrays.asList(imports.value())) + .contains(KafkaSenderAutoConfiguration.class, KafkaAdapterConfig.class); + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxCanonicalPublishRoutingTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxCanonicalPublishRoutingTest.java new file mode 100644 index 00000000..f488776d --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxCanonicalPublishRoutingTest.java @@ -0,0 +1,165 @@ +package dev.caskeleton.adapter.outbound.messaging.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; +import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage; +import dev.caskeleton.application.messaging.contract.ContractId; +import dev.caskeleton.application.messaging.contract.LogicalDestinationId; +import dev.caskeleton.application.messaging.contract.Sha256; +import dev.caskeleton.application.messaging.event.AggregateIdentity; +import dev.caskeleton.application.messaging.event.AggregateOrder; +import dev.caskeleton.application.messaging.event.EventId; +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; +import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent; +import dev.caskeleton.application.outbox.CanonicalClaimedOutboxEvent; +import dev.caskeleton.application.outbox.OutboxEvent; +import dev.caskeleton.application.outbox.OutboxEventStatus; +import dev.caskeleton.application.outbox.OutboxPublishOutcome; +import dev.caskeleton.shared.error.AdapterDisabledException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +class OutboxCanonicalPublishRoutingTest { + + @Test + void canonicalClaimUsesOnlyCanonicalPublisherAndPreservesExactEvent() { + RecordingBroker broker = new RecordingBroker(); + RecordingCanonicalPublisher canonical = new RecordingCanonicalPublisher(); + canonical.result = OutboxPublishOutcome.AMBIGUOUS; + OutboxMessagePublishAdapter adapter = + new OutboxMessagePublishAdapter(Optional.of(broker), Optional.of(canonical)); + CanonicalClaimedOutboxEvent claim = + new CanonicalClaimedOutboxEvent(canonicalEvent(), OutboxEventStatus.IN_FLIGHT, 1); + + OutboxPublishOutcome outcome = adapter.publishForOutcome(claim); + + assertThat(outcome).isEqualTo(OutboxPublishOutcome.AMBIGUOUS); + assertThat(canonical.events).containsExactly(claim.event()); + assertThat(broker.sent).isEmpty(); + assertThat(canonical.events.getFirst().envelopeBytes()) + .containsExactly(claim.event().envelopeBytes()); + } + + @Test + void legacyClaimUsesOnlyLegacyBroker() { + RecordingBroker broker = new RecordingBroker(); + RecordingCanonicalPublisher canonical = new RecordingCanonicalPublisher(); + OutboxMessagePublishAdapter adapter = + new OutboxMessagePublishAdapter(Optional.of(broker), Optional.of(canonical)); + + OutboxPublishOutcome outcome = adapter.publishForOutcome(legacyEvent()); + + assertThat(outcome).isEqualTo(OutboxPublishOutcome.CONFIRMED); + assertThat(broker.sent).hasSize(1); + assertThat(canonical.events).isEmpty(); + } + + @Test + void disabledCanonicalTransportRejectsCanonicalClaimEvenWhenPublisherExists() { + RecordingBroker broker = new RecordingBroker(); + RecordingCanonicalPublisher canonical = new RecordingCanonicalPublisher(); + OutboxMessagePublishAdapter adapter = + new OutboxMessagePublishAdapter(Optional.of(broker), Optional.of(canonical), false); + + assertThatThrownBy( + () -> + adapter.publishForOutcome( + new CanonicalClaimedOutboxEvent( + canonicalEvent(), OutboxEventStatus.IN_FLIGHT, 1))) + .isInstanceOf(AdapterDisabledException.class) + .hasMessageContaining("canonical"); + + assertThat(canonical.events).isEmpty(); + assertThat(broker.sent).isEmpty(); + } + + @Test + void missingBranchDependencyFailsClosedWithoutFallingAcrossTransports() { + OutboxMessagePublishAdapter canonicalOnly = + new OutboxMessagePublishAdapter( + Optional.empty(), Optional.of(new RecordingCanonicalPublisher())); + OutboxMessagePublishAdapter legacyOnly = + new OutboxMessagePublishAdapter(Optional.of(new RecordingBroker()), Optional.empty()); + + assertThatThrownBy(() -> canonicalOnly.publishForOutcome(legacyEvent())) + .isInstanceOf(AdapterDisabledException.class); + assertThatThrownBy( + () -> + legacyOnly.publishForOutcome( + new CanonicalClaimedOutboxEvent( + canonicalEvent(), OutboxEventStatus.IN_FLIGHT, 1))) + .isInstanceOf(AdapterDisabledException.class); + } + + private static OutboxEvent legacyEvent() { + return new OutboxEvent( + "legacy-1", + "LegacyEvent", + "aggregate-1", + "{}", + Instant.parse("2026-09-18T03:00:00Z"), + "corr-1", + "legacy-1", + OutboxEventStatus.IN_FLIGHT, + 1); + } + + private static ValidatedIntegrationEvent canonicalEvent() { + String partition = "a".repeat(64); + Sha256 zero = new Sha256(new byte[32]); + return new ValidatedIntegrationEvent( + new EventId("01994e11-4d88-7000-8000-000000000001"), + new ContractId("portfolio.worklog.reserved"), + 1, + 3, + new LogicalDestinationId("portfolio-domain-events"), + new AggregateIdentity("tenant-a", "worklog", "worklog-42"), + new AggregateOrder(17, 2), + Instant.parse("2026-09-18T03:00:00Z"), + "corr-1", + Optional.empty(), + partition, + partition.getBytes(StandardCharsets.US_ASCII), + "{\"wire\":\"exact\"}".getBytes(StandardCharsets.UTF_8), + "application/json", + zero, + zero, + zero, + zero, + "catalog-r1", + "binding-r1"); + } + + private static final class RecordingBroker implements MessageBroker { + private final List sent = new ArrayList<>(); + + @Override + public String brokerId() { + return "kafka"; + } + + @Override + public void send(OutboundMessage message) { + sent.add(message); + } + } + + private static final class RecordingCanonicalPublisher implements IntegrationEventPublishPort { + private final List events = new ArrayList<>(); + private OutboxPublishOutcome result = OutboxPublishOutcome.CONFIRMED; + + @Override + public java.util.concurrent.CompletionStage publish( + ValidatedIntegrationEvent event) { + events.add(event); + return CompletableFuture.completedFuture(result); + } + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java index 49fc4ae2..2dbcd915 100644 --- a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java @@ -171,11 +171,11 @@ class OutboxMessagePublishAdapterTest { } @Test - void adapterStateContainsOnlyTheBroker() { + void adapterStateContainsOnlyTheTwoTransportReferencesAndCanonicalGate() { assertThat( Arrays.stream(OutboxMessagePublishAdapter.class.getDeclaredFields()) .map(field -> field.getName())) - .containsExactly("broker"); + .containsExactlyInAnyOrder("broker", "canonicalPublisher", "canonicalTransportEnabled"); } } diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/platformbridge/PlatformIntegrationEventPublishAdapterTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/platformbridge/PlatformIntegrationEventPublishAdapterTest.java new file mode 100644 index 00000000..2401d22a --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/platformbridge/PlatformIntegrationEventPublishAdapterTest.java @@ -0,0 +1,198 @@ +package dev.caskeleton.adapter.outbound.messaging.platformbridge; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.messaging.contract.ContractId; +import dev.caskeleton.application.messaging.contract.LogicalDestinationId; +import dev.caskeleton.application.messaging.contract.Sha256; +import dev.caskeleton.application.messaging.event.AggregateIdentity; +import dev.caskeleton.application.messaging.event.AggregateOrder; +import dev.caskeleton.application.messaging.event.EventId; +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; +import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent; +import dev.caskeleton.application.outbox.OutboxPublishOutcome; +import dev.caskeleton.messaging.api.ContentType; +import dev.caskeleton.messaging.api.MessageEnvelope; +import dev.caskeleton.messaging.api.destination.MessageDestination; +import dev.caskeleton.messaging.api.error.FailureCategory; +import dev.caskeleton.messaging.api.error.FailureDescriptor; +import dev.caskeleton.messaging.api.publish.ConfirmationLevel; +import dev.caskeleton.messaging.api.publish.PublishCompletion; +import dev.caskeleton.messaging.api.publish.PublishEvidence; +import dev.caskeleton.messaging.api.publish.PublishOptions; +import dev.caskeleton.messaging.api.publish.PublishResult; +import dev.caskeleton.messaging.api.publish.RoutingOutcome; +import dev.caskeleton.messaging.api.publish.TransmissionEvidence; +import dev.caskeleton.messaging.schema.EncodedMessage; +import dev.caskeleton.messaging.schema.EncodedMessagePublisher; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +class PlatformIntegrationEventPublishAdapterTest { + private static final String EVENT_ID = "0190f4aa-0000-7000-8000-000000000001"; + private static final String CAUSATION_ID = "0190f4aa-0000-7000-8000-000000000002"; + + @Test + void goldenMappingPreservesCanonicalIdentityRoutingAndExactEnvelopeBytes() { + RecordingPublisher publisher = new RecordingPublisher(confirmed()); + IntegrationEventPublishPort bridge = + new PlatformIntegrationEventPublishAdapter(publisher, "portfolio-service"); + ValidatedIntegrationEvent event = event(EVENT_ID, Optional.of(CAUSATION_ID)); + + assertThat(bridge.publish(event).toCompletableFuture().join()) + .isEqualTo(OutboxPublishOutcome.CONFIRMED); + + MessageDestination destination = publisher.destination; + MessageEnvelope envelope = publisher.envelope; + assertThat(destination.name().value()).isEqualTo("portfolio-domain-events"); + assertThat(destination.messageType().value()).isEqualTo("portfolio.worklog.reserved"); + assertThat(envelope.messageId().value()).isEqualTo(UUID.fromString(EVENT_ID)); + assertThat(envelope.messageType().value()).isEqualTo("portfolio.worklog.reserved"); + assertThat(envelope.schemaVersion().value()).isEqualTo(3); + assertThat(envelope.producedAt()).isEqualTo(event.occurredAt()); + assertThat(envelope.occurredAt()).contains(event.occurredAt()); + assertThat(envelope.producer().value()).isEqualTo("portfolio-service"); + assertThat(envelope.correlationId().orElseThrow().value()).isEqualTo("corr-1"); + assertThat(envelope.causationId().orElseThrow().value().value()) + .isEqualTo(UUID.fromString(CAUSATION_ID)); + assertThat(envelope.partitionKey()).contains(event.partitionKeyText()); + assertThat(envelope.orderingKey()).contains("17:2"); + assertThat(envelope.tenantContext().orElseThrow().tenantId()).isEqualTo("tenant-a"); + assertThat(envelope.traceContext().traceparent()).isEmpty(); + assertThat(envelope.contentType()).isEqualTo(ContentType.JSON); + assertThat(envelope.payload().bytes()).containsExactly(event.envelopeBytes()); + assertThat(envelope.payload().schemaReference().orElseThrow().subject()) + .isEqualTo(event.contractId().value()); + assertThat(envelope.headers().find("x-ca-envelope-version").orElseThrow().value()) + .isEqualTo("1"); + assertThat(envelope.headers().find("x-ca-envelope-sha256").orElseThrow().value()) + .isEqualTo(event.envelopeSha256().toString()); + assertThat(envelope.headers().find("x-ca-destination-binding-revision").orElseThrow().value()) + .isEqualTo("binding-r1"); + assertThat(publisher.calls).isEqualTo(1); + } + + @Test + void outcomeMappingUsesTransmissionEvidenceRatherThanEnumNameGuessing() { + RecordingPublisher publisher = new RecordingPublisher(confirmed()); + PlatformIntegrationEventPublishAdapter bridge = + new PlatformIntegrationEventPublishAdapter(publisher, "portfolio-service"); + ValidatedIntegrationEvent event = event(EVENT_ID, Optional.empty()); + + assertThat(bridge.publish(event).toCompletableFuture().join()) + .isEqualTo(OutboxPublishOutcome.CONFIRMED); + publisher.result = ambiguous(); + assertThat(bridge.publish(event).toCompletableFuture().join()) + .isEqualTo(OutboxPublishOutcome.AMBIGUOUS); + publisher.result = rejected(PublishEvidence.notTransmitted()); + assertThat(bridge.publish(event).toCompletableFuture().join()) + .isEqualTo(OutboxPublishOutcome.REJECTED_BEFORE_SEND); + publisher.result = + rejected( + new PublishEvidence( + true, TransmissionEvidence.TRANSMITTED, false, ConfirmationLevel.NONE)); + assertThat(bridge.publish(event).toCompletableFuture().join()) + .isEqualTo(OutboxPublishOutcome.REJECTED_AFTER_BROKER); + } + + @Test + void incompatibleApplicationIdentityFailsClosedBeforePublisherInvocation() { + RecordingPublisher publisher = new RecordingPublisher(confirmed()); + PlatformIntegrationEventPublishAdapter bridge = + new PlatformIntegrationEventPublishAdapter(publisher, "portfolio-service"); + + assertThat(bridge.publish(event("event-1", Optional.empty())).toCompletableFuture().join()) + .isEqualTo(OutboxPublishOutcome.REJECTED_BEFORE_SEND); + assertThat(publisher.calls).isZero(); + } + + private static ValidatedIntegrationEvent event(String eventId, Optional causationId) { + byte[] envelope = "{\"wire\":\"exact\"}".getBytes(StandardCharsets.UTF_8); + Sha256 zero = new Sha256(new byte[32]); + String partition = "a".repeat(64); + return new ValidatedIntegrationEvent( + new EventId(eventId), + new ContractId("portfolio.worklog.reserved"), + 1, + 3, + new LogicalDestinationId("portfolio-domain-events"), + new AggregateIdentity("tenant-a", "worklog", "worklog-42"), + new AggregateOrder(17, 2), + Instant.parse("2026-07-28T05:10:30.123Z"), + "corr-1", + causationId, + partition, + partition.getBytes(StandardCharsets.US_ASCII), + envelope, + "application/json", + zero, + zero, + zero, + zero, + "catalog-r1", + "binding-r1"); + } + + private static PublishResult confirmed() { + return new PublishResult( + PublishCompletion.CONFIRMED, + PublishEvidence.confirmed(ConfirmationLevel.BROKER_ACK), + RoutingOutcome.ROUTED, + Optional.empty(), + 1, + Duration.ZERO, + Optional.empty()); + } + + private static PublishResult ambiguous() { + return new PublishResult( + PublishCompletion.AMBIGUOUS, + PublishEvidence.ambiguous(), + RoutingOutcome.UNKNOWN, + Optional.empty(), + 1, + Duration.ZERO, + Optional.of( + FailureDescriptor.of(FailureCategory.AMBIGUOUS, "AMBIGUOUS_TEST", "ambiguous"))); + } + + private static PublishResult rejected(PublishEvidence evidence) { + return new PublishResult( + PublishCompletion.REJECTED, + evidence, + RoutingOutcome.UNKNOWN, + Optional.empty(), + 1, + Duration.ZERO, + Optional.of( + FailureDescriptor.of(FailureCategory.CONFIGURATION, "REJECTED_TEST", "rejected"))); + } + + private static final class RecordingPublisher implements EncodedMessagePublisher { + private PublishResult result; + private int calls; + private MessageDestination destination; + private MessageEnvelope envelope; + + private RecordingPublisher(PublishResult result) { + this.result = result; + } + + @Override + public CompletionStage publishEncoded( + MessageDestination destination, + MessageEnvelope message, + PublishOptions options) { + calls++; + this.destination = destination; + this.envelope = message; + return CompletableFuture.completedFuture(result); + } + } +} diff --git a/src/adapter/outbound/notification/build.gradle b/src/adapter/outbound/notification/build.gradle index b2b5cbbd..fb0a347f 100644 --- a/src/adapter/outbound/notification/build.gradle +++ b/src/adapter/outbound/notification/build.gradle @@ -57,10 +57,10 @@ dependencies { // asserts. verifyDependencyPolicy resolves runtimeClasspath and fails if the coordinate is present. dependencyPolicy { absent 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml', - because: 'schemas arrive as JSON strings; a second YAML parser is surface for a format ' + + 'schemas arrive as JSON strings; a second YAML parser is surface for a format ' + 'this leaf never reads' absent 'tools.jackson.dataformat:jackson-dataformat-yaml', - because: 'the Jackson 3 coordinate of the same parser, excluded for the same reason' + 'the Jackson 3 coordinate of the same parser, excluded for the same reason' } // The three notification gates run with the leaf they are about. diff --git a/src/adapter/outbound/objectstorage/build.gradle b/src/adapter/outbound/objectstorage/build.gradle index 3098970b..f45f2a4b 100644 --- a/src/adapter/outbound/objectstorage/build.gradle +++ b/src/adapter/outbound/objectstorage/build.gradle @@ -1,6 +1,7 @@ plugins { id 'ca.spring-library' id 'ca.spring-config' + id 'ca.auxiliary-source-set' } // Driven adapter: provider-neutral semantic object-storage ports plus a bounded local-development @@ -27,7 +28,7 @@ description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)' // source sets import no type from `dev.caskeleton` at all (they drive MinIO and S3 through the AWS // SDK), so the `test` edge was carrying nothing and a `testFixtures` variant would have been an // empty one. Verified by compiling all three with the edge removed. -strictTestLanes { +auxiliarySourceSets { sourceSet('objectStorageMinioContractTest') { compilesAgainst 'main' } sourceSet('objectStorageMinioFaultTest') { compilesAgainst 'main' } sourceSet('objectStorageAwsQualificationTest') { compilesAgainst 'main' } @@ -67,35 +68,38 @@ tasks.named('test') { systemProperty 'objectstorage.readiness.registry', objectStorageReadinessRegistry.absolutePath } -def objectStorageMinioContractQualification = registerStrictQualificationTest( - name: 'objectStorageMinioContractTest', - sourceSet: sourceSets.objectStorageMinioContractTest, - requiredClasses: [ +def objectStorageMinioContractQualification = extensions.getByName('strictQualification').register( + 'objectStorageMinioContractTest', + sourceSets.objectStorageMinioContractTest, + [ 'dev.caskeleton.adapter.outbound.objectstorage.qualification.MinioDirectTransferContractTest', 'dev.caskeleton.adapter.outbound.objectstorage.qualification.MinioManagedObjectContractTest' ], - description: 'Runs the non-skipping exact-release MinIO managed object contract.') + 'Runs the non-skipping exact-release MinIO managed object contract.' +) objectStorageMinioContractQualification.configure { shouldRunAfter tasks.named('test') } -def objectStorageMinioFaultQualification = registerStrictQualificationTest( - name: 'objectStorageMinioFaultTest', - sourceSet: sourceSets.objectStorageMinioFaultTest, - requiredClasses: [ +def objectStorageMinioFaultQualification = extensions.getByName('strictQualification').register( + 'objectStorageMinioFaultTest', + sourceSets.objectStorageMinioFaultTest, + [ 'dev.caskeleton.adapter.outbound.objectstorage.qualification.MinioDirectTransferFaultTest', 'dev.caskeleton.adapter.outbound.objectstorage.qualification.MinioManagedObjectFaultTest' ], - description: 'Runs the non-skipping digest-pinned MinIO/Toxiproxy fault contract.') + 'Runs the non-skipping digest-pinned MinIO/Toxiproxy fault contract.' +) objectStorageMinioFaultQualification.configure { shouldRunAfter objectStorageMinioContractQualification } -registerStrictQualificationTest( - name: 'objectStorageAwsQualificationTest', - sourceSet: sourceSets.objectStorageAwsQualificationTest, - requiredClasses: [ +extensions.getByName('strictQualification').register( + 'objectStorageAwsQualificationTest', + sourceSets.objectStorageAwsQualificationTest, + [ 'dev.caskeleton.adapter.outbound.objectstorage.qualification.AwsS3DirectTransferQualificationTest', 'dev.caskeleton.adapter.outbound.objectstorage.qualification.AwsS3ManagedCommonSubsetQualificationTest' ], - description: 'Runs only with explicit protected AWS sandbox authority and exact inputs.') + 'Runs only with explicit protected AWS sandbox authority and exact inputs.' +) diff --git a/src/adapter/outbound/persistence-jpa/CLAUDE.md b/src/adapter/outbound/persistence-jpa/CLAUDE.md index e7247bc4..c868447b 100644 --- a/src/adapter/outbound/persistence-jpa/CLAUDE.md +++ b/src/adapter/outbound/persistence-jpa/CLAUDE.md @@ -59,7 +59,13 @@ cd src ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest # EXPLAIN structure ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest # runtime role privileges ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest # pool behaviour -./gradlew jpaReleaseGate # every gate, from the root +# Release blocking composition is CI-owned; locally run the exact database qualification set: +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest \ + :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest \ + :adapter:outbound:persistence-jpa:jpaPlatformFailureTest \ + :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest \ + :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest \ + :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest ``` Every Docker-backed lane fails closed. A selected lane that discovers nothing, or a container that @@ -72,6 +78,7 @@ nobody tested. - Spring Data repositories. - Persistence mappers. - Repository adapter implementations. +- Outbox transport-only compatibility: raw `NewOutboxEvent` append belongs only to `LegacyOutboxAppendPort`; canonical `OutboxAppendPort` persists `ValidatedIntegrationEvent` exact bytes. Partial canonical metadata must fail closed and this path must not activate `POLLING_V2`. - `TransactionPort` implementation (`SpringTransactionPort`) — the bridge between application transactional intent and Spring's `PlatformTransactionManager`. - Audit-metadata base + actor seam (`audit/AuditableEntity`, `audit/AuditContextPort`, diff --git a/src/adapter/outbound/persistence-jpa/README.md b/src/adapter/outbound/persistence-jpa/README.md index cc0d8196..96881cf5 100644 --- a/src/adapter/outbound/persistence-jpa/README.md +++ b/src/adapter/outbound/persistence-jpa/README.md @@ -146,7 +146,18 @@ DB 저장으로 fallback. 실제 구현(S3 / GCS / MinIO)을 와이어링하면 ## outbox — `OutboxStoreAdapter` 외 -스키마는 Flyway(`V3__outbox_event.sql`)가 소유한다. +스키마는 Flyway(`V3__outbox_event.sql`)가 소유하고, MSG-015 transport-only cutover를 위한 +`V13__outbox_canonical_transport_compatibility.sql`이 기존 row를 깨지 않으면서 canonical metadata와 +exact `BYTEA envelope_bytes`를 additive하게 확장한다. 이 V13은 `POLLING_V2` authority 전환이 아니다. + +append 경계도 명시적으로 분리된다. `LegacyOutboxAppendPort`는 기존 `NewOutboxEvent` compatibility +producer 전용이고, `OutboxAppendPort`는 `ValidatedIntegrationEvent`만 받는다. +`CanonicalOutboxAppendAdapter`는 `ca-skeleton.outbox.enabled=true`와 +`canonical-transport-enabled=true`가 모두 만족될 때만 존재하며 exact bytes를 저장한다. legacy 필수 +컬럼에는 rollback window용 UTF-8 compatibility projection을 같이 기록한다. + +claim 시 canonical column이 전부 null이면 legacy `OutboxEvent`, 전부 존재하면 +`CanonicalClaimedOutboxEvent`로 매핑한다. 일부만 존재하는 row는 legacy로 강등하지 않고 fail-closed한다. ### 트랜잭션 경계 계약 - **append**: 호출자의 `TransactionPort.inWrite()` 경계 안에서 호출되어야 하며, 내부에서 새 diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index 3185b6f6..84210711 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -3,6 +3,8 @@ plugins { id 'ca.spring-config' id 'java-test-fixtures' id 'ca.jpa-evidence' + id 'ca.jpa-test-lanes' + id 'ca.auxiliary-source-set' } // Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001. @@ -20,7 +22,7 @@ plugins { // The testkit is its own source set rather than part of `test` because more than one lane consumes // it and because a source set whose dependencies are declared only on the test configurations gives // the design's "no production module depends on the testkit" guarantee without a new Gradle project. -strictTestLanes { +auxiliarySourceSets { sourceSet('postgresqlIntegrationTest') { // 'testFixtures' as well as 'main': the integration lane consumed the testkit through the // convention's `consumedBy 'test', 'postgresqlIntegrationTest'`, and java-test-fixtures only @@ -101,143 +103,10 @@ dependencies { jpaPlatformPerformanceTestRuntimeOnly 'org.postgresql:postgresql' } -// The fourteen no-skip PostgreSQL readiness lanes, declared rather than assembled. -// -// They were fourteen calls to a local `tasks.register(..., Test)` factory that re-spelled the five -// lines `ca.strict-test-lane` owns. The convention adds what the factory could not: naming the test -// through `requires(...)` turns on `failOnNoMatchingTests` AND the post-run check that the named -// selector actually executed, so a renamed readiness class fails its lane instead of leaving it -// with nothing to run. -String jpaPostgreSqlEvidenceImage = providers.gradleProperty('jpaPostgreSqlEvidenceImage') - .getOrElse('postgres:16-alpine') -String readinessPackage = 'dev.caskeleton.adapter.outbound.persistence.readiness' -Map postgresqlReadinessLanes = [ - postgresqlLifecycleIntegrationTest : 'PostgreSqlLifecycleIntegrationTest', - postgresqlSecurityBaselineIntegrationTest : 'PostgreSqlSecurityBaselineIntegrationTest', - postgresqlMigrationIntegrationTest : 'PostgreSqlMigrationIntegrationTest', - postgresqlTransactionIntegrationTest : 'PostgreSqlTransactionIntegrationTest', - postgresqlAggregateIntegrationTest : 'PostgreSqlAggregateIntegrationTest', - postgresqlQueryIntegrationTest : 'PostgreSqlQueryIntegrationTest', - postgresqlIdempotencyIntegrationTest : 'PostgreSqlIdempotencyIntegrationTest', - postgresqlOutboxStorageIntegrationTest : 'PostgreSqlOutboxStorageIntegrationTest', - postgresqlOutboxPollingIntegrationTest : 'PostgreSqlOutboxPollingIntegrationTest', - postgresqlInboxIntegrationTest : 'PostgreSqlInboxIntegrationTest', - postgresqlFileserverMigrationIntegrationTest : 'PostgreSqlFileserverMigrationIntegrationTest', - postgresqlFileserverMetadataIntegrationTest : 'PostgreSqlFileserverMetadataStoreIntegrationTest', - postgresqlFileserverReclamationIntegrationTest : 'PostgreSqlFileserverReclamationIntegrationTest', - // The notification stream is opt-in and lives outside the default Flyway location, so "is it - // applied and promoted" is a real deployment question with a real wrong answer. This lane - // asks it against a real server; the entity-scan half is a unit test. - postgresqlNotificationSchemaActivationIntegrationTest : - 'PostgreSqlNotificationSchemaActivationIntegrationTest' -] -postgresqlReadinessLanes.each { String taskName, String simpleName -> - String testClass = "${readinessPackage}.${simpleName}" - strictTestLanes.lane(taskName) { - sourceSet = 'postgresqlIntegrationTest' - description = "Runs the no-skip real PostgreSQL readiness scenario ${testClass}." - requires(testClass) - customize = { test -> - test.jvmArgs( - '-Duser.timezone=UTC', - "-Djpa.evidence.postgresql.image=${jpaPostgreSqlEvidenceImage}") - } - } -} - -// The rule verifyJpaSecurityFixtures was reaching for is now a selector. -// -// It read PostgreSqlSecurityBaselineIntegrationTest.java as text and failed when three strings were -// absent from it — which three strings sitting in a comment would have satisfied, and which said -// nothing about whether the scenario ran. Naming the method on the lane means the convention fails -// when the runtime-role denial scenario is renamed or stops executing. -strictTestLanes.lanes.named('postgresqlSecurityBaselineIntegrationTest').configure { lane -> - lane.requires("${readinessPackage}.PostgreSqlSecurityBaselineIntegrationTest" + - '.runtimeRoleCannotCreateInApplicationSchema') -} - -// The task itself stays, and its name is not negotiable: config/jpa/readiness-cards.yaml lists it as -// a support task of the `jpa-security-baseline` card, and the ca.jpa-evidence qualification plugin resolves every -// listed path through `tasks.findByName` and fails the build when one is missing. What changed is -// what it checks. Grepping the fixture's source text for 'runtimeRoleCannotCreateInApplicationSchema', -// 'assertDockerAvailable' and '42501' passed on three strings in a comment and proved nothing about -// execution; the lane's `requires(...)` above is the thing that now enforces the scenario, so this -// task verifies that the enforcement is declared rather than re-deriving it from source text. -tasks.register('verifyJpaSecurityFixtures') { - group = 'verification' - description = 'Verifies the no-skip PostgreSQL security lane still names the runtime-role namespace denial scenario.' - String laneName = 'postgresqlSecurityBaselineIntegrationTest' - String requiredSelector = "${readinessPackage}.PostgreSqlSecurityBaselineIntegrationTest" + - '.runtimeRoleCannotCreateInApplicationSchema' - // A live reference to the lane spec's own list, captured at configuration time. Reading it in - // `doLast` therefore sees the final declaration without touching `Task.project` at execution - // time, which Gradle 9 deprecates and the `--warning-mode=fail` gates reject. - List declaredSelectors = strictTestLanes.lanes.getByName(laneName).requiredTests - doLast { - if (!declaredSelectors.contains(requiredSelector)) { - throw new GradleException( - "verifyJpaSecurityFixtures: strict test lane '${laneName}' no longer requires " + - "'${requiredSelector}'. Without that selector the lane can run the " + - 'security baseline class with the runtime-role namespace denial scenario ' + - "renamed or deleted and still report success. It requires ${declaredSelectors}.") - } - logger.lifecycle( - "verifyJpaSecurityFixtures: OK — '${laneName}' requires the runtime-role namespace denial scenario.") - } -} - -// verifyJpaSqlConstructionSafety keeps its name for the same registry reason, and gives up the half -// of its job that a real tool already does. -// -// It used to also match `(createNativeQuery|queryForObject|update)\s*\([^;]*"\s*\+` against Java -// source text: a regex that matches any method named `update`, and that stops at the first `;` -// inside a string literal, so it over- and under-reported at once. Concatenated SQL is covered -// repo-wide and inter-procedurally by FindSecBugs, which the root build puts on every leaf -// (`spotbugsPlugins libs.findsecbugs.plugin`) with SpotBugs' `ignoreFailures` left at its blocking -// default and no SQL_INJECTION / SQL_NONCONSTANT exclusion in config/spotbugs/exclude.xml. A -// bytecode dataflow check with no package restriction is strictly better than that regex, so the -// regex is gone rather than duplicated. -// -// What no tool covers is the PostgreSQL-specific rule: a `set_config` value must be bound, never -// interpolated, because that value carries the tenant id and the search_path. That check stays, and -// two things about it changed. It no longer parses Java — it matches the SQL token `set_config('` -// and asks whether the same line binds a parameter. And it scans the whole main source root: it was -// pinned to `.../persistence/postgresql`, which is why it never saw the two real call sites in -// experimental/rls/RlsTenantSessionBinder.java and -// experimental/schema/SchemaMultiTenantConnectionProvider.java. -tasks.register('verifyJpaSqlConstructionSafety') { - group = 'verification' - description = 'Rejects non-parameterized PostgreSQL set_config values anywhere in this leaf.' - File mainSource = file('src/main/java') - inputs.dir(mainSource) - doLast { - List violations = [] - mainSource.eachFileRecurse { File source -> - if (!source.name.endsWith('.java')) { - return - } - source.readLines().eachWithIndex { String line, int index -> - String trimmed = line.trim() - // Javadoc and line comments mention set_config to explain why it is used; a comment - // is not a call site, and treating one as a violation is how a correct build turns - // red for a sentence. - if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) { - return - } - if (line.contains("set_config('") && !line.contains('?')) { - violations << "${source}:${index + 1}: set_config value is not parameterized" - } - } - } - if (!violations.isEmpty()) { - throw new GradleException( - "verifyJpaSqlConstructionSafety: ${violations.size()} violation(s):\n " + - violations.join('\n ')) - } - logger.lifecycle( - "verifyJpaSqlConstructionSafety: OK — every set_config value in ${mainSource} binds a parameter.") - } -} +// PostgreSQL readiness, tagged platform, and pool-contract lanes are owned by +// `ca.jpa-test-lanes`. Their metadata is represented as Java records, so task names, selectors, +// tags, descriptions and runtime property wiring are compiled rather than assembled from Groovy Maps. +// The security lane includes the runtime-role namespace-denial method selector in that typed model. tasks.named('postgresqlSecurityBaselineIntegrationTest') { // Cross-leaf task edge: see the handoff. verifyCleanArchitectureDependencies inspects @@ -245,73 +114,8 @@ tasks.named('postgresqlSecurityBaselineIntegrationTest') { dependsOn project(':adapter:inbound:web').tasks.named('jpaPersistenceRedactionContractTest') } -// JPA platform lanes (design §40-§41). Each maps one of the plan's JVM test suites onto this -// leaf's existing Docker-backed source set; the mapping is recorded in -// docs/jpa/repository-adaptation.md §3. -// -// Every lane fails closed. `failOnNoDiscoveredTests` matters more here than usual: a selected lane -// that discovers nothing reports success, and a contract suite that silently stopped running is -// indistinguishable from one that passes. -Map> jpaPlatformLanes = [ - jpaPlatformContractTest : ['jpa-contract', - 'Runs the JPA platform contract suite against real PostgreSQL (design §40).'], - jpaPlatformMigrationTest : ['jpa-migration', - 'Runs the Flyway upgrade snapshot scenarios (design §31).'], - jpaPlatformFailureTest : ['jpa-failure', - 'Reproduces deadlock, serialization, and commit-ambiguity failures (design §39).'], - jpaPlatformQueryPlanTest : ['jpa-queryplan', - 'Asserts query plan structure and planner estimate error (design §33).'], - jpaPlatformSecurityTest : ['jpa-security', - 'Verifies runtime role privileges and search_path safety (design §36).'] -] -jpaPlatformLanes.each { String taskName, List spec -> - strictTestLanes.lane(taskName) { - sourceSet = 'postgresqlIntegrationTest' - tag = spec[0] - description = spec[1] - customize = { test -> - test.jvmArgs('-Duser.timezone=UTC') - // The Stable matrix selection. An unknown or empty value is an error in - // PostgreSqlVersion.parseSelection rather than an empty run. - test.systemProperty 'jpa.matrix.versions', - (project.findProperty('jpa.matrix.versions') ?: '16').toString() - } - } -} - -// The pool behaviour contract. Named for what it does. -// -// It was `jpaPlatformPerformanceTest`, described as certifying pool and REQUIRES_NEW pressure, and -// gated behind a boolean that defaulted to off everywhere it appeared — in this file, and in the -// nightly workflow that set it explicitly to off. So the release gate depended on a lane whose only -// threshold assertion was that thresholds were not being asserted, and "certified" described a run -// in which no latency or throughput bound was ever compared to anything. The property is gone; its -// name is deliberately not repeated here, because a name in a comment is the next thing somebody -// tries to set. -// -// What the lane genuinely verifies is a behaviour contract: a REQUIRES_NEW depth of one needs two -// connections per concurrent thread, a saturated pool reports its pending count, and a caller waits -// rather than proceeding without a connection. Those are true on any machine, so they need no flag -// — and this name does not promise a number nobody measured. A real performance gate needs a -// dedicated runner, warmup and sample counts, and recorded thresholds; when that exists it belongs -// in a lane of its own rather than behind a boolean on this one. -strictTestLanes { - lane('jpaPlatformPoolContractTest') { - sourceSet = 'jpaPlatformPerformanceTest' - description = 'Verifies Hikari pool and REQUIRES_NEW connection behaviour (design §38).' - customize = { test -> test.jvmArgs('-Duser.timezone=UTC') } - } -} - -// The JPA release gate (design §41). Aggregates every lane whose absence would let one of the -// documented gates in docs/jpa/support-matrix.md pass unverified. -tasks.register('jpaPlatformReleaseGate') { - group = 'verification' - description = 'Runs every JPA platform lane required for a release (design §41).' - dependsOn tasks.named('test') - jpaPlatformLanes.keySet().each { String laneName -> dependsOn tasks.named(laneName) } - dependsOn tasks.named('jpaPlatformPoolContractTest') -} +// Which JPA lanes block a release is declared by .github/workflows/jpa-release.yml. +// This leaf declares the lanes and how each runs; it does not own release orchestration. // The unit lane reads three files that are not Java sources: the release registry and its two // renderings. Without declaring them, Gradle calls the lane up-to-date after a registry demotion or @@ -361,7 +165,7 @@ apiSurface { // The JPA readiness registry describes this platform's lanes and resolves their task paths, so it // runs with this leaf's `check` rather than with all 62. The task itself is registered by -// the ca.jpa-qualification plugin from build-qualification, which the root applies. +// the ca.jpa-qualification plugin from build-tools, which the root applies. tasks.named('check') { dependsOn rootProject.tasks.named('verifyJpaReadinessRegistry') } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/CanonicalOutboxAppendAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/CanonicalOutboxAppendAdapter.java new file mode 100644 index 00000000..4f4e249c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/CanonicalOutboxAppendAdapter.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox; + +import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.outbox.OutboxEventStatus; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +/** + * Canonical append path for the transport-only outbox cutover. + * + *

The existing {@code outbox_event} row remains the durable authority. Canonical metadata and + * exact envelope bytes are stored additively while legacy required columns receive a reversible + * compatibility projection. This adapter never opens its own transaction. + */ +@Repository +@ConditionalOnProperty( + prefix = "ca-skeleton.outbox", + name = {"enabled", "canonical-transport-enabled"}, + havingValue = "true") +final class CanonicalOutboxAppendAdapter implements OutboxAppendPort { + + private final OutboxEventJpaRepository repository; + + CanonicalOutboxAppendAdapter(OutboxEventJpaRepository repository) { + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + } + + @Override + public void append(ValidatedIntegrationEvent event) { + Objects.requireNonNull(event, "event must not be null"); + byte[] envelopeBytes = event.envelopeBytes(); + String compatibilityPayload = strictUtf8(envelopeBytes); + + OutboxEventEntity entity = new OutboxEventEntity(); + + // Legacy compatibility projection retained while LEGACY_POLLING owns the row lifecycle. + entity.setEventId(event.eventId().value()); + entity.setAggregateId(event.aggregate().aggregateId()); + entity.setEventType(event.contractId().value()); + entity.setPayload(compatibilityPayload); + entity.setOccurredAt(event.occurredAt()); + entity.setStatus(OutboxEventStatus.PENDING.name()); + entity.setAttemptCount(0); + entity.setNextAttemptAt(event.occurredAt()); + entity.setCorrelationId(event.correlationId()); + entity.setIdempotencyKey(event.eventId().value()); + + // Canonical authority for the new platform transport branch. + entity.setContractId(event.contractId().value()); + entity.setEnvelopeVersion(event.envelopeVersion()); + entity.setPayloadVersion(event.payloadVersion()); + entity.setLogicalDestination(event.logicalDestinationId().value()); + entity.setTenantScope(event.aggregate().tenantScope()); + entity.setAggregateType(event.aggregate().aggregateType()); + entity.setAggregateSequence(event.order().sequence()); + entity.setEventIndex(event.order().eventIndex()); + entity.setPartitionKey(event.partitionKeyText()); + entity.setEnvelopeBytes(envelopeBytes); + entity.setContentType(event.contentType()); + entity.setSchemaSetHash(event.schemaSetHash().toString()); + entity.setEnvelopeSha256(event.envelopeSha256().toString()); + entity.setEnvelopeSchemaHash(event.envelopeSchemaHash().toString()); + entity.setPayloadSchemaHash(event.payloadSchemaHash().toString()); + entity.setContractCatalogRevision(event.contractCatalogRevision()); + entity.setDestinationBindingRevision(event.destinationBindingRevision()); + entity.setCausationId(event.causationId().orElse(null)); + + repository.save(entity); + } + + private static String strictUtf8(byte[] bytes) { + try { + String decoded = + StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + if (!Arrays.equals(decoded.getBytes(StandardCharsets.UTF_8), bytes)) { + throw new IllegalArgumentException( + "canonical envelope must round-trip as exact UTF-8 for the legacy compatibility projection"); + } + return decoded; + } catch (CharacterCodingException exception) { + throw new IllegalArgumentException( + "canonical envelope must be valid UTF-8 for the legacy compatibility projection", + exception); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java index e5d7d1d5..37c0692f 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java @@ -1,27 +1,43 @@ package dev.caskeleton.adapter.outbound.persistence.outbox; import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import dev.caskeleton.application.messaging.contract.ContractId; +import dev.caskeleton.application.messaging.contract.LogicalDestinationId; +import dev.caskeleton.application.messaging.contract.Sha256; +import dev.caskeleton.application.messaging.event.AggregateIdentity; +import dev.caskeleton.application.messaging.event.AggregateOrder; +import dev.caskeleton.application.messaging.event.EventId; +import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent; +import dev.caskeleton.application.outbox.CanonicalClaimedOutboxEvent; +import dev.caskeleton.application.outbox.ClaimedOutboxEvent; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.outbox.OutboxEvent; import dev.caskeleton.application.outbox.OutboxEventStatus; import dev.caskeleton.application.outbox.OutboxStorePort; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.EnumMap; import java.util.HashMap; +import java.util.HexFormat; import java.util.List; import java.util.Map; +import java.util.Optional; import org.springframework.stereotype.Repository; /** - * JPA-backed {@link OutboxAppendPort} + {@link OutboxStorePort}. {@link #append} and the claim/mark - * operations run inside the caller's {@code TransactionPort.inWrite()} boundary and declare no - * {@code @Transactional} of their own; {@link #claimBatch} delegates the vendor claim to {@link - * OutboxClaimRepository}. See README "outbox" for the append/claim/no-@Transactional contracts. + * JPA-backed {@link LegacyOutboxAppendPort} + {@link OutboxStorePort}. {@link #append} and the + * claim/mark operations run inside the caller's {@code TransactionPort.inWrite()} boundary and + * declare no {@code @Transactional} of their own; {@link #claimBatch} delegates the vendor claim to + * {@link OutboxClaimRepository}. See README "outbox" for the append/claim/no-@Transactional + * contracts. */ @Repository -public class OutboxStoreAdapter implements OutboxAppendPort, OutboxStorePort { +public class OutboxStoreAdapter implements LegacyOutboxAppendPort, OutboxStorePort { private final OutboxEventJpaRepository repository; private final OutboxClaimRepository claimRepository; @@ -50,7 +66,7 @@ public class OutboxStoreAdapter implements OutboxAppendPort, OutboxStorePort { } @Override - public List claimBatch(int batchSize, Instant now, Duration inFlightTimeout) { + public List claimBatch(int batchSize, Instant now, Duration inFlightTimeout) { List eligible = claimRepository.claimEligible(now, batchSize); return eligible.stream() .map( @@ -58,7 +74,7 @@ public class OutboxStoreAdapter implements OutboxAppendPort, OutboxStorePort { entity.setStatus(OutboxEventStatus.IN_FLIGHT.name()); entity.setAttemptCount(entity.getAttemptCount() + 1); entity.setNextAttemptAt(now.plus(inFlightTimeout)); - return toOutboxEvent(entity); + return toClaimedOutboxEvent(entity); }) .toList(); } @@ -127,16 +143,133 @@ public class OutboxStoreAdapter implements OutboxAppendPort, OutboxStorePort { return result; } - private static OutboxEvent toOutboxEvent(OutboxEventEntity e) { + private static ClaimedOutboxEvent toClaimedOutboxEvent(OutboxEventEntity entity) { + if (!hasAnyCanonicalMetadata(entity)) { + return toLegacyOutboxEvent(entity); + } + if (!hasCompleteCanonicalMetadata(entity)) { + throw new IllegalStateException( + "outbox row contains partial canonical metadata for eventId=" + entity.getEventId()); + } + return toCanonicalOutboxEvent(entity); + } + + private static OutboxEvent toLegacyOutboxEvent(OutboxEventEntity entity) { return new OutboxEvent( - e.getEventId(), - e.getEventType(), - e.getAggregateId(), - e.getPayload(), - e.getOccurredAt(), - e.getCorrelationId(), - e.getIdempotencyKey(), - OutboxEventStatus.valueOf(e.getStatus()), - e.getAttemptCount()); + entity.getEventId(), + entity.getEventType(), + entity.getAggregateId(), + entity.getPayload(), + entity.getOccurredAt(), + entity.getCorrelationId(), + entity.getIdempotencyKey(), + OutboxEventStatus.valueOf(entity.getStatus()), + entity.getAttemptCount()); + } + + private static CanonicalClaimedOutboxEvent toCanonicalOutboxEvent(OutboxEventEntity entity) { + byte[] envelopeBytes = entity.getEnvelopeBytes(); + String compatibilityPayload = strictUtf8(envelopeBytes, entity.getEventId()); + if (!entity.getEventType().equals(entity.getContractId()) + || !entity.getIdempotencyKey().equals(entity.getEventId()) + || !entity.getPayload().equals(compatibilityPayload)) { + throw new IllegalStateException( + "canonical outbox compatibility projection mismatch for eventId=" + entity.getEventId()); + } + + try { + ValidatedIntegrationEvent event = + new ValidatedIntegrationEvent( + new EventId(entity.getEventId()), + new ContractId(entity.getContractId()), + entity.getEnvelopeVersion(), + entity.getPayloadVersion(), + new LogicalDestinationId(entity.getLogicalDestination()), + new AggregateIdentity( + entity.getTenantScope(), entity.getAggregateType(), entity.getAggregateId()), + new AggregateOrder(entity.getAggregateSequence(), entity.getEventIndex()), + entity.getOccurredAt(), + entity.getCorrelationId(), + Optional.ofNullable(entity.getCausationId()), + entity.getPartitionKey(), + entity.getPartitionKey().getBytes(StandardCharsets.US_ASCII), + envelopeBytes, + entity.getContentType(), + digest(entity.getSchemaSetHash(), "schema_set_hash", entity.getEventId()), + digest(entity.getEnvelopeSha256(), "envelope_sha256", entity.getEventId()), + digest(entity.getEnvelopeSchemaHash(), "envelope_schema_hash", entity.getEventId()), + digest(entity.getPayloadSchemaHash(), "payload_schema_hash", entity.getEventId()), + entity.getContractCatalogRevision(), + entity.getDestinationBindingRevision()); + return new CanonicalClaimedOutboxEvent( + event, OutboxEventStatus.valueOf(entity.getStatus()), entity.getAttemptCount()); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException( + "invalid canonical outbox metadata for eventId=" + entity.getEventId(), exception); + } + } + + private static boolean hasAnyCanonicalMetadata(OutboxEventEntity entity) { + return entity.getContractId() != null + || entity.getEnvelopeVersion() != null + || entity.getPayloadVersion() != null + || entity.getLogicalDestination() != null + || entity.getTenantScope() != null + || entity.getAggregateType() != null + || entity.getAggregateSequence() != null + || entity.getEventIndex() != null + || entity.getPartitionKey() != null + || entity.getEnvelopeBytes() != null + || entity.getContentType() != null + || entity.getSchemaSetHash() != null + || entity.getEnvelopeSha256() != null + || entity.getEnvelopeSchemaHash() != null + || entity.getPayloadSchemaHash() != null + || entity.getContractCatalogRevision() != null + || entity.getDestinationBindingRevision() != null + || entity.getCausationId() != null; + } + + private static boolean hasCompleteCanonicalMetadata(OutboxEventEntity entity) { + return entity.getContractId() != null + && entity.getEnvelopeVersion() != null + && entity.getPayloadVersion() != null + && entity.getLogicalDestination() != null + && entity.getTenantScope() != null + && entity.getAggregateType() != null + && entity.getAggregateSequence() != null + && entity.getEventIndex() != null + && entity.getPartitionKey() != null + && entity.getEnvelopeBytes() != null + && entity.getContentType() != null + && entity.getSchemaSetHash() != null + && entity.getEnvelopeSha256() != null + && entity.getEnvelopeSchemaHash() != null + && entity.getPayloadSchemaHash() != null + && entity.getContractCatalogRevision() != null + && entity.getDestinationBindingRevision() != null; + } + + private static Sha256 digest(String hex, String field, String eventId) { + try { + return new Sha256(HexFormat.of().parseHex(hex)); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException( + "invalid " + field + " for canonical outbox eventId=" + eventId, exception); + } + } + + private static String strictUtf8(byte[] bytes, String eventId) { + try { + return StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException exception) { + throw new IllegalStateException( + "canonical outbox envelope is not valid UTF-8 for eventId=" + eventId, exception); + } } } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxEventEntity.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxEventEntity.java index 7a7bc80e..3dad6f76 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxEventEntity.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxEventEntity.java @@ -9,17 +9,17 @@ import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; /** - * JPA row for the {@code outbox_event} table; schema owned by Flyway ({@code - * V3__outbox_event.sql}). Does not extend {@code AuditableEntity} (infra record, not a domain - * aggregate) and exposes mutating setters on purpose (relay transitions state in-place). See README - * "outbox" for both. + * JPA row for the {@code outbox_event} table; schema owned by Flyway ({@code V3__outbox_event.sql} + * + additive canonical metadata in {@code V13__outbox_canonical_transport_compatibility.sql}). Does + * not extend {@code AuditableEntity} (infra record, not a domain aggregate) and exposes mutating + * setters on purpose (relay transitions state in-place). */ @Entity @Table(name = "outbox_event") public class OutboxEventEntity { @Id - @Column(name = "event_id", nullable = false, length = 64, updatable = false) + @Column(name = "event_id", nullable = false, length = 96, updatable = false) private String eventId; @Column(name = "aggregate_id", nullable = false, length = 256, updatable = false) @@ -46,12 +46,67 @@ public class OutboxEventEntity { @Column(name = "next_attempt_at", nullable = false) private Instant nextAttemptAt; - @Column(name = "correlation_id", nullable = false, length = 64, updatable = false) + @Column(name = "correlation_id", nullable = false, length = 128, updatable = false) private String correlationId; @Column(name = "idempotency_key", nullable = false, length = 256, updatable = false) private String idempotencyKey; + // V13 canonical transport-only compatibility columns. Null as a set means an R0 legacy row. + @Column(name = "contract_id", length = 160, updatable = false) + private String contractId; + + @Column(name = "envelope_version", updatable = false) + private Integer envelopeVersion; + + @Column(name = "payload_version", updatable = false) + private Integer payloadVersion; + + @Column(name = "logical_destination", length = 96, updatable = false) + private String logicalDestination; + + @Column(name = "tenant_scope", length = 96, updatable = false) + private String tenantScope; + + @Column(name = "aggregate_type", length = 64, updatable = false) + private String aggregateType; + + @Column(name = "aggregate_sequence", updatable = false) + private Long aggregateSequence; + + @Column(name = "event_index", updatable = false) + private Integer eventIndex; + + @Column(name = "partition_key", length = 64, updatable = false) + private String partitionKey; + + @Column(name = "envelope_bytes", updatable = false) + private byte[] envelopeBytes; + + @Column(name = "content_type", length = 96, updatable = false) + private String contentType; + + @Column(name = "schema_set_hash", length = 64, updatable = false) + private String schemaSetHash; + + @Column(name = "envelope_sha256", length = 64, updatable = false) + private String envelopeSha256; + + @Column(name = "envelope_schema_hash", length = 64, updatable = false) + private String envelopeSchemaHash; + + @Column(name = "payload_schema_hash", length = 64, updatable = false) + private String payloadSchemaHash; + + @Column(name = "contract_catalog_revision", length = 96, updatable = false) + private String contractCatalogRevision; + + @Column(name = "destination_binding_revision", length = 96, updatable = false) + private String destinationBindingRevision; + + @Column(name = "causation_id", length = 128, updatable = false) + private String causationId; + /** JPA no-arg constructor. */ public OutboxEventEntity() {} @@ -134,4 +189,148 @@ public class OutboxEventEntity { public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; } + + public String getContractId() { + return contractId; + } + + public void setContractId(String contractId) { + this.contractId = contractId; + } + + public Integer getEnvelopeVersion() { + return envelopeVersion; + } + + public void setEnvelopeVersion(Integer envelopeVersion) { + this.envelopeVersion = envelopeVersion; + } + + public Integer getPayloadVersion() { + return payloadVersion; + } + + public void setPayloadVersion(Integer payloadVersion) { + this.payloadVersion = payloadVersion; + } + + public String getLogicalDestination() { + return logicalDestination; + } + + public void setLogicalDestination(String logicalDestination) { + this.logicalDestination = logicalDestination; + } + + public String getTenantScope() { + return tenantScope; + } + + public void setTenantScope(String tenantScope) { + this.tenantScope = tenantScope; + } + + public String getAggregateType() { + return aggregateType; + } + + public void setAggregateType(String aggregateType) { + this.aggregateType = aggregateType; + } + + public Long getAggregateSequence() { + return aggregateSequence; + } + + public void setAggregateSequence(Long aggregateSequence) { + this.aggregateSequence = aggregateSequence; + } + + public Integer getEventIndex() { + return eventIndex; + } + + public void setEventIndex(Integer eventIndex) { + this.eventIndex = eventIndex; + } + + public String getPartitionKey() { + return partitionKey; + } + + public void setPartitionKey(String partitionKey) { + this.partitionKey = partitionKey; + } + + public byte[] getEnvelopeBytes() { + return envelopeBytes == null ? null : envelopeBytes.clone(); + } + + public void setEnvelopeBytes(byte[] envelopeBytes) { + this.envelopeBytes = envelopeBytes == null ? null : envelopeBytes.clone(); + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getSchemaSetHash() { + return schemaSetHash; + } + + public void setSchemaSetHash(String schemaSetHash) { + this.schemaSetHash = schemaSetHash; + } + + public String getEnvelopeSha256() { + return envelopeSha256; + } + + public void setEnvelopeSha256(String envelopeSha256) { + this.envelopeSha256 = envelopeSha256; + } + + public String getEnvelopeSchemaHash() { + return envelopeSchemaHash; + } + + public void setEnvelopeSchemaHash(String envelopeSchemaHash) { + this.envelopeSchemaHash = envelopeSchemaHash; + } + + public String getPayloadSchemaHash() { + return payloadSchemaHash; + } + + public void setPayloadSchemaHash(String payloadSchemaHash) { + this.payloadSchemaHash = payloadSchemaHash; + } + + public String getContractCatalogRevision() { + return contractCatalogRevision; + } + + public void setContractCatalogRevision(String contractCatalogRevision) { + this.contractCatalogRevision = contractCatalogRevision; + } + + public String getDestinationBindingRevision() { + return destinationBindingRevision; + } + + public void setDestinationBindingRevision(String destinationBindingRevision) { + this.destinationBindingRevision = destinationBindingRevision; + } + + public String getCausationId() { + return causationId; + } + + public void setCausationId(String causationId) { + this.causationId = causationId; + } } diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V13__outbox_canonical_transport_compatibility.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V13__outbox_canonical_transport_compatibility.sql new file mode 100644 index 00000000..f00b50b8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V13__outbox_canonical_transport_compatibility.sql @@ -0,0 +1,112 @@ +-- MSG-015 transport-only cutover: preserve canonical messaging metadata in the existing +-- legacy outbox_event authority. This migration is additive for old rows. POLLING_V2 is not +-- activated here and the legacy status/claim columns remain authoritative. + +ALTER TABLE outbox_event + ALTER COLUMN event_id TYPE varchar(96), + ALTER COLUMN correlation_id TYPE varchar(128); + +ALTER TABLE outbox_event + ADD COLUMN contract_id varchar(160), + ADD COLUMN envelope_version integer, + ADD COLUMN payload_version integer, + ADD COLUMN logical_destination varchar(96), + ADD COLUMN tenant_scope varchar(96), + ADD COLUMN aggregate_type varchar(64), + ADD COLUMN aggregate_sequence bigint, + ADD COLUMN event_index integer, + ADD COLUMN partition_key char(64), + ADD COLUMN envelope_bytes bytea, + ADD COLUMN content_type varchar(96), + ADD COLUMN schema_set_hash char(64), + ADD COLUMN envelope_sha256 char(64), + ADD COLUMN envelope_schema_hash char(64), + ADD COLUMN payload_schema_hash char(64), + ADD COLUMN contract_catalog_revision varchar(96), + ADD COLUMN destination_binding_revision varchar(96), + ADD COLUMN causation_id varchar(128); + +ALTER TABLE outbox_event + ADD CONSTRAINT ck_outbox_event_canonical_shape CHECK ( + ( + envelope_version IS NULL + AND contract_id IS NULL + AND payload_version IS NULL + AND logical_destination IS NULL + AND tenant_scope IS NULL + AND aggregate_type IS NULL + AND aggregate_sequence IS NULL + AND event_index IS NULL + AND partition_key IS NULL + AND envelope_bytes IS NULL + AND content_type IS NULL + AND schema_set_hash IS NULL + AND envelope_sha256 IS NULL + AND envelope_schema_hash IS NULL + AND payload_schema_hash IS NULL + AND contract_catalog_revision IS NULL + AND destination_binding_revision IS NULL + AND causation_id IS NULL + ) + OR + ( + envelope_version IS NOT NULL + AND contract_id IS NOT NULL + AND payload_version IS NOT NULL + AND logical_destination IS NOT NULL + AND tenant_scope IS NOT NULL + AND aggregate_type IS NOT NULL + AND aggregate_sequence IS NOT NULL + AND event_index IS NOT NULL + AND partition_key IS NOT NULL + AND envelope_bytes IS NOT NULL + AND content_type IS NOT NULL + AND schema_set_hash IS NOT NULL + AND envelope_sha256 IS NOT NULL + AND envelope_schema_hash IS NOT NULL + AND payload_schema_hash IS NOT NULL + AND contract_catalog_revision IS NOT NULL + AND destination_binding_revision IS NOT NULL + ) + ), + ADD CONSTRAINT ck_outbox_event_canonical_versions CHECK ( + envelope_version IS NULL + OR ( + envelope_version > 0 + AND payload_version > 0 + AND aggregate_sequence > 0 + AND event_index >= 0 + ) + ), + ADD CONSTRAINT ck_outbox_event_canonical_partition_key CHECK ( + partition_key IS NULL OR partition_key ~ '^[0-9a-f]{64}$' + ), + ADD CONSTRAINT ck_outbox_event_canonical_hashes CHECK ( + schema_set_hash IS NULL + OR ( + schema_set_hash ~ '^[0-9a-f]{64}$' + AND envelope_sha256 ~ '^[0-9a-f]{64}$' + AND envelope_schema_hash ~ '^[0-9a-f]{64}$' + AND payload_schema_hash ~ '^[0-9a-f]{64}$' + ) + ), + ADD CONSTRAINT ck_outbox_event_canonical_envelope_bytes CHECK ( + envelope_bytes IS NULL OR octet_length(envelope_bytes) > 0 + ), + ADD CONSTRAINT ck_outbox_event_canonical_required_text CHECK ( + contract_id IS NULL + OR ( + btrim(contract_id) <> '' + AND btrim(logical_destination) <> '' + AND btrim(tenant_scope) <> '' + AND btrim(aggregate_type) <> '' + AND btrim(content_type) <> '' + AND btrim(contract_catalog_revision) <> '' + AND btrim(destination_binding_revision) <> '' + ) + ); + +COMMENT ON COLUMN outbox_event.envelope_bytes IS + 'Exact canonical ValidatedIntegrationEvent envelope bytes. Never reconstructed from payload.'; +COMMENT ON COLUMN outbox_event.payload IS + 'Legacy compatibility text projection. Canonical authority, when present, is envelope_bytes.'; diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java index 0697bcbe..0bf0bfcb 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java @@ -3,10 +3,14 @@ package dev.caskeleton.adapter.outbound.persistence.readiness; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.nio.charset.StandardCharsets; import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import org.flywaydb.core.Flyway; @@ -44,7 +48,7 @@ class PostgreSqlMigrationIntegrationTest { .migrate(); assertThat(appliedVersions(postgres, "flyway_schema_history")) - .containsExactly("1", "3", "4", "5", "6"); + .containsExactly("1", "3", "4", "5", "6", "9", "10", "11", "12", "13"); Flyway coreStream = Flyway.configure() @@ -59,7 +63,7 @@ class PostgreSqlMigrationIntegrationTest { coreStream.baseline(); coreStream.migrate(); - assertThat(appliedVersions(postgres, "flyway_jpa_core_history")).containsExactly("0", "1"); + assertThat(appliedVersions(postgres, "flyway_jpa_core_history")).containsExactly("0", "1", "2"); try (Connection connection = postgres.connection(); Statement statement = connection.createStatement(); ResultSet result = @@ -86,7 +90,7 @@ class PostgreSqlMigrationIntegrationTest { .load() .migrate(); - assertThat(appliedVersions(fresh, "flyway_jpa_core_history")).containsExactly("1"); + assertThat(appliedVersions(fresh, "flyway_jpa_core_history")).containsExactly("1", "2"); assertThat( singleValue( fresh, @@ -197,6 +201,154 @@ class PostgreSqlMigrationIntegrationTest { } } + @Test + void outboxCanonicalCompatibilityMigrationPreservesLegacyAndExactEnvelopeBytes() + throws Exception { + try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) { + Flyway.configure() + .dataSource(database.dataSource()) + .locations("classpath:db/migration/postgresql") + .table("flyway_schema_history") + .load() + .migrate(); + + assertColumn(database, "event_id", "character varying", 96); + assertColumn(database, "correlation_id", "character varying", 128); + assertColumn(database, "envelope_bytes", "bytea", null); + assertColumn(database, "tenant_scope", "character varying", 96); + + database.execute( + """ + insert into outbox_event ( + event_id, aggregate_id, event_type, payload, occurred_at, + status, attempt_count, next_attempt_at, correlation_id, idempotency_key + ) values ( + 'legacy-1', 'aggregate-1', 'LegacyEvent', '{}', clock_timestamp(), + 'PENDING', 0, clock_timestamp(), 'correlation-1', 'legacy-1' + ) + """); + + assertThatThrownBy( + () -> + database.execute( + """ + insert into outbox_event ( + event_id, aggregate_id, event_type, payload, occurred_at, + status, attempt_count, next_attempt_at, correlation_id, idempotency_key, + envelope_version + ) values ( + 'partial-1', 'aggregate-1', 'PartialEvent', '{}', clock_timestamp(), + 'PENDING', 0, clock_timestamp(), 'correlation-1', 'partial-1', + 1 + ) + """)) + .isInstanceOf(java.sql.SQLException.class) + .hasMessageContaining("ck_outbox_event_canonical_shape"); + + byte[] exactEnvelope = + "{\"event\":\"canonical\",\"value\":\"한글\"}".getBytes(StandardCharsets.UTF_8); + insertCanonical(database, exactEnvelope); + + try (Connection connection = database.connection(); + PreparedStatement statement = + connection.prepareStatement( + """ + select envelope_bytes, tenant_scope, contract_id, aggregate_sequence, event_index + from outbox_event + where event_id = 'canonical-1' + """); + ResultSet result = statement.executeQuery()) { + assertThat(result.next()).isTrue(); + assertThat(result.getBytes("envelope_bytes")).containsExactly(exactEnvelope); + assertThat(result.getString("tenant_scope")).isEqualTo("tenant-a"); + assertThat(result.getString("contract_id")).isEqualTo("portfolio.work-log-changed"); + assertThat(result.getLong("aggregate_sequence")).isEqualTo(7L); + assertThat(result.getInt("event_index")).isEqualTo(1); + } + } + } + + private static void insertCanonical(PostgreSqlReadinessSupport database, byte[] exactEnvelope) + throws Exception { + try (Connection connection = database.connection(); + PreparedStatement statement = + connection.prepareStatement( + """ + insert into outbox_event ( + event_id, aggregate_id, event_type, payload, occurred_at, + status, attempt_count, next_attempt_at, correlation_id, idempotency_key, + contract_id, envelope_version, payload_version, logical_destination, + tenant_scope, aggregate_type, aggregate_sequence, event_index, + partition_key, envelope_bytes, content_type, schema_set_hash, + envelope_sha256, envelope_schema_hash, payload_schema_hash, + contract_catalog_revision, destination_binding_revision, causation_id + ) values ( + ?, ?, ?, ?, ?, 'PENDING', 0, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + """)) { + String hexA = "a".repeat(64); + String hexB = "b".repeat(64); + String hexC = "c".repeat(64); + String hexD = "d".repeat(64); + String partitionKey = "1".repeat(64); + OffsetDateTime occurredAt = OffsetDateTime.of(2026, 9, 18, 3, 0, 0, 0, ZoneOffset.UTC); + int index = 1; + statement.setString(index++, "canonical-1"); + statement.setString(index++, "work-log-42"); + statement.setString(index++, "portfolio.work-log-changed"); + statement.setString(index++, new String(exactEnvelope, StandardCharsets.UTF_8)); + statement.setObject(index++, occurredAt); + statement.setObject(index++, occurredAt); + statement.setString(index++, "correlation-1"); + statement.setString(index++, "canonical-1"); + statement.setString(index++, "portfolio.work-log-changed"); + statement.setInt(index++, 1); + statement.setInt(index++, 3); + statement.setString(index++, "portfolio-events"); + statement.setString(index++, "tenant-a"); + statement.setString(index++, "work-log"); + statement.setLong(index++, 7L); + statement.setInt(index++, 1); + statement.setString(index++, partitionKey); + statement.setBytes(index++, exactEnvelope); + statement.setString(index++, "application/json"); + statement.setString(index++, hexA); + statement.setString(index++, hexB); + statement.setString(index++, hexC); + statement.setString(index++, hexD); + statement.setString(index++, "catalog-2026-09"); + statement.setString(index++, "binding-2026-09"); + statement.setString(index, "01994e11-4d88-7000-8000-000000000001"); + assertThat(statement.executeUpdate()).isEqualTo(1); + } + } + + private static void assertColumn( + PostgreSqlReadinessSupport database, String columnName, String dataType, Integer length) + throws Exception { + try (Connection connection = database.connection(); + PreparedStatement statement = + connection.prepareStatement( + """ + select data_type, character_maximum_length + from information_schema.columns + where table_schema = 'public' + and table_name = 'outbox_event' + and column_name = ? + """)) { + statement.setString(1, columnName); + try (ResultSet result = statement.executeQuery()) { + assertThat(result.next()).as("column %s exists", columnName).isTrue(); + assertThat(result.getString("data_type")).isEqualTo(dataType); + if (length != null) { + assertThat(result.getInt("character_maximum_length")).isEqualTo(length); + } + } + } + } + private static List appliedVersions( PostgreSqlReadinessSupport database, String historyTable) throws Exception { List versions = new ArrayList<>(); diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlReadinessSupport.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlReadinessSupport.java index de835c3d..532e9aee 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlReadinessSupport.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlReadinessSupport.java @@ -116,6 +116,11 @@ final class PostgreSqlReadinessSupport implements AutoCloseable { } } + static String dockerHost() { + assertDockerAvailable(); + return DockerClientFactory.instance().dockerHostIpAddress(); + } + HikariDataSource dataSource() { return dataSource; } diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java index a77c76ac..1ba8ddfc 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java @@ -23,7 +23,7 @@ class PostgreSqlSecurityBaselineIntegrationTest { static void startPostgreSql() throws Exception { PostgreSqlReadinessSupport.assertDockerAvailable(); postgres = PostgreSqlReadinessSupport.start(); - trustedTls = PostgreSqlTlsMaterial.generate(false); + trustedTls = PostgreSqlTlsMaterial.generate(false, PostgreSqlReadinessSupport.dockerHost()); tlsPostgres = PostgreSqlReadinessSupport.startTls(trustedTls); } @@ -120,31 +120,37 @@ class PostgreSqlSecurityBaselineIntegrationTest { @Test void verifyFullAcceptsTrustedHostAndRejectsHostnameMismatchAndUntrustedCertificate() throws Exception { - try (Connection connection = - tlsPostgres.tlsConnection("localhost", trustedTls.caCertificate()); + String dockerHost = PostgreSqlReadinessSupport.dockerHost(); + try (Connection connection = tlsPostgres.tlsConnection(dockerHost, trustedTls.caCertificate()); Statement statement = connection.createStatement()) { assertThat(singleValue(statement, "select ssl from pg_stat_ssl where pid = pg_backend_pid()")) .isEqualTo("t"); } - assertThatThrownBy( - () -> tlsPostgres.tlsConnection("127.0.0.1", trustedTls.caCertificate()).close()) - .isInstanceOf(SQLException.class); - - try (PostgreSqlTlsMaterial untrustedTls = PostgreSqlTlsMaterial.generate(false)) { + try (PostgreSqlTlsMaterial wrongHostTls = + PostgreSqlTlsMaterial.generate(false, "jpa-readiness.invalid"); + PostgreSqlReadinessSupport wrongHostServer = + PostgreSqlReadinessSupport.startTls(wrongHostTls)) { assertThatThrownBy( - () -> tlsPostgres.tlsConnection("localhost", untrustedTls.caCertificate()).close()) + () -> wrongHostServer.tlsConnection(dockerHost, wrongHostTls.caCertificate()).close()) + .isInstanceOf(SQLException.class); + } + + try (PostgreSqlTlsMaterial untrustedTls = PostgreSqlTlsMaterial.generate(false, dockerHost)) { + assertThatThrownBy( + () -> tlsPostgres.tlsConnection(dockerHost, untrustedTls.caCertificate()).close()) .isInstanceOf(SQLException.class); } } @Test void verifyFullRejectsAnExpiredServerCertificate() throws Exception { - try (PostgreSqlTlsMaterial expiredTls = PostgreSqlTlsMaterial.generate(true); + String dockerHost = PostgreSqlReadinessSupport.dockerHost(); + try (PostgreSqlTlsMaterial expiredTls = PostgreSqlTlsMaterial.generate(true, dockerHost); PostgreSqlReadinessSupport expiredServer = PostgreSqlReadinessSupport.startTls(expiredTls)) { assertThatThrownBy( - () -> expiredServer.tlsConnection("localhost", expiredTls.caCertificate()).close()) + () -> expiredServer.tlsConnection(dockerHost, expiredTls.caCertificate()).close()) .isInstanceOf(SQLException.class); } } diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java index 06ff197b..87995e09 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java @@ -24,6 +24,11 @@ final class PostgreSqlTlsMaterial implements AutoCloseable { } static PostgreSqlTlsMaterial generate(boolean expired) throws IOException, InterruptedException { + return generate(expired, "localhost"); + } + + static PostgreSqlTlsMaterial generate(boolean expired, String certificateHost) + throws IOException, InterruptedException { Path directory = Files.createTempDirectory("jpa-postgresql-tls-"); Path caKey = directory.resolve("ca.key"); Path caCertificate = directory.resolve("ca.crt"); @@ -59,9 +64,9 @@ final class PostgreSqlTlsMaterial implements AutoCloseable { "-out", serverRequest.toString(), "-subj", - "/CN=localhost", + "/CN=" + certificateHost, "-addext", - "subjectAltName=DNS:localhost")); + "subjectAltName=" + subjectAlternativeName(certificateHost))); run( List.of( "openssl", @@ -96,6 +101,11 @@ final class PostgreSqlTlsMaterial implements AutoCloseable { return serverPrivateKey; } + private static String subjectAlternativeName(String host) { + boolean numericAddress = host.contains(":") || host.matches("\\d{1,3}(?:\\.\\d{1,3}){3}"); + return (numericAddress ? "IP:" : "DNS:") + host; + } + private static void run(List command) throws IOException, InterruptedException { Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/CanonicalOutboxAppendAdapterActivationTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/CanonicalOutboxAppendAdapterActivationTest.java new file mode 100644 index 00000000..6c066e59 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/CanonicalOutboxAppendAdapterActivationTest.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import dev.caskeleton.application.outbox.OutboxAppendPort; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +class CanonicalOutboxAppendAdapterActivationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(TestConfig.class); + + @Test + void canonicalFlagAloneDoesNotExposeAppendWhenOutboxCapabilityIsOff() { + runner + .withPropertyValues( + "ca-skeleton.outbox.enabled=false", + "ca-skeleton.outbox.canonical-transport-enabled=true") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(OutboxAppendPort.class); + }); + } + + @Test + void bothOutboxAndCanonicalFlagsExposeExactlyOneCanonicalAppendPort() { + runner + .withPropertyValues( + "ca-skeleton.outbox.enabled=true", + "ca-skeleton.outbox.canonical-transport-enabled=true") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(OutboxAppendPort.class); + assertThat(context.getBean(OutboxAppendPort.class)) + .isInstanceOf(CanonicalOutboxAppendAdapter.class); + }); + } + + @Test + void defaultOffDoesNotExposeCanonicalAppendPort() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(OutboxAppendPort.class); + }); + } + + @Configuration(proxyBeanMethods = false) + @Import(CanonicalOutboxAppendAdapter.class) + static class TestConfig { + + @Bean + OutboxEventJpaRepository outboxEventJpaRepository() { + return mock(OutboxEventJpaRepository.class); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/CanonicalOutboxAppendAdapterTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/CanonicalOutboxAppendAdapterTest.java new file mode 100644 index 00000000..d54b4a49 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/CanonicalOutboxAppendAdapterTest.java @@ -0,0 +1,114 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentCaptor.forClass; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import dev.caskeleton.application.messaging.contract.ContractId; +import dev.caskeleton.application.messaging.contract.LogicalDestinationId; +import dev.caskeleton.application.messaging.contract.Sha256; +import dev.caskeleton.application.messaging.event.AggregateIdentity; +import dev.caskeleton.application.messaging.event.AggregateOrder; +import dev.caskeleton.application.messaging.event.EventId; +import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class CanonicalOutboxAppendAdapterTest { + + private static final Instant OCCURRED_AT = Instant.parse("2026-09-18T03:00:00Z"); + + private final OutboxEventJpaRepository repository = mock(OutboxEventJpaRepository.class); + private final CanonicalOutboxAppendAdapter adapter = new CanonicalOutboxAppendAdapter(repository); + + @Test + void appendPersistsExactCanonicalMetadataAndLegacyCompatibilityProjection() { + byte[] exactEnvelope = + "{\"event\":\"canonical\",\"value\":\"한글\"}".getBytes(StandardCharsets.UTF_8); + ValidatedIntegrationEvent event = event(exactEnvelope); + + adapter.append(event); + + ArgumentCaptor saved = forClass(OutboxEventEntity.class); + verify(repository).save(saved.capture()); + OutboxEventEntity entity = saved.getValue(); + + assertThat(entity.getEventId()).isEqualTo("01994e11-4d88-7000-8000-000000000001"); + assertThat(entity.getEventType()).isEqualTo("portfolio.worklog.reserved"); + assertThat(entity.getAggregateId()).isEqualTo("worklog-42"); + assertThat(entity.getPayload()).isEqualTo(new String(exactEnvelope, StandardCharsets.UTF_8)); + assertThat(entity.getOccurredAt()).isEqualTo(OCCURRED_AT); + assertThat(entity.getStatus()).isEqualTo("PENDING"); + assertThat(entity.getAttemptCount()).isZero(); + assertThat(entity.getNextAttemptAt()).isEqualTo(OCCURRED_AT); + assertThat(entity.getCorrelationId()).isEqualTo("corr-1"); + assertThat(entity.getIdempotencyKey()).isEqualTo(event.eventId().value()); + + assertThat(entity.getContractId()).isEqualTo("portfolio.worklog.reserved"); + assertThat(entity.getEnvelopeVersion()).isEqualTo(1); + assertThat(entity.getPayloadVersion()).isEqualTo(3); + assertThat(entity.getLogicalDestination()).isEqualTo("portfolio-domain-events"); + assertThat(entity.getTenantScope()).isEqualTo("tenant-a"); + assertThat(entity.getAggregateType()).isEqualTo("worklog"); + assertThat(entity.getAggregateSequence()).isEqualTo(17L); + assertThat(entity.getEventIndex()).isEqualTo(2); + assertThat(entity.getPartitionKey()).isEqualTo("a".repeat(64)); + assertThat(entity.getEnvelopeBytes()).containsExactly(exactEnvelope); + assertThat(entity.getContentType()).isEqualTo("application/json"); + assertThat(entity.getSchemaSetHash()).isEqualTo("1".repeat(64)); + assertThat(entity.getEnvelopeSha256()).isEqualTo("2".repeat(64)); + assertThat(entity.getEnvelopeSchemaHash()).isEqualTo("3".repeat(64)); + assertThat(entity.getPayloadSchemaHash()).isEqualTo("4".repeat(64)); + assertThat(entity.getContractCatalogRevision()).isEqualTo("catalog-r1"); + assertThat(entity.getDestinationBindingRevision()).isEqualTo("binding-r1"); + assertThat(entity.getCausationId()).isEqualTo("01994e11-4d88-7000-8000-000000000000"); + } + + @Test + void invalidUtf8EnvelopeIsRejectedBeforeRepositoryWrite() { + ValidatedIntegrationEvent event = event(new byte[] {(byte) 0xC3, (byte) 0x28}); + + assertThatThrownBy(() -> adapter.append(event)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("UTF-8") + .hasMessageContaining("compatibility"); + + verifyNoInteractions(repository); + } + + private static ValidatedIntegrationEvent event(byte[] envelopeBytes) { + String partition = "a".repeat(64); + return new ValidatedIntegrationEvent( + new EventId("01994e11-4d88-7000-8000-000000000001"), + new ContractId("portfolio.worklog.reserved"), + 1, + 3, + new LogicalDestinationId("portfolio-domain-events"), + new AggregateIdentity("tenant-a", "worklog", "worklog-42"), + new AggregateOrder(17, 2), + OCCURRED_AT, + "corr-1", + Optional.of("01994e11-4d88-7000-8000-000000000000"), + partition, + partition.getBytes(StandardCharsets.US_ASCII), + envelopeBytes, + "application/json", + sha("1"), + sha("2"), + sha("3"), + sha("4"), + "catalog-r1", + "binding-r1"); + } + + private static Sha256 sha(String nibble) { + return new Sha256(java.util.HexFormat.of().parseHex(nibble.repeat(64))); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java index 60b77bed..ff0bfe09 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java @@ -11,6 +11,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import dev.caskeleton.application.outbox.CanonicalClaimedOutboxEvent; +import dev.caskeleton.application.outbox.ClaimedOutboxEvent; import dev.caskeleton.application.outbox.NewOutboxEvent; import dev.caskeleton.application.outbox.OutboxEvent; import dev.caskeleton.application.outbox.OutboxEventStatus; @@ -66,6 +68,35 @@ class OutboxStoreAdapterTest { return e; } + private static OutboxEventEntity canonicalPendingEntity(String eventId) { + OutboxEventEntity e = pendingEntity(eventId); + byte[] envelope = "{\"wire\":\"exact\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8); + e.setAggregateId("worklog-42"); + e.setEventType("portfolio.worklog.reserved"); + e.setPayload(new String(envelope, java.nio.charset.StandardCharsets.UTF_8)); + e.setCorrelationId("corr-1"); + e.setIdempotencyKey(eventId); + e.setContractId("portfolio.worklog.reserved"); + e.setEnvelopeVersion(1); + e.setPayloadVersion(3); + e.setLogicalDestination("portfolio-domain-events"); + e.setTenantScope("tenant-a"); + e.setAggregateType("worklog"); + e.setAggregateSequence(17L); + e.setEventIndex(2); + e.setPartitionKey("a".repeat(64)); + e.setEnvelopeBytes(envelope); + e.setContentType("application/json"); + e.setSchemaSetHash("1".repeat(64)); + e.setEnvelopeSha256("2".repeat(64)); + e.setEnvelopeSchemaHash("3".repeat(64)); + e.setPayloadSchemaHash("4".repeat(64)); + e.setContractCatalogRevision("catalog-r1"); + e.setDestinationBindingRevision("binding-r1"); + e.setCausationId("01994e11-4d88-7000-8000-000000000000"); + return e; + } + // ---- append ---- @Test @@ -97,7 +128,7 @@ class OutboxStoreAdapterTest { OutboxEventEntity entity = pendingEntity("evt-002"); when(claimRepo.claimEligible(eq(NOW), eq(2))).thenReturn(List.of(entity)); - List claimed = adapter.claimBatch(2, NOW, IN_FLIGHT_TIMEOUT); + List claimed = adapter.claimBatch(2, NOW, IN_FLIGHT_TIMEOUT); // status and attemptCount updated on entity assertThat(entity.getStatus()).isEqualTo("IN_FLIGHT"); @@ -106,12 +137,46 @@ class OutboxStoreAdapterTest { // returned OutboxEvent reflects post-transition state assertThat(claimed).hasSize(1); - OutboxEvent result = claimed.get(0); + OutboxEvent result = (OutboxEvent) claimed.get(0); assertThat(result.eventId()).isEqualTo("evt-002"); assertThat(result.status()).isEqualTo(OutboxEventStatus.IN_FLIGHT); assertThat(result.attemptCount()).isEqualTo(1); } + @Test + void claimBatchMapsCompleteCanonicalMetadataToCanonicalClaim() { + OutboxEventEntity entity = canonicalPendingEntity("01994e11-4d88-7000-8000-000000000001"); + when(claimRepo.claimEligible(eq(NOW), eq(2))).thenReturn(List.of(entity)); + + List claimed = adapter.claimBatch(2, NOW, IN_FLIGHT_TIMEOUT); + + assertThat(claimed).hasSize(1); + assertThat(claimed.getFirst()).isInstanceOf(CanonicalClaimedOutboxEvent.class); + CanonicalClaimedOutboxEvent result = (CanonicalClaimedOutboxEvent) claimed.getFirst(); + assertThat(result.event().eventId().value()).isEqualTo("01994e11-4d88-7000-8000-000000000001"); + assertThat(result.event().contractId().value()).isEqualTo("portfolio.worklog.reserved"); + assertThat(result.event().aggregate().tenantScope()).isEqualTo("tenant-a"); + assertThat(result.event().aggregate().aggregateType()).isEqualTo("worklog"); + assertThat(result.event().order().sequence()).isEqualTo(17); + assertThat(result.event().order().eventIndex()).isEqualTo(2); + assertThat(result.event().envelopeBytes()) + .containsExactly("{\"wire\":\"exact\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertThat(result.status()).isEqualTo(OutboxEventStatus.IN_FLIGHT); + assertThat(result.attemptCount()).isEqualTo(1); + } + + @Test + void claimBatchRejectsPartialCanonicalMetadataInsteadOfDowngradingToLegacy() { + OutboxEventEntity entity = pendingEntity("partial-canonical"); + entity.setEnvelopeVersion(1); + when(claimRepo.claimEligible(eq(NOW), eq(1))).thenReturn(List.of(entity)); + + assertThatThrownBy(() -> adapter.claimBatch(1, NOW, IN_FLIGHT_TIMEOUT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("partial canonical") + .hasMessageContaining("partial-canonical"); + } + @Test void claimBatchReturnsEmptyWhenRepoReturnsNothing() { when(claimRepo.claimEligible(any(), anyInt())).thenReturn(List.of()); @@ -145,12 +210,12 @@ class OutboxStoreAdapterTest { when(claimRepo.claimEligible(eq(NOW), eq(10))).thenReturn(List.of(head, tail)); - List claimed = adapter.claimBatch(10, NOW, IN_FLIGHT_TIMEOUT); + List claimed = adapter.claimBatch(10, NOW, IN_FLIGHT_TIMEOUT); // Adapter must return both rows — no in-memory FIFO filtering assertThat(claimed) .hasSize(2) - .extracting(OutboxEvent::eventId) + .extracting(ClaimedOutboxEvent::eventId) .containsExactly("evt-head", "evt-tail"); } diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaDocumentationContractTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaDocumentationContractTest.java index 95783f52..95c1dada 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaDocumentationContractTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaDocumentationContractTest.java @@ -2,7 +2,6 @@ package dev.caskeleton.adapter.outbound.persistence.testkit; import static org.assertj.core.api.Assertions.assertThat; -import dev.caskeleton.adapter.outbound.persistence.testkit.release.JpaReleaseGate; import dev.caskeleton.adapter.outbound.persistence.testkit.release.JpaReleaseManifest; import java.io.IOException; import java.io.UncheckedIOException; @@ -47,7 +46,7 @@ class JpaDocumentationContractTest { void everyRegistryGateIsNamedInTheSupportMatrix() { String matrix = read("docs/jpa/support-matrix.md"); - for (String gate : JpaReleaseGate.required()) { + for (String gate : manifest.gates()) { assertThat(matrix).as("gate %s is declared but undocumented", gate).contains(gate); } } diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseManifestTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseManifestTest.java index fd6cad36..88234fa1 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseManifestTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseManifestTest.java @@ -3,7 +3,6 @@ package dev.caskeleton.adapter.outbound.persistence.testkit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import dev.caskeleton.adapter.outbound.persistence.testkit.release.JpaReleaseGate; import dev.caskeleton.adapter.outbound.persistence.testkit.release.JpaReleaseManifest; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -41,8 +40,7 @@ class JpaReleaseManifestTest { @Test @DisplayName("every required gate is declared and names a task") void everyRequiredGateIsDeclaredAndNamesATask() { - assertThat(manifest.declaresEveryRequiredGate()).isTrue(); - for (String gate : JpaReleaseGate.required()) { + for (String gate : manifest.gates()) { assertThat(manifest.taskFor(gate)) .as("gate %s must name the task that produces its evidence", gate) .startsWith(":"); diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseRenderingTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseRenderingTest.java index ae0df9b1..93ebab24 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseRenderingTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseRenderingTest.java @@ -84,6 +84,22 @@ class JpaReleaseRenderingTest { .isNotEqualTo(manifest.gates()); } + @Test + @DisplayName("the release workflow invokes the registry-driven aggregate instead of duplicating gate tasks") + void theReleaseWorkflowUsesTheRegistryDrivenAggregate() { + String flowed = releaseWorkflow.replaceAll("[\t\n\r ]+", " "); + + assertThat(flowed).contains("./gradlew jpaReleaseQualification"); + for (String task : manifest.gateTasks().values()) { + assertThat(releaseWorkflow) + .as("CI must not own the release gate task inventory: %s", task) + .doesNotContain(task); + } + assertThat(releaseWorkflow) + .as("pool pressure remains a nightly lane unless the release registry promotes it") + .doesNotContain(":adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest"); + } + @Test @DisplayName("the release workflow fans out to exactly the Stable majors") void theReleaseWorkflowFansOutToExactlyTheStableMajors() { diff --git a/src/adapter/outbound/persistence-jpa/src/testFixtures/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java b/src/adapter/outbound/persistence-jpa/src/testFixtures/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java deleted file mode 100644 index dad7c64f..00000000 --- a/src/adapter/outbound/persistence-jpa/src/testFixtures/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java +++ /dev/null @@ -1,53 +0,0 @@ -package dev.caskeleton.adapter.outbound.persistence.testkit.release; - -import java.util.List; - -/** - * The gates a release must satisfy (design §41). - * - *

Each one names a way the platform could pass its tests while being wrong in production: a - * suite that only ran against H2, a runtime with OSIV left on, a deployment where Hibernate can - * still alter the schema, a retry path that re-runs an unknown commit, or a runtime credential that - * can execute DDL. - */ -public final class JpaReleaseGate { - - /** The real database ran the contract suite, not H2. */ - public static final String POSTGRESQL_CONTRACT = "postgresql-contract"; - - /** Completion-unknown failures are never automatically retried. */ - public static final String COMPLETION_UNKNOWN_NO_RETRY = "completion-unknown-no-retry"; - - /** Open Session In View is off in every runtime profile. */ - public static final String OSIV_DISABLED = "osiv-disabled"; - - /** Hibernate validates the schema and never mutates it. */ - public static final String FLYWAY_VALIDATE = "flyway-validate"; - - /** The runtime database role cannot execute DDL. */ - public static final String RUNTIME_ROLE_NO_DDL = "runtime-role-no-ddl"; - - /** - * Collection fetch pagination is bounded in SQL by the Stable provider. - * - *

Renamed off the provider version. It was {@code hibernate-7.4-fetch-pagination} while every - * run of this gate used the version the Boot BOM resolves — 7.1.8.Final — so the evidence was - * labelled with a provider the gate had never executed against. The registry records both the - * tested baseline and the compatibility target; the gate name records neither, because a gate - * that renames itself when a dependency moves is a gate whose history cannot be compared. - */ - public static final String FETCH_PAGINATION = "collection-fetch-pagination"; - - private JpaReleaseGate() {} - - /** Every gate a release must pass. */ - public static List required() { - return List.of( - POSTGRESQL_CONTRACT, - COMPLETION_UNKNOWN_NO_RETRY, - OSIV_DISABLED, - FLYWAY_VALIDATE, - RUNTIME_ROLE_NO_DDL, - FETCH_PAGINATION); - } -} diff --git a/src/adapter/outbound/persistence-jpa/src/testFixtures/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java b/src/adapter/outbound/persistence-jpa/src/testFixtures/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java index 0268f83e..e113da59 100644 --- a/src/adapter/outbound/persistence-jpa/src/testFixtures/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java +++ b/src/adapter/outbound/persistence-jpa/src/testFixtures/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java @@ -147,11 +147,6 @@ public record JpaReleaseManifest( return new JpaReleaseManifest(stable, experimental, gates, tasks, baseline.group(1)); } - /** Whether every required release gate is declared. */ - public boolean declaresEveryRequiredGate() { - return gates.containsAll(JpaReleaseGate.required()); - } - /** The Gradle task that produces a gate's evidence. */ public String taskFor(String gate) { String task = gateTasks.get(gate); diff --git a/src/adapter/outbound/persistence-mongo/build.gradle b/src/adapter/outbound/persistence-mongo/build.gradle index ac538637..a7046eb6 100644 --- a/src/adapter/outbound/persistence-mongo/build.gradle +++ b/src/adapter/outbound/persistence-mongo/build.gradle @@ -2,6 +2,8 @@ plugins { id 'ca.spring-library' id 'ca.spring-config' id 'java-test-fixtures' + id 'ca.auxiliary-source-set' + id 'ca.mongo-verification' } // Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001. Applied here rather than @@ -61,7 +63,7 @@ dependencies { // variant whether or not anybody asks for them. ADR-BUILD-001 records that as an accepted loss — // unconsumed fixtures are unconsumed, and a leaf that genuinely must not offer them needs a module, // not a third convention. -strictTestLanes { +auxiliarySourceSets { sourceSet('mongoPerformanceTest') { compilesAgainst 'main', 'testFixtures' } } @@ -86,18 +88,6 @@ dependencies { testFixturesImplementation libs.toxiproxy.java } -// Pinned server images. The design forbids `latest` for a certification lane (Task 44): a mutable -// tag makes a red run unattributable. `-PmongoPrimaryImage=` / `-PmongoCompatibilityImage=` -// override them for a one-off run. -Closure applyMongoImageSelection = { task -> - task.systemProperty 'mongodb.primary.image', - (project.findProperty('mongoPrimaryImage') ?: 'mongo:8.0.16').toString() - task.systemProperty 'mongodb.compatibility.image', - (project.findProperty('mongoCompatibilityImage') ?: 'mongo:7.0.28').toString() - task.systemProperty 'mongodb.toxiproxy.image', - (project.findProperty('mongoToxiproxyImage') ?: 'ghcr.io/shopify/toxiproxy:2.12.0').toString() -} - // Docker-backed lanes are excluded from the default unit run: they fail closed without Docker, and // a `check` that fails on a laptop without Docker teaches people to skip `check`. // @@ -122,33 +112,63 @@ tasks.named('test', Test) { // copied once per lane here and again in four other leaves. strictTestLanes { lane('mongoReplicaSetTest') { + integration() tag = 'mongodb-replicaset' description = 'Single-node replica set contract lane: mapping, atomic write, transaction, ' + 'change stream (design §29).' - customize = { test -> applyMongoImageSelection(test) } + systemProperty 'mongodb.primary.image', + providers.gradleProperty('mongoPrimaryImage').orElse('mongo:8.0.16').get() + systemProperty 'mongodb.compatibility.image', + providers.gradleProperty('mongoCompatibilityImage').orElse('mongo:7.0.28').get() + systemProperty 'mongodb.toxiproxy.image', + providers.gradleProperty('mongoToxiproxyImage').orElse('ghcr.io/shopify/toxiproxy:2.12.0').get() } lane('mongoFailoverTest') { + integration() tag = 'mongodb-failover' description = 'Three-node replica set failover lane: primary kill, partition, unknown ' + 'commit, resume (design §29).' - customize = { test -> applyMongoImageSelection(test) } + systemProperty 'mongodb.primary.image', + providers.gradleProperty('mongoPrimaryImage').orElse('mongo:8.0.16').get() + systemProperty 'mongodb.compatibility.image', + providers.gradleProperty('mongoCompatibilityImage').orElse('mongo:7.0.28').get() + systemProperty 'mongodb.toxiproxy.image', + providers.gradleProperty('mongoToxiproxyImage').orElse('ghcr.io/shopify/toxiproxy:2.12.0').get() } lane('mongoMigrationTest') { + integration() tag = 'mongodb-migration' description = 'Migration lane: empty / N-1 / oldest-supported snapshots, lock, checkpoint ' + 'restart (design §12).' - customize = { test -> applyMongoImageSelection(test) } + systemProperty 'mongodb.primary.image', + providers.gradleProperty('mongoPrimaryImage').orElse('mongo:8.0.16').get() + systemProperty 'mongodb.compatibility.image', + providers.gradleProperty('mongoCompatibilityImage').orElse('mongo:7.0.28').get() + systemProperty 'mongodb.toxiproxy.image', + providers.gradleProperty('mongoToxiproxyImage').orElse('ghcr.io/shopify/toxiproxy:2.12.0').get() } lane('mongoCompatibilityTest') { + integration() tag = 'mongodb-compatibility' description = 'MongoDB 7.0 compatibility and 8.0 primary certification matrix (design §30).' - customize = { test -> applyMongoImageSelection(test) } + systemProperty 'mongodb.primary.image', + providers.gradleProperty('mongoPrimaryImage').orElse('mongo:8.0.16').get() + systemProperty 'mongodb.compatibility.image', + providers.gradleProperty('mongoCompatibilityImage').orElse('mongo:7.0.28').get() + systemProperty 'mongodb.toxiproxy.image', + providers.gradleProperty('mongoToxiproxyImage').orElse('ghcr.io/shopify/toxiproxy:2.12.0').get() } lane('mongoSecurityIntegrationTest') { + integration() tag = 'mongodb-security-integration' description = 'RBAC, TLS, injection and redaction release gate against a real server ' + '(design §26).' - customize = { test -> applyMongoImageSelection(test) } + systemProperty 'mongodb.primary.image', + providers.gradleProperty('mongoPrimaryImage').orElse('mongo:8.0.16').get() + systemProperty 'mongodb.compatibility.image', + providers.gradleProperty('mongoCompatibilityImage').orElse('mongo:7.0.28').get() + systemProperty 'mongodb.toxiproxy.image', + providers.gradleProperty('mongoToxiproxyImage').orElse('ghcr.io/shopify/toxiproxy:2.12.0').get() } lane('mongoStableContractTest') { @@ -160,17 +180,19 @@ strictTestLanes { // Driven by its own source set rather than a tag: for this shape the source set is the // selection, so the convention asks for no tag. lane('mongoPerformanceTest') { + performance() sourceSet = 'mongoPerformanceTest' description = 'Certifies contention, aggregation spill, pagination and pool resource ' + 'bounds (design §29).' - customize = { test -> - applyMongoImageSelection(test) - // Assertions on by default. They defaulted to false, so the lane measured numbers and - // compared them to nothing — a performance gate whose bounds are never evaluated is a - // report, and the release evidence called it a certification. - test.systemProperty 'performance.assertions.enabled', - (project.findProperty('performance.assertions.enabled') ?: 'true').toString() - } + systemProperty 'mongodb.primary.image', + providers.gradleProperty('mongoPrimaryImage').orElse('mongo:8.0.16').get() + systemProperty 'mongodb.compatibility.image', + providers.gradleProperty('mongoCompatibilityImage').orElse('mongo:7.0.28').get() + systemProperty 'mongodb.toxiproxy.image', + providers.gradleProperty('mongoToxiproxyImage').orElse('ghcr.io/shopify/toxiproxy:2.12.0').get() + // Assertions on by default: a performance gate without bounds is only a report. + systemProperty 'performance.assertions.enabled', + providers.gradleProperty('performance.assertions.enabled').orElse('true').get() } } @@ -181,91 +203,9 @@ tasks.named('check') { 'verifyMongoReleaseContractLanes' } -// The tag exclusion above is a claim about two task configurations. This checks the claim against -// what the two tasks actually ran, because the failure it prevents — every hermetic contract test -// executing twice per `check` — is invisible in a green build and only shows up as time. -tasks.register('verifyMongoTestLaneDisjointness') { - group = 'verification' - description = 'Fails when the unit lane and the stable contract lane execute the same test.' - dependsOn 'test', 'mongoStableContractTest' - def unitResults = layout.buildDirectory.dir('test-results/test') - def contractResults = layout.buildDirectory.dir('test-results/mongoStableContractTest') - inputs.dir(unitResults) - inputs.dir(contractResults) - outputs.file(layout.buildDirectory.file('reports/mongo-test-lane-disjointness.txt')) - doLast { - def executed = { java.io.File directory -> - def names = [] as Set - directory.listFiles({ File file -> file.name.endsWith('.xml') } as FileFilter) - ?.each { file -> - new groovy.xml.XmlParser().parse(file).testcase.each { testcase -> - names << "${testcase.@classname}#${testcase.@name}".toString() - } - } - names - } - def unit = executed(unitResults.get().asFile) - def contract = executed(contractResults.get().asFile) - def overlap = unit.intersect(contract) - if (!overlap.isEmpty()) { - throw new GradleException( - "${overlap.size()} tests run in both the unit lane and the stable contract lane, " + - "so `check` executes them twice: ${overlap.take(5)}") - } - def report = outputs.files.singleFile - report.parentFile.mkdirs() - report.text = "unit=${unit.size()} contract=${contract.size()} overlap=0\n" - } -} - -// Splitting the two lanes moved every tagged contract out of `test`, and the release manifest kept -// naming the lane it had left. `MongoReleaseEvidenceVerifier` resolves -// `test-results//TEST-.xml`, so a contract whose class now runs somewhere else -// resolves to a file that will never exist: the Stable gate reports the transaction retry -// invariants as evidence the run failed to produce, for a suite that ran them. -// -// Checked against the XML the lanes wrote rather than against a tag table, because a tag table here -// would be a second copy of the selection above, and the copy is what drifted the first time. -tasks.register('verifyMongoReleaseContractLanes') { - group = 'verification' - description = 'Fails when a blocking release contract names a lane that did not run its class.' - dependsOn 'test', 'mongoStableContractTest' - def manifest = rootProject.file('../src/config/mongodb/release-contracts.json') - def hermeticLanes = ['test', 'mongoStableContractTest'] - def resultsRoot = layout.buildDirectory.dir('test-results') - inputs.file(manifest) - inputs.dir(resultsRoot) - outputs.file(layout.buildDirectory.file('reports/mongo-release-contract-lanes.txt')) - doLast { - def contracts = new groovy.json.JsonSlurper().parse(manifest).contracts - def checked = [] - def wrongLane = [] - contracts.findAll { hermeticLanes.contains(it.task) }.each { contract -> - def results = resultsRoot.get().dir(contract.task).file( - "TEST-${contract.className}.xml").asFile - if (!results.isFile()) { - wrongLane << "${contract.id} names lane '${contract.task}', which did not run " + - "${contract.className}" - return - } - def suite = new groovy.xml.XmlParser().parse(results) - int executed = (suite.@tests as int) - (suite.@skipped as int) - if (executed < contract.minimumExecuted) { - wrongLane << "${contract.id} requires ${contract.minimumExecuted} executed test(s) " + - "in '${contract.task}' and the lane ran ${executed}" - } - checked << contract.id - } - if (!wrongLane.isEmpty()) { - throw new GradleException( - 'the Mongo release manifest points at lanes that cannot produce its evidence: ' + - wrongLane.join('; ')) - } - def report = outputs.files.singleFile - report.parentFile.mkdirs() - report.text = "hermetic release contracts verified: ${checked.join(', ')}\n" - } -} +// The two verification tasks are registered by `ca.mongo-verification`. The leaf owns only +// the semantic lane selection above and the lifecycle dependency below; JSON/JUnit parsing and +// evidence decisions live in typed Java build tooling. // verifyMongoApiSurface — every public type this leaf exposes is a committed decision. diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index 588b8230..9ed68a60 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -3,8 +3,13 @@ plugins { id 'ca.spring-config' id 'org.springframework.boot' id 'ca.config-contract' + id 'ca.auxiliary-source-set' + id 'ca.bootrun-dotenv' } +def contractRegistriesDirectory = rootProject.projectDir.parentFile.toPath() + .resolve('docs/registries').toFile() + // Application entry point. Wires the default runtime module set and runs Spring Boot. // Optional leaves require an explicit registry allowance plus a composition-root dependency. // The infrastructure lane. Testcontainers-backed tests boot a real PostgreSQL, so they belong to a @@ -13,7 +18,7 @@ plugins { // feedback loop starts a database is a leaf nobody runs the fast loop on. This is the same shape // persistence-jpa, web, websocket and objectstorage already use, declared through the repository's // own strictTestLanes DSL rather than by hand. -strictTestLanes { +auxiliarySourceSets { sourceSet('integrationTest') { compilesAgainst 'main' inherits 'implementation', 'compileOnly', 'runtimeOnly', 'annotationProcessor' @@ -21,31 +26,45 @@ strictTestLanes { sourceSet('architectureTest') { compilesAgainst 'main', 'test' inherits 'implementation', 'compileOnly', 'runtimeOnly', 'annotationProcessor' + runtimeFrom 'test' } - sourceSet('bootCompositionTest') { + sourceSet('systemTest') { compilesAgainst 'main', 'test' inherits 'implementation', 'compileOnly', 'runtimeOnly', 'annotationProcessor' - } - lane('integrationTest') { - description = 'Testcontainers-backed real-PostgreSQL integration contracts.' - sourceSet = 'integrationTest' + runtimeFrom 'test' } } -// These two lanes were split physically from `test`, but they must preserve the ordinary test -// runtime semantics. The generic auxiliary-source-set helper includes compileClasspath in runtime, -// which is useful for some tool/qualification lanes but would leak testCompileOnly dependencies -// (notably Spring Cloud refresh scope) into these behavioral tests. -sourceSets.architectureTest.runtimeClasspath = - sourceSets.architectureTest.output + sourceSets.test.runtimeClasspath -sourceSets.bootCompositionTest.runtimeClasspath = - sourceSets.bootCompositionTest.output + sourceSets.test.runtimeClasspath +strictTestLanes { + lane('integrationTest') { + integration() + description = 'Testcontainers-backed real-PostgreSQL integration contracts.' + sourceSet = 'integrationTest' + } + lane('architectureTest') { + architecture() + description = 'Runs the whole-repository ArchUnit and architecture contract source set.' + sourceSet = 'architectureTest' + maxHeapSize = '2g' + jvmArgs '-Duser.timezone=UTC' + shouldRunAfter 'test' + inputDirectory contractRegistriesDirectory + } + lane('systemTest') { + system() + description = 'Runs full-application startup and shipped-profile characterization tests.' + sourceSet = 'systemTest' + maxHeapSize = '1g' + jvmArgs '-Duser.timezone=UTC' + shouldRunAfter 'test' + } +} + +// architectureTest and systemTest compile against test fixtures but intentionally mirror +// the ordinary test runtime rather than their compile classpath. `runtimeFrom 'test'` above keeps +// testCompileOnly analysis dependencies out of these behavioral lanes. sourceSets { - functionalTest { - java.srcDir 'src/functionalTest/java' - resources.srcDir 'src/functionalTest/resources' - } conditionalTransportTest { java.srcDir 'src/conditionalTransportTest/java' resources.srcDir 'src/conditionalTransportTest/resources' @@ -164,14 +183,11 @@ dependencies { testCompileOnly 'org.springframework:spring-webmvc' // SseEmitter, ResponseBodyEmitter, StreamingResponseBody testCompileOnly 'org.springframework:spring-websocket' // org.springframework.web.socket.. testCompileOnly 'jakarta.websocket:jakarta.websocket-api' // jakarta.websocket.. - // The Kafka client, on the runtime classpath so KafkaSenderConfig can supply the broker bridge - // the messaging adapter declares as a seam and nothing implemented (MSG-INT-003). Inert unless - // app.messaging.broker=kafka: no producer bean, no connection, no sender thread otherwise. - // - // It also serves the transport-free domain-event fixtures that used to need it testCompileOnly; - // an implementation dependency is visible to the test compile classpath, so the narrower - // declaration is now redundant rather than removed for a different reason. - implementation 'org.apache.kafka:kafka-clients' // org.apache.kafka.. + // architecture-only: positive-control fixture imports Kafka TopicPartition to prove domain code + // cannot depend on broker SDK types. The shipped main/test compile classpaths must not inherit it. + architectureTestCompileOnly 'org.apache.kafka:kafka-clients' + // Kafka producer ownership lives in adapter:outbound:messaging. The composition root sees only + // the adapter module; it no longer implements the broker seam itself. testCompileOnly 'jakarta.ws.rs:jakarta.ws.rs-api' // jakarta.ws.rs.. // test-only: @RefreshScope for the no-refresh-scope violation fixture (version pinned; not in the BOM). See README. testCompileOnly libs.spring.cloud.context // org.springframework.cloud.context.. @@ -195,12 +211,6 @@ dependencies { // test-only: JUnit Platform Test Kit — proves optional-adapter tests report SKIPPED (never FAILED) // when their enable-flag env var is unset (feature-contract-verification-test-suite D3, Claims #7). See README. testImplementation 'org.junit.platform:junit-platform-testkit' - // functional-test-only: executes isolated Gradle fixtures without placing Gradle's SLF4J - // provider on the ordinary test runtime classpath. - functionalTestImplementation gradleTestKit() - functionalTestImplementation 'org.junit.jupiter:junit-jupiter' - functionalTestImplementation 'org.assertj:assertj-core' - functionalTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' // Explicit qualification-only composition. These projects remain absent from main // api/implementation/compileOnly/runtimeOnly and therefore from both shipped runtime graphs. conditionalTransportTestImplementation project(':adapter:inbound:graphql') @@ -213,8 +223,6 @@ dependencies { } def repositoryRootForContractTests = rootProject.projectDir.parentFile.absolutePath -def contractRegistriesDirectory = rootProject.projectDir.parentFile.toPath() - .resolve('docs/registries').toFile() tasks.withType(Test).configureEach { systemProperty 'ca.repository.root', repositoryRootForContractTests } @@ -233,53 +241,15 @@ tasks.named('test', Test) { jvmArgs '-Duser.timezone=UTC' } -tasks.register('architectureTest', Test) { - group = 'verification' - description = 'Runs the whole-repository ArchUnit and architecture contract source set.' - testClassesDirs = sourceSets.architectureTest.output.classesDirs - classpath = sourceSets.architectureTest.runtimeClasspath - useJUnitPlatform() - failOnNoDiscoveredTests = true - shouldRunAfter tasks.named('test') - maxHeapSize = '2g' - inputs.dir(contractRegistriesDirectory) - .withPathSensitivity(PathSensitivity.RELATIVE) - jvmArgs '-Duser.timezone=UTC' -} -tasks.register('bootCompositionTest', Test) { - group = 'verification' - description = 'Runs slow full-composition startup and shipped-profile characterization tests.' - testClassesDirs = sourceSets.bootCompositionTest.output.classesDirs - classpath = sourceSets.bootCompositionTest.runtimeClasspath - useJUnitPlatform() - failOnNoDiscoveredTests = true - shouldRunAfter tasks.named('test') - maxHeapSize = '1g' - jvmArgs '-Duser.timezone=UTC' -} - -tasks.register('functionalTest', Test) { - group = 'verification' - description = 'Runs isolated Gradle TestKit contracts for repository build behavior.' - testClassesDirs = sourceSets.functionalTest.output.classesDirs - classpath = sourceSets.functionalTest.runtimeClasspath - useJUnitPlatform() - failOnNoDiscoveredTests = true - shouldRunAfter tasks.named('test') - // The expensive memory is in the nested Gradle invocations, not this JUnit worker. Keeping the - // TestKit worker bounded prevents a repository build from reserving another multi-GiB JVM. - maxHeapSize = '768m' - jvmArgs '-Duser.timezone=UTC' -} - -def conditionalTransportCompositionQualification = registerStrictQualificationTest( - name: 'conditionalTransportCompositionTest', - sourceSet: sourceSets.conditionalTransportTest, - requiredClasses: [ +def conditionalTransportCompositionQualification = extensions.getByName('strictQualification').register( + 'conditionalTransportCompositionTest', + sourceSets.conditionalTransportTest, + [ 'dev.caskeleton.bootstrap.transport.ConditionalTransportCompositionContractTest' ], - description: 'Proves the explicit test-only GraphQL/gRPC/WebSocket opt-in classpath.') + 'Proves the explicit test-only GraphQL/gRPC/WebSocket opt-in classpath.' +) conditionalTransportCompositionQualification.configure { shouldRunAfter tasks.named('test') } @@ -295,29 +265,8 @@ tasks.register('stageDockerJar', Sync) { rename { 'application.jar' } } -// Run from the repo's src/ root and inject src/.env into the Java process environment. -// Boot 4 initializes profiles/logging before spring-dotenv can reliably contribute .env values. -bootRun { - workingDir = rootProject.projectDir - doFirst { - File envFile = rootProject.file('.env') - if (!envFile.isFile()) { - return - } - envFile.eachLine { raw -> - String line = raw.trim() - if (line.isEmpty() || line.startsWith('#') || !line.contains('=')) { - return - } - int separator = line.indexOf('=') - String key = line.substring(0, separator).trim() - String value = line.substring(separator + 1).trim() - if (!key.isEmpty() && System.getenv(key) == null && !environment.containsKey(key)) { - environment key, value - } - } - } -} +// `ca.bootrun-dotenv` owns root working-directory and .env injection for bootRun. +// Boot 4 initializes profiles/logging before spring-dotenv can reliably contribute these values. // JPA-INT-003 — the H2 developer convenience, which now has to be asked for by name. // diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index 8d5803ae..10396e6c 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -1,100 +1,100 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -aopalliance:aopalliance:1.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -at.yawk.lz4:lz4-java:1.10.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.38=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.38=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.approvaltests:approvaltests-util:31.0.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.approvaltests:approvaltests:31.0.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.ethlo.time:itu:1.14.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.21=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.21.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.21.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.21.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -com.github.luben:zstd-jni:1.5.6-10=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +aopalliance:aopalliance:1.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +at.yawk.lz4:lz4-java:1.10.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=architectureTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.38=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.approvaltests:approvaltests-util:31.0.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.approvaltests:approvaltests:31.0.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.ethlo.time:itu:1.14.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.21.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.21.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.21.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +com.github.luben:zstd-jni:1.5.6-10=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs -com.github.stephenc.jcip:jcip-annotations:1.0-1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.github.stephenc.jcip:jcip-annotations:1.0-1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.android:annotations:4.1.1.4=conditionalTransportTestRuntimeClasspath com.google.api.grpc:proto-google-common-protos:2.41.0=conditionalTransportTestRuntimeClasspath -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor com.google.code.findbugs:jsr305:3.0.2=checkstyle,conditionalTransportTestRuntimeClasspath,spotbugs -com.google.code.gson:gson:2.13.2=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,spotbugs,testRuntimeClasspath -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,testCompileClasspath -com.google.errorprone:error_prone_annotations:2.41.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,spotbugs,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,spotbugs,systemTestRuntimeClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=architectureTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,spotbugs,systemTestRuntimeClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor com.google.guava:failureaccess:1.0.2=conditionalTransportTestRuntimeClasspath -com.google.guava:failureaccess:1.0.3=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,checkstyle,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,architectureTestAnnotationProcessor,checkstyle,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor com.google.guava:guava:33.2.1-jre=conditionalTransportTestRuntimeClasspath -com.google.guava:guava:33.5.0-jre=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,checkstyle,conditionalTransportTestAnnotationProcessor,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,architectureTestAnnotationProcessor,checkstyle,conditionalTransportTestAnnotationProcessor,conditionalTransportTestRuntimeClasspath,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor com.google.j2objc:j2objc-annotations:2.8=conditionalTransportTestRuntimeClasspath -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,checkstyle,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,architectureTestAnnotationProcessor,checkstyle,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor com.google.protobuf:protobuf-java-util:3.25.5=conditionalTransportTestRuntimeClasspath com.google.protobuf:protobuf-java:3.25.5=conditionalTransportTestRuntimeClasspath -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -com.graphql-java:graphql-java:25.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.graphql-java:java-dataloader:6.0.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.h2database:h2:2.4.240=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +com.graphql-java:graphql-java:25.0=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.graphql-java:java-dataloader:6.0.0=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.h2database:h2:2.4.240=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.10.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.networknt:json-schema-validator:3.0.2=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.nimbusds:content-type:2.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:lang-tag:1.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:10.4=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:oauth2-oidc-sdk:11.26.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.networknt:json-schema-validator:3.0.2=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.nimbusds:content-type:2.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:lang-tag:1.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:oauth2-oidc-sdk:11.26.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.rabbitmq:amqp-client:5.27.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.squareup.okhttp3:okhttp-jvm:5.2.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.squareup.okhttp3:okhttp:5.2.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.squareup.okio:okio-jvm:3.16.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.squareup.okio:okio:3.16.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.sun.istack:istack-commons-runtime:4.1.2=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-api:1.3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-engine:1.3.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5:1.3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit:1.3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.zaxxer:HikariCP:7.0.2=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.rabbitmq:amqp-client:5.27.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp-jvm:5.2.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:5.2.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.16.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.16.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.sun.istack:istack-commons-runtime:4.1.2=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-api:1.3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine:1.3.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5:1.3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit:1.3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.zaxxer:HikariCP:7.0.2=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle -commons-codec:commons-codec:1.19.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-codec:commons-codec:1.19.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle -commons-io:commons-io:2.20.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.20.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.6=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.cloudevents:cloudevents-api:4.0.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.cloudevents:cloudevents-core:4.0.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.github.cdimascio:dotenv-java:3.0.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -io.github.resilience4j:resilience4j-bulkhead:2.2.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-core:2.2.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-micrometer:2.2.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-ratelimiter:2.2.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-retry:2.2.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-timelimiter:2.2.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.cloudevents:cloudevents-api:4.0.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.cloudevents:cloudevents-core:4.0.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.github.cdimascio:dotenv-java:3.0.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +io.github.resilience4j:resilience4j-bulkhead:2.2.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-core:2.2.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-micrometer:2.2.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-ratelimiter:2.2.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-retry:2.2.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-timelimiter:2.2.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath io.grpc:grpc-api:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-context:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-core:1.68.1=conditionalTransportTestRuntimeClasspath @@ -104,310 +104,310 @@ io.grpc:grpc-protobuf:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-services:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath -io.lettuce:lettuce-core:6.8.2.RELEASE=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.micrometer:context-propagation:1.2.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-jakarta9:1.16.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-registry-prometheus:1.16.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-tracing-bridge-otel:1.6.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-tracing:1.6.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-base:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-classes-quic:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-compression:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-dns:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-http2:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-http3:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-http:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-marshalling:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-native-quic:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-protobuf:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-socks:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-common:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-handler-proxy:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-handler:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns-native-macos:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport-classes-epoll:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport-native-epoll:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport:4.2.17.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-api:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-common:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-context:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-common:1.55.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-common:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-logs:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-trace:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.lettuce:lettuce-core:6.8.2.RELEASE=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.micrometer:context-propagation:1.2.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-jakarta9:1.16.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-registry-prometheus:1.16.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing-bridge-otel:1.6.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing:1.6.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-classes-quic:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-compression:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-dns:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http2:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http3:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-marshalling:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-native-quic:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-protobuf:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-socks:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-handler-proxy:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns-native-macos:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-epoll:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.17.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-common:1.55.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-common:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-logs:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-trace:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk:1.55.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath -io.projectreactor.netty:reactor-netty-core:1.3.7=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.projectreactor.netty:reactor-netty-http:1.3.7=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-config:1.4.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-core:1.4.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-exposition-formats:1.4.3=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-model:1.4.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-tracer-common:1.4.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.smallrye:jandex:3.3.2=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-core-jakarta:2.2.38=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-models-jakarta:2.2.38=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.inject:jakarta.inject-api:2.0.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -jakarta.mail:jakarta.mail-api:2.1.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -jakarta.persistence:jakarta.persistence-api:3.2.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.servlet:jakarta.servlet-api:6.1.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,testRuntimeClasspath -jakarta.transaction:jakarta.transaction-api:2.0.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.validation:jakarta.validation-api:3.1.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.websocket:jakarta.websocket-api:2.2.0=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,integrationTestCompileClasspath,testCompileClasspath -jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,integrationTestCompileClasspath,testCompileClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor +io.projectreactor.netty:reactor-netty-core:1.3.7=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.projectreactor.netty:reactor-netty-http:1.3.7=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-config:1.4.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-core:1.4.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-formats:1.4.3=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-model:1.4.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-tracer-common:1.4.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.smallrye:jandex:3.3.2=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.38=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.38=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +jakarta.mail:jakarta.mail-api:2.1.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.2.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.servlet:jakarta.servlet-api:6.1.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.websocket:jakarta.websocket-api:2.2.0=architectureTestCompileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=architectureTestCompileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.6=spotbugs -me.paulschwarz:spring-dotenv:4.0.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy-agent:1.17.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.java.dev.jna:jna:5.18.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.logstash.logback:logstash-logback-encoder:8.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +me.paulschwarz:spring-dotenv:4.0.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.logstash.logback:logstash-logback-encoder:8.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs -ognl:ognl:3.3.4=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.antlr:antlr4-runtime:4.13.2=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,checkstyle,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ognl:ognl:3.3.4=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.antlr:antlr4-runtime:4.13.2=architectureTestCompileClasspath,architectureTestRuntimeClasspath,checkstyle,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-compress:1.28.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.commons:commons-lang3:3.20.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,checkstyle,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-compress:1.28.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,checkstyle,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle -org.apache.groovy:groovy-bom:5.0.8=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,integrationTestCompileClasspath,testCompileClasspath -org.apache.groovy:groovy:5.0.8=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,integrationTestCompileClasspath,testCompileClasspath -org.apache.httpcomponents.client5:httpclient5:5.5.2=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.httpcomponents.core5:httpcore5:5.3.6=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.apache.groovy:groovy-bom:5.0.8=architectureTestCompileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +org.apache.groovy:groovy:5.0.8=architectureTestCompileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +org.apache.httpcomponents.client5:httpclient5:5.5.2=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5:5.3.6=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.kafka:kafka-clients:4.1.2=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-api:2.25.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.kafka:kafka-clients:4.1.2=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.5=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.24=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.24=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,conditionalTransportTestCompileClasspath,functionalTestCompileClasspath,integrationTestCompileClasspath,testCompileClasspath -org.aspectj:aspectjweaver:1.9.25.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.assertj:assertj-core:3.27.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.attoparser:attoparser:2.0.7.RELEASE=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=architectureTestCompileClasspath,conditionalTransportTestCompileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +org.aspectj:aspectjweaver:1.9.25.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.attoparser:attoparser:2.0.7.RELEASE=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.42.0=conditionalTransportTestRuntimeClasspath -org.checkerframework:checker-qual:3.55.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.55.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath org.codehaus.mojo:animal-sniffer-annotations:1.24=conditionalTransportTestRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.eclipse.angus:angus-activation:2.0.3=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.eclipse.angus:angus-mail:2.0.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.eclipse.jetty.compression:jetty-compression-common:12.1.12=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.eclipse.jetty.compression:jetty-compression-gzip:12.1.12=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-alpn-client:12.1.12=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-client:12.1.12=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-http:12.1.12=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-io:12.1.12=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-util:12.1.12=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.flywaydb:flyway-core:11.14.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.flywaydb:flyway-database-postgresql:11.14.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-core:4.0.9=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-runtime:4.0.9=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:txw2:4.0.9=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hamcrest:hamcrest:3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hdrhistogram:HdrHistogram:2.2.2=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hibernate.models:hibernate-models:1.0.1=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hibernate.orm:hibernate-core:7.2.24.Final=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hibernate.orm:hibernate-envers:7.2.24.Final=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,testRuntimeClasspath -org.hibernate.validator:hibernate-validator:9.0.1.Final=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.angus:angus-activation:2.0.3=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.angus:angus-mail:2.0.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-common:12.1.12=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-gzip:12.1.12=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-alpn-client:12.1.12=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-client:12.1.12=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-http:12.1.12=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-io:12.1.12=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-util:12.1.12=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.flywaydb:flyway-core:11.14.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.flywaydb:flyway-database-postgresql:11.14.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-core:4.0.9=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.9=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.9=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.hibernate.models:hibernate-models:1.0.1=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.hibernate.orm:hibernate-core:7.2.24.Final=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.orm:hibernate-envers:7.2.24.Final=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.hibernate.validator:hibernate-validator:9.0.1.Final=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.javassist:javassist:3.29.0-GA=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.jboss.logging:jboss-logging:3.6.3.Final=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.2.21=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.javassist:javassist:3.29.0-GA=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.3.Final=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath -org.jetbrains:annotations:17.0.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.1=annotationProcessor,architectureTestAnnotationProcessor,architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestAnnotationProcessor,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,integrationTestAnnotationProcessor,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.3=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,integrationTestRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-testkit:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:17.0.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,architectureTestAnnotationProcessor,architectureTestCompileClasspath,architectureTestRuntimeClasspath,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestAnnotationProcessor,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestAnnotationProcessor,systemTestCompileClasspath,systemTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-testkit:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.latencyutils:LatencyUtils:2.0.3=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.mockito:mockito-core:5.20.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,mockitoAgent,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mongodb:bson-record-codec:5.6.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.mongodb:bson:5.6.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.mongodb:mongodb-driver-core:5.6.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.mongodb:mongodb-driver-sync:5.6.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,testRuntimeClasspath -org.openapitools:jackson-databind-nullable:0.2.6=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,testCompileClasspath +org.latencyutils:LatencyUtils:2.0.3=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,mockitoAgent,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.mongodb:bson-record-codec:5.6.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.mongodb:bson:5.6.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.mongodb:mongodb-driver-core:5.6.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.mongodb:mongodb-driver-sync:5.6.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=architectureTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=architectureTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=architectureTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=architectureTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,architectureTestAnnotationProcessor,bootCompositionTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,integrationTestAnnotationProcessor,testAnnotationProcessor -org.postgresql:postgresql:42.7.13=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.7.1=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,architectureTestAnnotationProcessor,conditionalTransportTestAnnotationProcessor,integrationTestAnnotationProcessor,systemTestAnnotationProcessor,testAnnotationProcessor +org.postgresql:postgresql:42.7.13=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle -org.rnorth.duct-tape:duct-tape:1.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.skyscreamer:jsonassert:1.5.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.18=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.18=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j org.slf4j:slf4j-simple:2.0.18=checkstyle -org.springdoc:springdoc-openapi-starter-common:3.0.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.amqp:spring-amqp:4.0.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.amqp:spring-rabbit:4.0.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-common:3.0.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.amqp:spring-amqp:4.0.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.amqp:spring-rabbit:4.0.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-mongodb:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-flyway:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-graphql:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-health:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-hibernate:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-mail:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-metrics:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-observation:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-mongodb:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-reactor:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-sql:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-actuator:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-jpa:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-mongodb:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-flyway:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-graphql:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jdbc:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-json:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-mail:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-mongodb:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-security:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-validation:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-web:4.0.8=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-commons:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-mongodb:4.0.8=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-graphql:4.0.8=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-health:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-mail:4.0.8=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-metrics:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-observation:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-mongodb:4.0.8=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-reactor:4.0.8=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.8=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-actuator:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-mongodb:4.0.8=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.8=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-graphql:4.0.8=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-mail:4.0.8=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-mongodb:4.0.8=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.8=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.8=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-websocket:4.0.8=conditionalTransportTestRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-websocket:4.0.8=conditionalTransportTestRuntimeClasspath -org.springframework.boot:spring-boot:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.cloud:spring-cloud-context:4.1.4=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,integrationTestCompileClasspath,testCompileClasspath -org.springframework.data:spring-data-commons:4.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-jpa:4.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-mongodb:5.0.7=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.graphql:spring-graphql:2.0.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-core:7.0.6=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-jdbc:7.0.6=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.kafka:spring-kafka:4.0.7=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-config:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-core:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-crypto:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-client:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-core:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-jose:7.0.7=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-resource-server:7.0.7=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-test:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-web:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.session:spring-session-core:4.0.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aspects:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context-support:7.0.9=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-jdbc:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.9=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-orm:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webflux:7.0.9=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-websocket:7.0.9=architectureTestCompileClasspath,bootCompositionTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,testCompileClasspath -org.testcontainers:testcontainers-database-commons:2.0.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-toxiproxy:2.0.5=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.thymeleaf:thymeleaf:3.1.5.RELEASE=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.unbescape:unbescape:1.1.6.RELEASE=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.xerial.snappy:snappy-java:1.1.10.7=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.cloud:spring-cloud-context:4.1.4=architectureTestCompileClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +org.springframework.data:spring-data-commons:4.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-mongodb:5.0.7=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.graphql:spring-graphql:2.0.5=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-core:7.0.6=architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:7.0.6=architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.kafka:spring-kafka:4.0.7=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-client:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.7=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.7=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-test:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.7=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aspects:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context-support:7.0.9=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.9=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webflux:7.0.9=architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-websocket:7.0.9=architectureTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,systemTestCompileClasspath,testCompileClasspath +org.testcontainers:testcontainers-database-commons:2.0.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-toxiproxy:2.0.5=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.thymeleaf:thymeleaf:3.1.5.RELEASE=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.unbescape:unbescape:1.1.6.RELEASE=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +org.xerial.snappy:snappy-java:1.1.10.7=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -redis.clients.authentication:redis-authx-core:0.1.1-beta2=architectureTestRuntimeClasspath,bootCompositionTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.1.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.1.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.1.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,bootCompositionTestCompileClasspath,bootCompositionTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=architectureTestCompileClasspath,architectureTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +redis.clients.authentication:redis-authx-core:0.1.1-beta2=architectureTestRuntimeClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestRuntimeClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=architectureTestCompileClasspath,architectureTestRuntimeClasspath,conditionalTransportTestRuntimeClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,systemTestCompileClasspath,systemTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath empty=developmentOnly,testAndDevelopmentOnly diff --git a/src/app-bootstrap/src/architectureTest/java/dev/caskeleton/bootstrap/architecture/ProductionClassImportOption.java b/src/app-bootstrap/src/architectureTest/java/dev/caskeleton/bootstrap/architecture/ProductionClassImportOption.java index 5e756bfa..8a968c9f 100644 --- a/src/app-bootstrap/src/architectureTest/java/dev/caskeleton/bootstrap/architecture/ProductionClassImportOption.java +++ b/src/app-bootstrap/src/architectureTest/java/dev/caskeleton/bootstrap/architecture/ProductionClassImportOption.java @@ -15,7 +15,7 @@ public final class ProductionClassImportOption implements ImportOption { // sets. // Keep production scans source-set neutral as verification lanes are split physically. && !location.contains("/architectureTest/") - && !location.contains("/bootCompositionTest/") + && !location.contains("/systemTest/") // The JPA testkit is on this module's test classpath so the production architecture suite // can use its rule pack. Its fixtures exist to be violations — an entity with a // varchar(255) id, a controller returning an entity — so importing them would have the diff --git a/src/app-bootstrap/src/conditionalTransportTest/java/dev/caskeleton/bootstrap/transport/ConditionalTransportCompositionContractTest.java b/src/app-bootstrap/src/conditionalTransportTest/java/dev/caskeleton/bootstrap/transport/ConditionalTransportCompositionContractTest.java index 1708c8b8..daec60cf 100644 --- a/src/app-bootstrap/src/conditionalTransportTest/java/dev/caskeleton/bootstrap/transport/ConditionalTransportCompositionContractTest.java +++ b/src/app-bootstrap/src/conditionalTransportTest/java/dev/caskeleton/bootstrap/transport/ConditionalTransportCompositionContractTest.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import java.util.regex.Pattern; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -44,9 +45,9 @@ class ConditionalTransportCompositionContractTest { case "adapter-inbound-websocket" -> ":adapter:inbound:websocket"; default -> throw new IllegalArgumentException(moduleId); }; - assertThat(bootstrapBuild) + assertThat(productionProjectDependency(bootstrapBuild, gradlePath)) .as("%s is qualification-only and must not ship in app-bootstrap", moduleId) - .doesNotContain("project('" + gradlePath + "')"); + .isFalse(); assertThatCodeLoads(typeName); }); } @@ -60,7 +61,8 @@ class ConditionalTransportCompositionContractTest { SWITCH_GATED_TRANSPORTS .values() .forEach(ConditionalTransportCompositionContractTest::assertThatCodeLoads); - assertThat(shippedApplicationYaml()).contains("${APP_GRAPHQL_ENABLED:false}"); + assertThat(shippedApplicationYaml()).contains("classpath:config/graphql.yml"); + assertThat(shippedGraphQlYaml()).contains("${APP_GRAPHQL_ENABLED:false}"); } @Test @@ -86,6 +88,21 @@ class ConditionalTransportCompositionContractTest { repositoryRoot().resolve("src/app-bootstrap/src/main/resources/application.yml")); } + private static String shippedGraphQlYaml() throws IOException { + return Files.readString( + repositoryRoot().resolve("src/app-bootstrap/src/main/resources/config/graphql.yml")); + } + + private static boolean productionProjectDependency(String buildScript, String gradlePath) { + String quotedPath = Pattern.quote(gradlePath); + Pattern productionDependency = + Pattern.compile( + "(?m)^\\s*(?:api|implementation|compileOnly|runtimeOnly)\\s*\\(?\\s*project\\(\\s*['\"]" + + quotedPath + + "['\"]\\s*\\)\\s*\\)?"); + return productionDependency.matcher(buildScript).find(); + } + private static void assertThatCodeLoads(String typeName) { try { assertThat(Class.forName(typeName)).isNotNull(); diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/ReleaseProvenanceFunctionalTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/ReleaseProvenanceFunctionalTest.java deleted file mode 100644 index 44af2a5e..00000000 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/ReleaseProvenanceFunctionalTest.java +++ /dev/null @@ -1,148 +0,0 @@ -package dev.caskeleton.bootstrap.contract; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.stream.Stream; -import org.gradle.testkit.runner.BuildResult; -import org.gradle.testkit.runner.GradleRunner; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Verifies that release provenance is a release concern rather than a configuration-time build - * prerequisite. - */ -class ReleaseProvenanceFunctionalTest { - - @Test - void aBuildWithNoGitMetadataConfiguresAndIsNotCalledARelease(@TempDir Path fixtureRoot) - throws Exception { - Path fixtureSrc = fixtureRoot.resolve("src"); - copyBuildFixture(fixtureSrc); - assertThat(fixtureSrc.resolve(".git")).doesNotExist(); - - BuildResult configured = run(fixtureSrc, "help"); - assertThat(configured.getOutput()).contains("BUILD SUCCESSFUL"); - - BuildResult provenance = runAndFail(fixtureSrc, "verifyReleaseProvenance"); - assertThat(provenance.getOutput()).contains("no source revision"); - } - - @Test - void anAttestedRevisionSatisfiesReleaseProvenanceWithoutGit(@TempDir Path fixtureRoot) - throws Exception { - Path fixtureSrc = fixtureRoot.resolve("src"); - copyBuildFixture(fixtureSrc); - - BuildResult result = - run( - fixtureSrc, - "verifyReleaseProvenance", - "-PgitRevision=0123456789abcdef0123456789abcdef01234567"); - - assertThat(result.getOutput()).contains("verifyReleaseProvenance: OK"); - } - - private static BuildResult run(Path projectDirectory, String... arguments) { - return runner(projectDirectory, arguments).build(); - } - - private static BuildResult runAndFail(Path projectDirectory, String... arguments) { - return runner(projectDirectory, arguments).buildAndFail(); - } - - private static GradleRunner runner(Path projectDirectory, String... arguments) { - String[] fullArguments = new String[arguments.length + 2]; - fullArguments[0] = "--console=plain"; - fullArguments[1] = "--stacktrace"; - System.arraycopy(arguments, 0, fullArguments, 2, arguments.length); - return GradleRunner.create() - .withProjectDir(projectDirectory.toFile()) - // Fixtures stay isolated; Gradle's immutable dependency/plugin caches and daemon do not. - .withTestKitDir(sourceRoot().resolve("app-bootstrap/build/test-kit-cache").toFile()) - .withArguments(fullArguments); - } - - private static void copyBuildFixture(Path fixtureSrc) throws IOException { - Path sourceRoot = sourceRoot(); - copyTree(sourceRoot.resolve("gradle"), fixtureSrc.resolve("gradle")); - copyTree(sourceRoot.resolve("config"), fixtureSrc.resolve("config")); - Path gradleProperties = sourceRoot.resolve("gradle.properties"); - if (Files.isRegularFile(gradleProperties)) { - copyFile(gradleProperties, fixtureSrc.resolve("gradle.properties")); - } - copyIncludedBuildSources(sourceRoot.resolve("build-logic"), fixtureSrc.resolve("build-logic")); - copyIncludedBuildSources( - sourceRoot.resolve("build-qualification"), fixtureSrc.resolve("build-qualification")); - - try (Stream paths = Files.walk(sourceRoot)) { - paths - .filter(Files::isRegularFile) - .filter( - path -> - path.getFileName().toString().equals("build.gradle") - || path.getFileName().toString().equals("gradle.lockfile") - || path.equals(sourceRoot.resolve("settings.gradle"))) - .filter(path -> !path.startsWith(sourceRoot.resolve("build"))) - .forEach( - source -> { - try { - copyFile(source, fixtureSrc.resolve(sourceRoot.relativize(source))); - } catch (IOException exception) { - throw new IllegalStateException( - "failed to copy Gradle fixture input " + source, exception); - } - }); - } - } - - private static void copyIncludedBuildSources(Path sourceRoot, Path targetRoot) - throws IOException { - copyFile(sourceRoot.resolve("settings.gradle"), targetRoot.resolve("settings.gradle")); - copyFile(sourceRoot.resolve("build.gradle"), targetRoot.resolve("build.gradle")); - Path sourceDirectory = sourceRoot.resolve("src"); - if (Files.isDirectory(sourceDirectory)) { - copyTree(sourceDirectory, targetRoot.resolve("src")); - } - Path lockfile = sourceRoot.resolve("gradle.lockfile"); - if (Files.isRegularFile(lockfile)) { - copyFile(lockfile, targetRoot.resolve("gradle.lockfile")); - } - } - - private static void copyTree(Path sourceRoot, Path targetRoot) throws IOException { - try (Stream paths = Files.walk(sourceRoot)) { - paths - .filter(Files::isRegularFile) - .forEach( - source -> { - try { - copyFile(source, targetRoot.resolve(sourceRoot.relativize(source))); - } catch (IOException exception) { - throw new IllegalStateException("failed to copy tree input " + source, exception); - } - }); - } - } - - private static void copyFile(Path source, Path target) throws IOException { - Files.createDirectories(target.getParent()); - Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); - } - - private static Path sourceRoot() { - for (Path candidate = Path.of("").toAbsolutePath(); - candidate != null; - candidate = candidate.getParent()) { - if (Files.isRegularFile(candidate.resolve("gradlew")) - && Files.isDirectory(candidate.resolve("app-bootstrap"))) { - return candidate; - } - } - throw new IllegalStateException("repository src root not found"); - } -} diff --git a/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java b/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java index 005af47e..724b8228 100644 --- a/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java +++ b/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java @@ -3,8 +3,8 @@ package dev.caskeleton.bootstrap.integration.outbox; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.transaction.TransactionPort; import dev.caskeleton.bootstrap.integration.PostgreSqlTestContainer; import java.time.Clock; @@ -57,7 +57,7 @@ class OutboxAppendTransactionalContractTest { OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); String eventId = "txn-rollback-" + System.nanoTime(); @@ -97,7 +97,7 @@ class OutboxAppendTransactionalContractTest { OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); String eventId = "txn-commit-" + System.nanoTime(); diff --git a/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java b/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java index 23cf0170..a0192bdd 100644 --- a/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java +++ b/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java @@ -2,8 +2,8 @@ package dev.caskeleton.bootstrap.integration.outbox; import static org.assertj.core.api.Assertions.assertThat; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.outbox.OutboxRelayResult; import dev.caskeleton.application.outbox.PublishPendingOutboxEventsCommand; import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase; @@ -76,7 +76,7 @@ class OutboxPublisherLeaderElectionContractTest { AnnotationConfigApplicationContext seedCtx = OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock); TransactionPort seedTx = seedCtx.getBean(TransactionPort.class); - OutboxAppendPort appendPort = seedCtx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort appendPort = seedCtx.getBean(LegacyOutboxAppendPort.class); seedTx.inWrite( () -> { diff --git a/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java b/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java index 78f735d1..40467334 100644 --- a/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java +++ b/src/app-bootstrap/src/integrationTest/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java @@ -5,10 +5,10 @@ import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository; import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper; import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxStoreAdapter; +import dev.caskeleton.application.outbox.ClaimedOutboxEvent; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.outbox.OutboxBackoffPolicy; -import dev.caskeleton.application.outbox.OutboxEvent; import dev.caskeleton.application.outbox.OutboxEventStatus; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; import dev.caskeleton.application.outbox.OutboxRelayResult; @@ -82,7 +82,7 @@ class OutboxRowLifecycleContractTest { OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); String eventId = "lifecycle-happy-" + System.nanoTime(); @@ -132,7 +132,7 @@ class OutboxRowLifecycleContractTest { clock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); String eventId = "lifecycle-fail-" + System.nanoTime(); @@ -188,7 +188,7 @@ class OutboxRowLifecycleContractTest { appendClock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); String eventId = "lifecycle-dead-" + System.nanoTime(); @@ -255,7 +255,7 @@ class OutboxRowLifecycleContractTest { OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); String aggId = "agg-fifo-order-" + System.nanoTime(); String headId = "fifo-order-head-" + System.nanoTime(); @@ -324,7 +324,7 @@ class OutboxRowLifecycleContractTest { OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); String aggregateId = "agg-fifo-tie-" + System.nanoTime(); String firstId = "fifo-tie-first-" + System.nanoTime(); @@ -378,7 +378,7 @@ class OutboxRowLifecycleContractTest { clock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); String aggId = "agg-fifo-blocked-" + System.nanoTime(); @@ -446,7 +446,7 @@ class OutboxRowLifecycleContractTest { OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); String aggId = "agg-fifo-unblock-" + System.nanoTime(); @@ -513,7 +513,7 @@ class OutboxRowLifecycleContractTest { Clock.fixed(t0, ZoneOffset.UTC))) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); String aggId = "agg-fifo-dead-" + System.nanoTime(); @@ -589,7 +589,7 @@ class OutboxRowLifecycleContractTest { OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, claimClock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); String eventId = "lifecycle-orphan-" + System.nanoTime(); @@ -646,7 +646,7 @@ class OutboxRowLifecycleContractTest { OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { TransactionPort tx = ctx.getBean(TransactionPort.class); - OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + LegacyOutboxAppendPort append = ctx.getBean(LegacyOutboxAppendPort.class); OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); String eventId = "lifecycle-reap-" + System.nanoTime(); @@ -659,7 +659,7 @@ class OutboxRowLifecycleContractTest { }); // Claim and mark as PUBLISHED - List claimed = + List claimed = tx.inWrite(() -> store.claimBatch(1, eventTime, Duration.ofSeconds(1))); assertThat(claimed).hasSize(1); tx.inWrite( diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxCanonicalTransportRequirementValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxCanonicalTransportRequirementValidator.java new file mode 100644 index 00000000..db090754 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxCanonicalTransportRequirementValidator.java @@ -0,0 +1,45 @@ +package dev.caskeleton.bootstrap.autoconfigure.outbox; + +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.util.Objects; +import org.springframework.beans.factory.InitializingBean; + +/** + * Fail-closed activation guard for the transport-only canonical outbox path. + * + *

Turning on canonical transport without a canonical publisher would let the legacy relay claim + * a canonical row and then spend its retry budget against a disabled transport. The flag therefore + * requires the application-owned canonical publish port at startup. + */ +public final class OutboxCanonicalTransportRequirementValidator implements InitializingBean { + + private static final String CANONICAL_KEY = "ca-skeleton.outbox.canonical-transport-enabled"; + + private final OutboxSettings settings; + private final IntegrationEventPublishPort publisher; + + public OutboxCanonicalTransportRequirementValidator( + OutboxSettings settings, IntegrationEventPublishPort publisher) { + this.settings = Objects.requireNonNull(settings, "settings must not be null"); + this.publisher = publisher; + } + + @Override + public void afterPropertiesSet() { + validate(); + } + + public void validate() { + if (!settings.canonicalTransportEnabled() || publisher != null) { + return; + } + throw StartupFailures.requiredAdapterDisabled( + CANONICAL_KEY + + "=true requires an IntegrationEventPublishPort so canonical claimed rows cannot be " + + "consumed by the relay without a platform publication route. Configure the canonical " + + "messaging bridge (including app.messaging.producer-id) or set " + + CANONICAL_KEY + + "=false."); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxConfig.java index 8b14c416..d7e055f0 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxConfig.java @@ -1,5 +1,6 @@ package dev.caskeleton.bootstrap.autoconfigure.outbox; +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; import dev.caskeleton.application.outbox.OutboxBackoffPolicy; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort; @@ -43,6 +44,15 @@ public class OutboxConfig { return new OutboxRelayBrokerRequirementValidator(environment); } + /** Requires the canonical messaging bridge whenever canonical outbox transport is enabled. */ + @Bean + public OutboxCanonicalTransportRequirementValidator outboxCanonicalTransportRequirementValidator( + OutboxSettings settings, + ObjectProvider canonicalPublisherProvider) { + return new OutboxCanonicalTransportRequirementValidator( + settings, canonicalPublisherProvider.getIfAvailable()); + } + /** * Relay use case is assembled manually here (not a bean) from {@link OutboxSettings} values. See * README for the design rationale. diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxSettings.java index f12a4eca..bf1a049d 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxSettings.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxSettings.java @@ -10,6 +10,8 @@ import org.springframework.validation.annotation.Validated; * placeholders). See README for the design rationale. * * @param relayEnabled whether the relay scheduler is enabled; default {@code true} + * @param canonicalTransportEnabled whether canonical rows may use the platform transport; default + * {@code false} * @param pollInterval how often the relay polls the outbox table; default {@code PT5S} * @param batchSize maximum rows claimed per relay cycle; default {@code 20} * @param inFlightTimeout in-flight orphan visibility window; default {@code PT5M} @@ -21,6 +23,7 @@ import org.springframework.validation.annotation.Validated; @ConfigurationProperties(prefix = "ca-skeleton.outbox") public record OutboxSettings( Boolean relayEnabled, + Boolean canonicalTransportEnabled, Duration pollInterval, Integer batchSize, Duration inFlightTimeout, @@ -31,6 +34,9 @@ public record OutboxSettings( if (relayEnabled == null) { relayEnabled = true; } + if (canonicalTransportEnabled == null) { + canonicalTransportEnabled = false; + } if (pollInterval == null) { pollInterval = Duration.ofSeconds(5); diff --git a/src/app-bootstrap/src/main/resources/config/outbox.yml b/src/app-bootstrap/src/main/resources/config/outbox.yml index 381b8628..4141ff6e 100644 --- a/src/app-bootstrap/src/main/resources/config/outbox.yml +++ b/src/app-bootstrap/src/main/resources/config/outbox.yml @@ -17,6 +17,9 @@ ca-skeleton: # port that a database-less deployment does not have. enabled: ${APP_OUTBOX_ENABLED:false} relay-enabled: ${APP_OUTBOX_RELAY_ENABLED:false} + # Transport-only MSG-015 cutover gate. It does not activate POLLING_V2 or a second relay. + # When true, startup also requires the canonical IntegrationEventPublishPort. + canonical-transport-enabled: ${APP_OUTBOX_CANONICAL_TRANSPORT_ENABLED:false} # ISO-8601 duration — how often the relay polls for pending rows poll-interval: PT5S # integer >= 1 — maximum rows claimed per relay cycle diff --git a/src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/DefaultProfileBootCharacterizationTest.java b/src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/DefaultProfileBootCharacterizationTest.java similarity index 100% rename from src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/DefaultProfileBootCharacterizationTest.java rename to src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/DefaultProfileBootCharacterizationTest.java diff --git a/src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/DependencyErrorStartupContractTest.java b/src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/DependencyErrorStartupContractTest.java similarity index 100% rename from src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/DependencyErrorStartupContractTest.java rename to src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/DependencyErrorStartupContractTest.java diff --git a/src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/FiveAdapterOffInventoryTest.java b/src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/FiveAdapterOffInventoryTest.java similarity index 100% rename from src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/FiveAdapterOffInventoryTest.java rename to src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/FiveAdapterOffInventoryTest.java diff --git a/src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/GraphQlShippedAndGatedTest.java b/src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/GraphQlShippedAndGatedTest.java similarity index 100% rename from src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/GraphQlShippedAndGatedTest.java rename to src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/GraphQlShippedAndGatedTest.java diff --git a/src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessorTest.java b/src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessorTest.java similarity index 100% rename from src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessorTest.java rename to src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessorTest.java diff --git a/src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/ShippedCompositionHarness.java b/src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/ShippedCompositionHarness.java similarity index 100% rename from src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/ShippedCompositionHarness.java rename to src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/ShippedCompositionHarness.java diff --git a/src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/StartupWarningZeroTest.java b/src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/StartupWarningZeroTest.java similarity index 100% rename from src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/activation/StartupWarningZeroTest.java rename to src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/activation/StartupWarningZeroTest.java diff --git a/src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/logging/LogProfileDriftCharacterizationTest.java b/src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/logging/LogProfileDriftCharacterizationTest.java similarity index 100% rename from src/app-bootstrap/src/bootCompositionTest/java/dev/caskeleton/bootstrap/logging/LogProfileDriftCharacterizationTest.java rename to src/app-bootstrap/src/systemTest/java/dev/caskeleton/bootstrap/logging/LogProfileDriftCharacterizationTest.java diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxCanonicalTransportCompositionTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxCanonicalTransportCompositionTest.java new file mode 100644 index 00000000..4dd9d409 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxCanonicalTransportCompositionTest.java @@ -0,0 +1,58 @@ +package dev.caskeleton.bootstrap.autoconfigure.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; +import dev.caskeleton.application.outbox.OutboxPublishOutcome; +import dev.caskeleton.application.outbox.OutboxStorePort; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class OutboxCanonicalTransportCompositionTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withUserConfiguration(OutboxConfig.class) + .withBean(OutboxStorePort.class, () -> mock(OutboxStorePort.class)) + .withPropertyValues( + "ca-skeleton.outbox.enabled=true", "ca-skeleton.outbox.relay-enabled=false"); + + @Test + void defaultCanonicalTransportOffStartsWithoutCanonicalPublisher() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(OutboxCanonicalTransportRequirementValidator.class); + assertThat(context).doesNotHaveBean(IntegrationEventPublishPort.class); + }); + } + + @Test + void canonicalTransportOnWithoutPublisherFailsTheComposedContext() { + runner + .withPropertyValues("ca-skeleton.outbox.canonical-transport-enabled=true") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasMessageContaining("IntegrationEventPublishPort"); + }); + } + + @Test + void canonicalTransportOnWithPublisherStartsWithoutCreatingAnotherScheduler() { + runner + .withPropertyValues("ca-skeleton.outbox.canonical-transport-enabled=true") + .withBean( + IntegrationEventPublishPort.class, + () -> event -> CompletableFuture.completedFuture(OutboxPublishOutcome.CONFIRMED)) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(IntegrationEventPublishPort.class); + assertThat(context).doesNotHaveBean(OutboxRelayScheduler.class); + }); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxCanonicalTransportRequirementValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxCanonicalTransportRequirementValidatorTest.java new file mode 100644 index 00000000..08d84805 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxCanonicalTransportRequirementValidatorTest.java @@ -0,0 +1,43 @@ +package dev.caskeleton.bootstrap.autoconfigure.outbox; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.messaging.event.IntegrationEventPublishPort; +import dev.caskeleton.application.outbox.OutboxPublishOutcome; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +class OutboxCanonicalTransportRequirementValidatorTest { + + @Test + void enabledCanonicalTransportWithoutPublisherFailsStartup() { + OutboxSettings settings = new OutboxSettings(true, true, null, null, null, null, null); + + assertThatThrownBy( + () -> new OutboxCanonicalTransportRequirementValidator(settings, null).validate()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("canonical-transport-enabled") + .hasMessageContaining("IntegrationEventPublishPort"); + } + + @Test + void disabledCanonicalTransportDoesNotRequirePublisher() { + OutboxSettings settings = new OutboxSettings(true, false, null, null, null, null, null); + + assertThatCode( + () -> new OutboxCanonicalTransportRequirementValidator(settings, null).validate()) + .doesNotThrowAnyException(); + } + + @Test + void enabledCanonicalTransportWithPublisherStarts() { + OutboxSettings settings = new OutboxSettings(true, true, null, null, null, null, null); + IntegrationEventPublishPort publisher = + event -> CompletableFuture.completedFuture(OutboxPublishOutcome.CONFIRMED); + + assertThatCode( + () -> new OutboxCanonicalTransportRequirementValidator(settings, publisher).validate()) + .doesNotThrowAnyException(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxRelayBrokerRequirementValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxRelayBrokerRequirementValidatorTest.java index 1043be33..20740eec 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxRelayBrokerRequirementValidatorTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxRelayBrokerRequirementValidatorTest.java @@ -52,6 +52,19 @@ class OutboxRelayBrokerRequirementValidatorTest { .doesNotThrowAnyException(); } + @Test + @DisplayName("canonical transport does not waive the legacy broker during the mixed-row window") + void canonicalTransportStillRequiresLegacyBrokerDuringCompatibilityWindow() { + MockEnvironment environment = new MockEnvironment(); + environment.setProperty("ca-skeleton.outbox.relay-enabled", "true"); + environment.setProperty("ca-skeleton.outbox.canonical-transport-enabled", "true"); + environment.setProperty("app.messaging.broker", ""); + + assertThatThrownBy(() -> new OutboxRelayBrokerRequirementValidator(environment).validate()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("app.messaging.broker"); + } + @Test @DisplayName("a disabled relay with no broker starts, and its PENDING rows are preserved") void aDisabledRelayWithNoBrokerStarts() { diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxSettingsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxSettingsTest.java index 5ea26086..24f1c649 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxSettingsTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/outbox/OutboxSettingsTest.java @@ -15,8 +15,9 @@ class OutboxSettingsTest { @Test void bindsAllSixDefaultsWhenFieldsAreNull() { - OutboxSettings props = new OutboxSettings(null, null, null, null, null, null); + OutboxSettings props = new OutboxSettings(null, null, null, null, null, null, null); assertThat(props.relayEnabled()).isTrue(); + assertThat(props.canonicalTransportEnabled()).isFalse(); assertThat(props.pollInterval()).isEqualTo(Duration.ofSeconds(5)); assertThat(props.batchSize()).isEqualTo(20); assertThat(props.inFlightTimeout()).isEqualTo(Duration.ofMinutes(5)); @@ -29,12 +30,14 @@ class OutboxSettingsTest { OutboxSettings props = new OutboxSettings( false, + true, Duration.ofSeconds(10), 50, Duration.ofMinutes(2), Duration.ofHours(1), Duration.ofDays(14)); assertThat(props.relayEnabled()).isFalse(); + assertThat(props.canonicalTransportEnabled()).isTrue(); assertThat(props.pollInterval()).isEqualTo(Duration.ofSeconds(10)); assertThat(props.batchSize()).isEqualTo(50); assertThat(props.inFlightTimeout()).isEqualTo(Duration.ofMinutes(2)); @@ -44,35 +47,35 @@ class OutboxSettingsTest { @Test void rejectsZeroBatchSize() { - assertThatThrownBy(() -> new OutboxSettings(true, null, 0, null, null, null)) + assertThatThrownBy(() -> new OutboxSettings(true, false, null, 0, null, null, null)) .isInstanceOf(StartupValidationException.class) .hasMessageContaining("batchSize"); } @Test void rejectsNegativeBatchSize() { - assertThatThrownBy(() -> new OutboxSettings(true, null, -1, null, null, null)) + assertThatThrownBy(() -> new OutboxSettings(true, false, null, -1, null, null, null)) .isInstanceOf(StartupValidationException.class) .hasMessageContaining("batchSize"); } @Test void rejectsNonPositivePollInterval() { - assertThatThrownBy(() -> new OutboxSettings(true, Duration.ZERO, null, null, null, null)) + assertThatThrownBy(() -> new OutboxSettings(true, false, Duration.ZERO, null, null, null, null)) .isInstanceOf(StartupValidationException.class) .hasMessageContaining("pollInterval"); } @Test void rejectsNonPositiveInFlightTimeout() { - assertThatThrownBy(() -> new OutboxSettings(true, null, null, Duration.ZERO, null, null)) + assertThatThrownBy(() -> new OutboxSettings(true, false, null, null, Duration.ZERO, null, null)) .isInstanceOf(StartupValidationException.class) .hasMessageContaining("inFlightTimeout"); } @Test void rejectsZeroReaperInterval() { - assertThatThrownBy(() -> new OutboxSettings(true, null, null, null, Duration.ZERO, null)) + assertThatThrownBy(() -> new OutboxSettings(true, false, null, null, null, Duration.ZERO, null)) .isInstanceOf(StartupValidationException.class) .hasMessageContaining("reaperInterval"); } @@ -80,21 +83,22 @@ class OutboxSettingsTest { @Test void rejectsNegativeReaperInterval() { assertThatThrownBy( - () -> new OutboxSettings(true, null, null, null, Duration.ofMinutes(-1), null)) + () -> new OutboxSettings(true, false, null, null, null, Duration.ofMinutes(-1), null)) .isInstanceOf(StartupValidationException.class) .hasMessageContaining("reaperInterval"); } @Test void rejectsZeroPublishedRetention() { - assertThatThrownBy(() -> new OutboxSettings(true, null, null, null, null, Duration.ZERO)) + assertThatThrownBy(() -> new OutboxSettings(true, false, null, null, null, null, Duration.ZERO)) .isInstanceOf(StartupValidationException.class) .hasMessageContaining("publishedRetention"); } @Test void rejectsNegativePublishedRetention() { - assertThatThrownBy(() -> new OutboxSettings(true, null, null, null, null, Duration.ofHours(-1))) + assertThatThrownBy( + () -> new OutboxSettings(true, false, null, null, null, null, Duration.ofHours(-1))) .isInstanceOf(StartupValidationException.class) .hasMessageContaining("publishedRetention"); } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java index 93b87c7f..e4408509 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java @@ -31,39 +31,29 @@ class ConditionalTransportQualificationContractTest { @Test void ownerQualificationsNameEveryRequiredWireClassAndRootOnlyAggregates() throws IOException { Path root = repositoryRoot(); - String convention = - Files.readString( - root.resolve("src/build-logic/src/main/groovy/ca.strict-qualification.gradle")); String rootBuild = Files.readString(root.resolve("src/build.gradle")); String graphql = Files.readString(root.resolve("src/adapter/inbound/graphql/build.gradle")); String grpc = Files.readString(root.resolve("src/adapter/inbound/grpc/build.gradle")); String websocket = Files.readString(root.resolve("src/adapter/inbound/websocket/build.gradle")); - assertThat(convention) - .contains("failOnNoMatchingTests = true") - .contains("failOnNoDiscoveredTests = true") - .contains("forbids skipped tests") - .contains("verifyRequiredJUnitClasses"); assertThat(graphql) - .contains("registerStrictQualificationTest") + .contains("extensions.getByName('strictQualification').register(") .contains("dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest"); assertThat(grpc) - .contains("registerStrictQualificationTest") + .contains("extensions.getByName('strictQualification').register(") .contains("dev.caskeleton.adapter.inbound.grpc.GrpcSafeActivationTest") .contains("dev.caskeleton.adapter.inbound.grpc.GrpcP1BoundaryWireTest"); assertThat(websocket) - .contains("registerStrictQualificationTest") + .contains("extensions.getByName('strictQualification').register(") .contains( "dev.caskeleton.adapter.inbound.websocket.stomp.WebSocketBoundaryQualificationTest"); - // The root aggregates and knows no test class name. A root that named the wire classes would be - // a second place to update when a leaf renames one, and the leaf's own lane is the one that - // fails closed on a class it cannot discover. + // The root delegates aggregation to the Java convention and knows no wire-test class name. + // The convention's TestKit contract owns the concrete aggregate task graph; this repository + // contract only prevents the root script from growing a second implementation of it. assertThat(rootBuild) - .contains("tasks.register('conditionalTransportQualification')") - .contains(":adapter:inbound:graphql:graphqlTransportQualificationTest") - .contains(":adapter:inbound:grpc:grpcTransportQualificationTest") - .contains(":adapter:inbound:websocket:websocketTransportQualificationTest") + .contains("id 'ca.conditional-transport-qualification'") + .doesNotContain("tasks.register('conditionalTransportQualification')") .doesNotContain("registerConditionalTransportQualificationTest") .doesNotContain("GraphqlHttpBoundaryQualificationTest") .doesNotContain("GrpcP1BoundaryWireTest") diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java index 0b501c61..ef50db6c 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java @@ -33,19 +33,21 @@ class DeveloperExperienceContractTest { } @Test - void bootstrapIsOneOrderedFourStageGradleEntrypoint() throws IOException { + void bootstrapEntrypointIsOwnedByDeveloperBootstrapPlugin() throws IOException { String build = read("src/build.gradle"); + // Registration and ordering are executable behavior and are covered by + // DeveloperBootstrapPluginFunctionalTest. The repository contract only fixes ownership: the + // root applies one Java convention and must not duplicate the bootstrap implementation in + // Groovy. assertThat(build) - .contains("tasks.register('bootstrapCompile')") - .contains("tasks.register('bootstrapDependencies'") - .contains("tasks.register('bootstrapMigrateAndStart'") - .contains("tasks.register('bootstrapSmoke'") - .contains("tasks.register('bootstrap')"); - assertThat(build) - .contains("bootstrapDependencies.configure { dependsOn bootstrapCompile }") - .contains("bootstrapMigrateAndStart.configure { dependsOn bootstrapDependencies }") - .contains("bootstrapSmoke.configure { dependsOn bootstrapMigrateAndStart }"); + .contains("id 'ca.developer-bootstrap'") + .doesNotContain("tasks.register('bootstrapCompile')") + .doesNotContain("tasks.register('bootstrapDockerPreflight')") + .doesNotContain("tasks.register('bootstrapDependencies')") + .doesNotContain("tasks.register('bootstrapMigrateAndStart')") + .doesNotContain("tasks.register('bootstrapSmoke')") + .doesNotContain("tasks.register('bootstrap')"); } @Test diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java index a1c2edc3..6eee2645 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java @@ -352,56 +352,6 @@ class MessagingCapabilityRegistryContractTest { .containsEntry("minLength", 1); } - @Test - void noUnimplementedQualificationIsRegisteredAsAGradleTask() throws Exception { - // The inverse of the assertion that used to be here. - // - // This test previously required the root build to register all nine R2 skeleton tasks and to - // route them through a guard that threw `FAIL_CLOSED`. It was pinning the existence of tasks - // that could not succeed: a card said "my evidence comes from verifyMessagingSecurityR2", the - // task existed, and running it always failed — so the registry looked wired to a build that - // could substantiate nothing. Registering a task for work with no producer does not make the - // absence safer; it makes the absence look like a gate. - // - // What is worth holding is that they are NOT registered, so nobody wires a release lane to one. - for (Path script : qualificationScripts()) { - String text = Files.readString(script); - for (String planned : PLANNED_VERIFICATION_TASKS) { - assertThat(text) - .as("%s must not register the unimplemented task %s", script.getFileName(), planned) - .doesNotContain("tasks.register('" + planned + "')") - .doesNotContain("tasks.register(\"" + planned + "\")"); - } - } - } - - @Test - void messagingQualificationSchemaValidatesTheEvidenceItWrites() throws Exception { - // Qualification lives in build-qualification/src/main/groovy/ca.messaging-qualification.gradle - // now, not in the - // root build file. The property held here is the one that makes the manifest evidence rather - // than a file: each producer is finalized by a JSON Schema validation of the exact bytes it - // wrote, and the combined producer runs after the JSON-schema one. - String qualification = - Files.readString( - repositorySrcRoot() - .resolve("build-qualification/src/main/groovy/ca.messaging-qualification.gradle")); - - assertThat(qualification) - .contains( - "MessagingEvidenceManifestSchemaValidator", - "finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema", - "dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema", - "finalizedBy validateMessagingContractsEvidenceManifestSchema"); - } - - private static List qualificationScripts() throws Exception { - Path src = repositorySrcRoot(); - return List.of( - src.resolve("build.gradle"), - src.resolve("build-qualification/src/main/groovy/ca.messaging-qualification.gradle")); - } - private static Path requiredConfig(String relativePath) { Path path = repositorySrcRoot().resolve("config/messaging").resolve(relativePath); assertThat(path).as("required Messaging configuration %s", path).isRegularFile(); diff --git a/src/application-core/build.gradle b/src/application-core/build.gradle index 1866233d..35af4c61 100644 --- a/src/application-core/build.gradle +++ b/src/application-core/build.gradle @@ -12,19 +12,20 @@ dependencies { testImplementation libs.jqwik } -def messagingApplicationContractQualification = registerStrictQualificationTest( - name: 'messagingApplicationContractQualificationTest', - sourceSet: sourceSets.test, - requiredClasses: [ +def messagingApplicationContractQualification = extensions.getByName('strictQualification').register( + 'messagingApplicationContractQualificationTest', + sourceSets.test, + [ 'dev.caskeleton.application.messaging.contract.IntegrationEventContractContributionTest', 'dev.caskeleton.application.messaging.event.IntegrationEventDraftTest', 'dev.caskeleton.application.messaging.event.ValidatedIntegrationEventTest' ], - junitXmlOutput: rootProject.layout.buildDirectory.dir( + rootProject.layout.buildDirectory.dir( 'test-results/messaging-evidence/application'), - binaryResultsOutput: rootProject.layout.buildDirectory.dir( + rootProject.layout.buildDirectory.dir( 'test-results/messaging-evidence-binary/application'), - description: 'Runs exact Messaging application contract qualification tests.') + 'Runs exact Messaging application contract qualification tests.' +) messagingApplicationContractQualification.configure { dependsOn ':prepareMessagingContractEvidence' } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventPublishPort.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventPublishPort.java new file mode 100644 index 00000000..5ead377c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventPublishPort.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.messaging.event; + +import dev.caskeleton.application.outbox.OutboxPublishOutcome; +import java.util.concurrent.CompletionStage; + +/** + * Application-owned boundary for publishing one fully validated integration event. + * + *

The application exposes its canonical event model and outcome vocabulary only. Broker, + * transport, codec, and messaging-platform types remain on the outbound side of the boundary. + */ +@FunctionalInterface +public interface IntegrationEventPublishPort { + + CompletionStage publish(ValidatedIntegrationEvent event); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java index 1729d0fa..8dbf3d47 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java @@ -3,8 +3,8 @@ package dev.caskeleton.application.operation; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.transaction.TransactionMode; import dev.caskeleton.application.transaction.TransactionPort; @@ -53,7 +53,7 @@ import java.util.Objects; public class SubmitDurableOperationUseCase { private final DurableOperationStorePort operations; - private final OutboxAppendPort outbox; + private final LegacyOutboxAppendPort outbox; private final TransactionPort transactions; private final Clock clock; @@ -67,7 +67,7 @@ public class SubmitDurableOperationUseCase { */ public SubmitDurableOperationUseCase( DurableOperationStorePort operations, - OutboxAppendPort outbox, + LegacyOutboxAppendPort outbox, TransactionPort transactions, Clock clock) { this.operations = Objects.requireNonNull(operations, "operations"); diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/CanonicalClaimedOutboxEvent.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/CanonicalClaimedOutboxEvent.java new file mode 100644 index 00000000..70c52bf0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/CanonicalClaimedOutboxEvent.java @@ -0,0 +1,46 @@ +package dev.caskeleton.application.outbox; + +import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent; +import java.time.Instant; +import java.util.Objects; + +/** + * Claimed legacy-authority row whose immutable payload is a complete canonical integration event. + */ +public record CanonicalClaimedOutboxEvent( + ValidatedIntegrationEvent event, OutboxEventStatus status, int attemptCount) + implements ClaimedOutboxEvent { + + public CanonicalClaimedOutboxEvent { + Objects.requireNonNull(event, "event must not be null"); + Objects.requireNonNull(status, "status must not be null"); + if (attemptCount < 0) { + throw new IllegalArgumentException("attemptCount must be >= 0, was " + attemptCount); + } + } + + @Override + public String eventId() { + return event.eventId().value(); + } + + @Override + public String eventType() { + return event.contractId().value(); + } + + @Override + public String aggregateId() { + return event.aggregate().aggregateId(); + } + + @Override + public String correlationId() { + return event.correlationId(); + } + + @Override + public Instant occurredAt() { + return event.occurredAt(); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/ClaimedOutboxEvent.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/ClaimedOutboxEvent.java new file mode 100644 index 00000000..cbbd1e51 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/ClaimedOutboxEvent.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.outbox; + +import java.time.Instant; + +/** + * Relay-facing claimed row identity shared by legacy and canonical outbox generations. + * + *

Only fields needed by the legacy relay state machine and diagnostics live here. Payload and + * canonical envelope data remain on their concrete subtype. + */ +public sealed interface ClaimedOutboxEvent permits OutboxEvent, CanonicalClaimedOutboxEvent { + + String eventId(); + + String eventType(); + + String aggregateId(); + + String correlationId(); + + Instant occurredAt(); + + OutboxEventStatus status(); + + int attemptCount(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxAppendPort.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxAppendPort.java new file mode 100644 index 00000000..1c3febba --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxAppendPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.outbox; + +/** + * Compatibility-only append boundary for the pre-canonical outbox payload model. + * + *

New integration-event code must use {@link OutboxAppendPort}. This port remains only while + * legacy/sample producers still persist {@link NewOutboxEvent} rows through the transport-only + * cutover window. + */ +@FunctionalInterface +public interface LegacyOutboxAppendPort { + + void append(NewOutboxEvent event); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAppendPort.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAppendPort.java index 734fbb36..62390e05 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAppendPort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAppendPort.java @@ -1,19 +1,16 @@ package dev.caskeleton.application.outbox; +import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent; + /** - * Outbound port for appending a new event to the transactional outbox. Dual-write - * prohibition: must be called inside the same DB transaction as the business operation - * that generates the event ({@code TransactionPort.inWrite(...)}, opened by the caller); - * implementations must not open their own transaction. See README. + * Canonical durable append boundary for a fully validated integration event. + * + *

The implementation participates in the caller's existing write transaction and persists the + * exact validated envelope bytes. Raw legacy payload append is intentionally separated into {@link + * LegacyOutboxAppendPort}. */ +@FunctionalInterface public interface OutboxAppendPort { - /** - * Appends {@code event} to the outbox table, participating in the caller's existing write - * transaction. Calling outside {@code TransactionPort.inWrite(...)} is a contract violation - * (silent event loss). - * - * @param event the new event to persist; must not be {@code null} - */ - void append(NewOutboxEvent event); + void append(ValidatedIntegrationEvent event); } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java index ab49e958..9d3a8fa1 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java @@ -31,7 +31,8 @@ public record OutboxEvent( String correlationId, String idempotencyKey, OutboxEventStatus status, - int attemptCount) { + int attemptCount) + implements ClaimedOutboxEvent { public OutboxEvent { Objects.requireNonNull(eventId, "eventId"); diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java index c8262525..39cb6972 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java @@ -16,7 +16,7 @@ public interface OutboxMessagePublishPort { * @param event the claimed outbox event to publish; must not be {@code null} * @throws RuntimeException if the publish fails for any reason */ - void publish(OutboxEvent event); + void publish(ClaimedOutboxEvent event); /** * Publishes and reports what was achieved. @@ -29,7 +29,7 @@ public interface OutboxMessagePublishPort { * @param event the claimed outbox event * @return what the attempt achieved */ - default OutboxPublishOutcome publishForOutcome(OutboxEvent event) { + default OutboxPublishOutcome publishForOutcome(ClaimedOutboxEvent event) { publish(event); // A method that either returns or throws can only report these two. Reporting CONFIRMED here // is honest for such an adapter — it is what "returned without throwing" has always meant — diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java index ee123a66..9de958f6 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java @@ -24,7 +24,7 @@ public interface OutboxStorePort { * @return claimed events ({@code status = IN_FLIGHT}, incremented {@code attemptCount}); empty if * none */ - List claimBatch(int batchSize, Instant now, Duration inFlightTimeout); + List claimBatch(int batchSize, Instant now, Duration inFlightTimeout); /** * Marks the event as successfully published. Must be called inside {@code diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java index 5ea05776..b420b4f4 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java @@ -87,7 +87,8 @@ public class PublishPendingOutboxEventsUseCase Instant now = clock.instant(); // Step 1: Claim a batch inside a short write transaction. - List claimed = tx.inWrite(() -> store.claimBatch(batchSize, now, inFlightTimeout)); + List claimed = + tx.inWrite(() -> store.claimBatch(batchSize, now, inFlightTimeout)); if (claimed.isEmpty()) { return new OutboxRelayResult(0, List.of()); @@ -95,12 +96,12 @@ public class PublishPendingOutboxEventsUseCase // Step 2: Defensive sort by occurredAt ascending (relay enforces FIFO even if the adapter does // not). - List sorted = new ArrayList<>(claimed); - sorted.sort(Comparator.comparing(OutboxEvent::occurredAt)); + List sorted = new ArrayList<>(claimed); + sorted.sort(Comparator.comparing(ClaimedOutboxEvent::occurredAt)); // Step 3: Publish each event outside any transaction; drive status machine per result. List outcomes = new ArrayList<>(sorted.size()); - for (OutboxEvent event : sorted) { + for (ClaimedOutboxEvent event : sorted) { OutboxRelayResult.Outcome outcome = publishOne(event, now); outcomes.add(new OutboxRelayResult.EventOutcome(event.eventId(), event.eventType(), outcome)); } @@ -114,7 +115,7 @@ public class PublishPendingOutboxEventsUseCase * successful publish is NOT caught — it propagates so the row is recovered via the in-flight * timeout. See README for both failure modes. */ - private OutboxRelayResult.Outcome publishOne(OutboxEvent event, Instant now) { + private OutboxRelayResult.Outcome publishOne(ClaimedOutboxEvent event, Instant now) { OutboxPublishOutcome achieved; try { // The four-valued call, not the throwing one. An adapter that cannot tell an ambiguous @@ -148,7 +149,7 @@ public class PublishPendingOutboxEventsUseCase * failure is contained and cannot change the persisted outcome. See README. */ private OutboxRelayResult.Outcome handlePublishFailure( - OutboxEvent event, Instant now, RuntimeException cause) { + ClaimedOutboxEvent event, Instant now, RuntimeException cause) { if (event.attemptCount() >= backoffPolicy.maxAttempts()) { // All attempts exhausted — DEAD-letter the event. @@ -171,7 +172,7 @@ public class PublishPendingOutboxEventsUseCase } } - private OutboxRelayResult.Outcome deadLetter(OutboxEvent event, RuntimeException cause) { + private OutboxRelayResult.Outcome deadLetter(ClaimedOutboxEvent event, RuntimeException cause) { tx.inWrite(() -> store.markDead(event.eventId())); reportFailure( () -> diff --git a/src/application-core/src/test/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCaseTest.java index 96463c97..fd3ceb29 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCaseTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCaseTest.java @@ -4,8 +4,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.transaction.TransactionPort; import java.time.Clock; import java.time.Duration; @@ -148,7 +148,7 @@ class SubmitDurableOperationUseCaseTest { .hasMessageContaining("reaches no worker"); } - private static final class RecordingOutbox implements OutboxAppendPort { + private static final class RecordingOutbox implements LegacyOutboxAppendPort { private final List appended = new ArrayList<>(); private final List appendedOutsideTransaction = new ArrayList<>(); diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/ClaimedOutboxEventContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/ClaimedOutboxEventContractTest.java new file mode 100644 index 00000000..5efc9781 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/ClaimedOutboxEventContractTest.java @@ -0,0 +1,78 @@ +package dev.caskeleton.application.outbox; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.messaging.contract.ContractId; +import dev.caskeleton.application.messaging.contract.LogicalDestinationId; +import dev.caskeleton.application.messaging.contract.Sha256; +import dev.caskeleton.application.messaging.event.AggregateIdentity; +import dev.caskeleton.application.messaging.event.AggregateOrder; +import dev.caskeleton.application.messaging.event.EventId; +import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ClaimedOutboxEventContractTest { + + @Test + void canonicalClaimExposesRelayIdentityWithoutLosingValidatedEvent() { + ValidatedIntegrationEvent event = canonicalEvent(); + + ClaimedOutboxEvent claimed = + new CanonicalClaimedOutboxEvent(event, OutboxEventStatus.IN_FLIGHT, 2); + + assertThat(claimed.eventId()).isEqualTo(event.eventId().value()); + assertThat(claimed.eventType()).isEqualTo(event.contractId().value()); + assertThat(claimed.aggregateId()).isEqualTo(event.aggregate().aggregateId()); + assertThat(claimed.correlationId()).isEqualTo(event.correlationId()); + assertThat(claimed.occurredAt()).isEqualTo(event.occurredAt()); + assertThat(claimed.status()).isEqualTo(OutboxEventStatus.IN_FLIGHT); + assertThat(claimed.attemptCount()).isEqualTo(2); + assertThat(((CanonicalClaimedOutboxEvent) claimed).event()).isEqualTo(event); + } + + @Test + void legacyOutboxEventRemainsAClaimedSubtype() { + ClaimedOutboxEvent claimed = + new OutboxEvent( + "legacy-1", + "LegacyEvent", + "aggregate-1", + "{}", + Instant.parse("2026-09-18T03:00:00Z"), + "corr-1", + "legacy-1", + OutboxEventStatus.IN_FLIGHT, + 1); + + assertThat(claimed).isInstanceOf(OutboxEvent.class); + } + + private static ValidatedIntegrationEvent canonicalEvent() { + String partition = "a".repeat(64); + Sha256 zero = new Sha256(new byte[32]); + return new ValidatedIntegrationEvent( + new EventId("01994e11-4d88-7000-8000-000000000001"), + new ContractId("portfolio.worklog.reserved"), + 1, + 3, + new LogicalDestinationId("portfolio-domain-events"), + new AggregateIdentity("tenant-a", "worklog", "worklog-42"), + new AggregateOrder(17, 2), + Instant.parse("2026-09-18T03:00:00Z"), + "corr-1", + Optional.empty(), + partition, + partition.getBytes(StandardCharsets.US_ASCII), + "{\"wire\":\"exact\"}".getBytes(StandardCharsets.UTF_8), + "application/json", + zero, + zero, + zero, + zero, + "catalog-r1", + "binding-r1"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxAppendPortBoundaryTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxAppendPortBoundaryTest.java new file mode 100644 index 00000000..472b67ef --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxAppendPortBoundaryTest.java @@ -0,0 +1,28 @@ +package dev.caskeleton.application.outbox; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent; +import java.lang.reflect.Method; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class OutboxAppendPortBoundaryTest { + + @Test + void canonicalAndLegacyAppendPortsHaveDisjointTypedInputs() { + assertThat(singleAppendParameter(OutboxAppendPort.class)) + .isEqualTo(ValidatedIntegrationEvent.class); + assertThat(singleAppendParameter(LegacyOutboxAppendPort.class)).isEqualTo(NewOutboxEvent.class); + } + + private static Class singleAppendParameter(Class portType) { + Method append = + Arrays.stream(portType.getDeclaredMethods()) + .filter(method -> method.getName().equals("append")) + .findFirst() + .orElseThrow(); + assertThat(append.getParameterCount()).isEqualTo(1); + return append.getParameterTypes()[0]; + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutcomeAwareRelayTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutcomeAwareRelayTest.java index 744016fd..2b34055e 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutcomeAwareRelayTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutcomeAwareRelayTest.java @@ -165,14 +165,14 @@ class OutcomeAwareRelayTest { private RuntimeException failure; @Override - public void publish(OutboxEvent event) { + public void publish(ClaimedOutboxEvent event) { if (failure != null) { throw failure; } } @Override - public OutboxPublishOutcome publishForOutcome(OutboxEvent event) { + public OutboxPublishOutcome publishForOutcome(ClaimedOutboxEvent event) { if (failure != null) { throw failure; } @@ -183,7 +183,7 @@ class OutcomeAwareRelayTest { /** Records the transitions the relay drove. */ private static final class OutcomeStore implements OutboxStorePort { - private final List claimable = new ArrayList<>(); + private final List claimable = new ArrayList<>(); private final List published = new ArrayList<>(); @@ -192,8 +192,9 @@ class OutcomeAwareRelayTest { private final List dead = new ArrayList<>(); @Override - public List claimBatch(int batchSize, Instant now, Duration inFlightTimeout) { - List claimed = List.copyOf(claimable); + public List claimBatch( + int batchSize, Instant now, Duration inFlightTimeout) { + List claimed = List.copyOf(claimable); claimable.clear(); return claimed; } diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java index c56dc63e..29a42201 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java @@ -450,7 +450,7 @@ class PublishPendingOutboxEventsUseCaseTest { // ---- test doubles ---- static class FakeOutboxStorePort implements OutboxStorePort { - final List claimable = new ArrayList<>(); + final List claimable = new ArrayList<>(); final List publishedEvents = new ArrayList<>(); final Map failedEvents = new LinkedHashMap<>(); final List deadEvents = new ArrayList<>(); @@ -460,7 +460,8 @@ class PublishPendingOutboxEventsUseCaseTest { } @Override - public List claimBatch(int batchSize, Instant now, Duration inFlightTimeout) { + public List claimBatch( + int batchSize, Instant now, Duration inFlightTimeout) { return List.copyOf(claimable); } @@ -526,7 +527,8 @@ class PublishPendingOutboxEventsUseCaseTest { } @Override - public List claimBatch(int batchSize, Instant now, Duration inFlightTimeout) { + public List claimBatch( + int batchSize, Instant now, Duration inFlightTimeout) { if (batchSize == 0 || currentStatus == OutboxEventStatus.PUBLISHED || currentStatus == OutboxEventStatus.DEAD @@ -638,7 +640,7 @@ class PublishPendingOutboxEventsUseCaseTest { } @Override - public void publish(OutboxEvent event) { + public void publish(ClaimedOutboxEvent event) { if (failureMap.containsKey(event.eventId())) { throw failureMap.get(event.eventId()); } diff --git a/src/build-logic/build.gradle b/src/build-logic/build.gradle index 266d64ed..75c6ab8a 100644 --- a/src/build-logic/build.gradle +++ b/src/build-logic/build.gradle @@ -1,10 +1,9 @@ -// 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' + id 'java-gradle-plugin' } dependencies { + implementation libs.jackson3.databind // The third-party Gradle plugins the convention plugins apply. // // The root build used to apply these to every leaf from `configure(subprojects)`, and the @@ -17,14 +16,131 @@ dependencies { implementation "net.ltgt.gradle:gradle-errorprone-plugin:${libs.versions.errorpronePlugin.get()}" implementation "io.spring.gradle:dependency-management-plugin:${libs.versions.springDependencyManagement.get()}" - // 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. + // TestKit verifies the binary plugins against the running Gradle distribution. testImplementation gradleTestKit() - testImplementation libs.spock.core.groovy4 testImplementation libs.junit.jupiter + testImplementation libs.assertj.core testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } + +gradlePlugin { + plugins { + publicPathSnapshot { + id = 'ca.public-path-snapshot' + implementationClass = 'dev.caskeleton.buildlogic.publicpath.PublicPathSnapshotPlugin' + } + archiveHygiene { + id = 'ca.archive-hygiene' + implementationClass = 'dev.caskeleton.buildlogic.archive.ArchiveHygienePlugin' + } + auxiliarySourceSet { + id = 'ca.auxiliary-source-set' + implementationClass = 'dev.caskeleton.buildlogic.auxiliary.AuxiliarySourceSetPlugin' + } + releaseProvenance { + id = 'ca.release-provenance' + implementationClass = 'dev.caskeleton.buildlogic.release.ReleaseProvenancePlugin' + } + apiSurface { + id = 'ca.api-surface' + implementationClass = 'dev.caskeleton.buildlogic.apisurface.ApiSurfacePlugin' + } + evidence { + id = 'ca.evidence' + implementationClass = 'dev.caskeleton.buildlogic.EvidencePlugin' + } + conditionalTransportQualification { + id = 'ca.conditional-transport-qualification' + implementationClass = 'dev.caskeleton.buildlogic.ConditionalTransportQualificationPlugin' + } + dependencyPolicy { + id = 'ca.dependency-policy' + implementationClass = 'dev.caskeleton.buildlogic.dependency.DependencyPolicyPlugin' + } + runtimeMembership { + id = 'ca.runtime-membership' + implementationClass = 'dev.caskeleton.buildlogic.runtime.RuntimeMembershipPlugin' + } + architecture { + id = 'ca.architecture' + implementationClass = 'dev.caskeleton.buildlogic.architecture.ArchitecturePlugin' + } + testJvmAgents { + id = 'ca.test-jvm-agents' + implementationClass = 'dev.caskeleton.buildlogic.testagent.TestJvmAgentsPlugin' + } + jmhBenchmarks { + id = 'ca.jmh-benchmarks' + implementationClass = 'dev.caskeleton.buildlogic.jmh.JmhBenchmarksPlugin' + } + graphqlPlatform { + id = 'ca.graphql-platform' + implementationClass = 'dev.caskeleton.buildlogic.graphql.GraphQlPlatformPlugin' + } + javaConventions { + id = 'ca.java-conventions' + implementationClass = 'dev.caskeleton.buildlogic.java.JavaConventionsPlugin' + } + developerBootstrap { + id = 'ca.developer-bootstrap' + implementationClass = 'dev.caskeleton.buildlogic.bootstrap.DeveloperBootstrapPlugin' + } + bootRunDotenv { + id = 'ca.bootrun-dotenv' + implementationClass = 'dev.caskeleton.buildlogic.bootstrap.BootRunDotenvPlugin' + } + qualityConventions { + id = 'ca.quality-conventions' + implementationClass = 'dev.caskeleton.buildlogic.quality.QualityConventionsPlugin' + } + strictTestLane { + id = 'ca.strict-test-lane' + implementationClass = 'dev.caskeleton.buildlogic.strictlane.StrictTestLanePlugin' + } + redisTopologyLane { + id = 'ca.redis-topology-lane' + implementationClass = 'dev.caskeleton.buildlogic.redis.RedisTopologyLanePlugin' + } + jpaTestLanes { + id = 'ca.jpa-test-lanes' + implementationClass = 'dev.caskeleton.buildlogic.jpa.JpaTestLanesPlugin' + } + strictQualification { + id = 'ca.strict-qualification' + implementationClass = 'dev.caskeleton.buildlogic.strictqualification.StrictQualificationPlugin' + } + architectureRegistrySettings { + id = 'ca.architecture-registry' + implementationClass = 'dev.caskeleton.buildlogic.settings.ArchitectureRegistrySettingsPlugin' + } + optionalArchitectureRegistrySettings { + id = 'ca.optional-architecture-registry' + implementationClass = 'dev.caskeleton.buildlogic.settings.OptionalArchitectureRegistrySettingsPlugin' + } + javaLibraryConvention { + id = 'ca.java-library' + implementationClass = 'dev.caskeleton.buildlogic.convention.JavaLibraryConventionPlugin' + } + springLibraryConvention { + id = 'ca.spring-library' + implementationClass = 'dev.caskeleton.buildlogic.convention.SpringLibraryConventionPlugin' + } + springConfigConvention { + id = 'ca.spring-config' + implementationClass = 'dev.caskeleton.buildlogic.convention.SpringConfigConventionPlugin' + } + platformModuleConvention { + id = 'ca.platform-module' + implementationClass = 'dev.caskeleton.buildlogic.convention.PlatformModuleConventionPlugin' + } + grpcPlatformModuleConvention { + id = 'ca.grpc-platform-module' + implementationClass = 'dev.caskeleton.buildlogic.convention.GrpcPlatformModuleConventionPlugin' + } + } +} + tasks.named('test') { useJUnitPlatform() } diff --git a/src/build-logic/settings.gradle b/src/build-logic/settings.gradle index 5ae40e31..7b7bb8ea 100644 --- a/src/build-logic/settings.gradle +++ b/src/build-logic/settings.gradle @@ -13,10 +13,9 @@ dependencyResolutionManagement { // does not see its parent's catalog. // // This is not the dependency the note above warns against. It reads a table of versions, not the - // build those versions configure: nothing here is evaluated, no project is resolved, and the two - // coordinates this build actually uses (Spock, JUnit) are the same two the leaves use. Pinning - // them separately is how build-logic's Spock and the leaves' Spock would drift apart without - // anybody noticing, which is the exact failure the catalog exists to make visible. + // build those versions configure: nothing here is evaluated and no project is resolved. + // Keeping Java/JUnit/plugin coordinates in the shared catalog prevents included-build versions + // from drifting away from the leaf modules they configure. versionCatalogs { libs { from(files('../gradle/libs.versions.toml')) diff --git a/src/build-logic/src/main/groovy/ca.api-surface.gradle b/src/build-logic/src/main/groovy/ca.api-surface.gradle deleted file mode 100644 index 985ef458..00000000 --- a/src/build-logic/src/main/groovy/ca.api-surface.gradle +++ /dev/null @@ -1,206 +0,0 @@ -// 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 rationale = [] - - /** Source root scanned for public top-level types. */ - String sourceRoot = 'src/main/java' - - /** - * Further source roots, for a surface that does not live under one directory. - * - *

Project-relative paths, added to {@code sourceRoot}. A platform whose API and its adapter - * are separate directories has one surface and should not need a second implementation of this - * convention to say so. - */ - List additionalSourceRoots = [] -} - -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" } - -// Resolved at configuration time, so the task action never reaches for `Task.project`. -def sourceRootFiles = { -> - ([apiSurface.sourceRoot] + apiSurface.additionalSourceRoots) - .findAll { it?.trim() } - .collect { project.file(it) } -} - -def renderSurface = { List roots -> - // Parsed with javac, not matched with a regular expression. The expression this replaces kept - // its own hand-maintained list of modifiers — already missing `strictfp` — and a second copy of - // it in another build script had drifted to a different list, so two files disagreed about what - // "public" means. A surface check that under-reports reads as "types were removed", which is the - // one answer it must not produce by accident. - List types - try { - types = dev.caskeleton.buildlogic.JavaPublicTypes.render(roots) - } catch (IllegalStateException unparseable) { - // Not prefixed with the verify task's name: the same render backs the update task, and a - // parse failure reported under the wrong task name sends the reader to the wrong place. - throw new GradleException( - "${owningProjectPath} ${apiSurface.label} API surface: ${unparseable.message}", - unparseable) - } - if (types.isEmpty()) { - // An empty rendering is a moved source root, not a leaf with no public types: it would - // compare equal to nothing and report every committed type as removed, or — after an - // approved update — silently blank the baseline. - throw new GradleException( - "${owningProjectPath}: found no public types under ${roots.join(', ')}. " + - 'The source roots moved; fix the paths rather than accepting an empty surface.') - } - 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.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()) - - List roots = sourceRootFiles() - - 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(roots) - if (!apiSurface.baseline.isFile()) { - throw new GradleException( - "${verifyName()}: missing committed baseline ${apiSurface.baseline}") - } - String committed = apiSurface.baseline.getText('UTF-8') - if (committed != rendered) { - List committedTypes = committed.readLines().findAll { !it.startsWith('#') } - List renderedTypes = rendered.readLines().findAll { !it.startsWith('#') } - List added = (renderedTypes - committedTypes).toSorted() - List 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(roots) - 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()) - } -} diff --git a/src/build-logic/src/main/groovy/ca.architecture-registry.settings.gradle b/src/build-logic/src/main/groovy/ca.architecture-registry.settings.gradle deleted file mode 100644 index 8ebe68fb..00000000 --- a/src/build-logic/src/main/groovy/ca.architecture-registry.settings.gradle +++ /dev/null @@ -1,38 +0,0 @@ -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, `verifyRuntimeModuleRegistry` validates resolved project dependencies against the registry. The registry is the SSOT for the -// leaf list, so it is the SSOT for its length. - -File repositoryRoot = settings.settingsDir.parentFile.canonicalFile -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 -} diff --git a/src/build-logic/src/main/groovy/ca.architecture.gradle b/src/build-logic/src/main/groovy/ca.architecture.gradle deleted file mode 100644 index 81456e90..00000000 --- a/src/build-logic/src/main/groovy/ca.architecture.gradle +++ /dev/null @@ -1,262 +0,0 @@ -import dev.caskeleton.buildlogic.ModuleRegistry -import org.gradle.api.artifacts.component.ModuleComponentIdentifier - -// The architecture rules. Applied to the root project, because their subject is the repository. -// -// These are the invariants the review kept: a dependency direction is what a Clean Architecture -// skeleton *is*, so it is worth automating, and it is worth having exactly one implementation of. -// They used to sit in the middle of a 3,200-line root build file next to a README command parser and -// a JPA certification registry, which is why they are here instead. -// -// One `architectureCheck`, not a dependency on every leaf's `check`. - -tasks.register('verifyCleanArchitectureDependencies') { - group = 'verification' - description = 'Verifies Clean Architecture project dependency direction.' - - File moduleRegistryFile = rootProject.file('config/architecture/modules.json') - inputs.file(moduleRegistryFile) - - // Reuse the same parser/validation implementation as the settings plugin. Parsing the small - // registry twice is intentional: it avoids hidden Settings -> Gradle global state while keeping - // exactly one definition of a valid registry. Source paths are repository-root-relative. - def registry = ModuleRegistry.read(moduleRegistryFile, rootProject.projectDir.parentFile.canonicalFile) - - // Registry-shape rules that used to run in settings, moved here. - // - // An unknown or self-referential `allowed_dependencies` entry is a real defect, but failing on - // it in settings meant failing before any project existed — no task could run, `--dry-run` - // could not run, and a derived project that mistyped an id had no way to reach a diagnostic - // other than editing the registry blind. Here the same mistake is a named task failure. - def productionModules = registry.modules.findAll { it.id != 'sample-portfolio' } - List registryViolations = [] - productionModules.each { module -> - module.allowedDependencies.each { String dependencyId -> - if (dependencyId == module.id) { - registryViolations << "'${module.id}' declares itself as an allowed dependency" - } else if (registry.byId(dependencyId) == null) { - registryViolations << "'${module.id}' allows unknown dependency id '${dependencyId}'" - } - } - } - - Map> allowedProjectDependencies = productionModules.collectEntries { module -> - String moduleName = module.gradlePath.replaceFirst('^:', '') - Set allowed = module.allowedDependencies - .collect { registry.byId(it) } - .findAll { it != null && it.id != 'sample-portfolio' } - .collect { it.gradlePath.replaceFirst('^:', '') } - .toSet() - [(moduleName): allowed] - } - - doLast { - if (!registryViolations.isEmpty()) { - throw new GradleException( - "config/architecture/modules.json declares impossible edges:\n " + - registryViolations.join('\n ')) - } - - Set declaredModules = rootProject.subprojects.findAll { - it.childProjects.isEmpty() && it.path != ':sample-portfolio' - }.collect { it.path.replaceFirst('^:', '') }.toSet() - Set governedModules = allowedProjectDependencies.keySet() - Set missingFromBuild = governedModules - declaredModules - Set missingFromPolicy = declaredModules - governedModules - - if (!missingFromBuild.isEmpty()) { - throw new GradleException( - "Clean Architecture dependency policy references missing Gradle modules ${missingFromBuild}. " + - "Declared modules are ${declaredModules}." - ) - } - - if (!missingFromPolicy.isEmpty()) { - throw new GradleException( - "Gradle modules ${missingFromPolicy} are not covered by verifyCleanArchitectureDependencies. " + - "Add an explicit dependency policy before using them." - ) - } - - allowedProjectDependencies.each { moduleName, allowed -> - Project module = rootProject.project(":${moduleName}") - Set actual = ['api', 'implementation', 'compileOnly', 'runtimeOnly'] - .collect { configurationName -> module.configurations.findByName(configurationName) } - .findAll { it != null } - .collectMany { configuration -> - configuration.dependencies.withType(ProjectDependency).collect { dependency -> - dependency.path.replaceFirst('^:', '') - } - } - .toSet() - - Set forbidden = actual - allowed - if (!forbidden.isEmpty()) { - throw new GradleException( - "Module ':${moduleName}' has forbidden project dependencies ${forbidden}. " + - "Allowed dependencies are ${allowed}; all production project edges " + - "must be explicitly registered." - ) - } - } - } -} - -Project applicationCoreProject = rootProject.project(':application-core') -tasks.register('verifyApplicationCoreDependencyPurity') { - group = 'verification' - description = 'Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths.' - notCompatibleWithConfigurationCache('Inspects project configurations at execution time') - - doLast { - Project application = applicationCoreProject - List violations = [] - - ['api', 'implementation', 'compileOnly', 'runtimeOnly'].each { configurationName -> - def configuration = application.configurations.findByName(configurationName) - if (configuration == null) { - return - } - configuration.dependencies.each { dependency -> - if (!(dependency instanceof ProjectDependency)) { - violations << "${configurationName}: non-project production dependency " + - "${dependency.group ?: ''}:${dependency.name}" - } - } - } - - Closure forbiddenGroup = { String groupName -> - groupName != null && ( - groupName.startsWith('org.springframework') || - groupName == 'org.slf4j' || - groupName == 'ch.qos.logback' || - groupName == 'org.apache.logging.log4j' || - groupName == 'io.micrometer') - } - ['compileClasspath', 'runtimeClasspath', 'testCompileClasspath', 'testRuntimeClasspath'] - .each { configurationName -> - def configuration = application.configurations.getByName(configurationName) - configuration.incoming.resolutionResult.allComponents.each { component -> - if (component.id instanceof ModuleComponentIdentifier && - forbiddenGroup(component.id.group)) { - violations << "${configurationName}: forbidden resolved dependency " + - "${component.id.group}:${component.id.module}:${component.id.version}" - } - } - } - - if (!violations.isEmpty()) { - throw new GradleException( - "verifyApplicationCoreDependencyPurity: ${violations.size()} violation(s):\n " + - violations.toSorted().join('\n ')) - } - logger.lifecycle( - 'verifyApplicationCoreDependencyPurity: OK — application-core production declarations are project-only and application classpaths contain no Spring/logging/metrics frameworks.') - } -} - -// verifyNoIgnoredSourcePackages — a Java package must never be invisible to Git. -// -// `src/.gitignore` carries an unanchored `build/` rule so every leaf's Gradle output directory is -// ignored at any depth. That rule cannot tell a build directory from a Java package, so a package -// named `build` is silently dropped from every commit. The GraphQL leaf lost its entire module -// boundary model that way: production code still imported the types, the author's working copy still -// compiled, and a fresh checkout failed with seven "package does not exist" errors. -// -// Kept where most of this file's neighbours were deleted, because it is an invariant rather than a -// snapshot: no source file may be one a fresh checkout would not carry. Nothing else can answer it — -// it is a question about the ignore rules, not about the code. -tasks.register('verifyNoIgnoredSourcePackages') { - group = 'verification' - description = 'Fails when a Java source file lives in a package that Git ignores or would ignore.' - - doLast { - Set outputDirectoryNames = ['build', 'out', 'target', 'bin', 'classes'] as Set - List violations = [] - List sourceFiles = [] - - rootProject.subprojects.findAll { it.path != ':sample-portfolio' }.each { sub -> - ['src/main/java', 'src/test/java'].each { String sourceRootPath -> - File sourceRoot = sub.file(sourceRootPath) - if (!sourceRoot.isDirectory()) { - return - } - sourceRoot.eachFileRecurse { File candidate -> - if (!candidate.isFile() || !candidate.name.endsWith('.java')) { - return - } - sourceFiles << candidate - String relative = sourceRoot.toPath().relativize(candidate.toPath()).toString() - List packageSegments = relative.split('/').toList().dropRight(1) - packageSegments.findAll { outputDirectoryNames.contains(it) }.each { String segment -> - violations << ("${candidate.path}: package segment '${segment}' collides with a " + - 'build output directory name').toString() - } - } - } - } - - if (sourceFiles.isEmpty()) { - throw new GradleException( - 'verifyNoIgnoredSourcePackages: found no Java sources at all; the gate would pass vacuously.') - } - - Closure runGit = { List command, String stdin -> - try { - Process process = new ProcessBuilder(command) - .directory(rootProject.projectDir) - .redirectErrorStream(false) - .start() - if (stdin != null) { - process.outputStream.withWriter('UTF-8') { it.write(stdin) } - } else { - process.outputStream.close() - } - String output = process.inputStream.getText('UTF-8') - process.errorStream.getText('UTF-8') - process.waitFor() - return output - } catch (IOException unavailable) { - logger.info("verifyNoIgnoredSourcePackages: git unavailable (${unavailable.message})") - return null - } - } - - String repositoryRoot = runGit(['git', 'rev-parse', '--show-toplevel'], null)?.trim() - - if (repositoryRoot == null || repositoryRoot.isEmpty()) { - logger.lifecycle('verifyNoIgnoredSourcePackages: not a Git checkout; naming rule only.') - } else { - // --no-index asks "would the rules drop this path", which is the question that matters. - // Without it, a file rescued by `git add -f` reports clean while still depending on every - // future contributor remembering to force-add it. - String ignoredOutput = runGit( - ['git', '-C', repositoryRoot, 'check-ignore', '--no-index', '-v', '--stdin'], - sourceFiles.collect { it.path }.join('\n')) - - (ignoredOutput ?: '').readLines().findAll { !it.isBlank() }.each { String line -> - List parts = line.split('\t').toList() - String rule = parts.size() > 1 ? parts[0] : '(unknown rule)' - String path = parts.size() > 1 ? parts[1..-1].join('\t') : line - violations << "${path}: ignored by ${rule}; it will not survive a fresh checkout".toString() - } - } - - if (!violations.isEmpty()) { - throw new GradleException( - "verifyNoIgnoredSourcePackages: ${violations.size()} source file(s) Git cannot carry:\n " + - violations.join('\n ')) - } - logger.lifecycle( - "verifyNoIgnoredSourcePackages: OK — ${sourceFiles.size()} Java sources are all committable.") - } -} - -tasks.register('architectureCheck') { - group = 'verification' - description = 'Runs the repository-wide architecture invariants.' - dependsOn tasks.named('verifyCleanArchitectureDependencies') - dependsOn tasks.named('verifyApplicationCoreDependencyPurity') - dependsOn tasks.named('verifyNoIgnoredSourcePackages') - dependsOn tasks.named('verifyRuntimeModuleMembership') -} diff --git a/src/build-logic/src/main/groovy/ca.archive-hygiene.gradle b/src/build-logic/src/main/groovy/ca.archive-hygiene.gradle deleted file mode 100644 index c985d535..00000000 --- a/src/build-logic/src/main/groovy/ca.archive-hygiene.gradle +++ /dev/null @@ -1,78 +0,0 @@ -import java.util.regex.Pattern -import org.gradle.api.tasks.bundling.Jar - -Closure isTraceableArchiveFor = { Jar archiveTask, String fileName -> - String baseName = Pattern.quote(archiveTask.archiveBaseName.get()) - String classifier = archiveTask.archiveClassifier.orNull - String classifierPart = classifier == null || classifier.isBlank() - ? '' - : "-${Pattern.quote(classifier)}" - fileName ==~ /^${baseName}-\d+\.\d+\.\d+\+[0-9a-f]{7,40}${classifierPart}\.jar$/ -} - -Closure> staleTraceableArchivesFor = { Jar archiveTask -> - File outputDirectory = archiveTask.destinationDirectory.get().asFile - if (!outputDirectory.isDirectory()) { - return [] - } - - String currentName = archiveTask.archiveFileName.get() - List stale = outputDirectory.listFiles({ File ignored, String fileName -> - isTraceableArchiveFor(archiveTask, fileName) && fileName != currentName - } as FilenameFilter)?.toList() ?: [] - stale.sort { it.name } -} - -tasks.register('cleanStaleTraceableJars') { - group = 'build' - description = 'Explicitly deletes older git-revision JARs from leaf build/libs directories.' - notCompatibleWithConfigurationCache( - 'Inspects subproject Jar task models at execution time') - - doLast { - int deleted = 0 - subprojects.each { subproject -> - subproject.tasks.withType(Jar).each { Jar archiveTask -> - staleTraceableArchivesFor(archiveTask).each { File stale -> - if (!stale.delete()) { - throw new GradleException("cleanStaleTraceableJars: failed to delete ${stale}") - } - deleted++ - logger.lifecycle("cleanStaleTraceableJars: deleted ${stale}") - } - } - } - logger.lifecycle("cleanStaleTraceableJars: deleted ${deleted} stale archive(s).") - } -} - -tasks.register('verifyNoStaleTraceableJars') { - group = 'verification' - description = 'Fails without mutation when leaf build/libs directories retain old traceable JARs.' - notCompatibleWithConfigurationCache( - 'Inspects subproject Jar task models at execution time') - - doLast { - List violations = [] - subprojects.each { subproject -> - subproject.tasks.withType(Jar).each { Jar archiveTask -> - List staleJars = - staleTraceableArchivesFor(archiveTask).collect { File stale -> stale.name } - if (!staleJars.isEmpty()) { - violations << - "${archiveTask.path}: stale JAR(s) ${staleJars}; " + - "current archive is ${archiveTask.archiveFileName.get()}" - } - } - } - - if (!violations.isEmpty()) { - throw new GradleException( - "verifyNoStaleTraceableJars: ${violations.size()} archive task(s) retain old " + - "traceable JARs. Run cleanStaleTraceableJars explicitly if removal is " + - "intended.\n ${violations.join('\n ')}") - } - logger.lifecycle( - 'verifyNoStaleTraceableJars: OK — no stale traceable JARs in build/libs.') - } -} diff --git a/src/build-logic/src/main/groovy/ca.config-contract.gradle b/src/build-logic/src/main/groovy/ca.config-contract.gradle deleted file mode 100644 index 88836736..00000000 --- a/src/build-logic/src/main/groovy/ca.config-contract.gradle +++ /dev/null @@ -1,284 +0,0 @@ -// The environment configuration contract, owned by the composition root. -// -// `verifyEnvKeys` compares docs/registries/env-keys.yaml, app-bootstrap's application.yml, -// src/.env.example and the annotation processor's configuration metadata. That is a question about -// what a deployment of THIS application must be given, so it belongs to the leaf that composes the -// application — not to the repository-wide `check` that `./gradlew :domain-core:check` reached. -// -// Applied from app-bootstrap/build.gradle. The task keeps its name because CI, the README and the -// runbooks call it; what changed is the project that owns it and the lifecycle it hangs off -// (`configContractCheck`, not `check`). - -// verifyEnvKeys — keep env-keys.yaml <-> application.yml <-> src/.env.example in lock-step. -// -// The example, not the real file. Reading src/.env made this check false in both directions: it -// passed only where an operator's own environment file happened to be present, and it would have -// passed with no example at all — so the thing an adopter actually copies was never verified, while -// a file full of real credentials was a build input. -// Rationale in README.md. -tasks.register('verifyEnvKeys') { - group = 'verification' - description = 'Verifies application.yml APP_ references, src/.env.example, and env-keys.yaml stay registered.' - - File envFile = file("${rootProject.projectDir}/.env.example") - File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml") - File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml") - // Check E reads the annotation processor's output, so the owning module has to have been - // compiled. Without this the check would quietly cover nothing on a clean checkout. - File redisSdkMetadata = file("${rootProject.projectDir}/adapter/outbound/cache-redis/build/" + - 'classes/java/main/META-INF/spring-configuration-metadata.json') - dependsOn ':adapter:outbound:cache-redis:compileJava' - - inputs.files(envFile, appYml, registryFile) - inputs.file(redisSdkMetadata).optional() - - doLast { - if (!envFile.exists()) { - throw new GradleException( - "verifyEnvKeys: missing ${envFile}. The tracked example is the contract an " + - "adopter copies; a real .env is operator input and is never read here.") - } - if (!appYml.exists()) { - throw new GradleException("verifyEnvKeys: missing ${appYml}") - } - if (!registryFile.exists()) { - throw new GradleException("verifyEnvKeys: missing ${registryFile}") - } - - def keyPattern = ~/^([A-Z][A-Z0-9_]*)=.*/ - Set envKeys = envFile.readLines().findResults { String line -> - def m = keyPattern.matcher(line) - m.matches() ? m.group(1) : null - }.toSet() - - // Parse application.yml placeholders: ${VAR} is required, ${VAR:default} is optional. - Set requiredPlaceholders = new TreeSet<>() - Set allPlaceholders = new TreeSet<>() - def pm = (appYml.text =~ /\$\{([A-Z][A-Z0-9_]*)(:[^}]*)?\}/) - while (pm.find()) { - allPlaceholders << pm.group(1) - if (pm.group(2) == null) { - requiredPlaceholders << pm.group(1) - } - } - Set environmentSecretReferences = new TreeSet<>() - def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/) - while (sm.find()) { - environmentSecretReferences << sm.group(1) - } - Set applicationAppReferences = new TreeSet<>( - allPlaceholders.findAll { it.startsWith('APP_') }) - applicationAppReferences.addAll(environmentSecretReferences) - - // A. Every required (no inline default) placeholder must exist in the example. - Set missingKeys = new TreeSet<>(requiredPlaceholders - envKeys) - if (!missingKeys.isEmpty()) { - throw new GradleException( - "verifyEnvKeys: application.yml references required env absent from src/.env.example: ${missingKeys}") - } - - // C. Every APP_ key in the example must be registered in env-keys.yaml (APP_-scoped; - // SPRING_* native keys are intentionally not tracked — see README.md). - def registryNamePattern = ~/^\s*- name: (APP_[A-Z0-9_]+)/ - Set registryAppKeys = registryFile.readLines().findResults { String line -> - def m = registryNamePattern.matcher(line) - m.find() ? m.group(1) : null - }.toSet() - - // B. Every registered APP_ key appears in the example. - // - // This used to run the other way — every key in the file had to be an application.yml - // placeholder — which was true of a hand-maintained .env and is false of a catalogue: most - // of these are bound by typed settings inside a leaf, not by a placeholder in the - // composition root's YAML. Inverted, it has teeth the original did not: a key added to the - // registry that never reached the file an adopter copies is exactly the drift this is for. - Set missingFromExample = new TreeSet<>(registryAppKeys - envKeys) - if (!missingFromExample.isEmpty()) { - throw new GradleException( - "verifyEnvKeys: docs/registries/env-keys.yaml registers APP_ keys absent from " + - "src/.env.example, so an adopter copying the example never sees them: " + - "${missingFromExample}") - } - - Set envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet() - Set unregisteredAppKeys = new TreeSet<>(envAppKeys - registryAppKeys) - if (!unregisteredAppKeys.isEmpty()) { - throw new GradleException( - "verifyEnvKeys: src/.env.example declares APP_ keys absent from docs/registries/env-keys.yaml " + - "(registry is the SSOT for APP_ keys): ${unregisteredAppKeys}") - } - - // D. Every application-owned reference is registered, including optional placeholders - // with inline defaults and literal secret://environment/APP_* references. - Set unregisteredApplicationReferences = - new TreeSet<>(applicationAppReferences - registryAppKeys) - if (!unregisteredApplicationReferences.isEmpty()) { - throw new GradleException( - "verifyEnvKeys: application.yml references APP_ keys absent from " + - "docs/registries/env-keys.yaml (optional defaults and environment " + - "secret references are included): ${unregisteredApplicationReferences}") - } - - // E. Typed properties that are deliberately absent from application.yml and the example. - // - // Checks A–D compare three text files, so a property that exists only as a typed - // @ConfigurationProperties field is invisible to them: the Redis SDK shipped 34 settings - // with no registered env name at all and verifyEnvKeys passed. Conditionally-composed - // adapters cannot be fixed by adding their settings to application.yml — that is what - // would make a Redis-free deployment carry Redis configuration — so the third SSOT for - // them is the annotation processor's own metadata, compared against the registry in both - // directions: a typed property with no row, and a row naming a property that no longer - // exists, are both failures. - // One prefix, deliberately, and the limit is worth stating because the summary line below - // ("N typed properties registered") reads like a repository-wide claim and is not one. - // - // Fourteen modules emit configuration metadata and it holds 311 distinct properties, of - // which 61 have `property:` rows in the registry. Those two sets are not meant to be equal: - // the registry's subject is the operator-facing environment surface, and most of the 250 - // others are internal — map-valued trees, experimental toggles, properties with no env - // spelling at all. Comparing them wholesale would fail on the difference rather than on - // drift. - // - // So widening this map is a policy decision — which properties are supposed to have a - // registry row — rather than a mechanical fix, and until that is decided this check covers - // the one namespace that opted in. - Map metadataScopes = [ - 'app.redis.': 'adapter/outbound/cache-redis' - ] - Set typedProperties = new TreeSet<>() - Set missingMetadata = new TreeSet<>() - metadataScopes.each { propertyPrefix, modulePath -> - File metadata = file( - "${rootProject.projectDir}/${modulePath}/build/classes/java/main/" + - 'META-INF/spring-configuration-metadata.json') - if (!metadata.exists()) { - missingMetadata << "${propertyPrefix} (${metadata})".toString() - return - } - def parsed = new groovy.json.JsonSlurper().parse(metadata) - (parsed.properties ?: []).each { property -> - if (property.name?.startsWith(propertyPrefix)) { - typedProperties << property.name.toString() - } - } - } - if (!missingMetadata.isEmpty()) { - throw new GradleException( - 'verifyEnvKeys: configuration metadata is missing for ' + missingMetadata + - ' — run the owning module\'s compileJava first (the annotation ' + - 'processor writes it), or the typed-property check silently covers ' + - 'nothing.') - } - - def registryPropertyPattern = ~/^\s*property:\s*(\S+)/ - Set registryProperties = registryFile.readLines().findResults { String line -> - def m = registryPropertyPattern.matcher(line) - m.find() ? m.group(1) : null - }.toSet() - - Set unregisteredTypedProperties = new TreeSet<>(typedProperties - registryProperties) - if (!unregisteredTypedProperties.isEmpty()) { - throw new GradleException( - 'verifyEnvKeys: typed configuration properties absent from ' + - "docs/registries/env-keys.yaml: ${unregisteredTypedProperties} — every " + - 'bindable property needs a registry row carrying its official env ' + - 'name, type, default, secret classification and required_when.') - } - - Set scopedRegistryProperties = registryProperties.findAll { String property -> - metadataScopes.keySet().any { property.startsWith(it) } - }.toSet() - Set orphanedRegistryProperties = - new TreeSet<>(scopedRegistryProperties - typedProperties) - if (!orphanedRegistryProperties.isEmpty()) { - throw new GradleException( - 'verifyEnvKeys: docs/registries/env-keys.yaml declares properties that no ' + - "typed settings class binds any more: ${orphanedRegistryProperties} — " + - 'remove the row or restore the property.') - } - - // F. Every registered key has a consumer, or says out loud that it does not. - // Checks A-E each compare two SSOTs, and a row that appears in none of them falls - // through all of them: APP_CACHE_REDIS_TRUST_PEM and four namespace keys sat in the - // registry with no typed property, no application.yml reference and no .env entry, - // documented as if a deployment could still use them. A key nothing reads is worse - // than an undocumented one — an operator sets it, nothing happens, and the - // configuration looks correct. - Map> registryRows = [:] - String currentRow = null - registryFile.readLines().each { String line -> - def nameMatch = (line =~ /^\s*- name: (APP_[A-Z0-9_]+)/) - if (nameMatch.find()) { - currentRow = nameMatch.group(1) - registryRows[currentRow] = [:] - return - } - if (currentRow == null) { - return - } - def fieldMatch = (line =~ /^\s*([a-z_]+):\s*(\S.*)?$/) - if (fieldMatch.find()) { - registryRows[currentRow][fieldMatch.group(1)] = (fieldMatch.group(2) ?: '').trim() - } - } - Set consumed = new TreeSet<>() - consumed.addAll(applicationAppReferences) - consumed.addAll(envAppKeys) - // A key can be read in ways checks A-D never look at: another production module's - // application.yml or Java that names a secret directly, as SecretSourceValidator does. - // Count those sources, but keep the removable preview sample outside the production config - // contract entirely. Source only: build outputs are excluded so stale processResources - // copies cannot make a deleted key look consumed. - def appKeyPattern = ~/APP_[A-Z][A-Z0-9_]*/ - rootProject.projectDir.eachFileRecurse { File candidate -> - if (!candidate.isFile() || candidate.path.contains('/build/') || - candidate.path.contains('/sample-portfolio/')) { - return - } - boolean interesting = - (candidate.name == 'application.yml' && candidate.path.contains('/main/')) || - (candidate.name.endsWith('.java') && candidate.path.contains('/src/main/')) - if (!interesting) { - return - } - def matcher = appKeyPattern.matcher(candidate.text) - while (matcher.find()) { - consumed << matcher.group() - } - } - Set unconsumed = new TreeSet<>(registryRows.keySet().findAll { String name -> - Map row = registryRows[name] - !consumed.contains(name) && - !row.containsKey('property') && - row['deprecated_orphaned'] != 'true' - }) - // Enforced for the surfaces this branch owns; reported for the rest. A key nothing reads is - // a defect wherever it lives, but silently adopting another feature's backlog into a - // blocking gate is how a gate acquires an exclusion list. The rest are named on every run so - // they cannot be forgotten, and their owning branch turns them into failures here. - def enforcedPrefixes = ['APP_REDIS_', 'APP_CACHE_REDIS_', 'APP_RATE_LIMIT_REDIS_', - 'APP_IDEMPOTENCY_REDIS_', 'APP_LEASE_REDIS_', 'APP_SESSION_REDIS_'] - Set unconsumedOwned = - new TreeSet<>(unconsumed.findAll { String name -> enforcedPrefixes.any { name.startsWith(it) } }) - if (!unconsumedOwned.isEmpty()) { - throw new GradleException( - 'verifyEnvKeys: registered Redis keys that nothing reads — no typed property, ' + - 'no application.yml reference, no src/.env entry, no Java consumer, ' + - "and not marked deprecated_orphaned: ${unconsumedOwned}. Wire the key " + - 'to a consumer, or mark the row deprecated_orphaned with a ' + - 'removal_deadline so a deployment still setting it is told rather ' + - 'than silently ignored.') - } - Set unconsumedElsewhere = new TreeSet<>(unconsumed - unconsumedOwned) - if (!unconsumedElsewhere.isEmpty()) { - logger.warn('verifyEnvKeys: registered keys outside the Redis surface that nothing ' + - "reads yet: ${unconsumedElsewhere} — owned by the branch that registered them.") - } - - logger.lifecycle("verifyEnvKeys: OK — ${envKeys.size()} env keys, " + - "${requiredPlaceholders.size()} required placeholders covered, " + - "${applicationAppReferences.size()} application APP_ references registered, " + - "${typedProperties.size()} typed properties registered, " + - "${registryRows.size() - unconsumed.size()} rows with a consumer or a deprecation.") - } -} diff --git a/src/build-logic/src/main/groovy/ca.dependency-policy.gradle b/src/build-logic/src/main/groovy/ca.dependency-policy.gradle deleted file mode 100644 index 6cd9cedb..00000000 --- a/src/build-logic/src/main/groovy/ca.dependency-policy.gradle +++ /dev/null @@ -1,119 +0,0 @@ -// 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>> absentByConfiguration = [:] - - /** - * States that a coordinate must not resolve onto a configuration. - * - *

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 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> 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 violations = [] - checks.each { check -> - Set 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') } -} diff --git a/src/build-logic/src/main/groovy/ca.evidence.gradle b/src/build-logic/src/main/groovy/ca.evidence.gradle deleted file mode 100644 index 34ff3bd3..00000000 --- a/src/build-logic/src/main/groovy/ca.evidence.gradle +++ /dev/null @@ -1,69 +0,0 @@ -import dev.caskeleton.buildlogic.JUnitEvidence -import dev.caskeleton.buildlogic.RequiredTestExecution - -// 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> 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, - executedSelectors: results.executedSelectors - ] as Map -} - -Closure> verifyNoSkipJUnitXml = { - String evidenceName, File resultDirectory -> - Map 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> verifyRequiredJUnitClasses = { - String evidenceName, File resultDirectory, List requiredClasses -> - Map evidence = verifyNoSkipJUnitXml(evidenceName, resultDirectory) - Set executedClasses = evidence.executedClasses as Set - // The same decision ca.strict-test-lane applies to a lane's `requires(...)`, from the same - // implementation. A nested class counts for its outer class — a required class whose cases - // all live in @Nested inner classes did execute, and exact-name matching would call it - // missing — and that rule is now stated once rather than once per convention. - List missingClasses = RequiredTestExecution.absent(requiredClasses, executedClasses) - 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 diff --git a/src/build-logic/src/main/groovy/ca.graphql-platform.gradle b/src/build-logic/src/main/groovy/ca.graphql-platform.gradle deleted file mode 100644 index 3b697b59..00000000 --- a/src/build-logic/src/main/groovy/ca.graphql-platform.gradle +++ /dev/null @@ -1,103 +0,0 @@ -// GraphQL API 실행 플랫폼 verification lanes. -// -// The GraphQL platform design package ships a 16-module Stable map and a 12-module Advanced map. -// Those maps are realised as bounded PACKAGES inside the single registered `adapter-inbound-graphql` -// leaf — the same mapping the httpclient capability already uses — because the GraphQL surface is one -// inbound transport boundary whose internal split does not have to reach the repository-wide leaf -// registry (`src/config/architecture/modules.json`). -// -// Note the counter-example: the sibling messaging platform made the opposite call and registered 24 -// leaves of its own. The registry is extensible, so this is a deliberate trade-off, not a constraint. -// Whichever pattern the repository standardises on, the module identities, their allowed internal -// dependency edges and the Stable→Advanced isolation rule stay machine-checked through -// `moduleboundary/GraphQlStableModule`, `moduleboundary/GraphQlAdvancedModule` (declaration) and -// the test-source `moduleboundary/GraphQlBuildModel` + `GraphQlModuleBoundaryTest` (the scan). -// -// The package is `moduleboundary`, not `build`: `src/.gitignore` carries an unanchored `build/` -// rule for Gradle output, which once swallowed this entire model — production code imported types -// that no fresh checkout contained. `verifyNoIgnoredSourcePackages` now blocks that class of -// mistake repository-wide, and the required-class check below blocks the other half of it, where -// the boundary test quietly disappears and the lane still reports green. -// -// One lane survives here: `graphqlStableTest`, the Stable platform unit + boundary lane that CI -// runs (.github/workflows/ci-quality-gates.yml). It earns its place next to the default `test` task -// for exactly one reason — the required-class check below, which refuses a green lane that executed -// no case of the module-boundary test. -// -// Three further lanes were registered here and are gone. `graphqlContractTest` and -// `graphqlAdvancedTest` re-selected `@Tag("graphql-contract")` and `@Tag("graphql-advanced")` tests -// that the default `test` task already runs, so deleting them changes the set of executed tests by -// nothing, and no workflow, build file or `check` ever named either one. `graphqlPerformanceTest` -// demanded load, soak and fault scenarios that do not exist — no test in this repository carries -// `@Tag("graphql-performance")` — so the lane failed by construction on every invocation, which is -// why nothing ever invoked it. A lane that always fails and that nobody runs blocks nothing. -// -// The `graphql-performance` exclusion went with them, from this lane and from the default `test` -// task (`excludeTags 'quarantine'` already reaches every leaf's `test` from src/build.gradle). -// Keeping an exclusion after deleting the only lane that selected the tag would mean a future -// `@Tag("graphql-performance")` test runs nowhere and says so nowhere. When a real load environment -// exists, declare the lane through the `ca.strict-test-lane` convention plugin rather than -// re-deriving `failOnNoDiscoveredTests` and an empty-result check by hand. -ext.registerGraphQlPlatformTestLanes = { -> - String platformPackage = 'dev.caskeleton.adapter.inbound.graphql' - - // Classes whose absence must fail the Stable lane instead of shrinking it. `failOnNoMatchingTests` - // only reacts to an empty lane, so deleting one boundary class out of four hundred tests is - // invisible to it — and losing exactly this class is how the platform shipped without an - // enforced module boundary in the first place. - List requiredStableClasses = [ - "${platformPackage}.moduleboundary.GraphQlModuleBoundaryTest".toString(), - ] - - // Resolved at configuration time: reaching for `rootProject` inside a task action is - // configuration-cache hostile and Gradle 10 removes it. - if (!rootProject.ext.has('readJUnitEvidence')) { - throw new GradleException( - 'graphql-platform-conventions.gradle requires gradle/junit-evidence.gradle.') - } - Closure> readJUnitEvidence = rootProject.ext.readJUnitEvidence - - tasks.register('graphqlStableTest', Test) { - description = 'Runs the Stable GraphQL platform test lane (Stable plan Task 1-48).' - group = 'verification' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - jvmArgs '-Duser.timezone=UTC' - outputs.upToDateWhen { false } - useJUnitPlatform { - excludeTags 'quarantine', 'graphql-advanced' - } - filter { - includeTestsMatching "${platformPackage}.*" - failOnNoMatchingTests = true - } - failOnNoDiscoveredTests = true - reports.junitXml.required = true - reports.junitXml.outputLocation = layout.buildDirectory.dir('test-results/graphqlStableTest') - - doFirst { - // Stale XML from a previous run would let a deleted class report as executed. - File staleResults = reports.junitXml.outputLocation.get().asFile - if (staleResults.exists() && !staleResults.deleteDir()) { - throw new GradleException( - "graphqlStableTest could not delete stale JUnit XML: ${staleResults}") - } - } - doLast { - Map evidence = readJUnitEvidence( - 'graphqlStableTest', reports.junitXml.outputLocation.get().asFile) - Set executed = evidence.executedClasses as Set - List missing = requiredStableClasses.findAll { String required -> - !executed.any { String actual -> - actual == required || actual.startsWith(required + '$') - } - } - if (!missing.isEmpty()) { - throw new GradleException( - 'graphqlStableTest executed no test case for required boundary class(es): ' + - "${missing}. The lane is green only because the class is gone; restore it " + - 'rather than removing it from requiredStableClasses.') - } - } - } -} diff --git a/src/build-logic/src/main/groovy/ca.grpc-platform-module.gradle b/src/build-logic/src/main/groovy/ca.grpc-platform-module.gradle deleted file mode 100644 index 7045410d..00000000 --- a/src/build-logic/src/main/groovy/ca.grpc-platform-module.gradle +++ /dev/null @@ -1,39 +0,0 @@ -import org.gradle.api.artifacts.VersionCatalogsExtension - -// A vendored platform leaf that compiles against io.grpc: `ca.platform-module` plus the grpc BOM. -// -// io.grpc is not managed by the Spring Boot BOM, so four `grpc:*` leaves each imported grpc-bom at -// module scope with the same five lines: -// -// dependencyManagement { -// imports { -// mavenBom "io.grpc:grpc-bom:${grpcVersion}" -// } -// } -// -// Module scope rather than the root `dependencyManagement` block is the decision those four made and -// this plugin keeps: importing grpc-bom for all sixty-two leaves would put io.grpc versions into the -// resolution of every leaf that has nothing to do with gRPC, and every configuration in this build is -// dependency-locked in STRICT mode, so that is not a tidier spelling of the same thing — it is a -// rewrite of lockfiles across the repository. -// -// The same reason is why this is a second plugin rather than a flag on `ca.platform-module`. Only the -// leaves that already import the BOM may acquire it; giving it to the other thirty-nine would change -// their resolved graphs and invalidate their lock state. -plugins { - id 'ca.platform-module' -} - -// The gRPC version is owned by the shared version catalog. Optional builds import the same -// catalog file, so the platform no longer reaches into a parent/root ext property. -def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs') -def grpcVersionConstraint = versionCatalog.findVersion('grpc').orElseThrow { - new GradleException("Version catalog 'libs' must define version 'grpc' for ca.grpc-platform-module") -} -String grpcVersion = grpcVersionConstraint.requiredVersion - -dependencyManagement { - imports { - mavenBom "io.grpc:grpc-bom:${grpcVersion}" - } -} diff --git a/src/build-logic/src/main/groovy/ca.java-conventions.gradle b/src/build-logic/src/main/groovy/ca.java-conventions.gradle deleted file mode 100644 index cd98e5a1..00000000 --- a/src/build-logic/src/main/groovy/ca.java-conventions.gradle +++ /dev/null @@ -1,177 +0,0 @@ -import org.gradle.api.artifacts.dsl.LockMode -import org.gradle.api.artifacts.VersionCatalogsExtension -import org.gradle.api.tasks.bundling.AbstractArchiveTask -import org.gradle.api.tasks.bundling.Jar - -// What every registered leaf is, before it is anything else: a Java 21 module with locked -// dependencies, reproducible archives, a traceable jar manifest and the Spring BOM available for -// version management. -// -// This was `configure(subprojects.findAll { it.childProjects.isEmpty() })` in the root build. The -// recorded reason for leaving it there (D8) was that a leaf's build file should have one place to -// look for the plugins it acquires. It had the opposite effect: `domain-core/build.gradle` is three -// lines and nothing in it says that Java, dependency locking, a BOM, four analysis tools and a -// strict test-lane container are applied to it. A leaf now names what it is — -// `ca.java-library`, `ca.spring-library`, `ca.platform-module` — and this file says what that means. - -plugins { - id 'java' - id 'io.spring.dependency-management' - // Lane, API-surface, dependency-policy and strict-qualification containers. Each is inert for a - // leaf that never configures it: an empty lane container registers no task, an unnamed - // apiSurface registers none, an empty dependency policy adds no check. - id 'ca.strict-test-lane' - id 'ca.api-surface' - id 'ca.dependency-policy' - id 'ca.strict-qualification' - id 'ca.test-jvm-agents' -} - -// The main build's catalog, read through the Gradle API rather than the `libs` accessor, which is -// not generated for a precompiled script plugin. Same table, same entries as the root build's -// `plugins {}` block reads. -def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs') -String springBootVersion = versionCatalog.findVersion('springBoot').get().requiredVersion - -group = 'dev.caskeleton' -version = rootProject.ext.has('traceableVersion') ? rootProject.ext.traceableVersion : '0.0.1-SNAPSHOT' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -// D8 — Gradle-default /gradle.lockfile files are Renovate-compatible. STRICT means a -// missing or stale lock state fails resolution instead of silently selecting a new version. -dependencyLocking { - lockAllConfigurations() - lockMode = LockMode.STRICT -} - -// D10 — normalize every archive, including Spring Boot's BootJar. Fixed timestamps/order and -// permissions remove host filesystem, locale-adjacent, and umask entropy from archive bytes. -tasks.withType(AbstractArchiveTask).configureEach { - preserveFileTimestamps = false - reproducibleFileOrder = true - dirPermissions { unix('755') } - filePermissions { unix('644') } -} - -// D1/D9 — a JAR is independently traceable even when copied out of its container/release. -// -// `unknown` when the root declares no revision, which is a source archive with no `.git` and no -// `-PgitRevision`. That used to fail the build during configuration, so `./gradlew test` on an -// unpacked tarball could not run at all; release traceability is enforced by `releaseCheck`, which -// is where a missing revision actually matters. -String buildRevision = - rootProject.ext.has('sourceRevision') ? rootProject.ext.sourceRevision : 'unknown' -tasks.withType(Jar).configureEach { - manifest { - attributes( - 'Implementation-Version': project.version.toString(), - 'Build-Revision': buildRevision - ) - } -} - -// Keep method parameter names in bytecode for Spring MVC @PathVariable/@RequestParam binding -// (rationale in README.md). -// -// Pinned encoding, not inherited from the platform. Sources carry non-ASCII — Korean comments and -// em dashes inside string literals — so a builder whose default charset is not UTF-8 compiles -// different bytes than this one does. It is also what the Gradle model hands the IDE as the project -// encoding; without it every imported project reports "no explicit encoding set". -tasks.withType(JavaCompile).configureEach { - options.encoding = 'UTF-8' - ['-parameters', '-Werror', '-Xlint:deprecation', '-Xlint:unchecked'].each { String compilerArg -> - if (!options.compilerArgs.contains(compilerArg)) { - options.compilerArgs.add(compilerArg) - } - } -} - -// SpotBugs 4.10.2 needs commons-lang3 3.20.0 (uses org.apache.commons.lang3.Strings); the Spring -// Boot BOM otherwise pins commons-lang3 to 3.17.0 — and io.spring.dependency-management overrides -// resolutionStrategy.force — so the analysis worker crashes with NoClassDefFoundError. Override the -// BOM-managed version property (the documented Spring mechanism). No production module imports -// commons.lang3, so this only affects the SpotBugs tool classpath in practice. -ext['commons-lang3.version'] = '3.20.0' -// Netty security floor. The Spring Boot BOM pinned 4.2.7.Final, which sits inside two published -// advisory ranges that reach productionRuntimeClasspath, not just a test tool classpath: -// - CVE-2026-42577, netty-transport-native-epoll >=4.2.0,<4.2.13 (GHSA-rwm7-x88c-3g2p) -// - CVE-2026-59901, netty-codec-compression >=4.2.0,<4.2.16 (GHSA-558v-64gr-wgg4) -// Netty is shared runtime surface here — HTTP, Reactor Netty and the Redis driver all sit on it — -// so the fix is the BOM-managed version property rather than a per-artifact exclusion, and it is -// the latest 4.2 patch rather than the exact advisory floor. Regenerate every lockfile after -// changing this (`./gradlew resolveAndLockAll --write-locks`). -ext['netty.version'] = '4.2.17.Final' - -dependencyManagement { - imports { - // The literal coordinate `SpringBootPlugin.BOM_COORDINATES` expands to, with the version - // read from the catalog. Spelling it out keeps spring-boot-gradle-plugin off build-logic's - // compile classpath: build-logic applies dependency-management, not Boot. - mavenBom "org.springframework.boot:spring-boot-dependencies:${springBootVersion}" - } -} - -dependencies { - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} - -// Official Gradle pattern: resolve every resolvable configuration while --write-locks is set. This -// captures transitive compile/test/analysis dependencies, not only direct declarations. -tasks.register('resolveAndLockAll') { - group = 'build setup' - description = 'Resolves every configuration and writes this project\'s dependency lock state.' - notCompatibleWithConfigurationCache('Filters configurations at execution time') - doFirst { - if (!gradle.startParameter.writeDependencyLocks) { - throw new GradleException("${path} requires the --write-locks command-line flag.") - } - } - doLast { - configurations.findAll { it.canBeResolved }.each { it.resolve() } - } -} - -// Unlike Gradle's diagnostic `dependencies` report, this task performs strict resolution and -// propagates a missing/stale lock entry as a non-zero build failure. -tasks.register('verifyDependencyLocks') { - group = 'verification' - description = 'Resolves every configuration and fails when strict dependency locks drift.' - notCompatibleWithConfigurationCache('Filters configurations at execution time') - doLast { - configurations.findAll { it.canBeResolved }.each { it.resolve() } - } -} - -// feature-ci-quality-gates-contract §4 (D7) — the main gate EXCLUDES the flaky quarantine bucket so -// a quarantined test can never block merge. Quarantined tests carry JUnit's built-in -// @Tag("quarantine") and run separately through `quarantineTest`, which never blocks. -// -// The 14-day sunset registry that used to enforce a fixed lifetime on those tags is gone: it was a -// 250-line YAML-and-Java parser guarding a registry with zero entries. The bucket itself is three -// lines and stays. -tasks.named('test') { - useJUnitPlatform { - excludeTags 'quarantine' - } -} - -tasks.register('quarantineTest', Test) { - group = 'verification' - description = 'Flaky-test quarantine bucket: runs only @Tag("quarantine") tests, non-blocking.' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - useJUnitPlatform { - includeTags 'quarantine' - } - ignoreFailures = true - failOnNoDiscoveredTests = false - // Always re-run; a flaky bucket must never serve a stale UP-TO-DATE result. - outputs.upToDateWhen { false } - // Pin UTC like the main test task for host-locale independence. - jvmArgs '-Duser.timezone=UTC' -} diff --git a/src/build-logic/src/main/groovy/ca.java-library.gradle b/src/build-logic/src/main/groovy/ca.java-library.gradle deleted file mode 100644 index 1ae668c9..00000000 --- a/src/build-logic/src/main/groovy/ca.java-library.gradle +++ /dev/null @@ -1,13 +0,0 @@ -// A leaf whose tests need no Spring context: `domain-core`, `application-core`, `shared-contract`. -// -// Keeping their test classpath on plain JUnit + AssertJ is what makes "application-core has no -// Spring dependency" verifiable rather than aspirational. A leaf that genuinely needs a Spring test -// context declares it in its own build file — or, more likely, is a `ca.spring-library`. -plugins { - id 'ca.quality-conventions' -} - -dependencies { - testImplementation 'org.junit.jupiter:junit-jupiter' - testImplementation 'org.assertj:assertj-core' -} diff --git a/src/build-logic/src/main/groovy/ca.jmh-benchmarks.gradle b/src/build-logic/src/main/groovy/ca.jmh-benchmarks.gradle deleted file mode 100644 index bf0f519a..00000000 --- a/src/build-logic/src/main/groovy/ca.jmh-benchmarks.gradle +++ /dev/null @@ -1,56 +0,0 @@ -// A leaf that carries JMH benchmarks: `messaging-kafka`, `messaging-rabbit`, `messaging-testkit`. -// -// A source set rather than the JMH plugin because the benchmarks are compiled and reviewed on every -// build but only *run* on demand: a benchmark that stops compiling is a defect, while a benchmark -// that runs in CI is a flaky test measuring the build agent. -// -// This was an `if (project.path in [three paths])` branch inside the root build's -// `configure(subprojects)` block. The three leaves it names now name it. -import org.gradle.api.artifacts.VersionCatalogsExtension - -plugins { - id 'ca.platform-module' -} - -def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs') -Closure versionOf = { String alias -> versionCatalog.findVersion(alias).get().requiredVersion } - -sourceSets { - jmh { - compileClasspath += sourceSets.main.output + sourceSets.test.output - runtimeClasspath += sourceSets.main.output + sourceSets.test.output - } -} - -configurations { - jmhImplementation.extendsFrom implementation, testImplementation - jmhRuntimeOnly.extendsFrom runtimeOnly, testRuntimeOnly -} - -dependencies { - jmhImplementation "org.openjdk.jmh:jmh-core:${versionOf('jmh')}" - jmhAnnotationProcessor "org.openjdk.jmh:jmh-generator-annprocess:${versionOf('jmh')}" - // Error Prone's -Werror would reject JMH's generated sources, which the platform does not own - // and cannot fix. - jmhAnnotationProcessor "com.google.errorprone:error_prone_core:${versionOf('errorprone')}" -} - -tasks.named('compileJmhJava') { - options.errorprone.enabled = false - options.compilerArgs.removeAll { it == '-Werror' } -} - -// JMH's annotation processor emits the generated harness into this source set, and its generated -// code trips DLS_DEAD_LOCAL_STORE by design (the dead stores are how it defeats dead-code -// elimination). Analysing code the platform neither wrote nor can fix would make the gate -// unactionable, so the jmh source set is excluded from the bug and style checks. The benchmarks -// themselves are still compiled, which is what catches a real breakage. -tasks.named('spotbugsJmh') { enabled = false } -tasks.named('checkstyleJmh') { enabled = false } - -tasks.register('jmh', JavaExec) { - group = 'verification' - description = 'Runs the JMH benchmarks in this leaf.' - classpath = sourceSets.jmh.runtimeClasspath - mainClass = 'org.openjdk.jmh.Main' -} diff --git a/src/build-logic/src/main/groovy/ca.notification-api-surface.gradle b/src/build-logic/src/main/groovy/ca.notification-api-surface.gradle deleted file mode 100644 index 145a0c98..00000000 --- a/src/build-logic/src/main/groovy/ca.notification-api-surface.gradle +++ /dev/null @@ -1,137 +0,0 @@ -// NTF-022 — the notification platform's public type surface, pinned. -// -// Nearly every top-level type in the platform is public, which means the boundary between "the API -// other code may build on" and "an implementation detail that happens to be reachable" is not -// written down anywhere. Enforcing internal-by-default across several hundred types is a design -// change; pinning the surface is not, and it converts surface growth from something that happens -// silently into something a reviewer sees. A new public type is then a line in a diff. - -Closure renderNotificationApiSurface = { List sourceRoots -> - List types = [] - sourceRoots.each { File root -> - if (!root.isDirectory()) { - return - } - root.eachFileRecurse { File file -> - if (!file.isFile() || !file.name.endsWith('.java') || file.name == 'package-info.java') { - return - } - String text = file.getText('UTF-8') - def packageMatcher = (text =~ /(?m)^package\s+([\w.]+);/) - if (!packageMatcher.find()) { - return - } - String packageName = packageMatcher.group(1) - // Only top-level public declarations count. A nested public type is reachable only - // through its owner, so it is part of that owner's surface, not a separate one. - def declarationMatcher = - (text =~ /(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+|static\s+)*(class|interface|record|enum|@interface)\s+(\w+)/) - while (declarationMatcher.find()) { - types << "${packageName}.${declarationMatcher.group(2)}".toString() - } - } - } - String header = - "# NTF-022 — public type surface of the notification platform.\n" + - "# Every top-level public type under the platform packages. Growth is a reviewed\n" + - "# change: ./gradlew updateNotificationApiSurface -PapproveNotificationApiChange\n" - header + (types.isEmpty() ? '' : types.toSorted().unique().join('\n') + '\n') -} - -List notificationApiSourceRoots = [ - rootProject.file('application-core/src/main/java/dev/caskeleton/application/notification'), - rootProject.file( - 'adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification'), -] -File notificationApiSnapshotFile = - rootProject.file('../docs/notification/api-surface-snapshot.txt') -boolean notificationApiUpdateApproved = project.hasProperty('approveNotificationApiChange') -// Growing the surface is a second decision. Approving additions one at a time is how a leaf grows -// past the size that would have justified splitting it, with every step reviewed and the total -// never discussed. A change that removes more than it adds needs only the approval. -boolean notificationApiCeilingRaiseApproved = - project.hasProperty('raiseNotificationApiCeiling') - -tasks.register('verifyNotificationApiSurface') { - group = 'verification' - description = 'Fails without mutation when the notification platform public type surface drifts.' - inputs.files(notificationApiSourceRoots.findAll { it.isDirectory() }) - inputs.property('updateApprovalRequested', notificationApiUpdateApproved) - - doLast { - if (notificationApiUpdateApproved) { - throw new GradleException( - 'verifyNotificationApiSurface is read-only; use updateNotificationApiSurface ' + - '-PapproveNotificationApiChange for an intentional update.') - } - String canonical = renderNotificationApiSurface(notificationApiSourceRoots) - List canonicalTypes = canonical.readLines().findAll { !it.startsWith('#') } - if (canonicalTypes.isEmpty()) { - // An empty rendering means the source roots moved and the check would pass vacuously — - // the exact failure this file exists to prevent, so it is an error rather than a pass. - throw new GradleException( - 'verifyNotificationApiSurface: found no public types under ' + - notificationApiSourceRoots.join(', ') + - '. The source roots moved; fix the paths rather than accepting an empty surface.') - } - if (!notificationApiSnapshotFile.isFile()) { - throw new GradleException( - "verifyNotificationApiSurface: missing committed baseline ${notificationApiSnapshotFile}") - } - - List committed = - notificationApiSnapshotFile.readLines('UTF-8').findAll { !it.startsWith('#') } - List added = (canonicalTypes - committed).toSorted() - List removed = (committed - canonicalTypes).toSorted() - if (!added.isEmpty() || !removed.isEmpty()) { - throw new GradleException( - 'verifyNotificationApiSurface: the notification public type surface changed.\n' + - (added.isEmpty() ? '' : " added (${added.size()}):\n " + added.join('\n ') + '\n') + - (removed.isEmpty() ? '' : " removed (${removed.size()}):\n " + removed.join('\n ') + '\n') + - 'A type added here is a type other code may now depend on forever. If that is intended:\n' + - ' ./gradlew updateNotificationApiSurface -PapproveNotificationApiChange') - } - logger.lifecycle( - "verifyNotificationApiSurface: OK — ${canonicalTypes.size()} public types, unchanged.") - } -} - -tasks.register('updateNotificationApiSurface') { - group = 'build setup' - description = 'Explicitly updates the committed notification public type surface after review.' - inputs.files(notificationApiSourceRoots.findAll { it.isDirectory() }) - inputs.property('approved', notificationApiUpdateApproved) - outputs.file(notificationApiSnapshotFile) - outputs.upToDateWhen { false } - - doLast { - if (!notificationApiUpdateApproved) { - throw new GradleException( - 'updateNotificationApiSurface requires -PapproveNotificationApiChange') - } - String canonical = renderNotificationApiSurface(notificationApiSourceRoots) - if (notificationApiSnapshotFile.isFile() && !notificationApiCeilingRaiseApproved) { - int committedCount = notificationApiSnapshotFile.readLines('UTF-8') - .count { !it.startsWith('#') && !it.trim().isEmpty() } - int renderedCount = canonical.readLines() - .count { !it.startsWith('#') && !it.trim().isEmpty() } - if (renderedCount > committedCount) { - throw new GradleException( - "updateNotificationApiSurface: the public surface would grow from " + - "${committedCount} to ${renderedCount} types.\n" + - 'Either land the addition together with a removal that pays for it, ' + - 'or raise the ceiling deliberately:\n' + - ' ./gradlew updateNotificationApiSurface ' + - '-PapproveNotificationApiChange -PraiseNotificationApiCeiling') - } - } - if (!notificationApiSnapshotFile.parentFile.isDirectory() - && !notificationApiSnapshotFile.parentFile.mkdirs()) { - throw new GradleException( - "updateNotificationApiSurface: failed to create ${notificationApiSnapshotFile.parentFile}") - } - notificationApiSnapshotFile.setText(canonical, 'UTF-8') - logger.lifecycle( - "updateNotificationApiSurface: wrote reviewed baseline ${notificationApiSnapshotFile}") - } -} diff --git a/src/build-logic/src/main/groovy/ca.notification-configuration.gradle b/src/build-logic/src/main/groovy/ca.notification-configuration.gradle deleted file mode 100644 index 0fe243cd..00000000 --- a/src/build-logic/src/main/groovy/ca.notification-configuration.gradle +++ /dev/null @@ -1,93 +0,0 @@ -// NTF-025 — an env key the registry lists and nothing reads. -// -// This task used to check four relationships at once: application.yml against the configuration -// reference document in both directions, and application.yml against the env-key registry in both -// directions. Three of the four are now owned elsewhere or were never a build concern. -// -// * application.yml -> env-keys.yaml is `verifyEnvKeys` check D (src/build.gradle), which makes -// the same comparison over every `APP_*` reference, optional inline defaults included. The -// notification platform's keys all carry the `APP_` prefix, so they were being compared twice by -// two implementations that could disagree. -// * application.yml <-> docs/notification/configuration-reference.md was documentation drift. A -// reference that names a property the binding never had is a bad document, not a broken -// platform: nothing fails to start, no request is mishandled, no data moves. It was failing the -// `check` of every leaf in the repository over prose. -// -// What remains is the one direction nothing else covers: a key registered in env-keys.yaml that no -// binding reads. That one is worth a build failure because the registry is what an operator -// configures from — a key listed there that reaches no binding is an instruction to set an -// environment variable that does nothing, and it is indistinguishable from one that works. -// -// It reads the composition root's whole YAML set, not application.yml alone. Two of the deleted -// checks located the platform tree by slicing application.yml between the literals -// ` notification:\n platform:` and `\n persistence:` — an indent width and the NAME OF A -// SIBLING KEY. The tree has since moved into config/notification.yml, imported by -// application.yml's `spring.config.import`, so both literals stopped matching and this task failed -// on every `check` in the repository at its first assertion. Scanning application.yml plus every -// config/*.yml it imports means the same keys are found wherever the composition root chooses to -// keep them. -tasks.register('verifyNotificationConfiguration') { - group = 'verification' - description = 'Fails when docs/registries/env-keys.yaml registers a notification platform key no binding reads.' - - File resourceRoot = rootProject.file('app-bootstrap/src/main/resources') - File applicationYaml = new File(resourceRoot, 'application.yml') - File configurationDirectory = new File(resourceRoot, 'config') - File environmentRegistry = rootProject.file('../docs/registries/env-keys.yaml') - inputs.files(applicationYaml, environmentRegistry) - inputs.dir(configurationDirectory) - - doLast { - [applicationYaml, environmentRegistry].each { File required -> - if (!required.isFile()) { - throw new GradleException("verifyNotificationConfiguration: missing ${required}") - } - } - - List boundSources = [applicationYaml] - if (configurationDirectory.isDirectory()) { - boundSources.addAll( - configurationDirectory.listFiles() - .findAll { File file -> file.isFile() && file.name.endsWith('.yml') } - .toSorted { File file -> file.name }) - } - - Set boundVariables = new TreeSet<>() - boundSources.each { File source -> - def placeholder = - (source.getText('UTF-8') =~ /\$\{(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)(:[^}]*)?\}/) - while (placeholder.find()) { - boundVariables << placeholder.group(1) - } - } - if (boundVariables.isEmpty()) { - // Fail closed. An empty set makes every registry entry look unread, but it far more - // likely means the platform tree moved again, and a check comparing nothing against - // nothing passes forever. - throw new GradleException( - 'verifyNotificationConfiguration: no APP_NOTIFICATION_PLATFORM_* placeholder is ' + - "bound anywhere in ${rootProject.relativePath(resourceRoot)}, so there is " + - 'nothing to compare the registry against.') - } - - Set registeredVariables = new TreeSet<>() - def registered = (environmentRegistry.getText('UTF-8') - =~ /(?m)^\s*- name:\s*(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]+)\s*$/) - while (registered.find()) { - registeredVariables << registered.group(1) - } - - List problems = (registeredVariables - boundVariables).collect { - "${it} is registered in env-keys.yaml and bound by nothing".toString() - } - if (!problems.isEmpty()) { - throw new GradleException( - 'verifyNotificationConfiguration: the env-key registry promises settings the ' + - "binding does not have.\n " + problems.join('\n ') + - '\nThe binding is the fact; the registry describes it.') - } - logger.lifecycle( - "verifyNotificationConfiguration: OK — ${registeredVariables.size()} registered " + - "platform keys, all bound under ${rootProject.relativePath(resourceRoot)}.") - } -} diff --git a/src/build-logic/src/main/groovy/ca.notification-evidence.gradle b/src/build-logic/src/main/groovy/ca.notification-evidence.gradle deleted file mode 100644 index e1162881..00000000 --- a/src/build-logic/src/main/groovy/ca.notification-evidence.gradle +++ /dev/null @@ -1,155 +0,0 @@ -// NTF-024 — a support grade may not outrun the evidence that backs it. -// -// The grade column in docs/notification/support-matrix.md is the strongest claim the platform makes -// about itself, and nothing connected it to anything executable. This task connects them: the -// manifest declares which claims each grade requires and which artifact proves each claim, and the -// task refuses a grade whose claims are not all satisfied by files that actually exist. -// -// It fails on the manifest as well as on the document. An "evidence" entry naming a test that has -// been renamed or deleted is exactly how a gate goes quiet without anyone noticing. - -import groovy.json.JsonSlurper - -File notificationEvidenceManifest = - rootProject.file('../docs/notification/evidence-manifest.json') - -tasks.register('verifyNotificationEvidence') { - group = 'verification' - description = 'Fails when a notification support grade claims more than the executable evidence proves.' - inputs.file(notificationEvidenceManifest) - - doLast { - if (!notificationEvidenceManifest.isFile()) { - throw new GradleException( - "verifyNotificationEvidence: missing manifest ${notificationEvidenceManifest}") - } - def manifest = new JsonSlurper().parse(notificationEvidenceManifest) - File repositoryDirectory = rootProject.projectDir.parentFile - File matrixFile = new File(repositoryDirectory, manifest.matrixDocument as String) - if (!matrixFile.isFile()) { - throw new GradleException( - "verifyNotificationEvidence: missing support matrix ${matrixFile}") - } - - List problems = [] - - // 1. Every claim that says it is satisfied must name artifacts that exist. - Set satisfied = [] as Set - manifest.claims.each { String claim, Object declaration -> - List evidence = (declaration.evidence ?: []) as List - if (declaration.status == 'satisfied') { - if (evidence.isEmpty()) { - problems << "claim '${claim}' is marked satisfied with no evidence at all" - return - } - List missing = evidence.findAll { String path -> - !new File(rootProject.projectDir, path).isFile() - } - if (missing.isEmpty()) { - satisfied << claim - } else { - problems << "claim '${claim}' names evidence that does not exist: ${missing.join(', ')}" - } - } else if (!evidence.isEmpty()) { - problems << "claim '${claim}' is not satisfied but names evidence; " + - 'either the status or the evidence list is wrong' - } - } - - // 2. Every grade the matrix uses must be one the manifest defines, and every claim that - // grade requires must be satisfied. - // - // The grade column is located by its header, not by its position. The previous pattern took - // the third cell of every line with four or more pipes, which is the channel table's grade - // column by coincidence: the document's two other tables happen to have two columns, so - // they never matched. Adding a third column to either of them, or reordering the channel - // table, would have fed an unrelated cell to the "unknown grade" failure below. - // - // Only tables that ASSIGN a grade are read. A claim is "subject X is at grade G", so the - // grade column must be preceded by the column naming the subject; a table whose FIRST - // column is Grade is defining what the grades mean ("| Grade | Requires |"), not claiming - // one, and its left column is the manifest's own vocabulary rather than a promise about a - // channel. - Set knownGrades = manifest.grades.keySet() as Set - Closure> tableCells = { String line -> - String trimmed = line.trim() - if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) { - return null - } - trimmed.substring(1, trimmed.length() - 1).split(/\|/, -1).collect { it.trim() } - } - boolean insideTable = false - int gradeColumn = -1 - int gradeAssigningTables = 0 - matrixFile.readLines('UTF-8').eachWithIndex { String line, int index -> - List cells = tableCells(line) - if (cells == null) { - insideTable = false - gradeColumn = -1 - return - } - if (cells.every { it.isEmpty() || it ==~ /:?-{2,}:?/ }) { - return - } - if (!insideTable) { - // Header row: does this table assign a grade to something? - insideTable = true - gradeColumn = cells.indexOf('Grade') - if (gradeColumn > 0) { - gradeAssigningTables++ - } else { - gradeColumn = -1 - } - return - } - if (gradeColumn < 0) { - return - } - if (gradeColumn >= cells.size()) { - problems << ("${matrixFile.name}:${index + 1} has ${cells.size()} cell(s) but its " + - "table's grade column is ${gradeColumn + 1}").toString() - return - } - String grade = cells[gradeColumn] - if (grade.isEmpty()) { - return - } - if (!knownGrades.contains(grade)) { - problems << "${matrixFile.name}:${index + 1} uses grade '${grade}', " + - "which the evidence manifest does not define" - return - } - List required = (manifest.grades[grade] ?: []) as List - List unmet = required.findAll { !satisfied.contains(it) } - if (!unmet.isEmpty()) { - problems << "${matrixFile.name}:${index + 1} claims '${grade}', which requires " + - "${unmet.join(', ')} — not proven by any artifact in the manifest" - } - } - - if (knownGrades.isEmpty()) { - // A manifest with no grades would let every document line pass unexamined. - throw new GradleException( - 'verifyNotificationEvidence: the manifest defines no grades, so the check ' + - 'would pass whatever the support matrix claims.') - } - if (gradeAssigningTables == 0) { - // Renaming or dropping the grade column would otherwise leave this task green while it - // examined nothing at all — the quiet failure the header of this file warns about. - throw new GradleException( - "verifyNotificationEvidence: ${matrixFile.name} has no table that assigns a " + - "grade (a 'Grade' column that is not the first column), so no claim in " + - 'it was checked.') - } - if (!problems.isEmpty()) { - throw new GradleException( - 'verifyNotificationEvidence: the support matrix claims more than the evidence ' + - "proves.\n " + problems.join('\n ') + - '\nEither add the artifact and mark the claim satisfied, or lower the grade. ' + - 'A grade is a promise about production behaviour; the manifest is where it is kept.') - } - logger.lifecycle( - "verifyNotificationEvidence: OK — ${satisfied.size()} claims proven, " + - "every grade in ${matrixFile.name} is backed.") - } -} diff --git a/src/build-logic/src/main/groovy/ca.platform-module.gradle b/src/build-logic/src/main/groovy/ca.platform-module.gradle deleted file mode 100644 index 23190649..00000000 --- a/src/build-logic/src/main/groovy/ca.platform-module.gradle +++ /dev/null @@ -1,20 +0,0 @@ -// A leaf of a vendored platform: `messaging:*`, `grpc:*`, `grpc-advanced:*`. -// -// Those families are not layers of this application. They are libraries that happen to live in this -// repository — their `*-api` leaves are ports, their broker and transport leaves are adapters, their -// starters are composition roots — and the thing every one of them needs that an application leaf -// does not is `java-library`: a consumer compiles against their types, so they have an `api` -// configuration and the distinction between `api` and `implementation` is load-bearing for them. -// -// Forty-three build files said that by each writing `apply plugin: 'java-library'` at line 1, which -// is how a platform leaf could be added without the line and compile until the first consumer wrote -// `api`. -// -// `ca.java-library` is applied here rather than left to the root build's `configure(subprojects)` -// block, which is where the toolchain, locking, analysis tools and lane containers used to come -// from invisibly. A vendored platform leaf's tests run on plain JUnit + AssertJ, which is what makes -// "messaging-core-api has no Spring dependency" — and the same claim for grpc-core-api — checkable. -plugins { - id 'ca.java-library' - id 'java-library' -} diff --git a/src/build-logic/src/main/groovy/ca.public-path-snapshot.gradle b/src/build-logic/src/main/groovy/ca.public-path-snapshot.gradle deleted file mode 100644 index 012be725..00000000 --- a/src/build-logic/src/main/groovy/ca.public-path-snapshot.gradle +++ /dev/null @@ -1,150 +0,0 @@ -// The snapshot's input is the COMMITTED binding default in config/security.yml, not src/.env. -// -// It used to read rootProject.file('.env'). /.gitignore:7 excludes `src/.env*` (allowing only the -// two *.example files), so `git ls-files src/.env` is empty and the file does not exist in a CI -// checkout — a gate whose expected value comes from an untracked file is not reproducible, and the -// first line of this closure turned that into a hard failure on any clean machine. Locally it was -// worse than a failure: it passed against one developer's file. The committed snapshot recorded -// `/api/healthcheck`, taken from that local .env, while the shipped default in -// app-bootstrap/src/main/resources/config/security.yml binds -// public-paths: ${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck} -// = /v1/healthcheck. The reviewed snapshot therefore described a surface no deployment had. -// -// What the snapshot now pins is the permitAll surface a deployment gets when no operator override -// is set — the thing a reviewer must see change. An operator's own SECURITY_PUBLIC_PATHS at run -// time is outside the repository and outside any build gate; the default is the part this -// repository is accountable for. -Closure renderPublicPathSnapshot = { File securityConfigFile -> - if (!securityConfigFile.isFile()) { - throw new GradleException( - "missing public-path security configuration ${securityConfigFile}") - } - - def bindingPattern = ~/^\s*public-paths:\s*(\S.*?)\s*$/ - List bindings = securityConfigFile.readLines('UTF-8').findResults { String line -> - def matcher = bindingPattern.matcher(line) - matcher.matches() ? matcher.group(1) : null - } - if (bindings.size() != 1) { - throw new GradleException( - "expected exactly one 'public-paths:' binding in ${securityConfigFile}, " + - "found ${bindings.size()} — the snapshot cannot say which surface it pins") - } - - // Resolve Spring placeholders to their defaults, innermost first: - // ${A:${B:/v1}/healthcheck} -> ${A:/v1/healthcheck} -> /v1/healthcheck. - // `[^{}]*` only ever matches the innermost placeholder, so one substitution per pass unwinds - // the nesting from the inside out without any replacement-string escaping. - def defaultedPlaceholder = ~/\$\{[A-Za-z0-9_.]+:([^{}]*)\}/ - String raw = bindings.first() - for (int guard = 0; guard < 16; guard++) { - def matcher = defaultedPlaceholder.matcher(raw) - if (!matcher.find()) { - break - } - raw = raw.substring(0, matcher.start()) + matcher.group(1) + raw.substring(matcher.end()) - } - if (raw.contains('${')) { - throw new GradleException( - "'public-paths' in ${securityConfigFile} resolves to '${raw}', which still holds a " + - 'placeholder with no default — the deployed public path surface is not ' + - 'determined by the repository and cannot be snapshotted') - } - List publicPaths = raw.split(',') - .collect { String value -> value.trim() } - .findAll { String value -> !value.isEmpty() } - .toSorted() - - String header = - "# feature-security-operational-baseline D5 — deny-by-default public path snapshot.\n" + - "# SSOT: ca-skeleton.security.public-paths default in " + - "app-bootstrap/src/main/resources/config/security.yml\n" + - "# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own " + - "SECURITY_PUBLIC_PATHS\n" + - "# overrides it at run time and is outside this snapshot.\n" + - "# Update only after review with: ./gradlew updatePublicPathSnapshot " + - "-PapprovePublicPathChange\n" - header + (publicPaths.isEmpty() ? '' : publicPaths.join('\n') + '\n') -} - -File publicPathSourceFile = - rootProject.file('app-bootstrap/src/main/resources/config/security.yml') -File publicPathSnapshotFile = - rootProject.file('../docs/security/public-paths-snapshot.txt') -boolean publicPathUpdateApproved = project.hasProperty('approvePublicPathChange') -def existingPublicPathSource = providers.provider { - publicPathSourceFile.isFile() ? publicPathSourceFile : null -} -def existingPublicPathSnapshot = providers.provider { - publicPathSnapshotFile.isFile() ? publicPathSnapshotFile : null -} - -tasks.register('verifyPublicPathSnapshot') { - group = 'verification' - description = 'Fails without mutation when the committed deny-by-default public path baseline drifts.' - inputs.file(existingPublicPathSource).optional() - inputs.file(existingPublicPathSnapshot).optional() - inputs.property('updateApprovalRequested', publicPathUpdateApproved) - - doLast { - if (publicPathUpdateApproved) { - throw new GradleException( - 'verifyPublicPathSnapshot is read-only; use updatePublicPathSnapshot ' + - '-PapprovePublicPathChange for an intentional update.') - } - String canonical - try { - canonical = renderPublicPathSnapshot(publicPathSourceFile) - } catch (GradleException exception) { - throw new GradleException( - "verifyPublicPathSnapshot: ${exception.message}", exception) - } - if (!publicPathSnapshotFile.isFile()) { - throw new GradleException( - "verifyPublicPathSnapshot: missing committed baseline ${publicPathSnapshotFile}") - } - - String existing = publicPathSnapshotFile.getText('UTF-8') - if (existing != canonical) { - throw new GradleException( - "verifyPublicPathSnapshot: the deny-by-default public path surface changed.\n" + - " expected (snapshot):\n${existing}\n" + - " actual (security.yml public-paths default):\n${canonical}\n" + - 'A protected endpoint may now be public. Review the change, then run:\n' + - ' ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange') - } - logger.lifecycle( - 'verifyPublicPathSnapshot: OK — committed public paths are unchanged.') - } -} - -tasks.register('updatePublicPathSnapshot') { - group = 'build setup' - description = 'Explicitly updates the committed public path baseline after security review.' - inputs.file(existingPublicPathSource).optional() - inputs.property('approved', publicPathUpdateApproved) - outputs.file(publicPathSnapshotFile) - outputs.upToDateWhen { false } - - doLast { - if (!publicPathUpdateApproved) { - throw new GradleException( - 'updatePublicPathSnapshot requires -PapprovePublicPathChange') - } - String canonical - try { - canonical = renderPublicPathSnapshot(publicPathSourceFile) - } catch (GradleException exception) { - throw new GradleException( - "updatePublicPathSnapshot: ${exception.message}", exception) - } - if (!publicPathSnapshotFile.parentFile.isDirectory() - && !publicPathSnapshotFile.parentFile.mkdirs()) { - throw new GradleException( - "updatePublicPathSnapshot: failed to create ${publicPathSnapshotFile.parentFile}") - } - publicPathSnapshotFile.setText(canonical, 'UTF-8') - logger.lifecycle( - "updatePublicPathSnapshot: wrote reviewed baseline ${publicPathSnapshotFile}") - } -} diff --git a/src/build-logic/src/main/groovy/ca.quality-conventions.gradle b/src/build-logic/src/main/groovy/ca.quality-conventions.gradle deleted file mode 100644 index c2a096d5..00000000 --- a/src/build-logic/src/main/groovy/ca.quality-conventions.gradle +++ /dev/null @@ -1,185 +0,0 @@ -import com.github.spotbugs.snom.Confidence -import groovy.xml.XmlSlurper -import com.github.spotbugs.snom.SpotBugsTask -import org.gradle.api.plugins.quality.Checkstyle -import org.gradle.api.artifacts.VersionCatalogsExtension - -// feature-static-analysis-quality-contract — the static analysis baseline. -// -// Tiered, which is the change. Every tool used to hang off every leaf's `check`, so -// `./gradlew :domain-core:check` ran a bytecode bug finder and a security scanner before it would -// tell a developer whether their unit test passed. The two fast, deterministic tools stay on -// `check`; the two slow, worker-forking production analysis moves to `qualityCheck`, which `ci` runs. -// -// check Spotless (formatting), Checkstyle (style), Error Prone (compile-time) -// qualityCheck SpotBugs + FindSecBugs (production `main` bytecode only) -// -// Nothing is disabled and no finding is downgraded: `./gradlew qualityCheck` runs the same tasks -// with the same configuration, and CI runs it on every pull request. - -plugins { - id 'ca.java-conventions' - id 'com.diffplug.spotless' // D1 formatter - id 'checkstyle' // D2 style linter (Gradle built-in — no plugins{} id) - id 'com.github.spotbugs' // D3 bytecode bug finder (+ D4 FindSecBugs) - id 'net.ltgt.errorprone' // D5 compile-time checker -} - -def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs') -Closure versionOf = { String alias -> versionCatalog.findVersion(alias).get().requiredVersion } - -// Main and optional-platform builds share one repository-level quality configuration. The optional -// build lives one directory below the main Gradle root, so resolve the shared config without -// assuming that every build root is the repository's src directory. -File repositoryConfigDirectory = rootProject.file('config') -if (!repositoryConfigDirectory.isDirectory()) { - repositoryConfigDirectory = new File(rootProject.projectDir.parentFile, 'config') -} - -// D1 — google-java-format owns formatting + import order; spotlessApply auto-fixes, spotlessCheck -// (wired into check) verifies. CI must NEVER run spotlessApply. -spotless { - java { - googleJavaFormat(versionOf('googleJavaFormat')) - importOrder() - removeUnusedImports() - } -} - -// D2 — naming + logical ruleset; formatter-owned modules suppressed in the XML. Checkstyle also -// owns code-conventions I6 (one top-level type per file) through OneTopLevelClass and -// OuterTypeFilename, which is why no hand-written Java scanner enforces it any more. -checkstyle { - toolVersion = versionOf('checkstyle') - configFile = new File(repositoryConfigDirectory, 'checkstyle/checkstyle.xml') - configDirectory = new File(repositoryConfigDirectory, 'checkstyle') - ignoreFailures = false - // No warning-tier checks in the default build. Javadoc coverage is a documentation backlog, not - // a signal to print on every migration/build run. - maxWarnings = Integer.MAX_VALUE - - // Keep the ordinary check lane limited to main and test. Auxiliary source sets belong to - // explicit verification lanes and are linted by auxiliaryStyleCheck instead. - sourceSets = [sourceSets.main, sourceSets.test] -} - -// D3/D4 — bytecode bug finder; FindSecBugs plugin loaded via spotbugsPlugins below. -// reportLevel='high' implements §4 "blocking (high priority)": only high-confidence findings block, -// which keeps the gate signal-rich (the medium tier is dominated by EI_EXPOSE_REP defensive-copy -// noise on DI'd collaborators). Confirmed false positives go in config/spotbugs/exclude.xml. -spotbugs { - toolVersion = versionOf('spotbugs') - reportLevel = Confidence.valueOf('HIGH') - excludeFilter = new File(repositoryConfigDirectory, 'spotbugs/exclude.xml') -} - -// An incomplete SpotBugs run is a failure, not a clean report. -// -// SpotBugs writes missing classes and analysis errors into the XML report's element and -// still exits zero, so a run that could not load half the classpath looks exactly like a run that -// found nothing. This reads that element and fails on it. It stays as a hand-written reader because -// no SpotBugs option expresses "fail when the analysis did not complete"; what does NOT stay is the -// task that mutated this reader with four XML fixtures to prove it fails — a validator's validator. -Closure> spotBugsAnalysisFailures = { File reportFile -> - List failures = [] - if (!reportFile.isFile()) { - failures << "missing XML report ${reportFile}" - return failures - } - try { - XmlSlurper parser = new XmlSlurper(false, false) - parser.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true) - def report = parser.parse(reportFile) - def errors = report.Errors - if (errors.size() != 1) { - failures << "expected one Errors element in ${reportFile.name}" - return failures - } - def errorsElement = errors[0] - errorsElement.MissingClass.each { missingClass -> - String className = missingClass.text().trim() - failures << "missing analysis class ${className.isBlank() ? '' : className}" - } - errorsElement.Error.each { error -> - String message = error.ErrorMessage.text().trim() - failures << "analysis error ${message.isBlank() ? '' : message}" - } - [missingClasses: errorsElement.MissingClass.size(), errors: errorsElement.Error.size()].each { - String attribute, int observed -> - String declared = errorsElement.attributes()[attribute]?.toString() - if (!(declared ==~ /\d+/)) { - failures << "invalid ${attribute} count '${declared}'" - } else if (declared.toInteger() > observed) { - failures << "${declared} ${attribute} reported but only ${observed} detailed" - } - } - } catch (Exception ex) { - failures << "unreadable XML report: ${ex.message}" - } - failures -} - -// Production bytecode is the blocking static-analysis surface. Auxiliary test/benchmark/ -// qualification source sets are already compiled and executed by their lanes; running SpotBugs for -// every one of them multiplied analysis workers as source sets grew without protecting shipped code. -tasks.named('spotbugsMain', SpotBugsTask) { - def mainSourceSet = sourceSets.main - auxClassPaths.from(mainSourceSet.runtimeClasspath - mainSourceSet.output) - def xmlAnalysisReport = reports.maybeCreate('xml') - xmlAnalysisReport.required.set(true) - doLast { - List analysisFailures = - spotBugsAnalysisFailures(xmlAnalysisReport.outputLocation.get().asFile) - if (!analysisFailures.isEmpty()) { - throw new GradleException( - "${path}: SpotBugs analysis incomplete:\n " + analysisFailures.join('\n ')) - } - } -} - -tasks.withType(JavaCompile).configureEach { - options.errorprone { - disableWarningsInGeneratedCode = true // D5 — MapStruct/Lombok generated code - } -} - -dependencies { - spotbugsPlugins "com.h3xstream.findsecbugs:findsecbugs-plugin:${versionOf('findsecbugs')}" - errorprone "com.google.errorprone:error_prone_core:${versionOf('errorprone')}" -} - -// SpotBugs off the local `check`, on to `qualityCheck`. -// -// The SpotBugs plugin wires its analysis into `check` with `check.dependsOn(tasks.withType( -// SpotBugsTask))` — a live TaskCollection, not a named TaskProvider. A filter that matched on task -// NAME therefore removed nothing and left `:domain-core:check` running a bytecode analyser, while -// reading in review as if it had worked. Matching on element type is what actually identifies it. -Closure isSpotBugsDependency = { Object dependency -> - if (dependency instanceof TaskCollection) { - // An empty collection would vacuously satisfy `every`, and dropping some other plugin's - // empty collection is exactly the kind of silent removal this file is correcting. - return !dependency.isEmpty() && dependency.every { it instanceof SpotBugsTask } - } - String name = dependency instanceof TaskProvider ? ((TaskProvider) dependency).name - : dependency instanceof Task ? ((Task) dependency).name - : null - name != null && name.startsWith('spotbugs') -} - -tasks.named('check') { - setDependsOn(dependsOn.findAll { !isSpotBugsDependency(it) }) -} - -tasks.register('auxiliaryStyleCheck') { - group = 'verification' - description = 'Runs Checkstyle for non-default source sets outside the fast local check lane.' - dependsOn tasks.withType(Checkstyle).matching { - it.name != 'checkstyleMain' && it.name != 'checkstyleTest' - } -} - -tasks.register('qualityCheck') { - group = 'verification' - description = 'Runs this leaf\'s SpotBugs and FindSecBugs bytecode analysis.' - dependsOn tasks.named('spotbugsMain') -} diff --git a/src/build-logic/src/main/groovy/ca.runtime-membership.gradle b/src/build-logic/src/main/groovy/ca.runtime-membership.gradle deleted file mode 100644 index 62823045..00000000 --- a/src/build-logic/src/main/groovy/ca.runtime-membership.gradle +++ /dev/null @@ -1,64 +0,0 @@ -import org.gradle.api.artifacts.component.ProjectComponentIdentifier - -// Runtime topology is derived from Gradle, not copied into modules.json. The registry only names -// composition roots and the application projects that are allowed to exist in this build. -ext.moduleRegistryRepositoryRoot = rootProject.projectDir.parentFile - -File registryFile = rootProject.file('config/architecture/modules.json') -def registry -try { - registry = dev.caskeleton.buildlogic.ModuleRegistry.read( - registryFile, project.moduleRegistryRepositoryRoot as File) -} catch (IllegalStateException invalid) { - throw new GradleException(invalid.message, invalid) -} -def productionModules = registry.modules.findAll { it.id != 'sample-portfolio' } -Map moduleIdByGradlePath = - productionModules.collectEntries { [(it.gradlePath): it.id] } -List compositionIds = registry.compositionRoots.findAll { it != 'sample-portfolio' } - -tasks.register('verifyRuntimeModuleRegistry') { - group = 'verification' - description = 'Verifies resolved composition runtime project dependencies are registered application modules.' - inputs.file(registryFile) - - doLast { - compositionIds.each { String compositionId -> - def composition = registry.byId(compositionId) - Project compositionProject = rootProject.findProject(composition.gradlePath) - if (compositionProject == null) { - throw new GradleException( - "Runtime composition '${compositionId}' references missing Gradle project '${composition.gradlePath}'.") - } - def runtimeClasspath = compositionProject.configurations.findByName('runtimeClasspath') - if (runtimeClasspath == null) { - throw new GradleException( - "Runtime composition '${compositionId}' has no runtimeClasspath configuration.") - } - - Set actualProjectPaths = runtimeClasspath.incoming.resolutionResult.allComponents - .findAll { it.id instanceof ProjectComponentIdentifier } - .collect { (it.id as ProjectComponentIdentifier).projectPath } - .findAll { String projectPath -> projectPath != compositionProject.path } - .toSet() - - Set unregistered = actualProjectPaths.findAll { !moduleIdByGradlePath.containsKey(it) }.toSet() - if (!unregistered.isEmpty()) { - throw new GradleException( - "Runtime composition '${compositionId}' resolves unregistered application projects " + - "${unregistered.toSorted()}.") - } - - } - logger.lifecycle( - "verifyRuntimeModuleRegistry: ${compositionIds.size()} composition root(s) resolve only registered application projects") - } -} - -// Compatibility alias for scripts/docs that still use the old task name. It no longer compares an -// exact membership snapshot; it delegates to the invariant-based runtime registry check above. -tasks.register('verifyRuntimeModuleMembership') { - group = 'verification' - description = 'Compatibility alias for verifyRuntimeModuleRegistry.' - dependsOn tasks.named('verifyRuntimeModuleRegistry') -} diff --git a/src/build-logic/src/main/groovy/ca.spring-config.gradle b/src/build-logic/src/main/groovy/ca.spring-config.gradle deleted file mode 100644 index cc84b008..00000000 --- a/src/build-logic/src/main/groovy/ca.spring-config.gradle +++ /dev/null @@ -1,23 +0,0 @@ -// A leaf that owns @ConfigurationProperties types, and therefore needs Spring's configuration -// metadata processor. -// -// This replaces `verifyConfigurationPropertiesProcessor`, which read every leaf's Java source -// looking for the string `@ConfigurationProperties` (after blanking comments and string literals -// with a 95-line hand-written Java lexer, because `{@code @ConfigurationProperties}` appears in -// twenty Javadoc comments), then read the same leaf's build.gradle with a regular expression looking -// for an `annotationProcessor` line, and failed when the two counts disagreed. Two custom parsers to -// enforce something a plugin can simply do, and writing the declaration in any equivalent form broke -// the checker rather than the build. -// -// Applies nothing else on purpose. The leaves that need the processor are not one family — four -// inbound adapters, seven outbound adapters, two platform starters, the composition root and the -// sample — so making it imply `ca.spring-library` would have changed the test classpath of the two -// platform starters, whose tests run on plain JUnit by design. -// -// Opt-in rather than automatic, because every configuration in this build is dependency-locked in -// STRICT mode: adding an annotation processor to a leaf that does not declare one today would -// invalidate its lock state for no change in what it compiles. - -dependencies { - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' -} diff --git a/src/build-logic/src/main/groovy/ca.spring-library.gradle b/src/build-logic/src/main/groovy/ca.spring-library.gradle deleted file mode 100644 index 62a97852..00000000 --- a/src/build-logic/src/main/groovy/ca.spring-library.gradle +++ /dev/null @@ -1,15 +0,0 @@ -// A leaf that runs inside a Spring context: the inbound and outbound adapters, the composition root -// and the sample. -// -// The split between this and `ca.java-library` used to be a path test inside the root build's -// `configure(subprojects)` block — `project.path in [':domain-core', ...] || path.startsWith(':messaging:')` -// — so which test framework a leaf got was decided by a string comparison in a file the leaf's -// author never opened, and adding an adapter under a new path silently changed its test classpath. -plugins { - id 'ca.quality-conventions' -} - -dependencies { - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' -} diff --git a/src/build-logic/src/main/groovy/ca.strict-qualification.gradle b/src/build-logic/src/main/groovy/ca.strict-qualification.gradle deleted file mode 100644 index ccd902b1..00000000 --- a/src/build-logic/src/main/groovy/ca.strict-qualification.gradle +++ /dev/null @@ -1,150 +0,0 @@ -// 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 specification -> - String taskName = specification.name as String - def qualificationSourceSet = specification.sourceSet - List requiredClasses = (specification.requiredClasses ?: []) as List - - 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 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 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 -} diff --git a/src/build-logic/src/main/groovy/ca.strict-test-lane.gradle b/src/build-logic/src/main/groovy/ca.strict-test-lane.gradle deleted file mode 100644 index 58dab09b..00000000 --- a/src/build-logic/src/main/groovy/ca.strict-test-lane.gradle +++ /dev/null @@ -1,386 +0,0 @@ -// 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. - * - *

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. - * - *

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. - * - *

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 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. - * - *

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. - * - *

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//. */ - final String name - - /** Source sets whose output this one compiles against. */ - final List visibleOutputs = ['main'] - - /** `test` configurations this source set's own configurations extend. */ - final List 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 lanes - private final org.gradle.api.NamedDomainObjectContainer 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 getLanes() { - return lanes - } - - org.gradle.api.NamedDomainObjectContainer getAuxiliarySourceSets() { - return sourceSets - } - - /** Declares one lane. */ - void lane(String name, Closure configuration) { - lanes.create(name, configuration) - } - - /** - * Declares one auxiliary source set. - * - *

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//java and src//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. - // - // "Executed" excludes skips, and that distinction is the whole value of the counter. - // Gradle reports a skipped test through this same `afterTest` listener, so counting every - // callback counted `@Disabled` methods and unmet `Assumptions` as executions — a lane whose - // every test was disabled or assumed away incremented the counter and passed the - // zero-execution check below, which is the same hollow green the check exists to refuse. - // The rest of this repository already treats a skip as a non-result: JUnitEvidence keeps a - // skipped case out of the executed-class set, and ca.strict-qualification rejects skips - // outright. - def executedTests = new java.util.concurrent.atomic.AtomicLong(0L) - def skippedTests = new java.util.concurrent.atomic.AtomicLong(0L) - def executedSelectors = - java.util.Collections.synchronizedSet(new java.util.LinkedHashSet()) - afterTest { descriptor, result -> - if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) { - skippedTests.incrementAndGet() - return - } - 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) { - // Two different faults produce zero executions and they need different messages: - // a selection that matched nothing, and a selection that matched tests which then - // all skipped. Saying "its tag matches nothing" about the second would send the - // reader to rename a tag that is in fact correct. - throw new GradleException( - "strict test lane '${lane.name}' in ${owningProjectPath} executed no test" + - (skippedTests.get() > 0L - ? "; all ${skippedTests.get()} test(s) it selected were " + - "skipped (@Disabled or an unmet assumption), and a skip " + - "is not a result" - : (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()) { - // The rule is not spelled out here. "A named test must actually have run" is also - // what ca.evidence decides for a qualification lane, and the two copies of it had - // each learned only the suffix rules their own input happened to produce — this one - // knew about `method(String)[1]` and not about `Outer$Inner`, the other the reverse. - // One implementation, two observation mechanisms. - List absent = dev.caskeleton.buildlogic.RequiredTestExecution.absent( - lane.requiredTests, executedSelectors) - 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") - } - } -} - -// One aggregate per leaf, so the root can offer `integrationCheck` without a hand-kept list. -// -// A lane is declared, not discovered by naming convention, so the container that holds the -// declarations is the only honest source for "every lane in this repository". Registered -// unconditionally — a leaf with no lanes gets a task that depends on nothing, which is what makes -// the root aggregate a plain `collect` rather than a `findAll` over task existence. -// -// Deliberately NOT wired into `check`. Several of these lanes need a container runtime, and a leaf -// check that needs Docker is a leaf check that people learn to skip. -tasks.register('strictTestLaneCheck') { - group = 'verification' - description = 'Runs every strict test lane this leaf declares.' - dependsOn provider { strictTestLanes.lanes.collect { tasks.named(it.name) } } -} diff --git a/src/build-logic/src/main/groovy/ca.test-jvm-agents.gradle b/src/build-logic/src/main/groovy/ca.test-jvm-agents.gradle deleted file mode 100644 index 0fa24bf8..00000000 --- a/src/build-logic/src/main/groovy/ca.test-jvm-agents.gradle +++ /dev/null @@ -1,47 +0,0 @@ -import org.gradle.api.GradleException -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Classpath -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.testing.Test -import org.gradle.process.CommandLineArgumentProvider - -abstract class MockitoAgentArgumentProvider implements CommandLineArgumentProvider { - @Classpath - abstract ConfigurableFileCollection getMockitoCoreClasspath() - - @Input - abstract Property getOwner() - - @Override - Iterable asArguments() { - List candidates = mockitoCoreClasspath.files.findAll { File file -> - file.isFile() && file.name ==~ 'mockito-core-[^/]+\\.jar' - }.sort { File left, File right -> left.absolutePath <=> right.absolutePath } - if (candidates.size() != 1) { - throw new GradleException( - "${owner.get()}: expected exactly one mockito-core JAR for the test JVM, " + - "found ${candidates.size()}: " + candidates.collect { it.absolutePath }) - } - ["-javaagent:${candidates[0].absolutePath}", '-Xshare:off'] - } -} - -// Leaf-owned configuration: no rootProject.subprojects traversal and no cross-project mutation. -pluginManager.withPlugin('java') { - pluginManager.withPlugin('io.spring.dependency-management') { - def mockitoAgentDependencies = configurations.dependencyScope('mockitoAgentDependencies') - def mockitoAgent = configurations.resolvable('mockitoAgent') { - description = 'Mockito core JAR used only as a Test JVM startup agent.' - extendsFrom(mockitoAgentDependencies.get()) - transitive = false - } - dependencies.add(mockitoAgentDependencies.get().name, 'org.mockito:mockito-core') - tasks.withType(Test).configureEach { - def provider = objects.newInstance(MockitoAgentArgumentProvider) - provider.mockitoCoreClasspath.from(mockitoAgent) - provider.owner.set("${project.path}:${name}") - jvmArgumentProviders.add(provider) - } - } -} diff --git a/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/JUnitEvidence.groovy b/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/JUnitEvidence.groovy deleted file mode 100644 index b545d248..00000000 --- a/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/JUnitEvidence.groovy +++ /dev/null @@ -1,147 +0,0 @@ -package dev.caskeleton.buildlogic - -import groovy.xml.XmlSlurper - -/** - * JUnit XML result files, read once and the same way everywhere. - * - *

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. - * - *

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. - * - *

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 executedClasses - /** {@code Class#method} selectors, method parameters stripped. */ - final Set executedSelectors - /** The files these totals came from, in stable order. */ - final List resultFiles - - private Results(int tests, int skipped, int failures, int errors, - Set executedClasses, Set executedSelectors, - List 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 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 classes = new LinkedHashSet<>() - Set 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. - * - *

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. - * - *

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) - } -} diff --git a/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/JavaPublicTypes.groovy b/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/JavaPublicTypes.groovy deleted file mode 100644 index 46d350c2..00000000 --- a/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/JavaPublicTypes.groovy +++ /dev/null @@ -1,122 +0,0 @@ -package dev.caskeleton.buildlogic - -import com.sun.source.tree.ClassTree -import com.sun.source.tree.CompilationUnitTree -import com.sun.source.tree.ExpressionTree -import com.sun.source.tree.Tree -import com.sun.source.util.JavacTask - -import javax.lang.model.element.Modifier -import javax.tools.Diagnostic -import javax.tools.DiagnosticCollector -import javax.tools.JavaCompiler -import javax.tools.JavaFileObject -import javax.tools.StandardJavaFileManager -import javax.tools.ToolProvider -import java.nio.charset.StandardCharsets - -/** - * Public top-level types under a set of source roots, read with the Java compiler's own parser. - * - *

This used to be a regular expression over the text of each {@code .java} file, matching - * {@code ^public (final|abstract|sealed|non-sealed)* (class|interface|enum|record|@interface) Name}. - * A regular expression cannot be a Java parser, and the ways it fails here are not hypothetical: - * the modifier alternation had to be maintained by hand and was already missing {@code strictfp}, - * so {@code public strictfp class Foo} would have been left out of a surface whose whole purpose is - * to be complete; a {@code public class} written at column zero inside a block comment or a text - * block is matched; a copy of the same expression in another build script had drifted to a different - * modifier list, which is how two files came to disagree about what "public" means. - * - *

javac's parser answers the same question by construction. It is parse-only — no attribution, no - * classpath, no annotation processing — so it needs nothing the regex did not and it cannot be - * wrong about Java's own grammar. - * - *

Fail-closed twice over. A JVM with no compiler is refused rather than rendering an empty - * surface, and a file that does not parse is refused rather than contributing no types: both would - * otherwise read as "this leaf exposes less than it did", which is the one answer a surface check - * must never produce by accident. - */ -final class JavaPublicTypes { - - private JavaPublicTypes() {} - - /** - * Fully-qualified names of every public top-level type under the roots, sorted and unique. - * - *

A file with no package declaration contributes nothing, which is what the text-matching - * version did and what the surface means: an unnamed package is not reachable from an adopter. - * - * @param sourceRoots directories to walk; a root that does not exist contributes nothing - */ - static List render(Collection sourceRoots) { - List sources = [] - (sourceRoots ?: []).each { File root -> - if (root == null || !root.isDirectory()) { - return - } - root.eachFileRecurse { File candidate -> - if (candidate.isFile() && candidate.name.endsWith('.java')) { - sources << candidate - } - } - } - if (sources.isEmpty()) { - return [] - } - sources = sources.toSorted { File left, File right -> left.path <=> right.path } - - JavaCompiler compiler = ToolProvider.getSystemJavaCompiler() - if (compiler == null) { - throw new IllegalStateException( - 'No Java compiler on this JVM, so the public API surface cannot be parsed. ' + - 'Run the build on a JDK rather than a JRE — rendering an empty surface ' + - 'instead would report every public type as removed.') - } - - DiagnosticCollector diagnostics = new DiagnosticCollector<>() - StandardJavaFileManager fileManager = - compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8) - Set types = new TreeSet<>() - try { - // `-proc:none`: parsing is the whole job. An annotation processor would need a resolved - // classpath this deliberately does not build, and could contribute generated types that - // are not in the source root the surface is declared over. - JavacTask task = (JavacTask) compiler.getTask( - new StringWriter(), fileManager, diagnostics, ['-proc:none'], null, - fileManager.getJavaFileObjectsFromFiles(sources)) - Iterable units = task.parse() - - List> errors = diagnostics.diagnostics - .findAll { it.kind == Diagnostic.Kind.ERROR } - if (!errors.isEmpty()) { - throw new IllegalStateException( - 'The public API surface could not be parsed:\n ' + - errors.take(5).collect { it.toString() }.join('\n ') + - (errors.size() > 5 ? "\n (${errors.size() - 5} more)" : '')) - } - - units.each { CompilationUnitTree unit -> - ExpressionTree packageName = unit.packageName - if (packageName == null) { - return - } - String packageText = packageName.toString() - unit.typeDecls.each { Tree declaration -> - // Only top-level declarations are visited here; a nested public type is reachable - // only through its owner and is part of that owner's surface, not a separate one. - if (!(declaration instanceof ClassTree)) { - return - } - ClassTree type = declaration as ClassTree - if (!type.modifiers.flags.contains(Modifier.PUBLIC)) { - return - } - types << "${packageText}.${type.simpleName}".toString() - } - } - } finally { - fileManager.close() - } - return new ArrayList(types) - } -} diff --git a/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/ModuleRegistry.groovy b/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/ModuleRegistry.groovy deleted file mode 100644 index 8c3d2951..00000000 --- a/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/ModuleRegistry.groovy +++ /dev/null @@ -1,173 +0,0 @@ -package dev.caskeleton.buildlogic - -import groovy.json.JsonSlurper - -/** - * Parses the application build's module registry. - * - * The registry owns project identity/path plus architecture-edge policy. It intentionally does not - * mirror Gradle's runtime graph: runtime membership is derived from the resolved runtimeClasspath, - * so adding a dependency never requires a second edit to this JSON just to restate what Gradle - * already knows. - */ -final class ModuleRegistry { - - private static final Set REQUIRED_MODULE_FIELDS = - ['id', 'gradle_path', 'source_path', 'allowed_dependencies'] as Set - private static final Set REQUIRED_ROOT_FIELDS = ['composition_roots', 'modules'] as Set - - final List modules - final List compositionRoots - final File source - - private ModuleRegistry(List modules, List compositionRoots, File source) { - this.modules = Collections.unmodifiableList(modules) - this.compositionRoots = Collections.unmodifiableList(compositionRoots) - this.source = source - } - - static final class Module { - final String id - final String gradlePath - final String sourcePath - final File sourceDirectory - final List allowedDependencies - - private Module(String id, String gradlePath, String sourcePath, File sourceDirectory, - List allowedDependencies) { - this.id = id - this.gradlePath = gradlePath - this.sourcePath = sourcePath - this.sourceDirectory = sourceDirectory - this.allowedDependencies = Collections.unmodifiableList(allowedDependencies) - } - } - - 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}") - } - Set missingRootFields = - REQUIRED_ROOT_FIELDS - parsed.keySet().collect { it as String }.toSet() - if (!missingRootFields.isEmpty()) { - throw new IllegalStateException( - "Module registry root is missing ${missingRootFields.toSorted()}: ${registryFile}") - } - if (!(parsed.modules instanceof List) || parsed.modules.isEmpty()) { - throw new IllegalStateException("Module registry has no modules: ${registryFile}") - } - - List compositionRoots = requireStringList( - parsed.composition_roots, '', 'composition_roots') - if (compositionRoots.isEmpty()) { - throw new IllegalStateException( - "Module registry needs a nonempty 'composition_roots' list: ${registryFile}") - } - if (compositionRoots.toSet().size() != compositionRoots.size()) { - throw new IllegalStateException( - "Module registry contains duplicate composition_roots: ${registryFile}") - } - - File canonicalRoot = repositoryRoot.canonicalFile - String rootPrefix = canonicalRoot.path + File.separator - Set ids = new LinkedHashSet<>() - Set gradlePaths = new LinkedHashSet<>() - Set sourceDirectories = new LinkedHashSet<>() - - List modules = parsed.modules.withIndex().collect { rawModule, index -> - if (!(rawModule instanceof Map)) { - throw new IllegalStateException("Module registry entry ${index} must be a JSON object.") - } - Map module = rawModule as Map - Set missingFields = - REQUIRED_MODULE_FIELDS - module.keySet().collect { it as String }.toSet() - if (!missingFields.isEmpty()) { - Object rawId = module['id'] - String named = (rawId instanceof String && !(rawId as String).isBlank()) - ? "'${rawId}'" - : "at index ${index}" - throw new IllegalStateException( - "Module registry entry ${named} is missing ${missingFields.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 allowedDependencies = - requireStringList(module.allowed_dependencies, id, 'allowed_dependencies') - if (allowedDependencies.toSet().size() != allowedDependencies.size()) { - throw new IllegalStateException( - "Module registry entry '${id}' contains duplicate allowed dependencies.") - } - 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}") - } - if (!sourceDirectories.add(sourceDirectory.path)) { - throw new IllegalStateException( - "Module registry entry '${id}' resolves to duplicate or aliased canonical source " + - "directory: ${sourceDirectory}") - } - - new Module(id, gradlePath, sourcePath, sourceDirectory, allowedDependencies) - } - - Set moduleIds = modules.collect { it.id }.toSet() - Set unknownRoots = compositionRoots.toSet() - moduleIds - if (!unknownRoots.isEmpty()) { - throw new IllegalStateException( - "Module registry composition_roots reference unknown module ids ${unknownRoots.toSorted()}.") - } - - new ModuleRegistry(modules, compositionRoots, registryFile) - } - - Module byId(String id) { - modules.find { it.id == id } - } - - private static List requireStringList(Object raw, String id, String field) { - if (!(raw instanceof List)) { - throw new IllegalStateException("Module registry entry '${id}' needs a '${field}' list.") - } - 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 - } - } -} diff --git a/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/RequiredTestExecution.groovy b/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/RequiredTestExecution.groovy deleted file mode 100644 index 650894a3..00000000 --- a/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/RequiredTestExecution.groovy +++ /dev/null @@ -1,74 +0,0 @@ -package dev.caskeleton.buildlogic - -/** - * "A test this build names must actually have run", decided in one place. - * - *

Two conventions enforced this rule with two copies of the decision. {@code ca.strict-test-lane} - * compared a lane's {@code requires(...)} selectors against what its {@code afterTest} listener saw; - * {@code ca.evidence} compared a qualification lane's required FQCNs against the classes it read back - * out of JUnit XML. Both answered the same question — is this named test in the set of things that - * ran — and they answered it differently, because each had written only the suffix rules its own - * input shape happened to produce. - * - *

That is the failure mode worth naming. The lane knew a parameterized method executes as - * {@code method(String)[1]} and the evidence reader did not; the evidence reader knew a class whose - * cases all live in {@code @Nested} inner classes executes as {@code Outer$Inner} and the lane did - * not. Neither gap shows up as a red build. Both show up as a required test reported absent when it - * ran, or — the direction that matters — as a gate that is weaker on one side than the reader of - * either plugin would guess. - * - *

How the observation is made stays where it was, deliberately. A lane watches a live Test task - * because it has one; a qualification lane re-reads the recorded XML precisely so its claim does not - * rest on a task's exit code. Those are different evidence sources for good reasons. What is shared - * is the rule applied to whatever they observed, and that is what lives here. - */ -final class RequiredTestExecution { - - /** - * Characters that begin a sub-identity of a named test. - * - *

An executed identity that starts with a required name followed by one of these is that - * required test, reported at a finer grain than the name asked for: - * - *

    - *
  • {@code $} — a {@code @Nested} inner class, reported as {@code Outer$Inner};
  • - *
  • {@code (} — a method's parameter list, reported as {@code method(String)};
  • - *
  • {@code [} — one invocation of a parameterized test, reported as {@code method[1]}.
  • - *
- * - *

A plain {@code .} is not here and must not be: {@code com.example.FooTest} would then be - * satisfied by {@code com.example.FooTestHelper}, and a required class would be provable by a - * different class whose name merely starts the same way. - */ - private static final List SUB_IDENTITY_SEPARATORS = ['$', '(', '['] - - private RequiredTestExecution() {} - - /** Whether one executed identity proves the required selector ran. */ - static boolean satisfies(String executed, String required) { - if (executed == null || required == null) { - return false - } - if (executed == required) { - return true - } - return SUB_IDENTITY_SEPARATORS.any { String separator -> executed.startsWith(required + separator) } - } - - /** - * The required selectors nothing in {@code executed} accounts for, in declaration order. - * - *

Every one of them, not the first. A lane naming five contracts of which four still exist - * would otherwise report a single miss and leave the reader believing the other four were the - * only ones checked. - */ - static List absent(Collection required, Collection executed) { - if (required == null || required.isEmpty()) { - return [] - } - Collection observed = executed ?: [] - return required.findAll { String requiredSelector -> - !observed.any { String executedSelector -> satisfies(executedSelector, requiredSelector) } - } - } -} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ApiSurfaceExtension.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ApiSurfaceExtension.java new file mode 100644 index 00000000..cf9dfa41 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ApiSurfaceExtension.java @@ -0,0 +1,33 @@ +package dev.caskeleton.buildlogic; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** Declarative configuration for one committed public API surface. */ +public class ApiSurfaceExtension { + private String label; + private File baseline; + private String description; + private List rationale = new ArrayList<>(); + private String sourceRoot = "src/main/java"; + private List additionalSourceRoots = new ArrayList<>(); + + public String getLabel() { return label; } + public void setLabel(String label) { this.label = label; } + public File getBaseline() { return baseline; } + public void setBaseline(File baseline) { this.baseline = baseline; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public List getRationale() { return rationale; } + public void setRationale(List rationale) { + this.rationale = rationale == null ? new ArrayList<>() : new ArrayList<>(rationale); + } + public String getSourceRoot() { return sourceRoot; } + public void setSourceRoot(String sourceRoot) { this.sourceRoot = sourceRoot; } + public List getAdditionalSourceRoots() { return additionalSourceRoots; } + public void setAdditionalSourceRoots(List additionalSourceRoots) { + this.additionalSourceRoots = + additionalSourceRoots == null ? new ArrayList<>() : new ArrayList<>(additionalSourceRoots); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ApiSurfacePolicy.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ApiSurfacePolicy.java new file mode 100644 index 00000000..b33dcf86 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ApiSurfacePolicy.java @@ -0,0 +1,148 @@ +package dev.caskeleton.buildlogic; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** Policy and rendering semantics for committed public Java API surfaces. */ +public final class ApiSurfacePolicy { + private ApiSurfacePolicy() {} + + public static String render( + Collection roots, + String label, + String description, + Collection rationale, + String owningProjectPath, + String updateTaskName, + String approvalProperty) { + List types; + try { + types = JavaPublicTypes.render(roots); + } catch (IllegalStateException unparseable) { + throw new IllegalStateException( + owningProjectPath + " " + label + " API surface: " + unparseable.getMessage(), + unparseable); + } + if (types.isEmpty()) { + throw new IllegalStateException( + owningProjectPath + + ": found no public types under " + + String.join(", ", roots.stream().map(File::toString).toList()) + + ". The source roots moved; fix the paths rather than accepting an empty surface."); + } + StringBuilder header = new StringBuilder(); + header.append("# ").append(description).append('\n'); + if (rationale != null) { + rationale.forEach(line -> header.append("# ").append(line).append('\n')); + } + header.append("# Update only after review with:\n"); + header + .append("# ./gradlew ") + .append(owningProjectPath) + .append(':') + .append(updateTaskName) + .append(" -P") + .append(approvalProperty) + .append('\n'); + header.append("# types: ").append(types.size()).append('\n'); + return header + String.join("\n", types) + "\n"; + } + + public static void verify( + String verifyTaskName, + String updateTaskName, + String owningProjectPath, + String approvalProperty, + File baseline, + String rendered) { + if (!baseline.isFile()) { + throw new IllegalStateException( + verifyTaskName + ": missing committed baseline " + baseline); + } + String committed = read(baseline); + if (committed.equals(rendered)) { + return; + } + List committedTypes = surfaceLines(committed); + List renderedTypes = surfaceLines(rendered); + List added = difference(renderedTypes, committedTypes); + List removed = difference(committedTypes, renderedTypes); + throw new IllegalStateException( + verifyTaskName + + ": the public API surface changed.\n" + + (added.isEmpty() ? "" : " added:\n " + String.join("\n ", added) + "\n") + + (removed.isEmpty() ? "" : " removed:\n " + String.join("\n ", removed) + "\n") + + "Review the change, then record it with:\n" + + " ./gradlew " + + owningProjectPath + + ":" + + updateTaskName + + " -P" + + approvalProperty); + } + + public static void requireNoUnapprovedGrowth( + String updateTaskName, + String owningProjectPath, + String approvalProperty, + String ceilingProperty, + int committedCount, + String rendered, + boolean ceilingRaiseApproved) { + if (ceilingRaiseApproved) { + return; + } + int renderedCount = countTypes(rendered); + if (renderedCount > committedCount) { + throw new IllegalStateException( + updateTaskName + + ": 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 + + ":" + + updateTaskName + + " -P" + + approvalProperty + + " -P" + + ceilingProperty); + } + } + + public static int countTypes(String surface) { + return (int) + surface.lines() + .filter(line -> !line.startsWith("#") && !line.trim().isEmpty()) + .count(); + } + + private static List surfaceLines(String surface) { + return surface.lines().filter(line -> !line.startsWith("#")).toList(); + } + + private static List difference(List left, List right) { + ArrayList result = new ArrayList<>(left); + result.removeAll(right); + result.sort(String::compareTo); + return List.copyOf(result); + } + + private static String read(File file) { + try { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ConditionalTransportQualificationPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ConditionalTransportQualificationPlugin.java new file mode 100644 index 00000000..b39f590c --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ConditionalTransportQualificationPlugin.java @@ -0,0 +1,90 @@ +package dev.caskeleton.buildlogic; + +import java.util.List; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.file.Directory; +import org.gradle.api.provider.Provider; + +public final class ConditionalTransportQualificationPlugin implements Plugin { + + @Override + public void apply(Project project) { + if (project != project.getRootProject()) { + throw new GradleException( + "ca.conditional-transport-qualification must be applied to the root project."); + } + project.getPluginManager().apply("ca.evidence"); + + List evidenceTargets = + List.of( + target( + project, + ":adapter:inbound:graphql", + "conditional-transport-graphql", + "graphqlTransportQualificationTest"), + target( + project, + ":adapter:inbound:grpc", + "conditional-transport-grpc", + "grpcTransportQualificationTest"), + target( + project, + ":adapter:inbound:websocket", + "conditional-transport-websocket", + "websocketTransportQualificationTest"), + target( + project, + ":app-bootstrap", + "conditional-transport-composition", + "conditionalTransportCompositionTest")); + + project + .getTasks() + .register( + "conditionalTransportQualification", + task -> { + task.setGroup("verification"); + task.setDescription( + "Runs the exact no-skip GraphQL, gRPC, and WebSocket P1 qualification evidence."); + task.dependsOn( + ":adapter:inbound:graphql:graphqlTransportQualificationTest", + ":adapter:inbound:grpc:grpcTransportQualificationTest", + ":adapter:inbound:websocket:websocketTransportQualificationTest", + ":app-bootstrap:conditionalTransportCompositionTest", + "verifyRuntimeModuleMembership"); + task.getInputs().files(evidenceTargets.stream().map(EvidenceTarget::directory).toList()); + task.doLast( + ignored -> { + EvidenceExtension evidence = + project.getExtensions().getByType(EvidenceExtension.class); + evidenceTargets.forEach( + target -> + evidence.verifyNoSkipJUnitXml( + target.name(), target.directory().get().getAsFile())); + }); + }); + } + + private static EvidenceTarget target( + Project root, String projectPath, String evidenceName, String resultDirectory) { + Provider directory = + root.project(projectPath) + .getLayout() + .getBuildDirectory() + .dir("test-results/" + resultDirectory); + return new EvidenceTarget(evidenceName, directory); + } + + private record EvidenceTarget(String name, Provider directory) { + private EvidenceTarget { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("evidence name is required"); + } + if (directory == null) { + throw new IllegalArgumentException("evidence directory is required"); + } + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/EvidenceExtension.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/EvidenceExtension.java new file mode 100644 index 00000000..fe12f292 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/EvidenceExtension.java @@ -0,0 +1,58 @@ +package dev.caskeleton.buildlogic; + +import java.io.File; +import java.util.List; +import org.gradle.api.GradleException; +import org.gradle.api.logging.Logger; + +/** Typed JUnit evidence API shared by qualification conventions. */ +public class EvidenceExtension { + private final Logger logger; + + public EvidenceExtension(Logger logger) { + this.logger = logger; + } + + public JUnitEvidence.Results readJUnitEvidence(String evidenceName, File resultDirectory) { + try { + return JUnitEvidence.read(evidenceName, resultDirectory); + } catch (IllegalStateException unreadable) { + throw new GradleException(unreadable.getMessage(), unreadable); + } + } + + public JUnitEvidence.Results verifyNoSkipJUnitXml( + String evidenceName, File resultDirectory) { + JUnitEvidence.Results 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( + "{}: {} tests, {} skipped", evidenceName, evidence.tests(), evidence.skipped()); + return evidence; + } + + public JUnitEvidence.Results verifyRequiredJUnitClasses( + String evidenceName, File resultDirectory, List requiredClasses) { + JUnitEvidence.Results evidence = verifyNoSkipJUnitXml(evidenceName, resultDirectory); + List missing = + RequiredTestExecution.absent(requiredClasses, evidence.executedClasses()); + if (!missing.isEmpty()) { + throw new GradleException( + evidenceName + ": no executed test cases for required classes: " + missing); + } + return evidence; + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/EvidencePlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/EvidencePlugin.java new file mode 100644 index 00000000..499cc004 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/EvidencePlugin.java @@ -0,0 +1,13 @@ +package dev.caskeleton.buildlogic; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class EvidencePlugin implements Plugin { + @Override + public void apply(Project project) { + project + .getExtensions() + .create("evidence", EvidenceExtension.class, project.getLogger()); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/JUnitEvidence.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/JUnitEvidence.java new file mode 100644 index 00000000..4ce14d78 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/JUnitEvidence.java @@ -0,0 +1,128 @@ +package dev.caskeleton.buildlogic; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +/** JUnit XML result files, read once and fail-closed. */ +public final class JUnitEvidence { + private JUnitEvidence() {} + + public record Results( + int tests, + int skipped, + int failures, + int errors, + Set executedClasses, + Set executedSelectors, + List resultFiles) { + public Results { + executedClasses = Set.copyOf(executedClasses); + executedSelectors = Set.copyOf(executedSelectors); + resultFiles = List.copyOf(resultFiles); + } + + public boolean isClean() { + return tests > 0 && skipped == 0 && failures == 0 && errors == 0; + } + } + + public static Results read(String evidenceName, File resultDirectory) { + List resultFiles = resultFiles(resultDirectory); + 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 classes = new LinkedHashSet<>(); + Set selectors = new TreeSet<>(); + + for (File resultFile : resultFiles) { + Element suite = parseSuite(evidenceName, resultFile); + tests += attribute(suite, "tests", evidenceName, resultFile); + skipped += attribute(suite, "skipped", evidenceName, resultFile); + failures += attribute(suite, "failures", evidenceName, resultFile); + errors += attribute(suite, "errors", evidenceName, resultFile); + NodeList testCases = suite.getElementsByTagName("testcase"); + for (int index = 0; index < testCases.getLength(); index++) { + Element testCase = (Element) testCases.item(index); + String className = testCase.getAttribute("classname"); + boolean wasSkipped = testCase.getElementsByTagName("skipped").getLength() > 0; + if (!className.isBlank() && !wasSkipped) { + classes.add(className); + } + String methodName = testCase.getAttribute("name").replaceFirst("\\([^)]*\\)$", ""); + selectors.add(className + "#" + methodName); + } + } + return new Results(tests, skipped, failures, errors, classes, selectors, resultFiles); + } + + private static List resultFiles(File resultDirectory) { + if (resultDirectory == null || !resultDirectory.isDirectory()) { + return List.of(); + } + try (var paths = Files.walk(resultDirectory.toPath())) { + return paths + .filter(Files::isRegularFile) + .map(java.nio.file.Path::toFile) + .filter(file -> file.getName().startsWith("TEST-") && file.getName().endsWith(".xml")) + .sorted(Comparator.comparing(File::getPath)) + .toList(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to inspect " + resultDirectory, exception); + } + } + + private static Element parseSuite(String evidenceName, File resultFile) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + Document document = factory.newDocumentBuilder().parse(resultFile); + Element root = document.getDocumentElement(); + if (!root.getTagName().equals("testsuite")) { + throw new IllegalStateException( + evidenceName + ": " + resultFile.getName() + " root must be testsuite"); + } + return root; + } catch (IllegalStateException exception) { + throw exception; + } catch (Exception unreadable) { + throw new IllegalStateException( + evidenceName + ": " + resultFile + " is not readable JUnit XML", unreadable); + } + } + + private static int attribute( + Element suite, String name, String evidenceName, File resultFile) { + String raw = suite.hasAttribute(name) ? suite.getAttribute(name) : null; + if (raw == null || !raw.matches("\\d+")) { + throw new IllegalStateException( + evidenceName + ": " + resultFile.getName() + " has invalid " + name + "='" + raw + "'"); + } + return Integer.parseInt(raw); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/JavaPublicTypes.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/JavaPublicTypes.java new file mode 100644 index 00000000..01e89d9e --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/JavaPublicTypes.java @@ -0,0 +1,105 @@ +package dev.caskeleton.buildlogic; + +import com.sun.source.tree.ClassTree; +import com.sun.source.tree.CompilationUnitTree; +import com.sun.source.tree.Tree; +import com.sun.source.util.JavacTask; +import java.io.File; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import javax.lang.model.element.Modifier; +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; + +/** Public top-level Java types parsed with javac rather than regular expressions. */ +public final class JavaPublicTypes { + private JavaPublicTypes() {} + + public static List render(Collection sourceRoots) { + List sources = new ArrayList<>(); + if (sourceRoots != null) { + for (File root : sourceRoots) { + if (root == null || !root.isDirectory()) { + continue; + } + try (var paths = Files.walk(root.toPath())) { + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".java")) + .map(java.nio.file.Path::toFile) + .forEach(sources::add); + } catch (IOException exception) { + throw new IllegalStateException("Could not walk Java source root " + root, exception); + } + } + } + if (sources.isEmpty()) { + return List.of(); + } + sources.sort(Comparator.comparing(File::getPath)); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new IllegalStateException( + "No Java compiler on this JVM, so the public API surface cannot be parsed. " + + "Run the build on a JDK rather than a JRE — rendering an empty surface " + + "instead would report every public type as removed."); + } + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + Set types = new TreeSet<>(); + try (StandardJavaFileManager fileManager = + compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { + JavacTask task = + (JavacTask) + compiler.getTask( + new StringWriter(), + fileManager, + diagnostics, + List.of("-proc:none"), + null, + fileManager.getJavaFileObjectsFromFiles(sources)); + Iterable units = task.parse(); + List> errors = + diagnostics.getDiagnostics().stream() + .filter(diagnostic -> diagnostic.getKind() == Diagnostic.Kind.ERROR) + .toList(); + if (!errors.isEmpty()) { + String first = + errors.stream().limit(5).map(Object::toString).reduce((a, b) -> a + "\n " + b).orElse(""); + String more = errors.size() > 5 ? "\n (" + (errors.size() - 5) + " more)" : ""; + throw new IllegalStateException( + "The public API surface could not be parsed:\n " + first + more); + } + + for (CompilationUnitTree unit : units) { + if (unit.getPackageName() == null) { + continue; + } + String packageName = unit.getPackageName().toString(); + for (Tree declaration : unit.getTypeDecls()) { + if (!(declaration instanceof ClassTree type)) { + continue; + } + if (type.getModifiers().getFlags().contains(Modifier.PUBLIC)) { + types.add(packageName + "." + type.getSimpleName()); + } + } + } + } catch (IOException exception) { + throw new IllegalStateException("Could not parse Java source files", exception); + } + return List.copyOf(types); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ModuleRegistry.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ModuleRegistry.java new file mode 100644 index 00000000..2e71f74f --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ModuleRegistry.java @@ -0,0 +1,246 @@ +package dev.caskeleton.buildlogic; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** Typed representation of config/architecture/modules.json. */ +public record ModuleRegistry(List modules, List compositionRoots, File source) { + private static final ObjectMapper JSON = JsonMapper.builder().build(); + private static final Set REQUIRED_MODULE_FIELDS = + Set.of("id", "gradle_path", "source_path", "allowed_dependencies"); + private static final Set REQUIRED_ROOT_FIELDS = Set.of("composition_roots", "modules"); + + public ModuleRegistry { + modules = List.copyOf(modules); + compositionRoots = List.copyOf(compositionRoots); + Objects.requireNonNull(source, "source"); + } + + public record Module( + String id, + String gradlePath, + String sourcePath, + File sourceDirectory, + String buildName, + List allowedDependencies) { + public Module { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(gradlePath, "gradlePath"); + Objects.requireNonNull(sourcePath, "sourcePath"); + Objects.requireNonNull(sourceDirectory, "sourceDirectory"); + Objects.requireNonNull(buildName, "buildName"); + allowedDependencies = List.copyOf(allowedDependencies); + } + } + + public static ModuleRegistry read(File registryFile, File repositoryRoot) { + if (!registryFile.isFile()) { + throw new IllegalStateException("Missing module registry: " + registryFile); + } + JsonNode root; + try { + root = JSON.readTree(Files.readString(registryFile.toPath(), StandardCharsets.UTF_8)); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + registryFile, exception); + } + if (root == null || !root.isObject()) { + throw new IllegalStateException("Module registry root must be a JSON object: " + registryFile); + } + Set rootKeys = keys(root); + Set missingRootFields = new TreeSet<>(REQUIRED_ROOT_FIELDS); + missingRootFields.removeAll(rootKeys); + if (!missingRootFields.isEmpty()) { + throw new IllegalStateException( + "Module registry root is missing " + missingRootFields + ": " + registryFile); + } + + JsonNode modulesNode = root.get("modules"); + if (modulesNode == null || !modulesNode.isArray() || modulesNode.isEmpty()) { + throw new IllegalStateException("Module registry has no modules: " + registryFile); + } + + List compositionRoots = + requireStringList(root.get("composition_roots"), "", "composition_roots"); + if (compositionRoots.isEmpty()) { + throw new IllegalStateException( + "Module registry needs a nonempty 'composition_roots' list: " + registryFile); + } + if (new HashSet<>(compositionRoots).size() != compositionRoots.size()) { + throw new IllegalStateException( + "Module registry contains duplicate composition_roots: " + registryFile); + } + + File canonicalRoot; + try { + canonicalRoot = repositoryRoot.getCanonicalFile(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to canonicalize " + repositoryRoot, exception); + } + String rootPrefix = canonicalRoot.getPath() + File.separator; + Set ids = new LinkedHashSet<>(); + Set gradlePaths = new LinkedHashSet<>(); + Set sourceDirectories = new LinkedHashSet<>(); + List modules = new ArrayList<>(); + + for (int index = 0; index < modulesNode.size(); index++) { + JsonNode module = modulesNode.get(index); + if (module == null || !module.isObject()) { + throw new IllegalStateException( + "Module registry entry " + index + " must be a JSON object."); + } + Set missingFields = new TreeSet<>(REQUIRED_MODULE_FIELDS); + missingFields.removeAll(keys(module)); + if (!missingFields.isEmpty()) { + JsonNode rawId = module.get("id"); + String named = + rawId != null && rawId.isString() && !rawId.asText().isBlank() + ? "'" + rawId.asText() + "'" + : "at index " + index; + throw new IllegalStateException( + "Module registry entry " + named + " is missing " + missingFields); + } + + String id = requireNonBlankText(module, "id", index); + String gradlePath = requireNonBlankText(module, "gradle_path", index); + String sourcePath = requireNonBlankText(module, "source_path", index); + String buildName = module.has("build") ? requireNonBlankText(module, "build", id) : "main"; + List allowedDependencies = + requireStringList(module.get("allowed_dependencies"), id, "allowed_dependencies"); + + if (new HashSet<>(allowedDependencies).size() != allowedDependencies.size()) { + throw new IllegalStateException( + "Module registry entry '" + id + "' contains duplicate allowed dependencies."); + } + if (!ids.add(id)) { + throw new IllegalStateException( + "Module registry contains duplicate module id '" + id + "'."); + } + 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 + "'."); + } + if (new File(sourcePath).isAbsolute()) { + throw new IllegalStateException( + "Module registry entry '" + + id + + "' source path must be repository-root-relative: '" + + sourcePath + + "'."); + } + + File sourceDirectory; + try { + sourceDirectory = new File(canonicalRoot, sourcePath).getCanonicalFile(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to canonicalize " + sourcePath, exception); + } + if (!sourceDirectory.getPath().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); + } + if (!sourceDirectories.add(sourceDirectory.getPath())) { + throw new IllegalStateException( + "Module registry entry '" + + id + + "' resolves to duplicate or aliased canonical source directory: " + + sourceDirectory); + } + modules.add( + new Module( + id, + gradlePath, + sourcePath, + sourceDirectory, + buildName, + allowedDependencies)); + } + + Set moduleIds = modules.stream().map(Module::id).collect(java.util.stream.Collectors.toSet()); + Set unknownRoots = new TreeSet<>(compositionRoots); + unknownRoots.removeAll(moduleIds); + if (!unknownRoots.isEmpty()) { + throw new IllegalStateException( + "Module registry composition_roots reference unknown module ids " + unknownRoots + "."); + } + return new ModuleRegistry(modules, compositionRoots, registryFile); + } + + public Module byId(String id) { + return modules.stream().filter(module -> module.id().equals(id)).findFirst().orElse(null); + } + + public List modulesForBuild(String buildName) { + return modules.stream().filter(module -> module.buildName().equals(buildName)).toList(); + } + + private static Set keys(JsonNode node) { + Set result = new LinkedHashSet<>(); + node.properties().forEach(entry -> result.add(entry.getKey())); + return result; + } + + private static String requireNonBlankText(JsonNode node, String field, Object entry) { + JsonNode value = node.get(field); + if (value == null || !value.isString() || value.asText().isBlank()) { + String prefix = entry instanceof Integer ? "entry " + entry : "entry '" + entry + "'"; + throw new IllegalStateException( + "Module registry " + prefix + " needs a nonblank string '" + field + "'."); + } + return value.asText(); + } + + private static List requireStringList(JsonNode raw, String id, String field) { + if (raw == null || !raw.isArray()) { + throw new IllegalStateException( + "Module registry entry '" + id + "' needs a '" + field + "' list."); + } + List result = new ArrayList<>(); + for (int index = 0; index < raw.size(); index++) { + JsonNode value = raw.get(index); + if (value == null || !value.isString() || value.asText().isBlank()) { + throw new IllegalStateException( + "Module registry entry '" + + id + + "' has a non-string or blank " + + field + + " entry at index " + + index + + "."); + } + result.add(value.asText()); + } + return List.copyOf(result); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ModuleRegistrySettingsInstaller.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ModuleRegistrySettingsInstaller.java new file mode 100644 index 00000000..075b5ccb --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/ModuleRegistrySettingsInstaller.java @@ -0,0 +1,63 @@ +package dev.caskeleton.buildlogic; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.gradle.api.GradleException; +import org.gradle.api.initialization.Settings; + +/** Settings-time adapter mapping one logical build from the shared module registry into Gradle. */ +public final class ModuleRegistrySettingsInstaller { + private ModuleRegistrySettingsInstaller() {} + + public static ModuleRegistry install( + Settings settings, File registryFile, File repositoryRoot, String buildName) { + ModuleRegistry registry; + try { + registry = ModuleRegistry.read(registryFile, repositoryRoot); + } catch (IllegalStateException invalid) { + throw new GradleException(invalid.getMessage(), invalid); + } + + List selected = registry.modulesForBuild(buildName); + if (selected.isEmpty()) { + throw new GradleException( + "Module registry declares no modules for build '" + buildName + "': " + registryFile); + } + + Map namespaceDirectories = new HashMap<>(); + for (ModuleRegistry.Module module : selected) { + settings.include(module.gradlePath()); + settings.project(module.gradlePath()).setProjectDir(module.sourceDirectory()); + + String[] segments = module.gradlePath().substring(1).split(":"); + File directory = module.sourceDirectory(); + for (int size = segments.length - 1; size >= 1; size--) { + directory = directory.getParentFile(); + String parentPath = ":" + String.join(":", java.util.Arrays.copyOf(segments, size)); + File canonical; + try { + canonical = directory.getCanonicalFile(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to canonicalize " + directory, exception); + } + File existing = namespaceDirectories.putIfAbsent(parentPath, canonical); + if (existing != null && !existing.equals(canonical)) { + throw new GradleException( + "Module registry maps namespace '" + + parentPath + + "' to both " + + existing + + " and " + + canonical + + "."); + } + settings.project(parentPath).setProjectDir(canonical); + } + } + return registry; + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/RequiredTestExecution.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/RequiredTestExecution.java new file mode 100644 index 00000000..f46104b5 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/RequiredTestExecution.java @@ -0,0 +1,40 @@ +package dev.caskeleton.buildlogic; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** One rule for proving that a named test selector actually executed. */ +public final class RequiredTestExecution { + private static final List SUB_IDENTITY_SEPARATORS = List.of("$", "(", "["); + + private RequiredTestExecution() {} + + public static boolean satisfies(String executed, String required) { + if (executed == null || required == null) { + return false; + } + if (executed.equals(required)) { + return true; + } + return SUB_IDENTITY_SEPARATORS.stream() + .anyMatch(separator -> executed.startsWith(required + separator)); + } + + public static List absent( + Collection required, Collection executed) { + if (required == null || required.isEmpty()) { + return List.of(); + } + Collection observed = executed == null ? List.of() : executed; + List absent = new ArrayList<>(); + for (String requiredSelector : required) { + boolean satisfied = + observed.stream().anyMatch(executedSelector -> satisfies(executedSelector, requiredSelector)); + if (!satisfied) { + absent.add(requiredSelector); + } + } + return List.copyOf(absent); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/apisurface/ApiSurfacePlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/apisurface/ApiSurfacePlugin.java new file mode 100644 index 00000000..20070cdd --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/apisurface/ApiSurfacePlugin.java @@ -0,0 +1,103 @@ +package dev.caskeleton.buildlogic.apisurface; + +import dev.caskeleton.buildlogic.ApiSurfaceExtension; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.language.base.plugins.LifecycleBasePlugin; + +public final class ApiSurfacePlugin implements Plugin { + @Override + public void apply(Project project) { + ApiSurfaceExtension extension = + project.getExtensions().create("apiSurface", ApiSurfaceExtension.class); + + project.afterEvaluate( + ignored -> { + String label = extension.getLabel(); + File baseline = extension.getBaseline(); + if ((label == null || label.isBlank()) && baseline == null) { + return; + } + if (label == null || label.isBlank()) { + throw new GradleException( + project.getPath() + " declares an apiSurface baseline without a label"); + } + if (baseline == null) { + throw new GradleException( + project.getPath() + " declares an apiSurface label without a baseline"); + } + + List sourcePaths = new ArrayList<>(); + if (extension.getSourceRoot() != null && !extension.getSourceRoot().isBlank()) { + sourcePaths.add(extension.getSourceRoot()); + } + extension.getAdditionalSourceRoots().stream() + .filter(path -> path != null && !path.isBlank()) + .forEach(sourcePaths::add); + List roots = sourcePaths.stream().map(project::file).toList(); + String verifyTaskName = "verify" + label + "ApiSurface"; + String updateTaskName = "update" + label + "ApiSurface"; + String approvalProperty = "approve" + label + "ApiSurfaceChange"; + String ceilingProperty = "raise" + label + "ApiSurfaceCeiling"; + boolean approved = project.hasProperty(approvalProperty); + boolean ceilingApproved = project.hasProperty(ceilingProperty); + + var verify = + project + .getTasks() + .register( + verifyTaskName, + VerifyApiSurfaceTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails without mutation when the committed " + + label + + " public API surface drifts."); + task.getSourceRoots().from(roots); + task.getBaseline().fileValue(baseline); + task.getLabel().set(label); + task.getSurfaceDescription() + .set(extension.getDescription() == null ? "" : extension.getDescription()); + task.getRationale().set(extension.getRationale()); + task.getOwningProjectPath().set(project.getPath()); + task.getUpdateTaskName().set(updateTaskName); + task.getApprovalProperty().set(approvalProperty); + task.getUpdateApproved().set(approved); + }); + + project + .getTasks() + .register( + updateTaskName, + UpdateApiSurfaceTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Rewrites the committed " + + label + + " public API surface baseline after review."); + task.getSourceRoots().from(roots); + task.getBaseline().fileValue(baseline); + task.getLabel().set(label); + task.getSurfaceDescription() + .set(extension.getDescription() == null ? "" : extension.getDescription()); + task.getRationale().set(extension.getRationale()); + task.getOwningProjectPath().set(project.getPath()); + task.getApprovalProperty().set(approvalProperty); + task.getCeilingProperty().set(ceilingProperty); + task.getApproved().set(approved); + task.getCeilingRaiseApproved().set(ceilingApproved); + }); + + project + .getTasks() + .named(LifecycleBasePlugin.CHECK_TASK_NAME) + .configure(task -> task.dependsOn(verify)); + }); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/apisurface/UpdateApiSurfaceTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/apisurface/UpdateApiSurfaceTask.java new file mode 100644 index 00000000..34513dca --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/apisurface/UpdateApiSurfaceTask.java @@ -0,0 +1,89 @@ +package dev.caskeleton.buildlogic.apisurface; + +import dev.caskeleton.buildlogic.ApiSurfacePolicy; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "This task intentionally updates a committed review baseline") +public abstract class UpdateApiSurfaceTask extends DefaultTask { + @InputFiles + public abstract ConfigurableFileCollection getSourceRoots(); + + @OutputFile + public abstract RegularFileProperty getBaseline(); + + @Input public abstract Property getLabel(); + @Input public abstract Property getSurfaceDescription(); + @Input public abstract ListProperty getRationale(); + @Input public abstract Property getOwningProjectPath(); + @Input public abstract Property getApprovalProperty(); + @Input public abstract Property getCeilingProperty(); + @Input public abstract Property getApproved(); + @Input public abstract Property getCeilingRaiseApproved(); + + @TaskAction + public void updateSurface() { + if (!getApproved().get()) { + throw new GradleException( + getName() + + " requires -P" + + getApprovalProperty().get() + + ": growing the public surface is a review decision, not a build step."); + } + List roots = getSourceRoots().getFiles().stream().sorted().toList(); + String rendered; + File baseline = getBaseline().get().getAsFile(); + try { + rendered = + ApiSurfacePolicy.render( + roots, + getLabel().get(), + getSurfaceDescription().get(), + getRationale().get(), + getOwningProjectPath().get(), + getName(), + getApprovalProperty().get()); + if (baseline.isFile()) { + ApiSurfacePolicy.requireNoUnapprovedGrowth( + getName(), + getOwningProjectPath().get(), + getApprovalProperty().get(), + getCeilingProperty().get(), + ApiSurfacePolicy.countTypes( + Files.readString(baseline.toPath(), StandardCharsets.UTF_8)), + rendered, + getCeilingRaiseApproved().get()); + } + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + baseline, exception); + } catch (IllegalStateException invalidSurface) { + throw new GradleException(invalidSurface.getMessage(), invalidSurface); + } + + File parent = baseline.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) { + throw new GradleException("Could not create API surface baseline directory " + parent); + } + try { + Files.writeString(baseline.toPath(), rendered, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to write " + baseline, exception); + } + getLogger().lifecycle("{}: wrote {}", getName(), baseline); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/apisurface/VerifyApiSurfaceTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/apisurface/VerifyApiSurfaceTask.java new file mode 100644 index 00000000..0fa3af02 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/apisurface/VerifyApiSurfaceTask.java @@ -0,0 +1,71 @@ +package dev.caskeleton.buildlogic.apisurface; + +import dev.caskeleton.buildlogic.ApiSurfacePolicy; +import java.io.File; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification task has no reusable outputs") +public abstract class VerifyApiSurfaceTask extends DefaultTask { + @InputFiles + public abstract ConfigurableFileCollection getSourceRoots(); + + @Internal + public abstract RegularFileProperty getBaseline(); + + @Input + public String getBaselinePath() { + return getBaseline().get().getAsFile().getAbsolutePath(); + } + + @Input public abstract Property getLabel(); + @Input public abstract Property getSurfaceDescription(); + @Input public abstract ListProperty getRationale(); + @Input public abstract Property getOwningProjectPath(); + @Input public abstract Property getUpdateTaskName(); + @Input public abstract Property getApprovalProperty(); + @Input public abstract Property getUpdateApproved(); + + @TaskAction + public void verifySurface() { + if (getUpdateApproved().get()) { + throw new GradleException( + getName() + + " is read-only; use " + + getUpdateTaskName().get() + + " to record an approved change."); + } + List roots = getSourceRoots().getFiles().stream().sorted().toList(); + try { + String rendered = + ApiSurfacePolicy.render( + roots, + getLabel().get(), + getSurfaceDescription().get(), + getRationale().get(), + getOwningProjectPath().get(), + getUpdateTaskName().get(), + getApprovalProperty().get()); + ApiSurfacePolicy.verify( + getName(), + getUpdateTaskName().get(), + getOwningProjectPath().get(), + getApprovalProperty().get(), + getBaseline().get().getAsFile(), + rendered); + } catch (IllegalStateException invalidSurface) { + throw new GradleException(invalidSurface.getMessage(), invalidSurface); + } + getLogger().lifecycle("{}: OK — the committed public API surface is unchanged.", getName()); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/ArchitectureModuleRule.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/ArchitectureModuleRule.java new file mode 100644 index 00000000..0ab18545 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/ArchitectureModuleRule.java @@ -0,0 +1,15 @@ +package dev.caskeleton.buildlogic.architecture; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import org.gradle.api.artifacts.Configuration; + +record ArchitectureModuleRule( + String moduleName, Set allowedDependencies, List productionConfigurations) { + ArchitectureModuleRule { + Objects.requireNonNull(moduleName, "moduleName"); + allowedDependencies = Set.copyOf(allowedDependencies); + productionConfigurations = List.copyOf(productionConfigurations); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/ArchitecturePlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/ArchitecturePlugin.java new file mode 100644 index 00000000..cbb50084 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/ArchitecturePlugin.java @@ -0,0 +1,170 @@ +package dev.caskeleton.buildlogic.architecture; + +import dev.caskeleton.buildlogic.ModuleRegistry; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; + +public final class ArchitecturePlugin implements Plugin { + private static final List PRODUCTION_DECLARATIONS = + List.of("api", "implementation", "compileOnly", "runtimeOnly"); + private static final List APPLICATION_CLASSPATHS = + List.of("compileClasspath", "runtimeClasspath", "testCompileClasspath", "testRuntimeClasspath"); + + @Override + public void apply(Project project) { + Project root = project.getRootProject(); + File registryFile = root.file("config/architecture/modules.json"); + File repositoryRoot; + try { + repositoryRoot = root.getProjectDir().getParentFile().getCanonicalFile(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to resolve repository root", exception); + } + ModuleRegistry registry = ModuleRegistry.read(registryFile, repositoryRoot); + List modules = + registry.modulesForBuild("main").stream() + .filter(module -> !module.id().equals("sample-portfolio")) + .toList(); + List registryViolations = registryViolations(registry, modules); + + var clean = + project + .getTasks() + .register( + "verifyCleanArchitectureDependencies", + VerifyCleanArchitectureDependenciesTask.class, + task -> { + task.setGroup("verification"); + task.setDescription("Verifies Clean Architecture project dependency direction."); + task.getRegistryFile().fileValue(registryFile); + task.getRegistryViolations().set(registryViolations); + }); + var purity = + project + .getTasks() + .register( + "verifyApplicationCoreDependencyPurity", + VerifyApplicationCoreDependencyPurityTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths."); + }); + var ignoredSources = + project + .getTasks() + .register( + "verifyNoIgnoredSourcePackages", + VerifyNoIgnoredSourcePackagesTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails when a Java source file lives in a package that Git ignores or would ignore."); + task.getBuildRoot().set(root.getLayout().getProjectDirectory()); + }); + + project.getGradle().projectsEvaluated( + ignored -> { + Set declaredModules = new HashSet<>(); + root.getSubprojects().stream() + .filter(sub -> sub.getChildProjects().isEmpty()) + .filter(sub -> !sub.getPath().equals(":sample-portfolio")) + .forEach(sub -> declaredModules.add(sub.getPath().replaceFirst("^:", ""))); + List rules = new ArrayList<>(); + for (ModuleRegistry.Module module : modules) { + Project owner = root.project(module.gradlePath()); + Set allowed = new HashSet<>(); + for (String dependencyId : module.allowedDependencies()) { + ModuleRegistry.Module dependency = registry.byId(dependencyId); + if (dependency != null && !dependency.id().equals("sample-portfolio")) { + allowed.add(dependency.gradlePath().replaceFirst("^:", "")); + } + } + List configurations = + PRODUCTION_DECLARATIONS.stream() + .map(name -> owner.getConfigurations().findByName(name)) + .filter(java.util.Objects::nonNull) + .toList(); + rules.add( + new ArchitectureModuleRule( + module.gradlePath().replaceFirst("^:", ""), allowed, configurations)); + } + clean.configure( + task -> { + task.getDeclaredModules().set(declaredModules); + task.setRules(rules); + }); + + Project application = root.project(":application-core"); + List production = + PRODUCTION_DECLARATIONS.stream() + .map(name -> application.getConfigurations().findByName(name)) + .filter(java.util.Objects::nonNull) + .toList(); + List classpaths = + APPLICATION_CLASSPATHS.stream() + .map(name -> application.getConfigurations().getByName(name)) + .toList(); + purity.configure( + task -> { + task.setProductionConfigurations(production); + task.setResolvedConfigurations(classpaths); + }); + + List sourceRoots = new ArrayList<>(); + root.getSubprojects().stream() + .filter(sub -> !sub.getPath().equals(":sample-portfolio")) + .forEach( + sub -> { + for (String relative : List.of("src/main/java", "src/test/java")) { + File sourceRoot = sub.file(relative); + if (sourceRoot.isDirectory()) { + sourceRoots.add(sourceRoot); + } + } + }); + ignoredSources.configure(task -> task.getSourceRoots().from(sourceRoots)); + }); + + project + .getTasks() + .register( + "architectureCheck", + task -> { + task.setGroup("verification"); + task.setDescription("Runs the repository-wide architecture invariants."); + task.dependsOn(clean, purity, ignoredSources, project.getTasks().named("verifyRuntimeModuleMembership")); + }); + } + + private static List registryViolations( + ModuleRegistry registry, List modules) { + List violations = new ArrayList<>(); + for (ModuleRegistry.Module module : modules) { + for (String dependencyId : module.allowedDependencies()) { + if (dependencyId.equals(module.id())) { + violations.add("'" + module.id() + "' declares itself as an allowed dependency"); + continue; + } + ModuleRegistry.Module dependency = registry.byId(dependencyId); + if (dependency == null) { + violations.add( + "'" + module.id() + "' allows unknown dependency id '" + dependencyId + "'"); + } else if (!dependency.buildName().equals(module.buildName())) { + violations.add( + "'" + module.id() + "' allows cross-build dependency id '" + dependencyId + "'"); + } + } + } + return List.copyOf(violations); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/GitCommandResult.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/GitCommandResult.java new file mode 100644 index 00000000..1a262e2a --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/GitCommandResult.java @@ -0,0 +1,3 @@ +package dev.caskeleton.buildlogic.architecture; + +record GitCommandResult(String output, int exitCode) {} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/VerifyApplicationCoreDependencyPurityTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/VerifyApplicationCoreDependencyPurityTask.java new file mode 100644 index 00000000..ee1d19fb --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/VerifyApplicationCoreDependencyPurityTask.java @@ -0,0 +1,95 @@ +package dev.caskeleton.buildlogic.architecture; + +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.ProjectDependency; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification inspects and resolves live application-core configurations") +public class VerifyApplicationCoreDependencyPurityTask extends DefaultTask { + private List productionConfigurations = List.of(); + private List resolvedConfigurations = List.of(); + + @Internal + public List getProductionConfigurations() { + return productionConfigurations; + } + + public void setProductionConfigurations(List value) { + productionConfigurations = List.copyOf(value); + } + + @Internal + public List getResolvedConfigurations() { + return resolvedConfigurations; + } + + public void setResolvedConfigurations(List value) { + resolvedConfigurations = List.copyOf(value); + } + + @TaskAction + public void verifyPurity() { + List violations = new ArrayList<>(); + for (Configuration configuration : productionConfigurations) { + configuration.getDependencies().forEach( + dependency -> { + if (!(dependency instanceof ProjectDependency)) { + String group = dependency.getGroup() == null ? "" : dependency.getGroup(); + violations.add( + configuration.getName() + + ": non-project production dependency " + + group + + ":" + + dependency.getName()); + } + }); + } + for (Configuration configuration : resolvedConfigurations) { + configuration + .getIncoming() + .getResolutionResult() + .getAllComponents() + .forEach( + component -> { + if (component.getId() instanceof ModuleComponentIdentifier module + && forbiddenGroup(module.getGroup())) { + violations.add( + configuration.getName() + + ": forbidden resolved dependency " + + module.getGroup() + + ":" + + module.getModule() + + ":" + + module.getVersion()); + } + }); + } + if (!violations.isEmpty()) { + violations.sort(String::compareTo); + throw new GradleException( + "verifyApplicationCoreDependencyPurity: " + + violations.size() + + " violation(s):\n " + + String.join("\n ", violations)); + } + getLogger() + .lifecycle( + "verifyApplicationCoreDependencyPurity: OK — application-core production declarations are project-only and application classpaths contain no Spring/logging/metrics frameworks."); + } + + private static boolean forbiddenGroup(String group) { + return group != null + && (group.startsWith("org.springframework") + || group.equals("org.slf4j") + || group.equals("ch.qos.logback") + || group.equals("org.apache.logging.log4j") + || group.equals("io.micrometer")); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/VerifyCleanArchitectureDependenciesTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/VerifyCleanArchitectureDependenciesTask.java new file mode 100644 index 00000000..d43ef003 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/VerifyCleanArchitectureDependenciesTask.java @@ -0,0 +1,95 @@ +package dev.caskeleton.buildlogic.architecture; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.ProjectDependency; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.SetProperty; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification inspects live project dependency declarations") +public abstract class VerifyCleanArchitectureDependenciesTask extends DefaultTask { + private List rules = List.of(); + + @InputFile + public abstract RegularFileProperty getRegistryFile(); + + @Input + public abstract ListProperty getRegistryViolations(); + + @Input + public abstract SetProperty getDeclaredModules(); + + @Internal + public List getRules() { + return rules; + } + + public void setRules(List rules) { + this.rules = List.copyOf(rules); + } + + @TaskAction + public void verifyDependencies() { + List registryViolations = getRegistryViolations().get(); + if (!registryViolations.isEmpty()) { + throw new GradleException( + "config/architecture/modules.json declares impossible edges:\n " + + String.join("\n ", registryViolations)); + } + + Set declared = getDeclaredModules().get(); + Set governed = new HashSet<>(); + rules.forEach(rule -> governed.add(rule.moduleName())); + Set missingFromBuild = new TreeSet<>(governed); + missingFromBuild.removeAll(declared); + Set missingFromPolicy = new TreeSet<>(declared); + missingFromPolicy.removeAll(governed); + if (!missingFromBuild.isEmpty()) { + throw new GradleException( + "Clean Architecture dependency policy references missing Gradle modules " + + missingFromBuild + + ". Declared modules are " + + new TreeSet<>(declared) + + "."); + } + if (!missingFromPolicy.isEmpty()) { + throw new GradleException( + "Gradle modules " + + missingFromPolicy + + " are not covered by verifyCleanArchitectureDependencies. Add an explicit dependency policy before using them."); + } + + for (ArchitectureModuleRule rule : rules) { + Set actual = new HashSet<>(); + for (var configuration : rule.productionConfigurations()) { + configuration + .getDependencies() + .withType(ProjectDependency.class) + .forEach(dependency -> actual.add(dependency.getPath().replaceFirst("^:", ""))); + } + Set forbidden = new TreeSet<>(actual); + forbidden.removeAll(rule.allowedDependencies()); + if (!forbidden.isEmpty()) { + throw new GradleException( + "Module ':" + + rule.moduleName() + + "' has forbidden project dependencies " + + forbidden + + ". Allowed dependencies are " + + new TreeSet<>(rule.allowedDependencies()) + + "; all production project edges must be explicitly registered."); + } + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/VerifyNoIgnoredSourcePackagesTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/VerifyNoIgnoredSourcePackagesTask.java new file mode 100644 index 00000000..32402ed3 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/architecture/VerifyNoIgnoredSourcePackagesTask.java @@ -0,0 +1,125 @@ +package dev.caskeleton.buildlogic.architecture; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification consults Git ignore rules") +public abstract class VerifyNoIgnoredSourcePackagesTask extends DefaultTask { + private static final Set OUTPUT_DIRECTORY_NAMES = + Set.of("build", "out", "target", "bin", "classes"); + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getSourceRoots(); + + @Internal + public abstract DirectoryProperty getBuildRoot(); + + @TaskAction + public void verifySourcePackages() { + List violations = new ArrayList<>(); + List sourceFiles = new ArrayList<>(); + for (File sourceRoot : getSourceRoots().getFiles()) { + if (!sourceRoot.isDirectory()) { + continue; + } + try (var paths = Files.walk(sourceRoot.toPath())) { + paths + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".java")) + .forEach( + path -> { + File candidate = path.toFile(); + sourceFiles.add(candidate); + var relative = sourceRoot.toPath().relativize(path); + for (int index = 0; index < relative.getNameCount() - 1; index++) { + String segment = relative.getName(index).toString(); + if (OUTPUT_DIRECTORY_NAMES.contains(segment)) { + violations.add( + candidate + + ": package segment '" + + segment + + "' collides with a build output directory name"); + } + } + }); + } catch (IOException exception) { + throw new GradleException("verifyNoIgnoredSourcePackages: cannot scan " + sourceRoot, exception); + } + } + if (sourceFiles.isEmpty()) { + throw new GradleException( + "verifyNoIgnoredSourcePackages: found no Java sources at all; the gate would pass vacuously."); + } + + GitCommandResult rootResult = runGit(List.of("git", "rev-parse", "--show-toplevel"), null); + String repositoryRoot = rootResult == null ? "" : rootResult.output().trim(); + if (repositoryRoot.isEmpty()) { + getLogger().lifecycle("verifyNoIgnoredSourcePackages: not a Git checkout; naming rule only."); + } else { + String stdin = sourceFiles.stream().map(File::getPath).collect(java.util.stream.Collectors.joining("\n")); + GitCommandResult ignored = + runGit( + List.of("git", "-C", repositoryRoot, "check-ignore", "--no-index", "-v", "--stdin"), + stdin); + String output = ignored == null ? "" : ignored.output(); + for (String line : output.lines().filter(value -> !value.isBlank()).toList()) { + String[] parts = line.split("\\t", -1); + String rule = parts.length > 1 ? parts[0] : "(unknown rule)"; + String path = parts.length > 1 ? String.join("\t", java.util.Arrays.copyOfRange(parts, 1, parts.length)) : line; + violations.add(path + ": ignored by " + rule + "; it will not survive a fresh checkout"); + } + } + + if (!violations.isEmpty()) { + throw new GradleException( + "verifyNoIgnoredSourcePackages: " + + violations.size() + + " source file(s) Git cannot carry:\n " + + String.join("\n ", violations)); + } + getLogger() + .lifecycle( + "verifyNoIgnoredSourcePackages: OK — {} Java sources are all committable.", + sourceFiles.size()); + } + + private GitCommandResult runGit(List command, String stdin) { + try { + Process process = + new ProcessBuilder(command) + .directory(getBuildRoot().get().getAsFile()) + .redirectErrorStream(false) + .start(); + if (stdin != null) { + process.getOutputStream().write(stdin.getBytes(StandardCharsets.UTF_8)); + } + process.getOutputStream().close(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + process.getErrorStream().readAllBytes(); + int exitCode = process.waitFor(); + return new GitCommandResult(output, exitCode); + } catch (IOException unavailable) { + getLogger().info("verifyNoIgnoredSourcePackages: git unavailable ({})", unavailable.getMessage()); + return null; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new GradleException("verifyNoIgnoredSourcePackages: git command interrupted", interrupted); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/ArchiveHygienePlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/ArchiveHygienePlugin.java new file mode 100644 index 00000000..89050757 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/ArchiveHygienePlugin.java @@ -0,0 +1,65 @@ +package dev.caskeleton.buildlogic.archive; + +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.bundling.Jar; + +public final class ArchiveHygienePlugin implements Plugin { + @Override + public void apply(Project project) { + TaskProvider cleanTask = + project + .getTasks() + .register( + "cleanStaleTraceableJars", + CleanStaleTraceableJarsTask.class, + task -> { + task.setGroup("build"); + task.setDescription( + "Explicitly deletes older git-revision JARs from leaf build/libs directories."); + }); + + TaskProvider verifyTask = + project + .getTasks() + .register( + "verifyNoStaleTraceableJars", + VerifyNoStaleTraceableJarsTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails without mutation when leaf build/libs directories retain old traceable JARs."); + }); + + project + .getGradle() + .projectsEvaluated( + ignored -> { + List targets = collectTargets(project); + cleanTask.configure(task -> task.setTargets(targets)); + verifyTask.configure(task -> task.setTargets(targets)); + }); + } + + private static List collectTargets(Project rootProject) { + List targets = new ArrayList<>(); + for (Project subproject : rootProject.getSubprojects()) { + subproject + .getTasks() + .withType(Jar.class) + .forEach( + jar -> + targets.add( + new ArchiveTarget( + jar.getPath(), + jar.getDestinationDirectory().get().getAsFile(), + jar.getArchiveBaseName().get(), + jar.getArchiveClassifier().getOrElse(""), + jar.getArchiveFileName().get()))); + } + return List.copyOf(targets); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/ArchiveHygienePolicy.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/ArchiveHygienePolicy.java new file mode 100644 index 00000000..3ffc8bcf --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/ArchiveHygienePolicy.java @@ -0,0 +1,39 @@ +package dev.caskeleton.buildlogic.archive; + +import java.io.File; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; + +public final class ArchiveHygienePolicy { + private ArchiveHygienePolicy() {} + + public static List staleArchives(ArchiveTarget target) { + File outputDirectory = target.outputDirectory(); + if (!outputDirectory.isDirectory()) { + return List.of(); + } + + String classifierPart = + target.classifier().isBlank() ? "" : "-" + Pattern.quote(target.classifier()); + Pattern traceableArchive = + Pattern.compile( + "^" + + Pattern.quote(target.baseName()) + + "-\\d+\\.\\d+\\.\\d+\\+[0-9a-f]{7,40}" + + classifierPart + + "\\.jar$"); + + File[] matches = + outputDirectory.listFiles( + file -> + file.isFile() + && traceableArchive.matcher(file.getName()).matches() + && !file.getName().equals(target.currentArchiveName())); + if (matches == null || matches.length == 0) { + return List.of(); + } + return Arrays.stream(matches).sorted(Comparator.comparing(File::getName)).toList(); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/ArchiveTarget.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/ArchiveTarget.java new file mode 100644 index 00000000..10337e16 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/ArchiveTarget.java @@ -0,0 +1,22 @@ +package dev.caskeleton.buildlogic.archive; + +import java.io.File; +import java.io.Serializable; +import java.util.Objects; + +public record ArchiveTarget( + String taskPath, + File outputDirectory, + String baseName, + String classifier, + String currentArchiveName) + implements Serializable { + + public ArchiveTarget { + Objects.requireNonNull(taskPath, "taskPath"); + Objects.requireNonNull(outputDirectory, "outputDirectory"); + Objects.requireNonNull(baseName, "baseName"); + classifier = classifier == null ? "" : classifier; + Objects.requireNonNull(currentArchiveName, "currentArchiveName"); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/CleanStaleTraceableJarsTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/CleanStaleTraceableJarsTask.java new file mode 100644 index 00000000..7435e8ea --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/CleanStaleTraceableJarsTask.java @@ -0,0 +1,38 @@ +package dev.caskeleton.buildlogic.archive; + +import java.io.File; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.work.DisableCachingByDefault; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; + +@DisableCachingByDefault(because = "Explicit cleanup task mutates existing archives") +public abstract class CleanStaleTraceableJarsTask extends DefaultTask { + private List targets = List.of(); + + @Internal + public final List getTargets() { + return targets; + } + + public final void setTargets(List targets) { + this.targets = List.copyOf(targets); + } + + @TaskAction + public void cleanArchives() { + int deleted = 0; + for (ArchiveTarget target : targets) { + for (File stale : ArchiveHygienePolicy.staleArchives(target)) { + if (!stale.delete()) { + throw new GradleException("cleanStaleTraceableJars: failed to delete " + stale); + } + deleted++; + getLogger().lifecycle("cleanStaleTraceableJars: deleted {}", stale); + } + } + getLogger().lifecycle("cleanStaleTraceableJars: deleted {} stale archive(s).", deleted); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/VerifyNoStaleTraceableJarsTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/VerifyNoStaleTraceableJarsTask.java new file mode 100644 index 00000000..61d5e600 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/archive/VerifyNoStaleTraceableJarsTask.java @@ -0,0 +1,52 @@ +package dev.caskeleton.buildlogic.archive; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.work.DisableCachingByDefault; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; + +@DisableCachingByDefault(because = "Verification task has no outputs") +public abstract class VerifyNoStaleTraceableJarsTask extends DefaultTask { + private List targets = List.of(); + + @Internal + public final List getTargets() { + return targets; + } + + public final void setTargets(List targets) { + this.targets = List.copyOf(targets); + } + + @TaskAction + public void verifyArchives() { + List violations = new ArrayList<>(); + for (ArchiveTarget target : targets) { + List staleJars = + ArchiveHygienePolicy.staleArchives(target).stream().map(File::getName).toList(); + if (!staleJars.isEmpty()) { + violations.add( + target.taskPath() + + ": stale JAR(s) " + + staleJars + + "; current archive is " + + target.currentArchiveName()); + } + } + + if (!violations.isEmpty()) { + throw new GradleException( + "verifyNoStaleTraceableJars: " + + violations.size() + + " archive task(s) retain old traceable JARs. Run cleanStaleTraceableJars explicitly " + + "if removal is intended.\n " + + String.join("\n ", violations)); + } + getLogger() + .lifecycle("verifyNoStaleTraceableJars: OK — no stale traceable JARs in build/libs."); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetExtension.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetExtension.java new file mode 100644 index 00000000..d3462c4d --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetExtension.java @@ -0,0 +1,87 @@ +package dev.caskeleton.buildlogic.auxiliary; + +import java.util.Locale; +import org.gradle.api.Action; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.file.FileCollection; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; + +public class AuxiliarySourceSetExtension { + private final Project owner; + + public AuxiliarySourceSetExtension(Project owner) { + this.owner = owner; + } + + public void sourceSet(String name, Action configuration) { + AuxiliarySourceSetSpec spec = new AuxiliarySourceSetSpec(name); + configuration.execute(spec); + realize(spec); + } + + private void realize(AuxiliarySourceSetSpec spec) { + SourceSetContainer sourceSets = owner.getExtensions().getByType(SourceSetContainer.class); + SourceSet created = sourceSets.create(spec.getName()); + for (String visible : spec.getVisibleOutputs()) { + SourceSet source = sourceSets.findByName(visible); + if (source == null) { + throw new GradleException( + "source set '" + + spec.getName() + + "' in " + + owner.getPath() + + " compiles against '" + + visible + + "', which does not exist. Declare it first — source sets are created in declaration order."); + } + created.setCompileClasspath(created.getCompileClasspath().plus(source.getOutput())); + } + if (spec.getRuntimeSourceSets().isEmpty()) { + created.setRuntimeClasspath( + created.getRuntimeClasspath().plus(created.getOutput()).plus(created.getCompileClasspath())); + } else { + FileCollection runtimeClasspath = created.getOutput(); + for (String runtimeSourceName : spec.getRuntimeSourceSets()) { + SourceSet runtimeSource = sourceSets.findByName(runtimeSourceName); + if (runtimeSource == null) { + throw new GradleException( + "source set '" + + spec.getName() + + "' in " + + owner.getPath() + + " takes runtime from '" + + runtimeSourceName + + "', which does not exist. Declare it first — source sets are created in declaration order."); + } + runtimeClasspath = runtimeClasspath.plus(runtimeSource.getRuntimeClasspath()); + } + created.setRuntimeClasspath(runtimeClasspath); + } + + for (String suffix : spec.getInheritedTestConfigurations()) { + String capitalized = suffix.substring(0, 1).toUpperCase(Locale.ROOT) + suffix.substring(1); + String inherited = "test" + capitalized; + String own = spec.getName() + capitalized; + Configuration target = owner.getConfigurations().findByName(own); + Configuration source = owner.getConfigurations().findByName(inherited); + if (target == null || source == null) { + throw new GradleException( + "source set '" + + spec.getName() + + "' in " + + owner.getPath() + + " cannot inherit '" + + suffix + + "': expected configurations '" + + own + + "' and '" + + inherited + + "'."); + } + target.extendsFrom(source); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetPlugin.java new file mode 100644 index 00000000..585e4e1f --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetPlugin.java @@ -0,0 +1,11 @@ +package dev.caskeleton.buildlogic.auxiliary; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class AuxiliarySourceSetPlugin implements Plugin { + @Override + public void apply(Project project) { + project.getExtensions().create("auxiliarySourceSets", AuxiliarySourceSetExtension.class, project); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetSpec.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetSpec.java new file mode 100644 index 00000000..6d0c2052 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetSpec.java @@ -0,0 +1,48 @@ +package dev.caskeleton.buildlogic.auxiliary; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class AuxiliarySourceSetSpec { + private final String name; + private final List visibleOutputs = new ArrayList<>(List.of("main")); + private final List inheritedTestConfigurations = + new ArrayList<>(List.of("implementation", "runtimeOnly")); + private final List runtimeSourceSets = new ArrayList<>(); + + public AuxiliarySourceSetSpec(String name) { + this.name = Objects.requireNonNull(name, "name"); + } + + public String getName() { + return name; + } + + public List getVisibleOutputs() { + return List.copyOf(visibleOutputs); + } + + public List getInheritedTestConfigurations() { + return List.copyOf(inheritedTestConfigurations); + } + + public List getRuntimeSourceSets() { + return List.copyOf(runtimeSourceSets); + } + + public void compilesAgainst(String... names) { + visibleOutputs.clear(); + visibleOutputs.addAll(List.of(names)); + } + + public void inherits(String... names) { + inheritedTestConfigurations.clear(); + inheritedTestConfigurations.addAll(List.of(names)); + } + + public void runtimeFrom(String... names) { + runtimeSourceSets.clear(); + runtimeSourceSets.addAll(List.of(names)); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/bootstrap/BootRunDotenvPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/bootstrap/BootRunDotenvPlugin.java new file mode 100644 index 00000000..56ac6432 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/bootstrap/BootRunDotenvPlugin.java @@ -0,0 +1,54 @@ +package dev.caskeleton.buildlogic.bootstrap; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.tasks.JavaExec; + +public final class BootRunDotenvPlugin implements Plugin { + @Override + public void apply(Project project) { + File rootDirectory = project.getRootProject().getProjectDir(); + File envFile = new File(rootDirectory, ".env"); + + project + .getTasks() + .withType(JavaExec.class) + .configureEach( + task -> { + if (!task.getName().equals("bootRun")) { + return; + } + task.setWorkingDir(rootDirectory); + task.doFirst(ignored -> injectDotenv(task, envFile)); + }); + } + + private static void injectDotenv(JavaExec task, File envFile) { + if (!envFile.isFile()) { + return; + } + try { + for (String raw : Files.readAllLines(envFile.toPath(), StandardCharsets.UTF_8)) { + String line = raw.trim(); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separator = line.indexOf('='); + String key = line.substring(0, separator).trim(); + String value = line.substring(separator + 1).trim(); + if (!key.isEmpty() + && System.getenv(key) == null + && !task.getEnvironment().containsKey(key)) { + task.environment(key, value); + } + } + } catch (IOException failure) { + throw new GradleException("failed to read " + envFile, failure); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/bootstrap/BootstrapSmokeTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/bootstrap/BootstrapSmokeTask.java new file mode 100644 index 00000000..f089fbc0 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/bootstrap/BootstrapSmokeTask.java @@ -0,0 +1,75 @@ +package dev.caskeleton.buildlogic.bootstrap; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Probes a live local application endpoint") +public abstract class BootstrapSmokeTask extends DefaultTask { + + @Input + public abstract Property getEndpoint(); + + @Input + public abstract Property getTimeoutSeconds(); + + @TaskAction + public void verifyHealth() { + URI endpoint = URI.create(getEndpoint().get()); + long deadline = + System.nanoTime() + TimeUnit.SECONDS.toNanos(getTimeoutSeconds().get().longValue()); + String lastFailure = "no response"; + + while (System.nanoTime() < deadline) { + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) endpoint.toURL().openConnection(); + connection.setConnectTimeout(2_000); + connection.setReadTimeout(2_000); + connection.setRequestMethod("GET"); + int status = connection.getResponseCode(); + byte[] bodyBytes = + status >= 200 && status < 400 + ? connection.getInputStream().readAllBytes() + : connection.getErrorStream() == null + ? new byte[0] + : connection.getErrorStream().readAllBytes(); + String body = new String(bodyBytes, StandardCharsets.UTF_8); + if (status == 200 && body.contains("\"status\":\"UP\"")) { + getLogger() + .lifecycle( + "bootstrapSmoke: OK — GET /api/healthcheck returned HTTP 200 and status=UP."); + return; + } + lastFailure = "HTTP " + status + ": " + body; + } catch (IOException exception) { + lastFailure = exception.getMessage(); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + + try { + Thread.sleep(1_000L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new GradleException("bootstrapSmoke: interrupted while waiting for healthcheck", interrupted); + } + } + + throw new GradleException( + "bootstrapSmoke: /api/healthcheck did not become healthy within " + + getTimeoutSeconds().get() + + "s; last result: " + + lastFailure); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/bootstrap/DeveloperBootstrapPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/bootstrap/DeveloperBootstrapPlugin.java new file mode 100644 index 00000000..a671d24f --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/bootstrap/DeveloperBootstrapPlugin.java @@ -0,0 +1,134 @@ +package dev.caskeleton.buildlogic.bootstrap; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.tasks.Exec; +import org.gradle.api.tasks.TaskProvider; + +public final class DeveloperBootstrapPlugin implements Plugin { + private static final String GROUP = "developer experience"; + + @Override + public void apply(Project project) { + if (project != project.getRootProject()) { + throw new GradleException("ca.developer-bootstrap must be applied to the root project."); + } + + File repositoryDirectory = project.getProjectDir().getParentFile(); + File baseComposeFile = new File(repositoryDirectory, "docker-compose.yml"); + File localComposeFile = new File(repositoryDirectory, "docker-compose.local.yml"); + List composeCommand = + List.of( + "docker", + "compose", + "-f", + baseComposeFile.getAbsolutePath(), + "-f", + localComposeFile.getAbsolutePath()); + + TaskProvider compile = + project + .getTasks() + .register( + "bootstrapCompile", + task -> { + task.setGroup(GROUP); + task.setDescription( + "Stage 1/4: compiles the default application composition and its required upstream projects."); + task.dependsOn(":app-bootstrap:compileTestJava"); + }); + + ByteArrayOutputStream dockerInfoOutput = new ByteArrayOutputStream(); + ByteArrayOutputStream dockerInfoError = new ByteArrayOutputStream(); + TaskProvider dockerPreflight = + project + .getTasks() + .register( + "bootstrapDockerPreflight", + Exec.class, + task -> { + task.setGroup(GROUP); + task.setDescription("Checks that the Docker CLI can reach a running Docker daemon."); + task.dependsOn(compile); + task.commandLine("docker", "info"); + task.setIgnoreExitValue(true); + task.setStandardOutput(dockerInfoOutput); + task.setErrorOutput(dockerInfoError); + task.doLast( + ignored -> { + if (task.getExecutionResult().get().getExitValue() != 0) { + throw new GradleException( + "bootstrap: Docker가 필요합니다. Docker Desktop/daemon을 시작한 뒤 " + + "`docker info`가 성공하는지 확인하세요.\n" + + dockerInfoError); + } + }); + }); + + TaskProvider dependencies = + project + .getTasks() + .register( + "bootstrapDependencies", + Exec.class, + task -> { + task.setGroup(GROUP); + task.setDescription( + "Stage 2/4: starts the local PostgreSQL dependency and waits for readiness."); + task.dependsOn(dockerPreflight); + task.commandLine(command(composeCommand, "up", "-d", "--wait", "db")); + }); + + TaskProvider migrateAndStart = + project + .getTasks() + .register( + "bootstrapMigrateAndStart", + Exec.class, + task -> { + task.setGroup(GROUP); + task.setDescription( + "Stage 3/4: builds/starts the app; startup Flyway must finish before health is ready."); + task.dependsOn(dependencies); + task.commandLine( + command(composeCommand, "up", "-d", "--build", "--wait", "app")); + }); + + TaskProvider smoke = + project + .getTasks() + .register( + "bootstrapSmoke", + BootstrapSmokeTask.class, + task -> { + task.setGroup(GROUP); + task.setDescription( + "Stage 4/4: requires HTTP 200 and status=UP from GET /api/healthcheck."); + task.dependsOn(migrateAndStart); + task.getEndpoint().set("http://localhost:8080/api/healthcheck"); + task.getTimeoutSeconds().set(60); + }); + + project + .getTasks() + .register( + "bootstrap", + task -> { + task.setGroup(GROUP); + task.setDescription("Runs the complete four-stage local bootstrap contract."); + task.dependsOn(smoke); + }); + } + + private static List command(List prefix, String... arguments) { + List command = new ArrayList<>(prefix.size() + arguments.length); + command.addAll(prefix); + command.addAll(List.of(arguments)); + return List.copyOf(command); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/GrpcPlatformModuleConventionPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/GrpcPlatformModuleConventionPlugin.java new file mode 100644 index 00000000..145058ca --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/GrpcPlatformModuleConventionPlugin.java @@ -0,0 +1,28 @@ +package dev.caskeleton.buildlogic.convention; + +import io.spring.gradle.dependencymanagement.dsl.DependencyManagementExtension; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.VersionCatalogsExtension; + +public final class GrpcPlatformModuleConventionPlugin implements Plugin { + @Override + public void apply(Project project) { + project.getPluginManager().apply("ca.platform-module"); + + var catalogs = project.getExtensions().getByType(VersionCatalogsExtension.class); + var grpc = + catalogs + .named("libs") + .findVersion("grpc") + .orElseThrow( + () -> + new GradleException( + "Version catalog 'libs' must define version 'grpc' for ca.grpc-platform-module")); + String grpcVersion = grpc.getRequiredVersion(); + DependencyManagementExtension dependencyManagement = + project.getExtensions().getByType(DependencyManagementExtension.class); + dependencyManagement.imports(imports -> imports.mavenBom("io.grpc:grpc-bom:" + grpcVersion)); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/JavaLibraryConventionPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/JavaLibraryConventionPlugin.java new file mode 100644 index 00000000..23d745b4 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/JavaLibraryConventionPlugin.java @@ -0,0 +1,13 @@ +package dev.caskeleton.buildlogic.convention; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class JavaLibraryConventionPlugin implements Plugin { + @Override + public void apply(Project project) { + project.getPluginManager().apply("ca.quality-conventions"); + project.getDependencies().add("testImplementation", "org.junit.jupiter:junit-jupiter"); + project.getDependencies().add("testImplementation", "org.assertj:assertj-core"); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/PlatformModuleConventionPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/PlatformModuleConventionPlugin.java new file mode 100644 index 00000000..13b20571 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/PlatformModuleConventionPlugin.java @@ -0,0 +1,12 @@ +package dev.caskeleton.buildlogic.convention; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class PlatformModuleConventionPlugin implements Plugin { + @Override + public void apply(Project project) { + project.getPluginManager().apply("ca.java-library"); + project.getPluginManager().apply("java-library"); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/SpringConfigConventionPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/SpringConfigConventionPlugin.java new file mode 100644 index 00000000..f6b98a04 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/SpringConfigConventionPlugin.java @@ -0,0 +1,13 @@ +package dev.caskeleton.buildlogic.convention; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class SpringConfigConventionPlugin implements Plugin { + @Override + public void apply(Project project) { + project + .getDependencies() + .add("annotationProcessor", "org.springframework.boot:spring-boot-configuration-processor"); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/SpringLibraryConventionPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/SpringLibraryConventionPlugin.java new file mode 100644 index 00000000..f4757d60 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/convention/SpringLibraryConventionPlugin.java @@ -0,0 +1,17 @@ +package dev.caskeleton.buildlogic.convention; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class SpringLibraryConventionPlugin implements Plugin { + @Override + public void apply(Project project) { + project.getPluginManager().apply("ca.quality-conventions"); + project + .getDependencies() + .add("testImplementation", "org.springframework.boot:spring-boot-starter-test"); + project + .getDependencies() + .add("testImplementation", "org.springframework.boot:spring-boot-starter-webmvc-test"); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyAbsence.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyAbsence.java new file mode 100644 index 00000000..eea30fda --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyAbsence.java @@ -0,0 +1,24 @@ +package dev.caskeleton.buildlogic.dependency; + +import java.util.Objects; + +public record DependencyAbsence(String coordinate, String reason, String configuration) { + public DependencyAbsence { + Objects.requireNonNull(coordinate, "coordinate"); + Objects.requireNonNull(reason, "reason"); + Objects.requireNonNull(configuration, "configuration"); + if (coordinate.chars().filter(ch -> ch == ':').count() != 1) { + throw new IllegalArgumentException( + "dependencyPolicy.absent('" + coordinate + "') must be group:module without a version"); + } + if (reason.isBlank()) { + throw new IllegalArgumentException( + "dependencyPolicy.absent('" + + coordinate + + "') needs a reason: an unexplained exclusion is the comment drift this check exists to prevent"); + } + if (configuration.isBlank()) { + throw new IllegalArgumentException("dependency policy configuration must not be blank"); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyAbsencePattern.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyAbsencePattern.java new file mode 100644 index 00000000..05696954 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyAbsencePattern.java @@ -0,0 +1,26 @@ +package dev.caskeleton.buildlogic.dependency; + +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +public record DependencyAbsencePattern(String regex, String reason, String configuration) { + public DependencyAbsencePattern { + Objects.requireNonNull(regex, "regex"); + Objects.requireNonNull(reason, "reason"); + Objects.requireNonNull(configuration, "configuration"); + try { + Pattern.compile(regex); + } catch (PatternSyntaxException invalid) { + throw new IllegalArgumentException( + "dependencyPolicy.absentMatching('" + regex + "') is not a valid regex", invalid); + } + if (reason.isBlank()) { + throw new IllegalArgumentException( + "dependencyPolicy.absentMatching('" + regex + "') needs a reason"); + } + if (configuration.isBlank()) { + throw new IllegalArgumentException("dependency policy configuration must not be blank"); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyCheck.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyCheck.java new file mode 100644 index 00000000..5b95c6f1 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyCheck.java @@ -0,0 +1,20 @@ +package dev.caskeleton.buildlogic.dependency; + +import java.util.List; +import java.util.Objects; +import org.gradle.api.artifacts.Configuration; + +record DependencyPolicyCheck( + String configurationName, + Configuration configuration, + List absences, + List absencePatterns, + List presences) { + DependencyPolicyCheck { + Objects.requireNonNull(configurationName, "configurationName"); + Objects.requireNonNull(configuration, "configuration"); + absences = List.copyOf(absences); + absencePatterns = List.copyOf(absencePatterns); + presences = List.copyOf(presences); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyExtension.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyExtension.java new file mode 100644 index 00000000..d1dfbea0 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyExtension.java @@ -0,0 +1,77 @@ +package dev.caskeleton.buildlogic.dependency; + +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.MinimalExternalModuleDependency; +import org.gradle.api.provider.Provider; + +public class DependencyPolicyExtension { + private final List absences = new ArrayList<>(); + private final List absencePatterns = new ArrayList<>(); + private final List presences = new ArrayList<>(); + + public void absent(String coordinate, String reason) { + absentOn("runtimeClasspath", coordinate, reason); + } + + public void absentOn(String configuration, String coordinate, String reason) { + try { + absences.add(new DependencyAbsence(coordinate, reason, configuration)); + } catch (IllegalArgumentException invalid) { + throw new GradleException(invalid.getMessage(), invalid); + } + } + + public void absentMatching(String regex, String reason) { + absentMatchingOn("runtimeClasspath", regex, reason); + } + + public void absentMatchingOn(String configuration, String regex, String reason) { + try { + absencePatterns.add(new DependencyAbsencePattern(regex, reason, configuration)); + } catch (IllegalArgumentException invalid) { + throw new GradleException(invalid.getMessage(), invalid); + } + } + + public void required(String coordinate, String reason) { + requiredOn("runtimeClasspath", coordinate, reason); + } + + public void required(Provider dependency, String reason) { + required(moduleCoordinate(dependency), reason); + } + + public void requiredOn(String configuration, String coordinate, String reason) { + try { + presences.add(new DependencyPresence(coordinate, reason, configuration)); + } catch (IllegalArgumentException invalid) { + throw new GradleException(invalid.getMessage(), invalid); + } + } + + public void requiredOn( + String configuration, + Provider dependency, + String reason) { + requiredOn(configuration, moduleCoordinate(dependency), reason); + } + + public List getDeclarations() { + return List.copyOf(absences); + } + + public List getAbsencePatterns() { + return List.copyOf(absencePatterns); + } + + public List getPresences() { + return List.copyOf(presences); + } + + private static String moduleCoordinate(Provider dependency) { + var module = dependency.get().getModule(); + return module.getGroup() + ":" + module.getName(); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyPlugin.java new file mode 100644 index 00000000..6e804d86 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyPlugin.java @@ -0,0 +1,90 @@ +package dev.caskeleton.buildlogic.dependency; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.language.base.plugins.LifecycleBasePlugin; + +public final class DependencyPolicyPlugin implements Plugin { + @Override + public void apply(Project project) { + DependencyPolicyExtension extension = + project.getExtensions().create("dependencyPolicy", DependencyPolicyExtension.class); + var verify = + project + .getTasks() + .register( + "verifyDependencyPolicy", + VerifyDependencyPolicyTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Verifies required and forbidden coordinates on declared dependency graphs."); + task.getOutputs().upToDateWhen(ignored -> false); + }); + + project.afterEvaluate( + ignored -> { + List absences = extension.getDeclarations(); + List patterns = extension.getAbsencePatterns(); + List presences = extension.getPresences(); + if (absences.isEmpty() && patterns.isEmpty() && presences.isEmpty()) { + return; + } + Set names = new LinkedHashSet<>(); + absences.forEach(declaration -> names.add(declaration.configuration())); + patterns.forEach(declaration -> names.add(declaration.configuration())); + presences.forEach(declaration -> names.add(declaration.configuration())); + List checks = new ArrayList<>(); + for (String name : names) { + Configuration configuration = project.getConfigurations().findByName(name); + if (configuration == null) { + throw new GradleException( + project.getPath() + + " declares a dependency policy for configuration '" + + name + + "', which does not exist"); + } + if (!configuration.isCanBeResolved()) { + throw new GradleException( + project.getPath() + + " declares a dependency policy for '" + + name + + "', which cannot be resolved"); + } + checks.add( + new DependencyPolicyCheck( + name, + configuration, + forConfiguration(absences, name), + patterns.stream() + .filter(declaration -> declaration.configuration().equals(name)) + .toList(), + presences.stream() + .filter(declaration -> declaration.configuration().equals(name)) + .toList())); + } + verify.configure( + task -> { + task.setOwnerPath(project.getPath()); + task.setChecks(checks); + }); + project + .getTasks() + .named(LifecycleBasePlugin.CHECK_TASK_NAME) + .configure(task -> task.dependsOn(verify)); + }); + } + + private static List forConfiguration( + List declarations, String configuration) { + return declarations.stream() + .filter(declaration -> declaration.configuration().equals(configuration)) + .toList(); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPresence.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPresence.java new file mode 100644 index 00000000..8338379e --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/DependencyPresence.java @@ -0,0 +1,22 @@ +package dev.caskeleton.buildlogic.dependency; + +import java.util.Objects; + +public record DependencyPresence(String coordinate, String reason, String configuration) { + public DependencyPresence { + Objects.requireNonNull(coordinate, "coordinate"); + Objects.requireNonNull(reason, "reason"); + Objects.requireNonNull(configuration, "configuration"); + if (coordinate.chars().filter(ch -> ch == ':').count() != 1) { + throw new IllegalArgumentException( + "dependencyPolicy.required('" + coordinate + "') must be group:module without a version"); + } + if (reason.isBlank()) { + throw new IllegalArgumentException( + "dependencyPolicy.required('" + coordinate + "') needs a reason"); + } + if (configuration.isBlank()) { + throw new IllegalArgumentException("dependency policy configuration must not be blank"); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/VerifyDependencyPolicyTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/VerifyDependencyPolicyTask.java new file mode 100644 index 00000000..31bb906f --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/dependency/VerifyDependencyPolicyTask.java @@ -0,0 +1,123 @@ +package dev.caskeleton.buildlogic.dependency; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.ModuleVersionIdentifier; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification depends on the live resolved dependency graph") +public class VerifyDependencyPolicyTask extends DefaultTask { + private List checks = List.of(); + private String ownerPath = ":"; + + @Internal + List getChecks() { + return checks; + } + + void setChecks(List checks) { + this.checks = List.copyOf(checks); + } + + @Internal + String getOwnerPath() { + return ownerPath; + } + + void setOwnerPath(String ownerPath) { + this.ownerPath = ownerPath; + } + + @TaskAction + public void verifyPolicy() { + List violations = new ArrayList<>(); + for (DependencyPolicyCheck check : checks) { + Set resolved = resolvedModules(check); + verifyExactAbsences(check, resolved, violations); + verifyPatternAbsences(check, resolved, violations); + verifyPresences(check, resolved, violations); + } + if (!violations.isEmpty()) { + throw new GradleException( + ownerPath + + ": the dependency graph contradicts what this leaf declares.\n" + + String.join("\n", violations) + + "\nFix the graph or update the declaration with an explicit reason."); + } + } + + private static Set resolvedModules(DependencyPolicyCheck check) { + Set resolved = new HashSet<>(); + check + .configuration() + .getIncoming() + .getResolutionResult() + .getAllComponents() + .forEach( + component -> { + ModuleVersionIdentifier version = component.getModuleVersion(); + if (version != null) { + resolved.add(version.getGroup() + ":" + version.getName()); + } + }); + return resolved; + } + + private static void verifyExactAbsences( + DependencyPolicyCheck check, Set resolved, List violations) { + for (DependencyAbsence absence : check.absences()) { + if (resolved.contains(absence.coordinate())) { + violations.add( + " " + + absence.coordinate() + + " is on " + + check.configurationName() + + " — the leaf states: " + + absence.reason()); + } + } + } + + private static void verifyPatternAbsences( + DependencyPolicyCheck check, Set resolved, List violations) { + for (DependencyAbsencePattern absence : check.absencePatterns()) { + Pattern pattern = Pattern.compile(absence.regex()); + resolved.stream() + .filter(coordinate -> pattern.matcher(coordinate).matches()) + .sorted() + .forEach( + coordinate -> + violations.add( + " " + + coordinate + + " matches forbidden pattern '" + + absence.regex() + + "' on " + + check.configurationName() + + " — the leaf states: " + + absence.reason())); + } + } + + private static void verifyPresences( + DependencyPolicyCheck check, Set resolved, List violations) { + for (DependencyPresence presence : check.presences()) { + if (!resolved.contains(presence.coordinate())) { + violations.add( + " required " + + presence.coordinate() + + " is missing from " + + check.configurationName() + + " — the leaf states: " + + presence.reason()); + } + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/graphql/ForbiddenJarMarker.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/graphql/ForbiddenJarMarker.java new file mode 100644 index 00000000..a8032b22 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/graphql/ForbiddenJarMarker.java @@ -0,0 +1,10 @@ +package dev.caskeleton.buildlogic.graphql; + +import java.util.Objects; + +record ForbiddenJarMarker(String marker, String reason) { + ForbiddenJarMarker { + Objects.requireNonNull(marker, "marker"); + Objects.requireNonNull(reason, "reason"); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/graphql/GraphQlPlatformPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/graphql/GraphQlPlatformPlugin.java new file mode 100644 index 00000000..c42efef1 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/graphql/GraphQlPlatformPlugin.java @@ -0,0 +1,122 @@ +package dev.caskeleton.buildlogic.graphql; + +import dev.caskeleton.buildlogic.EvidenceExtension; +import dev.caskeleton.buildlogic.JUnitEvidence; +import java.io.File; +import java.util.List; +import java.util.Set; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.bundling.Jar; +import org.gradle.api.tasks.testing.Test; +import org.gradle.language.base.plugins.LifecycleBasePlugin; + +public final class GraphQlPlatformPlugin implements Plugin { + private static final String PACKAGE = "dev.caskeleton.adapter.inbound.graphql"; + private static final List REQUIRED_STABLE_CLASSES = + List.of(PACKAGE + ".moduleboundary.GraphQlModuleBoundaryTest"); + + @Override + public void apply(Project project) { + project.getPluginManager().withPlugin("java", ignored -> configure(project)); + } + + private static void configure(Project project) { + EvidenceExtension evidence = + project.getRootProject().getExtensions().findByType(EvidenceExtension.class); + if (evidence == null) { + throw new GradleException("ca.graphql-platform requires ca.evidence on the root project."); + } + SourceSet testSource = + project + .getExtensions() + .getByType(JavaPluginExtension.class) + .getSourceSets() + .getByName(SourceSet.TEST_SOURCE_SET_NAME); + + project + .getTasks() + .register( + "graphqlStableTest", + Test.class, + test -> { + test.setDescription( + "Runs the Stable GraphQL platform test lane (Stable plan Task 1-48)."); + test.setGroup("verification"); + test.setTestClassesDirs(testSource.getOutput().getClassesDirs()); + test.setClasspath(testSource.getRuntimeClasspath()); + test.jvmArgs("-Duser.timezone=UTC"); + test.getOutputs().upToDateWhen(ignored -> false); + test.useJUnitPlatform(options -> options.excludeTags("quarantine", "graphql-advanced")); + test.filter( + filter -> { + filter.includeTestsMatching(PACKAGE + ".*"); + filter.setFailOnNoMatchingTests(true); + }); + test.getFilter().setFailOnNoMatchingTests(true); + test.getReports().getJunitXml().getRequired().set(true); + test.getReports() + .getJunitXml() + .getOutputLocation() + .set(project.getLayout().getBuildDirectory().dir("test-results/graphqlStableTest")); + test.doFirst( + ignored -> { + File stale = + test.getReports().getJunitXml().getOutputLocation().get().getAsFile(); + if (stale.exists()) { + project.delete(stale); + if (stale.exists()) { + throw new GradleException( + "graphqlStableTest could not delete stale JUnit XML: " + stale); + } + } + }); + test.doLast( + ignored -> { + JUnitEvidence.Results result = + evidence.readJUnitEvidence( + "graphqlStableTest", + test.getReports().getJunitXml().getOutputLocation().get().getAsFile()); + Set executed = result.executedClasses(); + List missing = + REQUIRED_STABLE_CLASSES.stream() + .filter( + required -> + executed.stream() + .noneMatch( + actual -> + actual.equals(required) + || actual.startsWith(required + "$"))) + .toList(); + if (!missing.isEmpty()) { + throw new GradleException( + "graphqlStableTest executed no test case for required boundary class(es): " + + missing + + ". The lane is green only because the class is gone; restore it rather than removing it from requiredStableClasses."); + } + }); + }); + + var jar = project.getTasks().named("jar", Jar.class); + var verifyJar = + project + .getTasks() + .register( + "verifyGraphQlProductionJar", + VerifyGraphQlProductionJarTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails when the GraphQL production jar contains testkit, fixture or in-memory-only types."); + task.dependsOn(jar); + task.getJarFile().set(jar.flatMap(Jar::getArchiveFile)); + }); + project + .getTasks() + .named(LifecycleBasePlugin.CHECK_TASK_NAME) + .configure(task -> task.dependsOn(verifyJar)); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/graphql/VerifyGraphQlProductionJarTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/graphql/VerifyGraphQlProductionJarTask.java new file mode 100644 index 00000000..53bc26ff --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/graphql/VerifyGraphQlProductionJarTask.java @@ -0,0 +1,56 @@ +package dev.caskeleton.buildlogic.graphql; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipFile; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Artifact contract verification emits no reusable output") +public abstract class VerifyGraphQlProductionJarTask extends DefaultTask { + private static final List FORBIDDEN = + List.of( + new ForbiddenJarMarker("/testkit/", "contract suites and integration fixtures belong to test fixtures"), + new ForbiddenJarMarker("InMemory", "an in-memory implementation is a development stand-in, not a shipped default"), + new ForbiddenJarMarker("ForTests", "a for-tests factory in the production jar is reachable from production code"), + new ForbiddenJarMarker("TestContext", "a credential-free authenticated context must not ship"), + new ForbiddenJarMarker("Fixture", "fixtures belong to test fixtures")); + + @InputFile + public abstract RegularFileProperty getJarFile(); + + @TaskAction + public void verifyJar() { + File jar = getJarFile().get().getAsFile(); + List violations = new ArrayList<>(); + try (ZipFile archive = new ZipFile(jar)) { + var entries = archive.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry.isDirectory() || !entry.getName().endsWith(".class")) { + continue; + } + for (ForbiddenJarMarker forbidden : FORBIDDEN) { + if (entry.getName().contains(forbidden.marker())) { + violations.add(entry.getName() + ": " + forbidden.reason()); + } + } + } + } catch (IOException exception) { + throw new GradleException("Could not inspect GraphQL production jar " + jar, exception); + } + if (!violations.isEmpty()) { + violations.sort(String::compareTo); + throw new GradleException( + "The GraphQL production jar contains non-production types:\n " + + String.join("\n ", violations) + + "\nMove them to src/testFixtures/java, or declare them with testFixturesImplementation."); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/java/JavaConventionsPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/java/JavaConventionsPlugin.java new file mode 100644 index 00000000..ee2714b1 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/java/JavaConventionsPlugin.java @@ -0,0 +1,160 @@ +package dev.caskeleton.buildlogic.java; + +import dev.caskeleton.buildlogic.release.ReleaseProvenanceExtension; +import io.spring.gradle.dependencymanagement.dsl.DependencyManagementExtension; +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.VersionCatalogsExtension; +import org.gradle.api.artifacts.dsl.LockMode; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.bundling.AbstractArchiveTask; +import org.gradle.api.tasks.bundling.Jar; +import org.gradle.api.tasks.compile.JavaCompile; +import org.gradle.api.tasks.testing.Test; +import org.gradle.jvm.toolchain.JavaLanguageVersion; + +public final class JavaConventionsPlugin implements Plugin { + private static final List COMPILER_ARGS = + List.of("-parameters", "-Werror", "-Xlint:deprecation", "-Xlint:unchecked"); + + @Override + public void apply(Project project) { + project.getPluginManager().apply("java"); + project.getPluginManager().apply("io.spring.dependency-management"); + project.getPluginManager().apply("ca.strict-test-lane"); + project.getPluginManager().apply("ca.api-surface"); + project.getPluginManager().apply("ca.dependency-policy"); + project.getPluginManager().apply("ca.strict-qualification"); + project.getPluginManager().apply("ca.test-jvm-agents"); + + var catalog = project.getExtensions().getByType(VersionCatalogsExtension.class).named("libs"); + String springBootVersion = catalog.findVersion("springBoot").orElseThrow().getRequiredVersion(); + + project.setGroup("dev.caskeleton"); + ReleaseProvenanceExtension provenance = + project.getRootProject().getExtensions().findByType(ReleaseProvenanceExtension.class); + project.setVersion(provenance == null ? "0.0.1-SNAPSHOT" : provenance.getTraceableVersion()); + String buildRevision = provenance == null ? "unknown" : provenance.getSourceRevision(); + + JavaPluginExtension javaExtension = project.getExtensions().getByType(JavaPluginExtension.class); + javaExtension.getToolchain().getLanguageVersion().set(JavaLanguageVersion.of(21)); + + project.getDependencyLocking().lockAllConfigurations(); + project.getDependencyLocking().getLockMode().set(LockMode.STRICT); + + project + .getTasks() + .withType(AbstractArchiveTask.class) + .configureEach( + archive -> { + archive.setPreserveFileTimestamps(false); + archive.setReproducibleFileOrder(true); + archive.dirPermissions(permissions -> permissions.unix("755")); + archive.filePermissions(permissions -> permissions.unix("644")); + }); + project + .getTasks() + .withType(Jar.class) + .configureEach( + jar -> + jar.getManifest() + .attributes( + java.util.Map.of( + "Implementation-Version", project.getVersion().toString(), + "Build-Revision", buildRevision))); + + project + .getTasks() + .withType(JavaCompile.class) + .configureEach( + compile -> { + compile.getOptions().setEncoding("UTF-8"); + for (String argument : COMPILER_ARGS) { + if (!compile.getOptions().getCompilerArgs().contains(argument)) { + compile.getOptions().getCompilerArgs().add(argument); + } + } + }); + + // Spring dependency-management reads Maven BOM property overrides from project extra properties. + project.getExtensions().getExtraProperties().set("commons-lang3.version", "3.20.0"); + project.getExtensions().getExtraProperties().set("netty.version", "4.2.17.Final"); + DependencyManagementExtension dependencyManagement = + project.getExtensions().getByType(DependencyManagementExtension.class); + dependencyManagement.imports( + imports -> + imports.mavenBom( + "org.springframework.boot:spring-boot-dependencies:" + springBootVersion)); + + project + .getDependencies() + .add("testRuntimeOnly", "org.junit.platform:junit-platform-launcher"); + + boolean writeLocksRequested = project.getGradle().getStartParameter().isWriteDependencyLocks(); + var resolveAndLockAll = + project + .getTasks() + .register( + "resolveAndLockAll", + ResolveConfigurationsTask.class, + task -> { + task.setGroup("build setup"); + task.setDescription( + "Resolves every configuration and writes this project's dependency lock state."); + task.getRequireWriteLocks().set(true); + task.getWriteLocksRequested().set(writeLocksRequested); + task.notCompatibleWithConfigurationCache( + "Resolves configurations captured from the owning project"); + }); + var verifyDependencyLocks = + project + .getTasks() + .register( + "verifyDependencyLocks", + ResolveConfigurationsTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Resolves every configuration and fails when strict dependency locks drift."); + task.getRequireWriteLocks().set(false); + task.getWriteLocksRequested().set(writeLocksRequested); + task.notCompatibleWithConfigurationCache( + "Resolves configurations captured from the owning project"); + }); + project.afterEvaluate( + ignored -> { + List resolvable = + project.getConfigurations().stream().filter(Configuration::isCanBeResolved).toList(); + resolveAndLockAll.configure(task -> task.setConfigurationsToResolve(resolvable)); + verifyDependencyLocks.configure(task -> task.setConfigurationsToResolve(resolvable)); + }); + + SourceSet testSource = javaExtension.getSourceSets().getByName(SourceSet.TEST_SOURCE_SET_NAME); + project + .getTasks() + .named("test", Test.class) + .configure( + test -> test.useJUnitPlatform(options -> options.excludeTags("quarantine"))); + project + .getTasks() + .register( + "quarantineTest", + Test.class, + test -> { + test.setGroup("verification"); + test.setDescription( + "Flaky-test quarantine bucket: runs only @Tag(\"quarantine\") tests, non-blocking."); + test.setTestClassesDirs(testSource.getOutput().getClassesDirs()); + test.setClasspath(testSource.getRuntimeClasspath()); + test.useJUnitPlatform(options -> options.includeTags("quarantine")); + test.setIgnoreFailures(true); + test.getFailOnNoDiscoveredTests().set(false); + test.getOutputs().upToDateWhen(ignored -> false); + test.jvmArgs("-Duser.timezone=UTC"); + }); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/java/ResolveConfigurationsTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/java/ResolveConfigurationsTask.java new file mode 100644 index 00000000..bcf93779 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/java/ResolveConfigurationsTask.java @@ -0,0 +1,39 @@ +package dev.caskeleton.buildlogic.java; + +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Dependency resolution verifies or writes external lock state") +public abstract class ResolveConfigurationsTask extends DefaultTask { + private List configurations = List.of(); + + @Internal + public List getConfigurationsToResolve() { + return configurations; + } + + public void setConfigurationsToResolve(List configurations) { + this.configurations = List.copyOf(configurations); + } + + @Input + public abstract Property getRequireWriteLocks(); + + @Input + public abstract Property getWriteLocksRequested(); + + @TaskAction + public void resolveConfigurations() { + if (getRequireWriteLocks().get() && !getWriteLocksRequested().get()) { + throw new GradleException(getPath() + " requires the --write-locks command-line flag."); + } + configurations.forEach(Configuration::resolve); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/jmh/JmhBenchmarksExtension.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/jmh/JmhBenchmarksExtension.java new file mode 100644 index 00000000..39008373 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/jmh/JmhBenchmarksExtension.java @@ -0,0 +1,27 @@ +package dev.caskeleton.buildlogic.jmh; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class JmhBenchmarksExtension { + private final List visibleOutputs = new ArrayList<>(List.of("main", "test")); + private String jsonReport; + + public void compilesAgainst(String... sourceSetNames) { + visibleOutputs.clear(); + visibleOutputs.addAll(List.of(sourceSetNames)); + } + + public void jsonReport(String relativePath) { + jsonReport = Objects.requireNonNull(relativePath, "relativePath"); + } + + List visibleOutputs() { + return List.copyOf(visibleOutputs); + } + + String jsonReport() { + return jsonReport; + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/jmh/JmhBenchmarksPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/jmh/JmhBenchmarksPlugin.java new file mode 100644 index 00000000..e855cc50 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/jmh/JmhBenchmarksPlugin.java @@ -0,0 +1,139 @@ +package dev.caskeleton.buildlogic.jmh; + +import java.util.List; +import net.ltgt.gradle.errorprone.ErrorProneOptions; +import net.ltgt.gradle.errorprone.ErrorProneOptionsKt; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.VersionCatalogsExtension; +import org.gradle.api.file.FileCollection; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.tasks.JavaExec; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.compile.JavaCompile; + +public final class JmhBenchmarksPlugin implements Plugin { + @Override + public void apply(Project project) { + JmhBenchmarksExtension extension = + project.getExtensions().create("jmhBenchmarks", JmhBenchmarksExtension.class); + project.getPluginManager().apply("ca.platform-module"); + project.getPluginManager().withPlugin("java", ignored -> configure(project, extension)); + } + + private static void configure(Project project, JmhBenchmarksExtension extension) { + var catalog = project.getExtensions().getByType(VersionCatalogsExtension.class).named("libs"); + String jmh = + catalog + .findVersion("jmh") + .orElseThrow(() -> new GradleException("Version catalog 'libs' must define 'jmh'")) + .getRequiredVersion(); + String errorprone = + catalog + .findVersion("errorprone") + .orElseThrow( + () -> new GradleException("Version catalog 'libs' must define 'errorprone'")) + .getRequiredVersion(); + + SourceSetContainer sourceSets = + project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets(); + SourceSet jmhSource = sourceSets.create("jmh"); + + project + .getConfigurations() + .getByName(jmhSource.getImplementationConfigurationName()) + .extendsFrom( + project.getConfigurations().getByName("implementation"), + project.getConfigurations().getByName("testImplementation")); + project + .getConfigurations() + .getByName(jmhSource.getRuntimeOnlyConfigurationName()) + .extendsFrom( + project.getConfigurations().getByName("runtimeOnly"), + project.getConfigurations().getByName("testRuntimeOnly")); + + project + .getDependencies() + .add(jmhSource.getImplementationConfigurationName(), "org.openjdk.jmh:jmh-core:" + jmh); + project + .getDependencies() + .add( + jmhSource.getAnnotationProcessorConfigurationName(), + "org.openjdk.jmh:jmh-generator-annprocess:" + jmh); + project + .getDependencies() + .add( + jmhSource.getAnnotationProcessorConfigurationName(), + "com.google.errorprone:error_prone_core:" + errorprone); + + project + .getTasks() + .named(jmhSource.getCompileJavaTaskName(), JavaCompile.class) + .configure( + compile -> { + ErrorProneOptions options = + ErrorProneOptionsKt.getErrorprone(compile.getOptions()); + options.getEnabled().set(false); + compile.getOptions().getCompilerArgs().removeIf("-Werror"::equals); + }); + project + .getTasks() + .matching(task -> task.getName().equals("spotbugsJmh")) + .configureEach(task -> task.setEnabled(false)); + project + .getTasks() + .matching(task -> task.getName().equals("checkstyleJmh")) + .configureEach(task -> task.setEnabled(false)); + + TaskProvider jmhTask = + project + .getTasks() + .register( + "jmh", + JavaExec.class, + task -> { + task.setGroup("verification"); + task.setDescription("Runs the JMH benchmarks in this leaf."); + task.setClasspath(jmhSource.getRuntimeClasspath()); + task.getMainClass().set("org.openjdk.jmh.Main"); + }); + + project.afterEvaluate( + ignored -> { + configureVisibleOutputs(project, sourceSets, jmhSource, extension.visibleOutputs()); + String reportPath = extension.jsonReport(); + if (reportPath != null && !reportPath.isBlank()) { + String reportFile = + project.getLayout().getBuildDirectory().file(reportPath).get().getAsFile().getAbsolutePath(); + jmhTask.configure(task -> task.args("-rf", "json", "-rff", reportFile)); + } + }); + } + + private static void configureVisibleOutputs( + Project project, + SourceSetContainer sourceSets, + SourceSet jmhSource, + List visibleSourceSets) { + FileCollection compileClasspath = jmhSource.getCompileClasspath(); + FileCollection runtimeClasspath = jmhSource.getRuntimeClasspath(); + for (String sourceSetName : visibleSourceSets) { + SourceSet visible = sourceSets.findByName(sourceSetName); + if (visible == null) { + throw new GradleException( + "jmh source set in " + + project.getPath() + + " compiles against unknown source set '" + + sourceSetName + + "'"); + } + compileClasspath = compileClasspath.plus(visible.getOutput()); + runtimeClasspath = runtimeClasspath.plus(visible.getOutput()); + } + jmhSource.setCompileClasspath(compileClasspath); + jmhSource.setRuntimeClasspath(runtimeClasspath); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/jpa/JpaTestLanesPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/jpa/JpaTestLanesPlugin.java new file mode 100644 index 00000000..646b0bd2 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/jpa/JpaTestLanesPlugin.java @@ -0,0 +1,141 @@ +package dev.caskeleton.buildlogic.jpa; + +import dev.caskeleton.buildlogic.strictlane.StrictTestLaneExtension; +import java.util.List; +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class JpaTestLanesPlugin implements Plugin { + private static final String READINESS_PACKAGE = + "dev.caskeleton.adapter.outbound.persistence.readiness"; + + private static final List READINESS_LANES = + List.of( + readiness("postgresqlLifecycleIntegrationTest", "PostgreSqlLifecycleIntegrationTest"), + new ReadinessLane( + "postgresqlSecurityBaselineIntegrationTest", + "PostgreSqlSecurityBaselineIntegrationTest", + List.of("runtimeRoleCannotCreateInApplicationSchemaOrTempAndUsesTrustedSearchPath")), + readiness("postgresqlMigrationIntegrationTest", "PostgreSqlMigrationIntegrationTest"), + readiness("postgresqlTransactionIntegrationTest", "PostgreSqlTransactionIntegrationTest"), + readiness("postgresqlAggregateIntegrationTest", "PostgreSqlAggregateIntegrationTest"), + readiness("postgresqlQueryIntegrationTest", "PostgreSqlQueryIntegrationTest"), + readiness("postgresqlIdempotencyIntegrationTest", "PostgreSqlIdempotencyIntegrationTest"), + readiness("postgresqlOutboxStorageIntegrationTest", "PostgreSqlOutboxStorageIntegrationTest"), + readiness("postgresqlOutboxPollingIntegrationTest", "PostgreSqlOutboxPollingIntegrationTest"), + readiness("postgresqlInboxIntegrationTest", "PostgreSqlInboxIntegrationTest"), + readiness( + "postgresqlFileserverMigrationIntegrationTest", + "PostgreSqlFileserverMigrationIntegrationTest"), + readiness( + "postgresqlFileserverMetadataIntegrationTest", + "PostgreSqlFileserverMetadataStoreIntegrationTest"), + readiness( + "postgresqlFileserverReclamationIntegrationTest", + "PostgreSqlFileserverReclamationIntegrationTest"), + readiness( + "postgresqlNotificationSchemaActivationIntegrationTest", + "PostgreSqlNotificationSchemaActivationIntegrationTest")); + + private static final List PLATFORM_LANES = + List.of( + new TaggedLane( + "jpaPlatformContractTest", + "jpa-contract", + "Runs the JPA platform contract suite against real PostgreSQL (design §40)."), + new TaggedLane( + "jpaPlatformMigrationTest", + "jpa-migration", + "Runs the Flyway upgrade snapshot scenarios (design §31)."), + new TaggedLane( + "jpaPlatformFailureTest", + "jpa-failure", + "Reproduces deadlock, serialization, and commit-ambiguity failures (design §39)."), + new TaggedLane( + "jpaPlatformQueryPlanTest", + "jpa-queryplan", + "Asserts query plan structure and planner estimate error (design §33)."), + new TaggedLane( + "jpaPlatformSecurityTest", + "jpa-security", + "Verifies runtime role privileges and search_path safety (design §36).")); + + @Override + public void apply(Project project) { + project.getPluginManager().apply("ca.strict-test-lane"); + project.getPluginManager().withPlugin("java", ignored -> configure(project)); + } + + private static void configure(Project project) { + String evidenceImage = + project + .getProviders() + .gradleProperty("jpaPostgreSqlEvidenceImage") + .orElse("postgres:16-alpine") + .get(); + String matrixVersions = + project.getProviders().gradleProperty("jpa.matrix.versions").orElse("16").get(); + StrictTestLaneExtension lanes = + project.getExtensions().getByType(StrictTestLaneExtension.class); + + READINESS_LANES.forEach(lane -> configureReadinessLane(lanes, lane, evidenceImage)); + PLATFORM_LANES.forEach(lane -> configurePlatformLane(lanes, lane, matrixVersions)); + configurePoolContractLane(lanes); + } + + private static void configureReadinessLane( + StrictTestLaneExtension lanes, ReadinessLane metadata, String evidenceImage) { + String testClass = READINESS_PACKAGE + "." + metadata.simpleName(); + lanes.lane( + metadata.taskName(), + lane -> { + lane.integration(); + lane.setSourceSet("postgresqlIntegrationTest"); + lane.setDescription("Runs the no-skip real PostgreSQL readiness scenario " + testClass + "."); + lane.requires(testClass); + metadata.additionalMethods().forEach(method -> lane.requires(testClass + "." + method)); + lane.jvmArgs( + "-Duser.timezone=UTC", + "-Djpa.evidence.postgresql.image=" + evidenceImage); + }); + } + + private static void configurePlatformLane( + StrictTestLaneExtension lanes, TaggedLane metadata, String matrixVersions) { + lanes.lane( + metadata.taskName(), + lane -> { + lane.integration(); + lane.setSourceSet("postgresqlIntegrationTest"); + lane.setTag(metadata.tag()); + lane.setDescription(metadata.description()); + lane.jvmArgs("-Duser.timezone=UTC"); + lane.systemProperty("jpa.matrix.versions", matrixVersions); + }); + } + + private static void configurePoolContractLane(StrictTestLaneExtension lanes) { + lanes.lane( + "jpaPlatformPoolContractTest", + lane -> { + lane.setSourceSet("jpaPlatformPerformanceTest"); + lane.setDescription( + "Verifies Hikari pool and REQUIRES_NEW connection behaviour (design §38)."); + lane.performance(); + lane.jvmArgs("-Duser.timezone=UTC"); + }); + } + + private static ReadinessLane readiness(String taskName, String simpleName) { + return new ReadinessLane(taskName, simpleName, List.of()); + } + + private record ReadinessLane( + String taskName, String simpleName, List additionalMethods) { + private ReadinessLane { + additionalMethods = List.copyOf(additionalMethods); + } + } + + private record TaggedLane(String taskName, String tag, String description) {} +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshot.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshot.java new file mode 100644 index 00000000..8b4ace11 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshot.java @@ -0,0 +1,9 @@ +package dev.caskeleton.buildlogic.publicpath; + +import java.util.List; + +public record PublicPathSnapshot(List publicPaths, String canonicalText) { + public PublicPathSnapshot { + publicPaths = List.copyOf(publicPaths); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshotPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshotPlugin.java new file mode 100644 index 00000000..507530fd --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshotPlugin.java @@ -0,0 +1,56 @@ +package dev.caskeleton.buildlogic.publicpath; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.provider.Provider; + +public final class PublicPathSnapshotPlugin implements Plugin { + static final String APPROVAL_PROPERTY = "approvePublicPathChange"; + + @Override + public void apply(Project project) { + var source = + project + .getLayout() + .getProjectDirectory() + .file("app-bootstrap/src/main/resources/config/security.yml"); + var snapshot = + project + .getLayout() + .getProjectDirectory() + .file("../docs/security/public-paths-snapshot.txt"); + Provider approvalRequested = + project.getProviders().gradleProperty(APPROVAL_PROPERTY).map(ignored -> true).orElse(false); + + project + .getTasks() + .register( + "verifyPublicPathSnapshot", + VerifyPublicPathSnapshotTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails without mutation when the committed deny-by-default public path baseline drifts."); + task.getSecurityConfigFile().set(source); + task.getSnapshotFile().set(snapshot); + task.getApprovalRequested().convention(approvalRequested); + task.getTrackedFiles().from(source, snapshot); + }); + + project + .getTasks() + .register( + "updatePublicPathSnapshot", + UpdatePublicPathSnapshotTask.class, + task -> { + task.setGroup("build setup"); + task.setDescription( + "Explicitly updates the committed public path baseline after security review."); + task.getSecurityConfigFile().set(source); + task.getSnapshotFile().set(snapshot); + task.getApproved().convention(approvalRequested); + task.getTrackedFiles().from(source); + task.getOutputs().upToDateWhen(ignored -> false); + }); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshotRenderer.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshotRenderer.java new file mode 100644 index 00000000..f4b783ec --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshotRenderer.java @@ -0,0 +1,89 @@ +package dev.caskeleton.buildlogic.publicpath; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class PublicPathSnapshotRenderer { + private static final Pattern BINDING_PATTERN = + Pattern.compile("^\\s*public-paths:\\s*(\\S.*?)\\s*$"); + private static final Pattern DEFAULTED_PLACEHOLDER = + Pattern.compile("\\$\\{[A-Za-z0-9_.]+:([^{}]*)}"); + private static final int MAX_PLACEHOLDER_DEPTH = 16; + private static final String HEADER = + "# feature-security-operational-baseline D5 — deny-by-default public path snapshot.\n" + + "# SSOT: ca-skeleton.security.public-paths default in " + + "app-bootstrap/src/main/resources/config/security.yml\n" + + "# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own " + + "SECURITY_PUBLIC_PATHS\n" + + "# overrides it at run time and is outside this snapshot.\n" + + "# Update only after review with: ./gradlew updatePublicPathSnapshot " + + "-PapprovePublicPathChange\n"; + + private PublicPathSnapshotRenderer() {} + + public static PublicPathSnapshot render(File securityConfigFile) { + if (!securityConfigFile.isFile()) { + throw new IllegalStateException( + "missing public-path security configuration " + securityConfigFile); + } + + List bindings = new ArrayList<>(); + for (String line : readLines(securityConfigFile)) { + Matcher matcher = BINDING_PATTERN.matcher(line); + if (matcher.matches()) { + bindings.add(matcher.group(1)); + } + } + if (bindings.size() != 1) { + throw new IllegalStateException( + "expected exactly one 'public-paths:' binding in " + + securityConfigFile + + ", found " + + bindings.size() + + " — the snapshot cannot say which surface it pins"); + } + + String raw = bindings.getFirst(); + for (int guard = 0; guard < MAX_PLACEHOLDER_DEPTH; guard++) { + Matcher matcher = DEFAULTED_PLACEHOLDER.matcher(raw); + if (!matcher.find()) { + break; + } + raw = raw.substring(0, matcher.start()) + matcher.group(1) + raw.substring(matcher.end()); + } + if (raw.contains("${")) { + throw new IllegalStateException( + "'public-paths' in " + + securityConfigFile + + " resolves to '" + + raw + + "', which still holds a placeholder with no default — the deployed public path " + + "surface is not determined by the repository and cannot be snapshotted"); + } + + List publicPaths = + Pattern.compile(",") + .splitAsStream(raw) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .sorted() + .toList(); + String canonical = HEADER + (publicPaths.isEmpty() ? "" : String.join("\n", publicPaths) + "\n"); + return new PublicPathSnapshot(publicPaths, canonical); + } + + private static List readLines(File file) { + try { + return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/UpdatePublicPathSnapshotTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/UpdatePublicPathSnapshotTask.java new file mode 100644 index 00000000..a5d8ddb0 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/UpdatePublicPathSnapshotTask.java @@ -0,0 +1,60 @@ +package dev.caskeleton.buildlogic.publicpath; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; + +public abstract class UpdatePublicPathSnapshotTask extends DefaultTask { + @Internal + public abstract RegularFileProperty getSecurityConfigFile(); + + @OutputFile + public abstract RegularFileProperty getSnapshotFile(); + + @Input + public abstract Property getApproved(); + + @InputFiles + public abstract ConfigurableFileCollection getTrackedFiles(); + + @TaskAction + public void updateSnapshot() { + if (!getApproved().get()) { + throw new GradleException( + "updatePublicPathSnapshot requires -PapprovePublicPathChange"); + } + + File source = getSecurityConfigFile().get().getAsFile(); + PublicPathSnapshot rendered; + try { + rendered = PublicPathSnapshotRenderer.render(source); + } catch (IllegalStateException invalidSource) { + throw new GradleException("updatePublicPathSnapshot: " + invalidSource.getMessage(), invalidSource); + } + + File snapshot = getSnapshotFile().get().getAsFile(); + File parent = snapshot.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) { + throw new GradleException( + "updatePublicPathSnapshot: failed to create " + parent); + } + try { + Files.writeString(snapshot.toPath(), rendered.canonicalText(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to write " + snapshot, exception); + } + getLogger().lifecycle("updatePublicPathSnapshot: wrote reviewed baseline {}", snapshot); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/VerifyPublicPathSnapshotTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/VerifyPublicPathSnapshotTask.java new file mode 100644 index 00000000..9d4478ef --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/publicpath/VerifyPublicPathSnapshotTask.java @@ -0,0 +1,74 @@ +package dev.caskeleton.buildlogic.publicpath; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; + +public abstract class VerifyPublicPathSnapshotTask extends DefaultTask { + @Internal + public abstract RegularFileProperty getSecurityConfigFile(); + + @Internal + public abstract RegularFileProperty getSnapshotFile(); + + @Input + public abstract Property getApprovalRequested(); + + @InputFiles + public abstract ConfigurableFileCollection getTrackedFiles(); + + @TaskAction + public void verifySnapshot() { + if (getApprovalRequested().get()) { + throw new GradleException( + "verifyPublicPathSnapshot is read-only; use updatePublicPathSnapshot " + + "-PapprovePublicPathChange for an intentional update."); + } + + File source = getSecurityConfigFile().get().getAsFile(); + File snapshot = getSnapshotFile().get().getAsFile(); + PublicPathSnapshot rendered; + try { + rendered = PublicPathSnapshotRenderer.render(source); + } catch (IllegalStateException invalidSource) { + throw new GradleException("verifyPublicPathSnapshot: " + invalidSource.getMessage(), invalidSource); + } + if (!snapshot.isFile()) { + throw new GradleException( + "verifyPublicPathSnapshot: missing committed baseline " + snapshot); + } + + String existing = read(snapshot); + if (!existing.equals(rendered.canonicalText())) { + throw new GradleException( + "verifyPublicPathSnapshot: the deny-by-default public path surface changed.\n" + + " expected (snapshot):\n" + + existing + + "\n" + + " actual (security.yml public-paths default):\n" + + rendered.canonicalText() + + "\nA protected endpoint may now be public. Review the change, then run:\n" + + " ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange"); + } + getLogger().lifecycle("verifyPublicPathSnapshot: OK — committed public paths are unchanged."); + } + + private static String read(File file) { + try { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/quality/QualityConventionsPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/quality/QualityConventionsPlugin.java new file mode 100644 index 00000000..0e2456ed --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/quality/QualityConventionsPlugin.java @@ -0,0 +1,146 @@ +package dev.caskeleton.buildlogic.quality; + +import com.diffplug.gradle.spotless.SpotlessExtension; +import com.github.spotbugs.snom.Confidence; +import com.github.spotbugs.snom.SpotBugsExtension; +import com.github.spotbugs.snom.SpotBugsReport; +import com.github.spotbugs.snom.SpotBugsTask; +import java.io.File; +import java.util.List; +import net.ltgt.gradle.errorprone.ErrorProneOptions; +import net.ltgt.gradle.errorprone.ErrorProneOptionsKt; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.VersionCatalog; +import org.gradle.api.artifacts.VersionCatalogsExtension; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.plugins.quality.Checkstyle; +import org.gradle.api.plugins.quality.CheckstyleExtension; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.compile.JavaCompile; + +public final class QualityConventionsPlugin implements Plugin { + @Override + public void apply(Project project) { + project.getPluginManager().apply("ca.java-conventions"); + project.getPluginManager().apply("com.diffplug.spotless"); + project.getPluginManager().apply("checkstyle"); + project.getPluginManager().apply("com.github.spotbugs"); + project.getPluginManager().apply("net.ltgt.errorprone"); + + VersionCatalog catalog = + project.getExtensions().getByType(VersionCatalogsExtension.class).named("libs"); + JavaPluginExtension javaExtension = + project.getExtensions().getByType(JavaPluginExtension.class); + SourceSet main = javaExtension.getSourceSets().getByName(SourceSet.MAIN_SOURCE_SET_NAME); + SourceSet test = javaExtension.getSourceSets().getByName(SourceSet.TEST_SOURCE_SET_NAME); + + File repositoryConfigDirectory = project.getRootProject().file("config"); + if (!repositoryConfigDirectory.isDirectory()) { + repositoryConfigDirectory = + new File(project.getRootProject().getProjectDir().getParentFile(), "config"); + } + + SpotlessExtension spotless = project.getExtensions().getByType(SpotlessExtension.class); + spotless.java( + java -> { + java.googleJavaFormat(version(catalog, "googleJavaFormat")); + java.importOrder(); + java.removeUnusedImports(); + }); + + CheckstyleExtension checkstyle = + project.getExtensions().getByType(CheckstyleExtension.class); + checkstyle.setToolVersion(version(catalog, "checkstyle")); + checkstyle.setConfigFile(new File(repositoryConfigDirectory, "checkstyle/checkstyle.xml")); + checkstyle.getConfigDirectory().set(new File(repositoryConfigDirectory, "checkstyle")); + checkstyle.setIgnoreFailures(false); + checkstyle.setMaxWarnings(Integer.MAX_VALUE); + checkstyle.setSourceSets(List.of(main, test)); + + SpotBugsExtension spotBugs = project.getExtensions().getByType(SpotBugsExtension.class); + spotBugs.getToolVersion().set(version(catalog, "spotbugs")); + spotBugs.getReportLevel().set(Confidence.HIGH); + spotBugs.getExcludeFilter().set(new File(repositoryConfigDirectory, "spotbugs/exclude.xml")); + // Keep local check fast; qualityCheck is the explicit bytecode-analysis lane. + spotBugs.getRunOnCheck().set(false); + + project + .getTasks() + .named("spotbugsMain", SpotBugsTask.class) + .configure( + task -> { + task.getAuxClassPaths().from(main.getRuntimeClasspath().minus(main.getOutput())); + SpotBugsReport xml = task.getReports().maybeCreate("xml"); + xml.getRequired().set(true); + task.doLast( + ignored -> { + List failures = + SpotBugsAnalysisReport.failures(xml.getOutputLocation().get().getAsFile()); + if (!failures.isEmpty()) { + throw new GradleException( + task.getPath() + + ": SpotBugs analysis incomplete:\n " + + String.join("\n ", failures)); + } + }); + }); + + project + .getTasks() + .withType(JavaCompile.class) + .configureEach( + compile -> { + ErrorProneOptions options = + ErrorProneOptionsKt.getErrorprone(compile.getOptions()); + options.getDisableWarningsInGeneratedCode().set(true); + }); + + project + .getDependencies() + .add( + "spotbugsPlugins", + "com.h3xstream.findsecbugs:findsecbugs-plugin:" + version(catalog, "findsecbugs")); + project + .getDependencies() + .add( + "errorprone", + "com.google.errorprone:error_prone_core:" + version(catalog, "errorprone")); + + project + .getTasks() + .register( + "auxiliaryStyleCheck", + task -> { + task.setGroup("verification"); + task.setDescription( + "Runs Checkstyle for non-default source sets outside the fast local check lane."); + task.dependsOn( + project + .getTasks() + .withType(Checkstyle.class) + .matching( + check -> + !check.getName().equals("checkstyleMain") + && !check.getName().equals("checkstyleTest"))); + }); + project + .getTasks() + .register( + "qualityCheck", + task -> { + task.setGroup("verification"); + task.setDescription("Runs this leaf's SpotBugs and FindSecBugs bytecode analysis."); + task.dependsOn(project.getTasks().named("spotbugsMain")); + }); + } + + private static String version(VersionCatalog catalog, String alias) { + return catalog + .findVersion(alias) + .orElseThrow( + () -> new GradleException("Version catalog 'libs' must define version '" + alias + "'")) + .getRequiredVersion(); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/quality/SpotBugsAnalysisReport.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/quality/SpotBugsAnalysisReport.java new file mode 100644 index 00000000..17d3d750 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/quality/SpotBugsAnalysisReport.java @@ -0,0 +1,63 @@ +package dev.caskeleton.buildlogic.quality; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +final class SpotBugsAnalysisReport { + private SpotBugsAnalysisReport() {} + + static List failures(File reportFile) { + List failures = new ArrayList<>(); + if (!reportFile.isFile()) { + return List.of("missing XML report " + reportFile); + } + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setExpandEntityReferences(false); + factory.setXIncludeAware(false); + var document = factory.newDocumentBuilder().parse(reportFile); + NodeList errorsNodes = document.getElementsByTagName("Errors"); + if (errorsNodes.getLength() != 1) { + return List.of("expected one Errors element in " + reportFile.getName()); + } + Element errors = (Element) errorsNodes.item(0); + NodeList missingClasses = errors.getElementsByTagName("MissingClass"); + for (int index = 0; index < missingClasses.getLength(); index++) { + String className = missingClasses.item(index).getTextContent().trim(); + failures.add("missing analysis class " + (className.isBlank() ? "" : className)); + } + NodeList errorNodes = errors.getElementsByTagName("Error"); + for (int index = 0; index < errorNodes.getLength(); index++) { + Element error = (Element) errorNodes.item(index); + NodeList messages = error.getElementsByTagName("ErrorMessage"); + String message = + messages.getLength() == 0 ? "" : messages.item(0).getTextContent().trim(); + failures.add("analysis error " + (message.isBlank() ? "" : message)); + } + validateDeclaredCount(failures, errors, "missingClasses", missingClasses.getLength()); + validateDeclaredCount(failures, errors, "errors", errorNodes.getLength()); + return List.copyOf(failures); + } catch (Exception exception) { + return List.of("unreadable XML report: " + exception.getMessage()); + } + } + + private static void validateDeclaredCount( + List failures, Element errors, String attribute, int observed) { + String declared = errors.getAttribute(attribute); + if (!declared.matches("\\d+")) { + failures.add("invalid " + attribute + " count '" + declared + "'"); + return; + } + if (Integer.parseInt(declared) > observed) { + failures.add(declared + " " + attribute + " reported but only " + observed + " detailed"); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/redis/RedisTopologyContract.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/redis/RedisTopologyContract.java new file mode 100644 index 00000000..35d1d01c --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/redis/RedisTopologyContract.java @@ -0,0 +1,99 @@ +package dev.caskeleton.buildlogic.redis; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class RedisTopologyContract { + private static final Set SUPPORTED_MODES = + Set.of("standalone", "sentinel", "cluster", "tls"); + private static final Map DEPLOYMENT_MODES = + Map.of( + "standalone", "standalone", + "sentinel", "sentinel", + "cluster", "cluster", + "tls", "standalone"); + private static final Map> REQUIRED_CLASSES = requiredClasses(); + + private RedisTopologyContract() {} + + public static String deploymentMode(String declaredMode) { + return DEPLOYMENT_MODES.getOrDefault(declaredMode, declaredMode); + } + + public static boolean tlsEnabled(String declaredMode) { + return "tls".equals(declaredMode); + } + + public static List requiredClasses(String declaredMode) { + return REQUIRED_CLASSES.getOrDefault(declaredMode, List.of()); + } + + public static void validateInvocation(String declaredMode, Set availableProperties) { + if (!SUPPORTED_MODES.contains(declaredMode)) { + List supported = new ArrayList<>(SUPPORTED_MODES); + supported.sort(String::compareTo); + throw new IllegalStateException( + "redisTopologyTest was selected with redis.topology.mode='" + + declaredMode + + "'; the supported modes are " + + String.join(", ", supported) + + ". An unrecognised mode selects no test and would otherwise report success."); + } + List required = new ArrayList<>(List.of("redis.topology.host", "redis.topology.port")); + if ("sentinel".equals(declaredMode)) { + required.add("redis.topology.master"); + } + if ("tls".equals(declaredMode)) { + required.add("redis.topology.trust-material"); + } + List missing = required.stream().filter(key -> !availableProperties.contains(key)).toList(); + if (!missing.isEmpty()) { + throw new IllegalStateException( + "redisTopologyTest was selected without " + + String.join(", ", missing) + + "; start a lane from infra/redis-sdk and pass -P=."); + } + } + + public static void verifyExecution( + String declaredMode, Set executedClasses, int skippedCount) { + List absent = + requiredClasses(declaredMode).stream() + .filter(required -> !executedClasses.contains(required)) + .toList(); + if (!absent.isEmpty()) { + throw new IllegalStateException( + "redisTopologyTest ran the " + + declaredMode + + " lane without " + + String.join(", ", absent) + + ". These classes are what the lane qualifies; a run that skipped them proves less than the lane claims."); + } + if (skippedCount > 0) { + throw new IllegalStateException( + "redisTopologyTest skipped " + + skippedCount + + " test(s) on the " + + declaredMode + + " lane. A qualification lane has no conditional coverage: what it cannot prove must not be selected, and what is selected must run."); + } + } + + private static Map> requiredClasses() { + Map> classes = new LinkedHashMap<>(); + classes.put("standalone", List.of( + "LiveRedisCompositionTest", "LiveRedisSemanticPortsTest", + "RedisTopologyContractTest", "LiveRedisGuardrailTest")); + classes.put("sentinel", List.of( + "LiveRedisCompositionTest", "LiveRedisSentinelPromotionTest", + "RedisTopologyContractTest")); + classes.put("cluster", List.of( + "LiveRedisCompositionTest", "LiveRedisClusterTest", + "LiveRedisClusterTransactionTest", "LiveRedisSemanticPortsTest")); + classes.put("tls", List.of("LiveRedisTlsTest")); + return Map.copyOf(classes); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/redis/RedisTopologyExecutionTracker.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/redis/RedisTopologyExecutionTracker.java new file mode 100644 index 00000000..a0100de7 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/redis/RedisTopologyExecutionTracker.java @@ -0,0 +1,41 @@ +package dev.caskeleton.buildlogic.redis; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.gradle.api.tasks.testing.TestDescriptor; +import org.gradle.api.tasks.testing.TestListener; +import org.gradle.api.tasks.testing.TestResult; + +final class RedisTopologyExecutionTracker implements TestListener { + private final AtomicInteger skipped = new AtomicInteger(); + private final Set executedClasses = + Collections.synchronizedSet(new LinkedHashSet<>()); + + int skippedCount() { return skipped.get(); } + + Set executedClasses() { + synchronized (executedClasses) { + return Set.copyOf(executedClasses); + } + } + + @Override public void beforeSuite(TestDescriptor suite) {} + @Override public void afterSuite(TestDescriptor suite, TestResult result) {} + @Override public void beforeTest(TestDescriptor testDescriptor) {} + + @Override + public void afterTest(TestDescriptor descriptor, TestResult result) { + if (result.getResultType() == TestResult.ResultType.SKIPPED) { + skipped.incrementAndGet(); + return; + } + String className = descriptor.getClassName(); + if (className == null || className.isBlank()) { + return; + } + int separator = className.lastIndexOf('.'); + executedClasses.add(separator >= 0 ? className.substring(separator + 1) : className); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/redis/RedisTopologyLanePlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/redis/RedisTopologyLanePlugin.java new file mode 100644 index 00000000..c67566d5 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/redis/RedisTopologyLanePlugin.java @@ -0,0 +1,102 @@ +package dev.caskeleton.buildlogic.redis; + +import dev.caskeleton.buildlogic.strictlane.StrictTestLaneExtension; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.tasks.testing.Test; + +public final class RedisTopologyLanePlugin implements Plugin { + private static final List TOPOLOGY_PROPERTIES = + List.of( + "redis.topology.host", + "redis.topology.port", + "redis.topology.master", + "redis.topology.username", + "redis.topology.password", + "redis.topology.trust-material"); + + @Override + public void apply(Project project) { + project.getPluginManager().apply("ca.strict-test-lane"); + project.getPluginManager().withPlugin("java", ignored -> configure(project)); + } + + private static void configure(Project project) { + String declaredMode = + project + .getProviders() + .gradleProperty("redis.topology.mode") + .orElse("unset") + .get() + .toLowerCase(Locale.ROOT); + + Map propertyValues = new LinkedHashMap<>(); + TOPOLOGY_PROPERTIES.forEach( + key -> { + var value = project.getProviders().gradleProperty(key); + if (value.isPresent()) { + propertyValues.put(key, value.get()); + } + }); + + project + .getTasks() + .named("test", Test.class) + .configure(test -> test.useJUnitPlatform(options -> options.excludeTags("redis-topology"))); + + StrictTestLaneExtension lanes = + project.getExtensions().getByType(StrictTestLaneExtension.class); + lanes.lane( + "redisTopologyTest", + lane -> { + lane.integration(); + lane.setTag("redis-topology & lane-" + declaredMode); + lane.setDescription( + "Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk."); + lane.customize(test -> configureTopologyTask(test, declaredMode, propertyValues)); + }); + } + + private static void configureTopologyTask( + Test test, String declaredMode, Map propertyValues) { + propertyValues.forEach(test::systemProperty); + test.systemProperty("redis.topology.mode", RedisTopologyContract.deploymentMode(declaredMode)); + test.systemProperty( + "redis.topology.tls", Boolean.toString(RedisTopologyContract.tlsEnabled(declaredMode))); + + RedisTopologyExecutionTracker tracker = new RedisTopologyExecutionTracker(); + test.addTestListener(tracker); + + Set availableProperties = new LinkedHashSet<>(propertyValues.keySet()); + test.doFirst( + ignored -> { + try { + RedisTopologyContract.validateInvocation(declaredMode, availableProperties); + } catch (IllegalStateException invalidInvocation) { + throw new GradleException(invalidInvocation.getMessage(), invalidInvocation); + } + }); + + test.doLast( + ignored -> { + try { + RedisTopologyContract.verifyExecution( + declaredMode, tracker.executedClasses(), tracker.skippedCount()); + } catch (IllegalStateException invalidExecution) { + throw new GradleException(invalidExecution.getMessage(), invalidExecution); + } + test.getLogger() + .lifecycle( + "redisTopologyTest: {} lane covered {} class(es).", + declaredMode, + tracker.executedClasses().size()); + }); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/ReleaseProvenance.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/ReleaseProvenance.java new file mode 100644 index 00000000..f50aeb0f --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/ReleaseProvenance.java @@ -0,0 +1,15 @@ +package dev.caskeleton.buildlogic.release; + +import java.util.Objects; + +public record ReleaseProvenance( + String releaseVersion, + String sourceRevision, + String traceableVersion, + boolean complete) { + public ReleaseProvenance { + Objects.requireNonNull(releaseVersion, "releaseVersion"); + Objects.requireNonNull(sourceRevision, "sourceRevision"); + Objects.requireNonNull(traceableVersion, "traceableVersion"); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/ReleaseProvenanceExtension.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/ReleaseProvenanceExtension.java new file mode 100644 index 00000000..b0ffd84d --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/ReleaseProvenanceExtension.java @@ -0,0 +1,31 @@ +package dev.caskeleton.buildlogic.release; + +import java.util.Objects; + +public class ReleaseProvenanceExtension { + private final ReleaseProvenance value; + + public ReleaseProvenanceExtension(ReleaseProvenance value) { + this.value = Objects.requireNonNull(value, "value"); + } + + public ReleaseProvenance getValue() { + return value; + } + + public String getReleaseVersion() { + return value.releaseVersion(); + } + + public String getSourceRevision() { + return value.sourceRevision(); + } + + public String getTraceableVersion() { + return value.traceableVersion(); + } + + public boolean isComplete() { + return value.complete(); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/ReleaseProvenancePlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/ReleaseProvenancePlugin.java new file mode 100644 index 00000000..fed72770 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/ReleaseProvenancePlugin.java @@ -0,0 +1,65 @@ +package dev.caskeleton.buildlogic.release; + +import java.util.Locale; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class ReleaseProvenancePlugin implements Plugin { + @Override + public void apply(Project project) { + String releaseVersion = + project + .getProviders() + .gradleProperty("releaseVersion") + .orElse(project.getProviders().environmentVariable("RELEASE_VERSION")) + .getOrElse("0.0.1"); + if (!releaseVersion.matches("\\d+\\.\\d+\\.\\d+")) { + throw new GradleException( + "releaseVersion must be MAJOR.MINOR.PATCH without a leading 'v', pre-release, or build metadata; got '" + + releaseVersion + + "'."); + } + + String declaredRevision = + project + .getProviders() + .gradleProperty("gitRevision") + .orElse(project.getProviders().environmentVariable("GIT_SHA")) + .orElse(project.getProviders().environmentVariable("GITHUB_SHA")) + .getOrElse(""); + boolean traceable = declaredRevision.matches("(?i)[0-9a-f]{7,40}"); + String sourceRevision = + traceable + ? declaredRevision.toLowerCase(Locale.ROOT).substring(0, Math.min(12, declaredRevision.length())) + : "unknown"; + String traceableVersion = + traceable ? releaseVersion + "+" + sourceRevision : releaseVersion + "-SNAPSHOT"; + ReleaseProvenance provenance = + new ReleaseProvenance(releaseVersion, sourceRevision, traceableVersion, traceable); + + project + .getExtensions() + .create("releaseProvenance", ReleaseProvenanceExtension.class, provenance); + // Temporary compatibility bridge while remaining conventions move to the typed extension. + project.getExtensions().getExtraProperties().set("releaseVersion", releaseVersion); + project.getExtensions().getExtraProperties().set("sourceRevision", sourceRevision); + project.getExtensions().getExtraProperties().set("traceableVersion", traceableVersion); + project.getExtensions().getExtraProperties().set("releaseProvenanceComplete", traceable); + project.setVersion(traceableVersion); + + project + .getTasks() + .register( + "verifyReleaseProvenance", + VerifyReleaseProvenanceTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails when the build cannot name the source revision it was produced from."); + task.getComplete().set(traceable); + task.getTraceableVersion().set(traceableVersion); + task.getOutputs().upToDateWhen(ignored -> false); + }); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/VerifyReleaseProvenanceTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/VerifyReleaseProvenanceTask.java new file mode 100644 index 00000000..8d1da40a --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/release/VerifyReleaseProvenanceTask.java @@ -0,0 +1,27 @@ +package dev.caskeleton.buildlogic.release; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification task has no reusable outputs") +public abstract class VerifyReleaseProvenanceTask extends DefaultTask { + @Input + public abstract Property getComplete(); + + @Input + public abstract Property getTraceableVersion(); + + @TaskAction + public void verifyProvenance() { + if (!getComplete().get()) { + throw new GradleException( + "verifyReleaseProvenance: no source revision. A release archive has to name the commit " + + "that produced it; supply -PgitRevision=, GIT_SHA or GITHUB_SHA."); + } + getLogger().lifecycle("verifyReleaseProvenance: OK — {}", getTraceableVersion().get()); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/runtime/RuntimeCompositionCheck.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/runtime/RuntimeCompositionCheck.java new file mode 100644 index 00000000..cab344c8 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/runtime/RuntimeCompositionCheck.java @@ -0,0 +1,13 @@ +package dev.caskeleton.buildlogic.runtime; + +import java.util.Objects; +import org.gradle.api.artifacts.Configuration; + +record RuntimeCompositionCheck( + String compositionId, String projectPath, Configuration runtimeClasspath) { + RuntimeCompositionCheck { + Objects.requireNonNull(compositionId, "compositionId"); + Objects.requireNonNull(projectPath, "projectPath"); + Objects.requireNonNull(runtimeClasspath, "runtimeClasspath"); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/runtime/RuntimeMembershipPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/runtime/RuntimeMembershipPlugin.java new file mode 100644 index 00000000..ed63cbb1 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/runtime/RuntimeMembershipPlugin.java @@ -0,0 +1,97 @@ +package dev.caskeleton.buildlogic.runtime; + +import dev.caskeleton.buildlogic.ModuleRegistry; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; + +public final class RuntimeMembershipPlugin implements Plugin { + @Override + public void apply(Project project) { + Project root = project.getRootProject(); + File registryFile = root.file("config/architecture/modules.json"); + File repositoryRoot; + try { + repositoryRoot = root.getProjectDir().getParentFile().getCanonicalFile(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to resolve repository root", exception); + } + ModuleRegistry registry; + try { + registry = ModuleRegistry.read(registryFile, repositoryRoot); + } catch (IllegalStateException invalid) { + throw new GradleException(invalid.getMessage(), invalid); + } + List productionModules = + registry.modulesForBuild("main").stream() + .filter(module -> !module.id().equals("sample-portfolio")) + .toList(); + Set registeredPaths = + productionModules.stream().map(ModuleRegistry.Module::gradlePath).collect(Collectors.toSet()); + List compositionIds = + registry.compositionRoots().stream() + .filter(id -> !id.equals("sample-portfolio")) + .toList(); + + var verify = + project + .getTasks() + .register( + "verifyRuntimeModuleRegistry", + VerifyRuntimeModuleRegistryTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Verifies resolved composition runtime project dependencies are registered application modules."); + task.getRegistryFile().fileValue(registryFile); + task.getRegisteredProjectPaths().set(registeredPaths); + }); + + project.getGradle().projectsEvaluated( + ignored -> { + List checks = new ArrayList<>(); + for (String compositionId : compositionIds) { + ModuleRegistry.Module composition = registry.byId(compositionId); + Project compositionProject = root.findProject(composition.gradlePath()); + if (compositionProject == null) { + throw new GradleException( + "Runtime composition '" + + compositionId + + "' references missing Gradle project '" + + composition.gradlePath() + + "'."); + } + Configuration runtimeClasspath = + compositionProject.getConfigurations().findByName("runtimeClasspath"); + if (runtimeClasspath == null) { + throw new GradleException( + "Runtime composition '" + + compositionId + + "' has no runtimeClasspath configuration."); + } + checks.add( + new RuntimeCompositionCheck( + compositionId, compositionProject.getPath(), runtimeClasspath)); + } + verify.configure(task -> task.setChecks(checks)); + }); + + project + .getTasks() + .register( + "verifyRuntimeModuleMembership", + task -> { + task.setGroup("verification"); + task.setDescription("Compatibility alias for verifyRuntimeModuleRegistry."); + task.dependsOn(verify); + }); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/runtime/VerifyRuntimeModuleRegistryTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/runtime/VerifyRuntimeModuleRegistryTask.java new file mode 100644 index 00000000..6844e34a --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/runtime/VerifyRuntimeModuleRegistryTask.java @@ -0,0 +1,71 @@ +package dev.caskeleton.buildlogic.runtime; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.component.ProjectComponentIdentifier; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.SetProperty; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification resolves live runtime classpaths") +public abstract class VerifyRuntimeModuleRegistryTask extends DefaultTask { + private List checks = List.of(); + + @InputFile + public abstract RegularFileProperty getRegistryFile(); + + @Input + public abstract SetProperty getRegisteredProjectPaths(); + + @Internal + List getChecks() { + return checks; + } + + void setChecks(List checks) { + this.checks = List.copyOf(checks); + } + + @TaskAction + public void verifyRuntimeRegistry() { + Set registered = getRegisteredProjectPaths().get(); + for (RuntimeCompositionCheck check : checks) { + Set actualProjectPaths = new HashSet<>(); + check + .runtimeClasspath() + .getIncoming() + .getResolutionResult() + .getAllComponents() + .forEach( + component -> { + if (component.getId() instanceof ProjectComponentIdentifier project + && !project.getProjectPath().equals(check.projectPath())) { + actualProjectPaths.add(project.getProjectPath()); + } + }); + Set unregistered = new TreeSet<>(actualProjectPaths); + unregistered.removeAll(registered); + if (!unregistered.isEmpty()) { + throw new GradleException( + "Runtime composition '" + + check.compositionId() + + "' resolves unregistered application projects " + + unregistered + + "."); + } + } + getLogger() + .lifecycle( + "verifyRuntimeModuleRegistry: {} composition root(s) resolve only registered application projects", + checks.size()); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/settings/ArchitectureRegistrySettingsPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/settings/ArchitectureRegistrySettingsPlugin.java new file mode 100644 index 00000000..aaa2331f --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/settings/ArchitectureRegistrySettingsPlugin.java @@ -0,0 +1,21 @@ +package dev.caskeleton.buildlogic.settings; + +import dev.caskeleton.buildlogic.ModuleRegistrySettingsInstaller; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import org.gradle.api.Plugin; +import org.gradle.api.initialization.Settings; + +public final class ArchitectureRegistrySettingsPlugin implements Plugin { + @Override + public void apply(Settings settings) { + try { + File repositoryRoot = settings.getSettingsDir().getParentFile().getCanonicalFile(); + File registryFile = new File(settings.getSettingsDir(), "config/architecture/modules.json"); + ModuleRegistrySettingsInstaller.install(settings, registryFile, repositoryRoot, "main"); + } catch (IOException exception) { + throw new UncheckedIOException("failed to resolve architecture registry paths", exception); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/settings/OptionalArchitectureRegistrySettingsPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/settings/OptionalArchitectureRegistrySettingsPlugin.java new file mode 100644 index 00000000..6580a552 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/settings/OptionalArchitectureRegistrySettingsPlugin.java @@ -0,0 +1,23 @@ +package dev.caskeleton.buildlogic.settings; + +import dev.caskeleton.buildlogic.ModuleRegistrySettingsInstaller; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import org.gradle.api.Plugin; +import org.gradle.api.initialization.Settings; + +public final class OptionalArchitectureRegistrySettingsPlugin implements Plugin { + @Override + public void apply(Settings settings) { + try { + File mainBuildDirectory = settings.getSettingsDir().getParentFile().getCanonicalFile(); + File repositoryRoot = mainBuildDirectory.getParentFile().getCanonicalFile(); + File registryFile = new File(mainBuildDirectory, "config/architecture/modules.json"); + ModuleRegistrySettingsInstaller.install( + settings, registryFile, repositoryRoot, "optional-grpc"); + } catch (IOException exception) { + throw new UncheckedIOException("failed to resolve optional architecture registry paths", exception); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictLaneExecutionTracker.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictLaneExecutionTracker.java new file mode 100644 index 00000000..d11b5269 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictLaneExecutionTracker.java @@ -0,0 +1,60 @@ +package dev.caskeleton.buildlogic.strictlane; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import org.gradle.api.tasks.testing.TestDescriptor; +import org.gradle.api.tasks.testing.TestListener; +import org.gradle.api.tasks.testing.TestResult; + +final class StrictLaneExecutionTracker implements TestListener { + private final boolean collectSelectors; + private final AtomicLong executed = new AtomicLong(); + private final AtomicLong skipped = new AtomicLong(); + private final Set executedSelectors = + Collections.synchronizedSet(new LinkedHashSet<>()); + + StrictLaneExecutionTracker(boolean collectSelectors) { + this.collectSelectors = collectSelectors; + } + + long executedCount() { + return executed.get(); + } + + long skippedCount() { + return skipped.get(); + } + + Set executedSelectors() { + synchronized (executedSelectors) { + return Set.copyOf(executedSelectors); + } + } + + @Override + public void beforeSuite(TestDescriptor suite) {} + + @Override + public void afterSuite(TestDescriptor suite, TestResult result) {} + + @Override + public void beforeTest(TestDescriptor testDescriptor) {} + + @Override + public void afterTest(TestDescriptor descriptor, TestResult result) { + if (result.getResultType() == TestResult.ResultType.SKIPPED) { + skipped.incrementAndGet(); + return; + } + executed.incrementAndGet(); + if (collectSelectors) { + String className = descriptor.getClassName(); + if (className != null) { + executedSelectors.add(className); + executedSelectors.add(className + "." + descriptor.getName()); + } + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictTestLaneExtension.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictTestLaneExtension.java new file mode 100644 index 00000000..515b5143 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictTestLaneExtension.java @@ -0,0 +1,20 @@ +package dev.caskeleton.buildlogic.strictlane; + +import org.gradle.api.Action; +import org.gradle.api.NamedDomainObjectContainer; + +public class StrictTestLaneExtension { + private final NamedDomainObjectContainer lanes; + + public StrictTestLaneExtension(NamedDomainObjectContainer lanes) { + this.lanes = lanes; + } + + public NamedDomainObjectContainer getLanes() { + return lanes; + } + + public void lane(String name, Action configuration) { + lanes.create(name, configuration); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictTestLanePlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictTestLanePlugin.java new file mode 100644 index 00000000..75b4e567 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictTestLanePlugin.java @@ -0,0 +1,272 @@ +package dev.caskeleton.buildlogic.strictlane; + +import dev.caskeleton.buildlogic.RequiredTestExecution; +import java.io.File; +import java.util.List; +import java.util.function.Predicate; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.GradleException; +import org.gradle.api.NamedDomainObjectContainer; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.testing.Test; + +public final class StrictTestLanePlugin implements Plugin { + @Override + public void apply(Project project) { + project.getPluginManager().withPlugin("java", ignored -> configure(project)); + } + + private static void configure(Project project) { + NamedDomainObjectContainer lanes = + project.container(StrictTestLaneSpec.class, StrictTestLaneSpec::new); + StrictTestLaneExtension extension = + project + .getExtensions() + .create("strictTestLanes", StrictTestLaneExtension.class, lanes); + String ownerPath = project.getPath(); + JavaPluginExtension java = project.getExtensions().getByType(JavaPluginExtension.class); + + lanes.all( + lane -> { + TaskProvider laneTask = + project + .getTasks() + .register( + lane.getName(), + Test.class, + test -> configureLane(project, java, ownerPath, lane, test)); + + project + .getGradle() + .getTaskGraph() + .whenReady( + graph -> { + boolean selected = + graph.getAllTasks().stream().anyMatch(task -> task == laneTask.get()); + if (!selected) { + return; + } + SourceSet sourceSet = java.getSourceSets().getByName(lane.getSourceSet()); + if (sourceSet.getAllSource().getFiles().isEmpty()) { + throw new GradleException( + "strict test lane '" + + lane.getName() + + "' in " + + ownerPath + + " has no sources in source set '" + + lane.getSourceSet() + + "'. Gradle would skip it as NO-SOURCE and report success for a lane that ran nothing."); + } + }); + }); + + project.afterEvaluate( + ignored -> lanes.forEach(lane -> validateLane(project, lane))); + + registerAggregate( + project, + extension, + "strictTestLaneCheck", + "Runs every strict test lane this leaf declares.", + lane -> true); + registerAggregate( + project, + extension, + "testLaneCheck", + "Runs this leaf's hermetic test lanes.", + lane -> lane.getCategory() == TestLaneCategory.TEST); + registerAggregate( + project, + extension, + "integrationTestLaneCheck", + "Runs this leaf's external-infrastructure integration test lanes.", + lane -> lane.getCategory() == TestLaneCategory.INTEGRATION); + registerAggregate( + project, + extension, + "systemTestLaneCheck", + "Runs this leaf's full application/system test lanes.", + lane -> lane.getCategory() == TestLaneCategory.SYSTEM); + registerAggregate( + project, + extension, + "architectureTestLaneCheck", + "Runs this leaf's architecture test lanes.", + lane -> lane.getCategory() == TestLaneCategory.ARCHITECTURE); + registerAggregate( + project, + extension, + "performanceTestLaneCheck", + "Runs this leaf's performance test lanes.", + lane -> lane.getCategory() == TestLaneCategory.PERFORMANCE); + } + + private static void registerAggregate( + Project project, + StrictTestLaneExtension extension, + String taskName, + String description, + Predicate selection) { + project + .getTasks() + .register( + taskName, + task -> { + task.setGroup("verification"); + task.setDescription(description); + task.dependsOn( + project.provider( + () -> + extension.getLanes().stream() + .filter(selection) + .map(lane -> project.getTasks().named(lane.getName())) + .toList())); + }); + } + + private static void configureLane( + Project project, + JavaPluginExtension java, + String ownerPath, + StrictTestLaneSpec lane, + Test test) { + SourceSet sourceSet = java.getSourceSets().getByName(lane.getSourceSet()); + test.setGroup( + lane.getGroup() == null || lane.getGroup().isBlank() ? "verification" : lane.getGroup()); + test.setDescription(lane.getDescription()); + test.setTestClassesDirs(sourceSet.getOutput().getClassesDirs()); + test.setClasspath(sourceSet.getRuntimeClasspath()); + String tag = lane.getTag(); + if (tag != null && !tag.trim().isEmpty()) { + test.useJUnitPlatform(options -> options.includeTags(tag)); + } else { + test.useJUnitPlatform(); + } + List required = lane.getRequiredTests(); + if (!required.isEmpty()) { + test.filter( + filter -> { + required.forEach(filter::includeTestsMatching); + filter.setFailOnNoMatchingTests(true); + }); + } + test.getFailOnNoDiscoveredTests().set(true); + test.getOutputs().upToDateWhen(ignored -> false); + if (lane.getMaxHeapSize() != null && !lane.getMaxHeapSize().isBlank()) { + test.setMaxHeapSize(lane.getMaxHeapSize()); + } + if (!lane.getJvmArgs().isEmpty()) { + test.setJvmArgs(lane.getJvmArgs()); + } + lane.getShouldRunAfter().forEach(test::shouldRunAfter); + lane.getDependsOn().forEach(test::dependsOn); + lane.getSystemProperties().forEach(test::systemProperty); + for (File directory : lane.getInputDirectories()) { + test.getInputs().dir(directory).withPathSensitivity(PathSensitivity.RELATIVE); + } + + StrictLaneExecutionTracker tracker = new StrictLaneExecutionTracker(!required.isEmpty()); + test.addTestListener(tracker); + test.doLast( + ignored -> { + if (tracker.executedCount() == 0L) { + String detail; + if (tracker.skippedCount() > 0L) { + detail = + "; all " + + tracker.skippedCount() + + " test(s) it selected were skipped (@Disabled or an unmet assumption), and a skip is not a result"; + } else if (tag != null && !tag.trim().isEmpty()) { + detail = + "; its tag '" + + tag + + "' matches nothing in source set '" + + lane.getSourceSet() + + "'"; + } else if (required.isEmpty()) { + detail = " in source set '" + lane.getSourceSet() + "'"; + } else { + detail = "; its required tests " + required + " matched no executable test"; + } + throw new GradleException( + "strict test lane '" + + lane.getName() + + "' in " + + ownerPath + + " executed no test" + + detail + + " — a lane that runs nothing reports success for whatever it was meant to prove."); + } + if (lane.isRejectSkipped() && tracker.skippedCount() > 0L) { + throw new GradleException( + "strict test lane '" + + lane.getName() + + "' in " + + ownerPath + + " forbids skipped tests but observed " + + tracker.skippedCount() + + " skipped test(s)."); + } + if (!required.isEmpty()) { + List absent = + RequiredTestExecution.absent(required, tracker.executedSelectors()); + if (!absent.isEmpty()) { + throw new GradleException( + "strict test lane '" + + lane.getName() + + "' in " + + ownerPath + + " required " + + absent + + " and executed neither. A lane that named a test it no longer runs proves less than it claims."); + } + } + }); + if (lane.getCustomize() != null) { + lane.getCustomize().execute(test); + } + } + + private static void validateLane(Project project, StrictTestLaneSpec lane) { + boolean hasTag = lane.getTag() != null && !lane.getTag().trim().isEmpty(); + boolean hasRequired = !lane.getRequiredTests().isEmpty(); + if (!hasTag && !hasRequired && lane.getSourceSet().equals("test")) { + throw new GradleException( + "strict test lane '" + + lane.getName() + + "' in " + + project.getPath() + + " 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 (hasTag && hasRequired) { + throw new GradleException( + "strict test lane '" + + lane.getName() + + "' in " + + project.getPath() + + " declares both a tag and required tests; pick one selection."); + } + if (lane.getDescription() == null || lane.getDescription().trim().isEmpty()) { + throw new GradleException( + "strict test lane '" + + lane.getName() + + "' in " + + project.getPath() + + " declares no description"); + } + if (project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets().findByName(lane.getSourceSet()) == null) { + throw new GradleException( + "strict test lane '" + + lane.getName() + + "' in " + + project.getPath() + + " references unknown source set '" + + lane.getSourceSet() + + "'."); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictTestLaneSpec.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictTestLaneSpec.java new file mode 100644 index 00000000..89ea8f0a --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/StrictTestLaneSpec.java @@ -0,0 +1,174 @@ +package dev.caskeleton.buildlogic.strictlane; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.gradle.api.Action; +import org.gradle.api.Named; +import org.gradle.api.tasks.testing.Test; + +public class StrictTestLaneSpec implements Named { + private final String name; + private String tag; + private String description; + private String group; + private String sourceSet = "test"; + private String maxHeapSize; + private boolean rejectSkipped; + private TestLaneCategory category = TestLaneCategory.TEST; + private final List requiredTests = new ArrayList<>(); + private final List jvmArgs = new ArrayList<>(); + private final List shouldRunAfter = new ArrayList<>(); + private final List dependsOn = new ArrayList<>(); + private final List inputDirectories = new ArrayList<>(); + private final Map systemProperties = new LinkedHashMap<>(); + private Action customize; + + public StrictTestLaneSpec(String name) { + this.name = Objects.requireNonNull(name, "name"); + } + + @Override + public String getName() { + return name; + } + + public String getTag() { + return tag; + } + + public void setTag(String tag) { + this.tag = tag; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGroup() { + return group; + } + + public void setGroup(String group) { + this.group = group; + } + + public String getSourceSet() { + return sourceSet; + } + + public void setSourceSet(String sourceSet) { + this.sourceSet = sourceSet; + } + + public String getMaxHeapSize() { + return maxHeapSize; + } + + public void setMaxHeapSize(String maxHeapSize) { + this.maxHeapSize = maxHeapSize; + } + + public boolean isRejectSkipped() { + return rejectSkipped; + } + + public void setRejectSkipped(boolean rejectSkipped) { + this.rejectSkipped = rejectSkipped; + } + + public TestLaneCategory getCategory() { + return category; + } + + public void setCategory(TestLaneCategory category) { + this.category = Objects.requireNonNull(category, "category"); + } + + public void test() { + category = TestLaneCategory.TEST; + } + + public void integration() { + category = TestLaneCategory.INTEGRATION; + } + + public void system() { + category = TestLaneCategory.SYSTEM; + } + + public void architecture() { + category = TestLaneCategory.ARCHITECTURE; + } + + public void performance() { + category = TestLaneCategory.PERFORMANCE; + } + + public List getRequiredTests() { + return List.copyOf(requiredTests); + } + + public void requires(String... selectors) { + requiredTests.addAll(List.of(selectors)); + } + + public List getJvmArgs() { + return List.copyOf(jvmArgs); + } + + public void jvmArgs(String... arguments) { + jvmArgs.addAll(List.of(arguments)); + } + + public List getShouldRunAfter() { + return List.copyOf(shouldRunAfter); + } + + public void shouldRunAfter(String... taskNames) { + shouldRunAfter.addAll(List.of(taskNames)); + } + + public List getDependsOn() { + return List.copyOf(dependsOn); + } + + public void dependsOn(String... taskNames) { + dependsOn.addAll(List.of(taskNames)); + } + + public Map getSystemProperties() { + return Map.copyOf(systemProperties); + } + + public void systemProperty(String name, String value) { + systemProperties.put(Objects.requireNonNull(name, "name"), Objects.requireNonNull(value, "value")); + } + + public List getInputDirectories() { + return List.copyOf(inputDirectories); + } + + public void inputDirectory(File directory) { + inputDirectories.add(Objects.requireNonNull(directory, "directory")); + } + + public Action getCustomize() { + return customize; + } + + public void setCustomize(Action customize) { + this.customize = customize; + } + + public void customize(Action customize) { + this.customize = customize; + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/TestLaneCategory.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/TestLaneCategory.java new file mode 100644 index 00000000..84eee1ca --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictlane/TestLaneCategory.java @@ -0,0 +1,9 @@ +package dev.caskeleton.buildlogic.strictlane; + +public enum TestLaneCategory { + TEST, + INTEGRATION, + SYSTEM, + ARCHITECTURE, + PERFORMANCE +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/RejectSkippedTestsListener.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/RejectSkippedTestsListener.java new file mode 100644 index 00000000..8efd1bbb --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/RejectSkippedTestsListener.java @@ -0,0 +1,31 @@ +package dev.caskeleton.buildlogic.strictqualification; + +import org.gradle.api.GradleException; +import org.gradle.api.tasks.testing.TestDescriptor; +import org.gradle.api.tasks.testing.TestListener; +import org.gradle.api.tasks.testing.TestResult; + +final class RejectSkippedTestsListener implements TestListener { + private final String taskName; + + RejectSkippedTestsListener(String taskName) { + this.taskName = taskName; + } + + @Override + public void beforeSuite(TestDescriptor suite) {} + + @Override + public void afterSuite(TestDescriptor suite, TestResult result) { + if (suite.getParent() == null && result.getSkippedTestCount() > 0) { + throw new GradleException( + taskName + " forbids skipped tests: " + result.getSkippedTestCount()); + } + } + + @Override + public void beforeTest(TestDescriptor testDescriptor) {} + + @Override + public void afterTest(TestDescriptor testDescriptor, TestResult result) {} +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/StrictQualificationDefinition.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/StrictQualificationDefinition.java new file mode 100644 index 00000000..0d9f17ff --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/StrictQualificationDefinition.java @@ -0,0 +1,24 @@ +package dev.caskeleton.buildlogic.strictqualification; + +import java.util.List; +import java.util.Objects; +import org.gradle.api.file.Directory; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.SourceSet; + +record StrictQualificationDefinition( + String taskName, + SourceSet sourceSet, + List requiredClasses, + Provider junitXmlOutput, + Provider binaryResultsOutput, + String description) { + StrictQualificationDefinition { + Objects.requireNonNull(taskName, "taskName"); + Objects.requireNonNull(sourceSet, "sourceSet"); + requiredClasses = List.copyOf(requiredClasses); + Objects.requireNonNull(junitXmlOutput, "junitXmlOutput"); + Objects.requireNonNull(binaryResultsOutput, "binaryResultsOutput"); + Objects.requireNonNull(description, "description"); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/StrictQualificationExtension.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/StrictQualificationExtension.java new file mode 100644 index 00000000..00c33c8c --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/StrictQualificationExtension.java @@ -0,0 +1,160 @@ +package dev.caskeleton.buildlogic.strictqualification; + +import java.util.HashSet; +import java.util.List; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.file.Directory; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.testing.Test; + +public class StrictQualificationExtension { + private final Project owner; + + public StrictQualificationExtension(Project owner) { + this.owner = owner; + } + + public TaskProvider register( + String taskName, + SourceSet sourceSet, + List requiredClasses, + String description) { + return register( + taskName, + sourceSet, + requiredClasses, + owner.getLayout().getBuildDirectory().dir("test-results/" + taskName), + owner.getLayout().getBuildDirectory().dir("test-results/" + taskName + "/binary"), + description); + } + + public TaskProvider register( + String taskName, + SourceSet sourceSet, + List requiredClasses, + Provider junitXmlOutput, + Provider binaryResultsOutput, + String description) { + validate(taskName, sourceSet, requiredClasses); + String effectiveDescription = + description == null || description.isBlank() + ? "Runs exact no-skip qualification evidence for " + owner.getPath() + "." + : description; + StrictQualificationDefinition definition = + new StrictQualificationDefinition( + taskName, + sourceSet, + requiredClasses, + junitXmlOutput, + binaryResultsOutput, + effectiveDescription); + return registerDefinition(definition); + } + + private TaskProvider registerDefinition(StrictQualificationDefinition definition) { + var requiredClassesCheck = + owner + .getTasks() + .register( + definition.taskName() + "RequiredClasses", + VerifyQualificationClassesTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails when " + + definition.taskName() + + " did not compile every required test class."); + task.dependsOn(definition.sourceSet().getClassesTaskName()); + task.getClassDirectories().from(definition.sourceSet().getOutput().getClassesDirs()); + task.getRequiredClasses().set(definition.requiredClasses()); + task.getQualificationTaskName().set(definition.taskName()); + task.getJunitXmlOutput().set(definition.junitXmlOutput()); + task.getOutputs().upToDateWhen(ignored -> false); + }); + + TaskProvider qualificationTest = + owner + .getTasks() + .register( + definition.taskName(), + Test.class, + test -> { + test.setGroup("verification"); + test.setDescription(definition.description()); + test.dependsOn(requiredClassesCheck); + test.setTestClassesDirs(definition.sourceSet().getOutput().getClassesDirs()); + test.setClasspath(definition.sourceSet().getRuntimeClasspath()); + test.useJUnitPlatform(); + test.filter( + filter -> { + definition.requiredClasses().forEach(filter::includeTestsMatching); + filter.setFailOnNoMatchingTests(true); + }); + test.getFailOnNoDiscoveredTests().set(true); + test.getReports().getJunitXml().getRequired().set(true); + test.getReports().getJunitXml().getOutputLocation().set(definition.junitXmlOutput()); + test.getReports().getHtml().getRequired().set(false); + test.getBinaryResultsDirectory().set(definition.binaryResultsOutput()); + test.getOutputs().upToDateWhen(ignored -> false); + test.jvmArgs("-Duser.timezone=UTC"); + test.addTestListener(new RejectSkippedTestsListener(definition.taskName())); + }); + + var evidenceCheck = + owner + .getTasks() + .register( + definition.taskName() + "Evidence", + VerifyQualificationEvidenceTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails unless " + + definition.taskName() + + " executed every required test class without skips."); + task.mustRunAfter(qualificationTest); + task.getJunitXmlOutput().set(definition.junitXmlOutput()); + task.getRequiredClasses().set(definition.requiredClasses()); + task.getQualificationTaskName().set(definition.taskName()); + task.getOutputs().upToDateWhen(ignored -> false); + }); + qualificationTest.configure(test -> test.finalizedBy(evidenceCheck)); + return qualificationTest; + } + + private void validate(String taskName, SourceSet sourceSet, List requiredClasses) { + if (taskName == null || taskName.isBlank()) { + throw new GradleException("A strict qualification task name is required."); + } + if (sourceSet == null) { + throw new GradleException(taskName + " requires an owner source set."); + } + SourceSet owned = + owner + .getExtensions() + .getByType(JavaPluginExtension.class) + .getSourceSets() + .findByName(sourceSet.getName()); + if (owned != sourceSet) { + throw new GradleException( + taskName + + " source set '" + + sourceSet.getName() + + "' does not belong to owner project " + + owner.getPath() + + "."); + } + if (requiredClasses == null + || requiredClasses.isEmpty() + || requiredClasses.stream().anyMatch(value -> value == null || value.isBlank())) { + throw new GradleException(taskName + " must name at least one required test FQCN."); + } + if (new HashSet<>(requiredClasses).size() != requiredClasses.size()) { + throw new GradleException(taskName + " contains duplicate required test FQCNs."); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/StrictQualificationPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/StrictQualificationPlugin.java new file mode 100644 index 00000000..c5d8ed8f --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/StrictQualificationPlugin.java @@ -0,0 +1,21 @@ +package dev.caskeleton.buildlogic.strictqualification; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class StrictQualificationPlugin implements Plugin { + @Override + public void apply(Project project) { + project + .getPluginManager() + .withPlugin( + "java", + ignored -> + project + .getExtensions() + .create( + "strictQualification", + StrictQualificationExtension.class, + project)); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/VerifyQualificationClassesTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/VerifyQualificationClassesTask.java new file mode 100644 index 00000000..3bc0d080 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/VerifyQualificationClassesTask.java @@ -0,0 +1,92 @@ +package dev.caskeleton.buildlogic.strictqualification; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Qualification must validate current compiled classes and clear stale evidence") +public abstract class VerifyQualificationClassesTask extends DefaultTask { + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getClassDirectories(); + + @Input + public abstract ListProperty getRequiredClasses(); + + @Input + public abstract Property getQualificationTaskName(); + + @Internal + public abstract DirectoryProperty getJunitXmlOutput(); + + @Input + public String getJunitXmlOutputPath() { + return getJunitXmlOutput().get().getAsFile().getAbsolutePath(); + } + + @TaskAction + public void verifyClasses() { + List classDirectories = + getClassDirectories().getFiles().stream().map(file -> file.toPath()).toList(); + boolean hasAnyClass = classDirectories.stream().anyMatch(VerifyQualificationClassesTask::hasClassFile); + String taskName = getQualificationTaskName().get(); + if (!hasAnyClass) { + throw new GradleException(taskName + " source set produced no test class files."); + } + + List missing = + getRequiredClasses().get().stream() + .filter( + required -> { + Path relative = Path.of(required.replace('.', '/') + ".class"); + return classDirectories.stream().noneMatch(directory -> Files.isRegularFile(directory.resolve(relative))); + }) + .toList(); + if (!missing.isEmpty()) { + throw new GradleException(taskName + " is missing required test class files: " + missing); + } + + Path staleEvidence = getJunitXmlOutput().get().getAsFile().toPath(); + deleteRecursively(staleEvidence, taskName); + } + + private static boolean hasClassFile(Path directory) { + if (!Files.isDirectory(directory)) { + return false; + } + try (var files = Files.walk(directory)) { + return files.anyMatch(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".class")); + } catch (IOException exception) { + throw new UncheckedIOException("failed to scan compiled classes under " + directory, exception); + } + } + + private static void deleteRecursively(Path directory, String taskName) { + if (!Files.exists(directory)) { + return; + } + try (var paths = Files.walk(directory)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } catch (IOException exception) { + throw new GradleException(taskName + " could not delete stale JUnit XML: " + directory, exception); + } + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/VerifyQualificationEvidenceTask.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/VerifyQualificationEvidenceTask.java new file mode 100644 index 00000000..92b5b57c --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/strictqualification/VerifyQualificationEvidenceTask.java @@ -0,0 +1,65 @@ +package dev.caskeleton.buildlogic.strictqualification; + +import dev.caskeleton.buildlogic.JUnitEvidence; +import dev.caskeleton.buildlogic.RequiredTestExecution; +import java.io.File; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Qualification evidence verifies the current Test task result") +public abstract class VerifyQualificationEvidenceTask extends DefaultTask { + @Internal + public abstract DirectoryProperty getJunitXmlOutput(); + + @Input + public String getJunitXmlOutputPath() { + return getJunitXmlOutput().get().getAsFile().getAbsolutePath(); + } + + @Input + public abstract ListProperty getRequiredClasses(); + + @Input + public abstract Property getQualificationTaskName(); + + @TaskAction + public void verifyEvidence() { + String evidenceName = getQualificationTaskName().get(); + File resultDirectory = getJunitXmlOutput().get().getAsFile(); + JUnitEvidence.Results evidence; + try { + evidence = JUnitEvidence.read(evidenceName, resultDirectory); + } catch (IllegalStateException unreadable) { + throw new GradleException(unreadable.getMessage(), unreadable); + } + 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()); + } + List missing = + RequiredTestExecution.absent(getRequiredClasses().get(), evidence.executedClasses()); + if (!missing.isEmpty()) { + throw new GradleException( + evidenceName + ": no executed test cases for required classes: " + missing); + } + getLogger().lifecycle("{}: {} tests, {} skipped", evidenceName, evidence.tests(), evidence.skipped()); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/testagent/MockitoAgentArgumentProvider.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/testagent/MockitoAgentArgumentProvider.java new file mode 100644 index 00000000..e8db0f4c --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/testagent/MockitoAgentArgumentProvider.java @@ -0,0 +1,38 @@ +package dev.caskeleton.buildlogic.testagent; + +import java.io.File; +import java.util.Comparator; +import java.util.List; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.process.CommandLineArgumentProvider; + +public abstract class MockitoAgentArgumentProvider implements CommandLineArgumentProvider { + @Classpath + public abstract ConfigurableFileCollection getMockitoCoreClasspath(); + + @Input + public abstract Property getOwner(); + + @Override + public Iterable asArguments() { + List candidates = + getMockitoCoreClasspath().getFiles().stream() + .filter(File::isFile) + .filter(file -> file.getName().matches("mockito-core-[^/]+\\.jar")) + .sorted(Comparator.comparing(File::getAbsolutePath)) + .toList(); + if (candidates.size() != 1) { + throw new GradleException( + getOwner().get() + + ": expected exactly one mockito-core JAR for the test JVM, found " + + candidates.size() + + ": " + + candidates.stream().map(File::getAbsolutePath).toList()); + } + return List.of("-javaagent:" + candidates.getFirst().getAbsolutePath(), "-Xshare:off"); + } +} diff --git a/src/build-logic/src/main/java/dev/caskeleton/buildlogic/testagent/TestJvmAgentsPlugin.java b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/testagent/TestJvmAgentsPlugin.java new file mode 100644 index 00000000..87804743 --- /dev/null +++ b/src/build-logic/src/main/java/dev/caskeleton/buildlogic/testagent/TestJvmAgentsPlugin.java @@ -0,0 +1,52 @@ +package dev.caskeleton.buildlogic.testagent; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.tasks.testing.Test; + +public final class TestJvmAgentsPlugin implements Plugin { + @Override + public void apply(Project project) { + project.getPluginManager().withPlugin( + "java", + ignoredJava -> + project.getPluginManager().withPlugin( + "io.spring.dependency-management", + ignoredDependencyManagement -> { + Configuration dependencies = + project + .getConfigurations() + .dependencyScope("mockitoAgentDependencies") + .get(); + Configuration agent = + project + .getConfigurations() + .resolvable( + "mockitoAgent", + configuration -> { + configuration.setDescription( + "Mockito core JAR used only as a Test JVM startup agent."); + configuration.extendsFrom(dependencies); + configuration.setTransitive(false); + }) + .get(); + project + .getDependencies() + .add(dependencies.getName(), "org.mockito:mockito-core"); + project + .getTasks() + .withType(Test.class) + .configureEach( + test -> { + MockitoAgentArgumentProvider provider = + project + .getObjects() + .newInstance(MockitoAgentArgumentProvider.class); + provider.getMockitoCoreClasspath().from(agent); + provider.getOwner().set(project.getPath() + ":" + test.getName()); + test.getJvmArgumentProviders().add(provider); + }); + })); + } +} diff --git a/src/build-logic/src/test/groovy/ApiSurfaceConventionTest.groovy b/src/build-logic/src/test/groovy/ApiSurfaceConventionTest.groovy deleted file mode 100644 index 6e876c9f..00000000 --- a/src/build-logic/src/test/groovy/ApiSurfaceConventionTest.groovy +++ /dev/null @@ -1,194 +0,0 @@ -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. - * - *

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. - * - *

TestKit against a real build rather than reading the script, because the defect was entirely - * about when 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 modifier the old regex did not list still reaches the surface") - void aStrictfpTypeIsRendered() { - // The renderer used to keep its own alternation of modifiers — final, abstract, sealed, - // non-sealed — and `strictfp` was not in it, so a public type declared with it rendered as - // absent. That is the direction a surface check must never be wrong in: a type nobody can - // see in the baseline is a type nobody reviews. javac has no list to forget. - Files.writeString(projectDir.resolve('src/main/java/app/Strict.java'), - "package app;\npublic strictfp class Strict {}\n") - - runner('updateFixtureApiSurface', '-PapproveFixtureApiSurfaceChange').build() - - assertTrue(Files.readString(projectDir.resolve('surface.txt')).contains('app.Strict'), - 'a strictfp public type belongs to the surface like any other') - } - - @Test - @DisplayName("a public class written inside a comment is not a public class") - void commentedOutCodeIsNotASurface() { - // The other direction of parsing text instead of Java: a line that begins with `public class` - // at column zero inside a block comment matched, and the baseline gained a type that does not - // exist. Reviewing an addition that is not there is the same waste as missing one that is. - Files.writeString(projectDir.resolve('src/main/java/app/Commented.java'), - "package app;\n/*\npublic class Ghost {}\n*/\npublic final class Commented {}\n") - - runner('updateFixtureApiSurface', '-PapproveFixtureApiSurfaceChange').build() - - String surface = Files.readString(projectDir.resolve('surface.txt')) - assertTrue(surface.contains('app.Commented'), 'the real type belongs to the surface') - assertEquals(false, surface.contains('app.Ghost'), - "a commented-out declaration is not a public type:\n${surface}") - } - - @Test - @DisplayName("a source root that renders nothing is an error, not an empty surface") - void anEmptyRenderingIsRefused() { - // A moved source root would otherwise report every committed type as removed on verify, and - // blank the committed baseline on an approved update. - Files.writeString(projectDir.resolve('build.gradle'), """ - plugins { - id 'java' - id 'ca.api-surface' - } - apiSurface { - label = 'Fixture' - sourceRoot = 'src/main/moved-away' - baseline = file('surface.txt') - description = 'The fixture leaf public surface.' - } - """.stripIndent()) - - def result = runner('verifyFixtureApiSurface').buildAndFail() - - assertTrue(result.output.contains('found no public types'), - "an empty rendering must be refused rather than compared:\n${result.output}") - } - - @Test - @DisplayName("a source file that does not parse fails the surface rather than shrinking it") - void anUnparseableSourceIsRefused() { - Files.writeString(projectDir.resolve('src/main/java/app/Broken.java'), - "package app;\npublic class Broken {\n") - - def result = runner('verifyFixtureApiSurface').buildAndFail() - - assertTrue(result.output.contains('could not be parsed'), - "a file javac cannot read must not silently contribute nothing:\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}") - } -} diff --git a/src/build-logic/src/test/groovy/JUnitEvidenceTest.groovy b/src/build-logic/src/test/groovy/JUnitEvidenceTest.groovy deleted file mode 100644 index ec58ee86..00000000 --- a/src/build-logic/src/test/groovy/JUnitEvidenceTest.groovy +++ /dev/null @@ -1,122 +0,0 @@ -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. - * - *

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', '') - - 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', ''' - - - ''') - - 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', ''' - - - ''') - - 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', '') - - 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', '') - - 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', ''']> - ''') - - def failure = assertThrows(IllegalStateException) { - JUnitEvidence.read('lane', results.toFile()) - } - assertTrue(failure.message.contains('not readable JUnit XML'), failure.message) - } -} diff --git a/src/build-logic/src/test/groovy/ModuleRegistryTest.groovy b/src/build-logic/src/test/groovy/ModuleRegistryTest.groovy deleted file mode 100644 index 777bb35a..00000000 --- a/src/build-logic/src/test/groovy/ModuleRegistryTest.groovy +++ /dev/null @@ -1,152 +0,0 @@ -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 - -/** Contract tests for the small amount of state modules.json still owns. */ -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) - file.toFile() - } - - private static String entry(String id, String path, String source, String deps = '[]') { - """{"id":"${id}","gradle_path":"${path}","source_path":"${source}","allowed_dependencies":${deps}}""" - } - - private String registry(String roots = '["app-bootstrap","sample-portfolio"]', String... entries) { - """{"composition_roots":${roots},"modules":[${entries.join(',')}]}""" - } - - private ModuleRegistry read(String json) { - ModuleRegistry.read(write(json), root.toFile()) - } - - private String valid() { - registry('["app-bootstrap","sample-portfolio"]', - entry('app-bootstrap', ':app-bootstrap', 'src/alpha'), - entry('sample-portfolio', ':sample-portfolio', 'src/beta')) - } - - @Test - @DisplayName('a well-formed registry parses project identity and composition roots') - void wellFormedRegistryParses() { - def parsed = read(valid()) - assertEquals(2, parsed.modules.size()) - assertEquals(['app-bootstrap', 'sample-portfolio'], parsed.compositionRoots) - assertTrue(parsed.byId('app-bootstrap').sourceDirectory.isDirectory()) - } - - @Test - void duplicateIdIsRefused() { - def failure = assertThrows(IllegalStateException) { - read(registry('["app-bootstrap","sample-portfolio"]', - entry('app-bootstrap', ':app-bootstrap', 'src/alpha'), - entry('sample-portfolio', ':sample-portfolio', 'src/beta'), - entry('app-bootstrap', ':other', 'src/alpha'))) - } - assertTrue(failure.message.contains('duplicate module id'), failure.message) - } - - @Test - void aliasedSourceDirectoryIsRefused() { - def failure = assertThrows(IllegalStateException) { - read(registry('["app-bootstrap","sample-portfolio"]', - entry('app-bootstrap', ':app-bootstrap', 'src/alpha'), - entry('sample-portfolio', ':sample-portfolio', 'src/beta'), - entry('aliased', ':aliased', 'src/beta/../alpha'))) - } - assertTrue(failure.message.contains('duplicate or aliased'), failure.message) - } - - @Test - void escapingSourcePathIsRefused() { - def failure = assertThrows(IllegalStateException) { - read(registry('["app-bootstrap","sample-portfolio"]', - entry('app-bootstrap', ':app-bootstrap', 'src/alpha'), - entry('sample-portfolio', ':sample-portfolio', 'src/beta'), - entry('escaping', ':escaping', '../outside'))) - } - assertTrue(failure.message.contains('escapes the repository root') || - failure.message.contains('not an existing directory'), failure.message) - } - - @Test - @DisplayName('architecture edges are data here; architectureCheck decides whether they are legal') - void edgeRulesDoNotFailProjectDiscovery() { - def parsed = read(registry('["app-bootstrap","sample-portfolio"]', - entry('app-bootstrap', ':app-bootstrap', 'src/alpha', - '["sample-portfolio","unknown","app-bootstrap"]'), - entry('sample-portfolio', ':sample-portfolio', 'src/beta'))) - assertEquals(['sample-portfolio', 'unknown', 'app-bootstrap'], - parsed.byId('app-bootstrap').allowedDependencies) - } - - @Test - void extraFieldIsAccepted() { - String json = """{"composition_roots":["app-bootstrap","sample-portfolio"],"modules":[ - {"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha", - "allowed_dependencies":[],"owner":"platform"}, - ${entry('sample-portfolio', ':sample-portfolio', 'src/beta')}]}""" - assertEquals(2, read(json).modules.size()) - } - - @Test - void missingRequiredFieldIsRefused() { - String json = """{"composition_roots":["app-bootstrap","sample-portfolio"],"modules":[ - {"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha"}, - ${entry('sample-portfolio', ':sample-portfolio', 'src/beta')}]}""" - def failure = assertThrows(IllegalStateException) { read(json) } - assertTrue(failure.message.contains('allowed_dependencies'), failure.message) - } - - @Test - void aDerivedBuildMayOwnOneCompositionRoot() { - def parsed = read(registry('["app-bootstrap"]', - entry('app-bootstrap', ':app-bootstrap', 'src/alpha'))) - assertEquals(['app-bootstrap'], parsed.compositionRoots) - } - - @Test - void unknownCompositionRootIsRefused() { - def failure = assertThrows(IllegalStateException) { - read(registry('["service-bootstrap"]', - entry('app-bootstrap', ':app-bootstrap', 'src/alpha'))) - } - assertTrue(failure.message.contains('unknown module ids'), failure.message) - } - - @Test - void emptyCompositionRootsAreRefused() { - def failure = assertThrows(IllegalStateException) { - read(registry('[]', entry('app-bootstrap', ':app-bootstrap', 'src/alpha'))) - } - assertTrue(failure.message.contains("nonempty 'composition_roots'"), failure.message) - } - - @Test - void duplicateCompositionRootsAreRefused() { - def failure = assertThrows(IllegalStateException) { - read(registry('["app-bootstrap","app-bootstrap"]', - entry('app-bootstrap', ':app-bootstrap', 'src/alpha'))) - } - assertTrue(failure.message.contains('duplicate composition_roots'), failure.message) - } -} diff --git a/src/build-logic/src/test/groovy/PlatformModuleConventionTest.groovy b/src/build-logic/src/test/groovy/PlatformModuleConventionTest.groovy deleted file mode 100644 index 6b1ff7ac..00000000 --- a/src/build-logic/src/test/groovy/PlatformModuleConventionTest.groovy +++ /dev/null @@ -1,148 +0,0 @@ -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 vendored-platform conventions give a leaf what its forty-three build files each wrote by hand, - * and refuse to give it half of that silently. - * - *

The grpc BOM is the part worth testing rather than reading. Its two preconditions are a shared - * {@code libs.versions.grpc} entry and Spring's dependency-management plugin to import into. The - * convention must fail at configuration time when the shared catalog contract is incomplete. - */ -class PlatformModuleConventionTest { - - Path projectDir - - @BeforeEach - void setUp() { - projectDir = Files.createTempDirectory('platform-module') - // The conventions read their tool versions from the consuming build's `libs` catalog rather - // than from constants of their own, so a fixture has to bring one. Only the entries - // ca.java-conventions and ca.quality-conventions look up are needed. - Files.createDirectories(projectDir.resolve('gradle')) - Files.writeString(projectDir.resolve('gradle/libs.versions.toml'), ''' - [versions] - springBoot = "4.0.8" - googleJavaFormat = "1.35.0" - checkstyle = "13.5.0" - spotbugs = "4.10.2" - findsecbugs = "1.14.0" - errorprone = "2.49.0" - grpc = "1.68.1" - '''.stripIndent()) - // No explicit `versionCatalogs` block: Gradle imports gradle/libs.versions.toml as `libs` - // by convention, and declaring it again is rejected as a second `from` call. - Files.writeString(projectDir.resolve('settings.gradle'), ''' - dependencyResolutionManagement { - repositories { mavenCentral() } - } - rootProject.name = 'fixture' - '''.stripIndent()) - } - - private void buildFile(String body) { - Files.writeString(projectDir.resolve('build.gradle'), body.stripIndent()) - } - - private GradleRunner runner(String... args) { - return GradleRunner.create() - .withProjectDir(projectDir.toFile()) - .withPluginClasspath() - .withArguments(args) - } - - @Test - @DisplayName("ca.platform-module gives a leaf the api configuration java-library provides") - void platformModuleProvidesJavaLibrary() { - // `api` is the reason these leaves are java-library rather than java: a consumer compiles - // against their types. Asserting the configuration exists asserts the thing that would break. - buildFile(''' - plugins { - id 'ca.platform-module' - } - tasks.register('reportApiConfiguration') { - boolean present = configurations.findByName('api') != null - doLast { logger.lifecycle("api-configuration-present=" + present) } - } - ''') - - def result = runner('reportApiConfiguration').build() - - assertTrue(result.output.contains('api-configuration-present=true'), - "the platform convention should apply java-library:\n${result.output}") - } - - @Test - @DisplayName("the grpc convention imports the BOM, so io.grpc coordinates need no version") - void grpcConventionImportsTheBom() { - // The four leaves that wrote this block by hand did so to declare `io.grpc:grpc-api` without - // a version. Asserting the managed version is asserting exactly that, and it resolves the - // BOM's POM rather than downloading any jar. - buildFile(''' - plugins { - id 'ca.platform-module' - id 'ca.grpc-platform-module' - } - tasks.register('reportManagedVersion') { - String managed = dependencyManagement.managedVersions['io.grpc:grpc-api'] - doLast { logger.lifecycle('managed-grpc-api=' + managed) } - } - ''') - def result = runner('reportManagedVersion').build() - - assertTrue(result.output.contains('managed-grpc-api=1.68.1'), - "the BOM should manage io.grpc versions for the leaf:\n${result.output}") - } - - @Test - @DisplayName("the grpc convention refuses a catalog that declares no grpc version") - void grpcConventionRefusesAMissingVersion() { - Files.writeString(projectDir.resolve('gradle/libs.versions.toml'), ''' - [versions] - springBoot = "4.0.8" - googleJavaFormat = "1.35.0" - checkstyle = "13.5.0" - spotbugs = "4.10.2" - findsecbugs = "1.14.0" - errorprone = "2.49.0" - '''.stripIndent()) - buildFile(''' - plugins { - id 'ca.grpc-platform-module' - } - ''') - - def result = runner('tasks').buildAndFail() - - assertTrue(result.output.contains("Version catalog 'libs' must define version 'grpc'"), - "the refusal should name the missing catalog entry:\n${result.output}") - } - - @Test - @DisplayName("the grpc convention brings dependency-management itself") - void grpcConventionBringsDependencyManagement() { - // The BOM import needs Spring's plugin, and this convention used to throw when a leaf had - // not applied it. It cannot be missing now: ca.platform-module -> ca.java-library -> - // ca.java-conventions applies it. Asserting the extension exists asserts that the chain - // still does, which is what the throw used to protect. - buildFile(''' - plugins { - id 'ca.grpc-platform-module' - } - tasks.register('reportDependencyManagement') { - boolean present = project.extensions.findByName('dependencyManagement') != null - doLast { logger.lifecycle('dependency-management-present=' + present) } - } - ''') - def result = runner('reportDependencyManagement').build() - - assertTrue(result.output.contains('dependency-management-present=true'), - "the platform chain should apply Spring's dependency-management:\n${result.output}") - } -} diff --git a/src/build-logic/src/test/groovy/RequiredTestExecutionTest.groovy b/src/build-logic/src/test/groovy/RequiredTestExecutionTest.groovy deleted file mode 100644 index 124d12e6..00000000 --- a/src/build-logic/src/test/groovy/RequiredTestExecutionTest.groovy +++ /dev/null @@ -1,87 +0,0 @@ -import dev.caskeleton.buildlogic.RequiredTestExecution -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 - -/** - * One rule, and it is the union of what the two copies each knew. - * - *

"A test this build names must actually have run" was decided twice — once in - * {@code ca.strict-test-lane} against an {@code afterTest} listener, once in {@code ca.evidence} - * against JUnit XML. Each copy handled only the identity suffixes its own input happened to produce, - * so the lane could not see a {@code @Nested} class and the evidence reader could not see a - * parameterized invocation. Neither gap fails a build; both report a test that ran as absent, and a - * gate that cries wolf is a gate somebody eventually loosens. - * - *

These cases pin the whole rule rather than each caller's half of it, which is the point of there - * being one implementation. - */ -class RequiredTestExecutionTest { - - @Test - @DisplayName("an exact match accounts for a required selector") - void exactMatchCounts() { - assertEquals([], RequiredTestExecution.absent(['com.example.FooTest'], ['com.example.FooTest'])) - } - - @Test - @DisplayName("a @Nested inner class accounts for the outer class it lives in") - void nestedClassCountsForItsOuterClass() { - // ca.evidence knew this; the lane did not. A required class whose cases all live in @Nested - // inner classes is reported by JUnit as Outer$Inner and did execute. - assertEquals([], RequiredTestExecution.absent( - ['com.example.FooTest'], ['com.example.FooTest$WhenEmpty'])) - } - - @Test - @DisplayName("a parameterized invocation accounts for the method it came from") - void parameterizedInvocationCountsForItsMethod() { - // The lane knew this; ca.evidence did not. - assertEquals([], RequiredTestExecution.absent( - ['com.example.FooTest.rejects'], ['com.example.FooTest.rejects(String)[1]'])) - assertEquals([], RequiredTestExecution.absent( - ['com.example.FooTest.rejects'], ['com.example.FooTest.rejects[2]'])) - } - - @Test - @DisplayName("a longer name that merely starts the same way proves nothing") - void aPrefixOfADifferentNameIsNotAMatch() { - // The separator list has no '.' in it precisely for this: FooTestHelper must not be able to - // stand in for FooTest, or a required class is provable by a different class. - assertEquals(['com.example.FooTest'], RequiredTestExecution.absent( - ['com.example.FooTest'], ['com.example.FooTestHelper'])) - } - - @Test - @DisplayName("every absent selector is reported, not the first one") - void everyAbsentSelectorIsReported() { - // failOnNoMatchingTests fails only when the whole filter matches nothing, so a lane naming - // five contracts of which four still exist passes. Reporting one miss out of two would - // recreate the same half-truth one level up. - List absent = RequiredTestExecution.absent( - ['com.example.A', 'com.example.B', 'com.example.C'], - ['com.example.B']) - - assertEquals(['com.example.A', 'com.example.C'], absent) - } - - @Test - @DisplayName("nothing required is nothing absent, and nothing executed leaves everything absent") - void emptyInputs() { - assertEquals([], RequiredTestExecution.absent([], ['com.example.A'])) - assertEquals([], RequiredTestExecution.absent(null, ['com.example.A'])) - assertEquals(['com.example.A'], RequiredTestExecution.absent(['com.example.A'], [])) - assertEquals(['com.example.A'], RequiredTestExecution.absent(['com.example.A'], null)) - } - - @Test - @DisplayName("satisfies is the single predicate both conventions ask") - void satisfiesIsThePredicate() { - assertTrue(RequiredTestExecution.satisfies('com.example.FooTest$Inner', 'com.example.FooTest')) - assertTrue(RequiredTestExecution.satisfies('com.example.FooTest.bar(int)', 'com.example.FooTest.bar')) - assertEquals(false, RequiredTestExecution.satisfies(null, 'com.example.FooTest')) - assertEquals(false, RequiredTestExecution.satisfies('com.example.FooTest', null)) - } -} diff --git a/src/build-logic/src/test/groovy/StrictTestLaneConventionTest.groovy b/src/build-logic/src/test/groovy/StrictTestLaneConventionTest.groovy deleted file mode 100644 index 6cc66a64..00000000 --- a/src/build-logic/src/test/groovy/StrictTestLaneConventionTest.groovy +++ /dev/null @@ -1,441 +0,0 @@ -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. - * - *

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 whose every test is skipped fails rather than counting the skips as runs") - void aLaneOfNothingButSkipsFails() { - // The third way a lane goes hollow, and the one neither guard above catches: the tests are - // discovered, the task runs, and every one of them is `@Disabled` or assumed away. Gradle - // reports a skipped test through the same `afterTest` listener the lane counts with, so the - // counter used to read two skips as two executions and pass — green for a broker, a - // datastore or a protocol nobody exercised, which is the precise failure the counter exists - // to refuse. - 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('skippedLane') { - tag = 'carried' - description = 'Every test it selects is skipped.' - } - } - """) - Path testSource = projectDir.resolve('src/test/java') - Files.createDirectories(testSource) - Files.writeString(testSource.resolve('SkippedTest.java'), """ - import org.junit.jupiter.api.Assumptions; - import org.junit.jupiter.api.Disabled; - import org.junit.jupiter.api.Tag; - import org.junit.jupiter.api.Test; - - class SkippedTest { - @Test - @Tag("carried") - @Disabled("quarantined") - void disabled() {} - - @Test - @Tag("carried") - void assumedAway() { Assumptions.assumeTrue(false, "no docker here"); } - } - """.stripIndent()) - - def result = runner('skippedLane').buildAndFail() - - assertTrue(result.output.contains('executed no test') && result.output.contains('skipped'), - "a lane of nothing but skips must fail and say so:" - + System.lineSeparator() + result.output) - } - - @Test - @DisplayName("a lane still passes when some of its tests are skipped but one actually ran") - void aLaneWithOneRealExecutionPasses() { - // The other side of the line the counter draws. Refusing every skip would be a different - // policy — ca.strict-qualification's, for lanes whose output is evidence — and imposing it - // here would fail every lane that carries one conditional test. Zero executions is the - // failure; a skip alongside a run is not. - 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('mixedLane') { - tag = 'carried' - description = 'One test runs, one is skipped.' - } - } - """) - Path testSource = projectDir.resolve('src/test/java') - Files.createDirectories(testSource) - Files.writeString(testSource.resolve('MixedTest.java'), """ - import org.junit.jupiter.api.Disabled; - import org.junit.jupiter.api.Tag; - import org.junit.jupiter.api.Test; - - class MixedTest { - @Test - @Tag("carried") - @Disabled("quarantined") - void disabled() {} - - @Test - @Tag("carried") - void ran() {} - } - """.stripIndent()) - - def result = runner('mixedLane').build() - - assertTrue(result.output.contains('BUILD SUCCESSFUL'), - "one real execution is enough:" + 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) - } -} diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java b/src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java similarity index 89% rename from src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java rename to src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java index 11244f74..fd9f6e3d 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java +++ b/src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java @@ -13,11 +13,6 @@ import org.junit.jupiter.api.io.TempDir; final class BuildVerificationPurityContractTest { - private static final Path SOURCE_ROOT = sourceRoot(); - private static final Path ARCHIVE_SCRIPT = - SOURCE_ROOT.resolve("build-logic/src/main/groovy/ca.archive-hygiene.gradle"); - private static final Path PUBLIC_PATH_SCRIPT = - SOURCE_ROOT.resolve("build-logic/src/main/groovy/ca.public-path-snapshot.gradle"); /** * What the renderer produces for the fixture's {@code security.yml}. @@ -55,9 +50,9 @@ final class BuildVerificationPurityContractTest { void staleArchiveVerificationFailsWithoutDeleting(@TempDir Path temporaryDirectory) throws IOException { ArchiveFixture fixture = archiveFixture(temporaryDirectory); - run(fixture.projectDirectory(), ":family:module:jar"); - BuildResult result = runAndFail(fixture.projectDirectory(), "verifyNoStaleTraceableJars"); + BuildResult result = + runAndFail(fixture.projectDirectory(), ":family:module:jar", "verifyNoStaleTraceableJars"); assertThat(result.getOutput()) .contains("verifyNoStaleTraceableJars") @@ -72,9 +67,9 @@ final class BuildVerificationPurityContractTest { void explicitStaleArchiveCleanupDeletesOnlyStaleArchive(@TempDir Path temporaryDirectory) throws IOException { ArchiveFixture fixture = archiveFixture(temporaryDirectory); - run(fixture.projectDirectory(), ":family:module:jar"); - BuildResult result = run(fixture.projectDirectory(), "cleanStaleTraceableJars"); + BuildResult result = + run(fixture.projectDirectory(), ":family:module:jar", "cleanStaleTraceableJars"); assertThat(result.getOutput()).contains("deleted 1 stale archive(s)"); assertThat(fixture.staleArchive()).doesNotExist(); @@ -190,12 +185,13 @@ final class BuildVerificationPurityContractTest { Files.writeString( projectDirectory.resolve("build.gradle"), """ - plugins { id 'base' } + plugins { + id 'base' + id 'ca.archive-hygiene' + } allprojects { version = '1.0.0+abcdef1' } project(':family:module') { apply plugin: 'java' } - apply from: uri('%s') - """ - .formatted(ARCHIVE_SCRIPT.toUri().toASCIIString()), + """, UTF_8); Files.writeString( moduleDirectory.resolve("src/main/java/example/Sample.java"), @@ -220,7 +216,7 @@ final class BuildVerificationPurityContractTest { projectDirectory.resolve("settings.gradle"), "rootProject.name='fixture'\n", UTF_8); Files.writeString( projectDirectory.resolve("build.gradle"), - "apply from: uri('%s')\n".formatted(PUBLIC_PATH_SCRIPT.toUri().toASCIIString()), + "plugins { id 'ca.public-path-snapshot' }\n", UTF_8); // The committed binding default, spelled exactly as the real file spells it — nested Spring // placeholders and all, because unwinding them to `/v1/healthcheck` is what the renderer does @@ -257,22 +253,10 @@ final class BuildVerificationPurityContractTest { System.arraycopy(arguments, 0, fullArguments, 2, arguments.length); return GradleRunner.create() .withProjectDir(projectDirectory.toFile()) - .withTestKitDir(sourceRoot().resolve("app-bootstrap/build/test-kit-cache").toFile()) + .withPluginClasspath() .withArguments(fullArguments); } - private static Path sourceRoot() { - for (Path candidate = Path.of("").toAbsolutePath(); - candidate != null; - candidate = candidate.getParent()) { - if (Files.isRegularFile(candidate.resolve("gradlew")) - && Files.isDirectory(candidate.resolve("app-bootstrap"))) { - return candidate; - } - } - throw new IllegalStateException("repository src root not found"); - } - private record ArchiveFixture( Path projectDirectory, Path staleArchive, Path currentArchive, Path nonmatchingArchive) {} diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/ConditionalTransportEvidenceFunctionalTest.java b/src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportEvidenceFunctionalTest.java similarity index 82% rename from src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/ConditionalTransportEvidenceFunctionalTest.java rename to src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportEvidenceFunctionalTest.java index 0888f531..817efbd2 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/ConditionalTransportEvidenceFunctionalTest.java +++ b/src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportEvidenceFunctionalTest.java @@ -13,9 +13,6 @@ import org.junit.jupiter.api.io.TempDir; final class ConditionalTransportEvidenceFunctionalTest { - /** The included build the evidence script's shared JUnit reader lives in. */ - private static final Path BUILD_LOGIC = sourceRoot().resolve("build-logic"); - @Test void positiveExecutedCountWithNoSkipPasses(@TempDir Path temporaryDirectory) throws IOException { EvidenceFixture fixture = fixture(temporaryDirectory); @@ -71,11 +68,7 @@ final class ConditionalTransportEvidenceFunctionalTest { // fails to compile the script rather than exercising what the script does. Files.writeString( projectDirectory.resolve("settings.gradle"), - """ - pluginManagement { includeBuild('%s') } - rootProject.name='fixture' - """ - .formatted(BUILD_LOGIC.toAbsolutePath().toString().replace("\\", "/")), + "rootProject.name='fixture'\n", UTF_8); Files.writeString( projectDirectory.resolve("build.gradle"), @@ -86,7 +79,7 @@ final class ConditionalTransportEvidenceFunctionalTest { } tasks.register('verifyEvidence') { doLast { - rootProject.ext.verifyNoSkipJUnitXml( + evidence.verifyNoSkipJUnitXml( 'conditional-transport', file('results')) } } @@ -110,22 +103,10 @@ final class ConditionalTransportEvidenceFunctionalTest { System.arraycopy(arguments, 0, fullArguments, 2, arguments.length); return GradleRunner.create() .withProjectDir(projectDirectory.toFile()) - .withTestKitDir(sourceRoot().resolve("app-bootstrap/build/test-kit-cache").toFile()) + .withPluginClasspath() .withArguments(fullArguments); } - private static Path sourceRoot() { - for (Path candidate = Path.of("").toAbsolutePath(); - candidate != null; - candidate = candidate.getParent()) { - if (Files.isRegularFile(candidate.resolve("gradlew")) - && Files.isDirectory(candidate.resolve("app-bootstrap"))) { - return candidate; - } - } - throw new IllegalStateException("repository src root not found"); - } - private record EvidenceFixture(Path projectDirectory, Path results) { void writeResult(int tests, int skipped, int failures, int errors) throws IOException { diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/RuntimeMembershipFunctionalTest.java b/src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/RuntimeMembershipFunctionalTest.java similarity index 83% rename from src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/RuntimeMembershipFunctionalTest.java rename to src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/RuntimeMembershipFunctionalTest.java index f23a1d8f..2bfab6bf 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/RuntimeMembershipFunctionalTest.java +++ b/src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/RuntimeMembershipFunctionalTest.java @@ -14,7 +14,6 @@ import org.junit.jupiter.api.io.TempDir; /** The runtime registry verifies invariants from Gradle's resolved graph; it does not mirror it. */ final class RuntimeMembershipFunctionalTest { - private static final Path BUILD_LOGIC = sourceRoot().resolve("build-logic"); @Test void registeredRuntimeGraphsPass(@TempDir Path temporaryDirectory) throws IOException { @@ -53,11 +52,7 @@ final class RuntimeMembershipFunctionalTest { Files.writeString( projectDirectory.resolve("settings.gradle"), - """ - pluginManagement { includeBuild('%s') } - include 'domain-core', 'app-bootstrap', 'unregistered' - """ - .formatted(BUILD_LOGIC.toAbsolutePath().toString().replace("\\", "/")), + "include 'domain-core', 'app-bootstrap', 'unregistered'\n", UTF_8); Files.writeString( @@ -116,21 +111,9 @@ final class RuntimeMembershipFunctionalTest { System.arraycopy(arguments, 0, fullArguments, 2, arguments.length); return GradleRunner.create() .withProjectDir(projectDirectory.toFile()) - .withTestKitDir(sourceRoot().resolve("app-bootstrap/build/test-kit-cache").toFile()) + .withPluginClasspath() .withArguments(fullArguments); } - private static Path sourceRoot() { - for (Path candidate = Path.of("").toAbsolutePath(); - candidate != null; - candidate = candidate.getParent()) { - if (Files.isRegularFile(candidate.resolve("gradlew")) - && Files.isDirectory(candidate.resolve("app-bootstrap"))) { - return candidate; - } - } - throw new IllegalStateException("repository src root not found"); - } - private record RuntimeFixture(Path projectDirectory, Path registry) {} } diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StrictQualificationTestConventionFunctionalTest.java b/src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/StrictQualificationTestConventionFunctionalTest.java similarity index 86% rename from src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StrictQualificationTestConventionFunctionalTest.java rename to src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/StrictQualificationTestConventionFunctionalTest.java index fde14ed0..a2bbee59 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StrictQualificationTestConventionFunctionalTest.java +++ b/src/build-logic/src/test/java/dev/caskeleton/bootstrap/contract/StrictQualificationTestConventionFunctionalTest.java @@ -14,9 +14,6 @@ import org.junit.jupiter.api.io.TempDir; final class StrictQualificationTestConventionFunctionalTest { - /** The included build the evidence convention lives in. */ - private static final Path BUILD_LOGIC = sourceRoot().resolve("build-logic"); - private static final String REQUIRED_TEST = "fixture.RequiredQualificationTest"; @Test @@ -88,7 +85,7 @@ final class StrictQualificationTestConventionFunctionalTest { throws IOException { QualificationFixture fixture = fixture(temporaryDirectory, REQUIRED_TEST); fixture.writeTest(REQUIRED_TEST, false); - run(fixture.projectDirectory(), "strictQualificationTest"); + fixture.writeStaleEvidence(REQUIRED_TEST); fixture.appendBuild("tasks.named('strictQualificationTest') { enabled = false }\n"); BuildResult result = runAndFail(fixture.projectDirectory(), "strictQualificationTest"); @@ -101,7 +98,7 @@ final class StrictQualificationTestConventionFunctionalTest { @TempDir Path temporaryDirectory) throws IOException { QualificationFixture fixture = fixture(temporaryDirectory, REQUIRED_TEST); fixture.writeTest(REQUIRED_TEST, false); - run(fixture.projectDirectory(), "strictQualificationTest"); + fixture.writeStaleEvidence(REQUIRED_TEST); fixture.appendBuild("tasks.named('strictQualificationTest') { onlyIf { false } }\n"); BuildResult result = runAndFail(fixture.projectDirectory(), "strictQualificationTest"); @@ -117,11 +114,9 @@ final class StrictQualificationTestConventionFunctionalTest { Files.writeString( projectDirectory.resolve("settings.gradle"), """ - pluginManagement { includeBuild('%s') } rootProject.name='fixture' include 'child' - """ - .formatted(BUILD_LOGIC.toAbsolutePath().toString().replace("\\", "/")), + """, UTF_8); Files.writeString( projectDirectory.resolve("child/build.gradle"), "plugins { id 'java' }\n", UTF_8); @@ -134,10 +129,11 @@ final class StrictQualificationTestConventionFunctionalTest { id 'ca.strict-qualification' } evaluationDependsOn(':child') - registerStrictQualificationTest([ - name: 'strictQualificationTest', - sourceSet: project(':child').sourceSets.test, - requiredClasses: ['%s']]) + strictQualification.register( + 'strictQualificationTest', + project(':child').sourceSets.test, + ['%s'], + 'fixture') """ .formatted(REQUIRED_TEST), UTF_8); @@ -158,11 +154,7 @@ final class StrictQualificationTestConventionFunctionalTest { Files.createDirectories(projectDirectory); Files.writeString( projectDirectory.resolve("settings.gradle"), - """ - pluginManagement { includeBuild('%s') } - rootProject.name='fixture' - """ - .formatted(BUILD_LOGIC.toAbsolutePath().toString().replace("\\", "/")), + "rootProject.name='fixture'\n", UTF_8); Files.writeString( projectDirectory.resolve("build.gradle"), @@ -177,10 +169,11 @@ final class StrictQualificationTestConventionFunctionalTest { testImplementation 'org.junit.jupiter:junit-jupiter:6.0.1' testRuntimeOnly 'org.junit.platform:junit-platform-launcher:6.0.1' } - registerStrictQualificationTest([ - name: 'strictQualificationTest', - sourceSet: sourceSets.test, - requiredClasses: [%s]]) + strictQualification.register( + 'strictQualificationTest', + sourceSets.test, + [%s], + 'fixture') """ .formatted( requiredTests.stream() @@ -205,22 +198,10 @@ final class StrictQualificationTestConventionFunctionalTest { System.arraycopy(arguments, 0, fullArguments, 2, arguments.length); return GradleRunner.create() .withProjectDir(projectDirectory.toFile()) - .withTestKitDir(sourceRoot().resolve("app-bootstrap/build/test-kit-cache").toFile()) + .withPluginClasspath() .withArguments(fullArguments); } - private static Path sourceRoot() { - for (Path candidate = Path.of("").toAbsolutePath(); - candidate != null; - candidate = candidate.getParent()) { - if (Files.isRegularFile(candidate.resolve("gradlew")) - && Files.isDirectory(candidate.resolve("app-bootstrap"))) { - return candidate; - } - } - throw new IllegalStateException("repository src root not found"); - } - private record QualificationFixture(Path projectDirectory) { void appendBuild(String buildScript) throws IOException { @@ -258,6 +239,24 @@ final class StrictQualificationTestConventionFunctionalTest { UTF_8); } + void writeStaleEvidence(String fullyQualifiedClassName) throws IOException { + Path result = + projectDirectory + .resolve("build/test-results/strictQualificationTest") + .resolve("TEST-" + fullyQualifiedClassName + ".xml"); + Files.createDirectories(result.getParent()); + Files.writeString( + result, + """ + + + + + """ + .formatted(fullyQualifiedClassName, fullyQualifiedClassName), + UTF_8); + } + void writeEmptyTest(String fullyQualifiedClassName) throws IOException { int separator = fullyQualifiedClassName.lastIndexOf('.'); String packageName = fullyQualifiedClassName.substring(0, separator); diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/ApiSurfacePolicyTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/ApiSurfacePolicyTest.java new file mode 100644 index 00000000..9ee64909 --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/ApiSurfacePolicyTest.java @@ -0,0 +1,48 @@ +package dev.caskeleton.buildlogic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ApiSurfacePolicyTest { + @Test void renderUsesJavacTypesAndStableHeader(@TempDir Path temp) throws Exception { + Path root = temp.resolve("src/main/java/app"); + Files.createDirectories(root); + Files.writeString(root.resolve("Visible.java"), "package app; public strictfp class Visible {}\n"); + String rendered = ApiSurfacePolicy.render( + List.of(temp.resolve("src/main/java").toFile()), + "Fixture", "Fixture public surface", List.of("Reviewed API only"), + ":fixture", "updateFixtureApiSurface", "approveFixtureApiSurfaceChange"); + assertTrue(rendered.contains("# Fixture public surface")); + assertTrue(rendered.contains("# Reviewed API only")); + assertTrue(rendered.contains("app.Visible")); + assertEquals(1, ApiSurfacePolicy.countTypes(rendered)); + } + + @Test void verifyReportsAddedAndRemovedTypes(@TempDir Path temp) throws Exception { + File baseline = temp.resolve("surface.txt").toFile(); + Files.writeString(baseline.toPath(), "# baseline\napp.Old\n"); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> + ApiSurfacePolicy.verify("verifyFixtureApiSurface", "updateFixtureApiSurface", ":fixture", + "approveFixtureApiSurfaceChange", baseline, "# rendered\napp.New\n")); + assertTrue(failure.getMessage().contains("added:")); + assertTrue(failure.getMessage().contains("app.New")); + assertTrue(failure.getMessage().contains("removed:")); + assertTrue(failure.getMessage().contains("app.Old")); + } + + @Test void growthRequiresSeparateCeilingApproval() { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> + ApiSurfacePolicy.requireNoUnapprovedGrowth( + "updateFixtureApiSurface", ":fixture", "approveFixtureApiSurfaceChange", + "raiseFixtureApiSurfaceCeiling", 1, "# rendered\napp.One\napp.Two\n", false)); + assertTrue(failure.getMessage().contains("grow from 1 to 2 types")); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/ConditionalTransportQualificationPluginFunctionalTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/ConditionalTransportQualificationPluginFunctionalTest.java new file mode 100644 index 00000000..393dbcbd --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/ConditionalTransportQualificationPluginFunctionalTest.java @@ -0,0 +1,111 @@ +package dev.caskeleton.buildlogic; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class ConditionalTransportQualificationPluginFunctionalTest { + + @Test + void aggregatesAndVerifiesAllConditionalTransportEvidence(@TempDir Path temporaryDirectory) + throws IOException { + Path projectDirectory = fixture(temporaryDirectory); + + BuildResult result = run(projectDirectory, "conditionalTransportQualification"); + + assertThat(result.getOutput()) + .contains("conditional-transport-graphql: 1 tests, 0 skipped") + .contains("conditional-transport-grpc: 1 tests, 0 skipped") + .contains("conditional-transport-websocket: 1 tests, 0 skipped") + .contains("conditional-transport-composition: 1 tests, 0 skipped"); + } + + private static Path fixture(Path temporaryDirectory) throws IOException { + Path projectDirectory = temporaryDirectory.resolve("src"); + Files.createDirectories(projectDirectory); + Files.writeString( + projectDirectory.resolve("settings.gradle"), + """ + rootProject.name='fixture' + include 'adapter:inbound:graphql' + include 'adapter:inbound:grpc' + include 'adapter:inbound:websocket' + include 'app-bootstrap' + """, + UTF_8); + Files.writeString( + projectDirectory.resolve("build.gradle"), + """ + plugins { + id 'ca.evidence' + id 'ca.conditional-transport-qualification' + } + tasks.register('verifyRuntimeModuleMembership') + """, + UTF_8); + + writeProducer( + projectDirectory, + "adapter/inbound/graphql", + "graphqlTransportQualificationTest", + "graphqlTransportQualificationTest"); + writeProducer( + projectDirectory, + "adapter/inbound/grpc", + "grpcTransportQualificationTest", + "grpcTransportQualificationTest"); + writeProducer( + projectDirectory, + "adapter/inbound/websocket", + "websocketTransportQualificationTest", + "websocketTransportQualificationTest"); + writeProducer( + projectDirectory, + "app-bootstrap", + "conditionalTransportCompositionTest", + "conditionalTransportCompositionTest"); + return projectDirectory; + } + + private static void writeProducer( + Path projectDirectory, String projectPath, String taskName, String resultDirectory) + throws IOException { + Path directory = projectDirectory.resolve(projectPath); + Files.createDirectories(directory); + Files.writeString( + directory.resolve("build.gradle"), + """ + tasks.register('%s') { + doLast { + def resultDir = layout.buildDirectory.dir('test-results/%s').get().asFile + resultDir.mkdirs() + new File(resultDir, 'TEST-fixture.xml').text = ''' + + + + ''' + } + } + """ + .formatted(taskName, resultDirectory), + UTF_8); + } + + private static BuildResult run(Path projectDirectory, String... arguments) { + String[] fullArguments = new String[arguments.length + 1]; + fullArguments[0] = "--console=plain"; + System.arraycopy(arguments, 0, fullArguments, 1, arguments.length); + return GradleRunner.create() + .withProjectDir(projectDirectory.toFile()) + .withPluginClasspath() + .withArguments(fullArguments) + .build(); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/JUnitEvidenceTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/JUnitEvidenceTest.java new file mode 100644 index 00000000..791aa34b --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/JUnitEvidenceTest.java @@ -0,0 +1,77 @@ +package dev.caskeleton.buildlogic; + +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; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JUnitEvidenceTest { + @TempDir Path results; + + private void suite(String name, String body) throws Exception { + Files.writeString(results.resolve("TEST-" + name + ".xml"), body); + } + + @Test void totalsComeFromAttributes() throws Exception { + suite("Broken", ""); + JUnitEvidence.Results read = JUnitEvidence.read("lane", results.toFile()); + assertEquals(1, read.tests()); + assertEquals(1, read.errors()); + assertFalse(read.isClean()); + } + + @Test void skippedCaseIsNotExecuted() throws Exception { + suite("Mixed", """ + + + + + """); + JUnitEvidence.Results read = JUnitEvidence.read("lane", results.toFile()); + assertTrue(read.executedClasses().contains("a.Ran")); + assertFalse(read.executedClasses().contains("a.Skipped")); + } + + @Test void selectorsIncludeEveryCase() throws Exception { + suite("Mixed", """ + + + + + """); + JUnitEvidence.Results read = JUnitEvidence.read("lane", results.toFile()); + assertTrue(read.executedSelectors().contains("a.Ran#ran")); + assertTrue(read.executedSelectors().contains("a.Skipped#skipped")); + } + + @Test void emptyDirectoryIsRefused() { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> JUnitEvidence.read("lane", results.toFile())); + assertTrue(failure.getMessage().contains("no JUnit XML result files")); + } + + @Test void malformedCountIsRefused() throws Exception { + suite("Odd", ""); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> JUnitEvidence.read("lane", results.toFile())); + assertTrue(failure.getMessage().contains("invalid tests=")); + } + + @Test void wrongRootIsRefused() throws Exception { + suite("Wrong", ""); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> JUnitEvidence.read("lane", results.toFile())); + assertTrue(failure.getMessage().contains("root must be testsuite")); + } + + @Test void doctypeIsRefused() throws Exception { + suite("Doctype", """ + ]> + + """); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> JUnitEvidence.read("lane", results.toFile())); + assertTrue(failure.getMessage().contains("not readable JUnit XML")); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/ModuleRegistryTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/ModuleRegistryTest.java new file mode 100644 index 00000000..92c6bfbc --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/ModuleRegistryTest.java @@ -0,0 +1,162 @@ +package dev.caskeleton.buildlogic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ModuleRegistryTest { + @TempDir Path root; + + @BeforeEach + void setUp() throws Exception { + Files.createDirectories(root.resolve("src/alpha")); + Files.createDirectories(root.resolve("src/beta")); + } + + @Test + void wellFormedRegistryParses() { + ModuleRegistry parsed = read(valid()); + assertEquals(2, parsed.modules().size()); + assertEquals(List.of("app-bootstrap", "sample-portfolio"), parsed.compositionRoots()); + assertTrue(parsed.byId("app-bootstrap").sourceDirectory().isDirectory()); + } + + @Test + void duplicateIdIsRefused() { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + read( + registry( + "[\"app-bootstrap\",\"sample-portfolio\"]", + entry("app-bootstrap", ":app-bootstrap", "src/alpha"), + entry("sample-portfolio", ":sample-portfolio", "src/beta"), + entry("app-bootstrap", ":other", "src/alpha")))); + assertTrue(failure.getMessage().contains("duplicate module id"), failure.getMessage()); + } + + @Test + void aliasedSourceDirectoryIsRefused() { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + read( + registry( + "[\"app-bootstrap\",\"sample-portfolio\"]", + entry("app-bootstrap", ":app-bootstrap", "src/alpha"), + entry("sample-portfolio", ":sample-portfolio", "src/beta"), + entry("aliased", ":aliased", "src/beta/../alpha")))); + assertTrue(failure.getMessage().contains("duplicate or aliased"), failure.getMessage()); + } + + @Test + void architectureEdgesRemainData() { + ModuleRegistry parsed = + read( + registry( + "[\"app-bootstrap\",\"sample-portfolio\"]", + entry( + "app-bootstrap", + ":app-bootstrap", + "src/alpha", + "[\"sample-portfolio\",\"unknown\",\"app-bootstrap\"]"), + entry("sample-portfolio", ":sample-portfolio", "src/beta"))); + assertEquals( + List.of("sample-portfolio", "unknown", "app-bootstrap"), + parsed.byId("app-bootstrap").allowedDependencies()); + } + + @Test + void missingRequiredFieldIsRefused() { + String json = + """ + {"composition_roots":["app-bootstrap","sample-portfolio"],"modules":[ + {"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha"}, + %s]} + """ + .formatted(entry("sample-portfolio", ":sample-portfolio", "src/beta")); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> read(json)); + assertTrue(failure.getMessage().contains("allowed_dependencies"), failure.getMessage()); + } + + @Test + void buildOwnershipDefaultsToMainAndCanSelectOptionalModules() throws Exception { + Files.createDirectories(root.resolve("src/grpc/grpc-client")); + String json = + registry( + "[\"app-bootstrap\"]", + entry("app-bootstrap", ":app-bootstrap", "src/alpha"), + """ + {"id":"grpc-client","gradle_path":":grpc:grpc-client","source_path":"src/grpc/grpc-client", + "allowed_dependencies":[],"build":"optional-grpc"} + """); + ModuleRegistry parsed = read(json); + assertEquals("main", parsed.byId("app-bootstrap").buildName()); + assertEquals("optional-grpc", parsed.byId("grpc-client").buildName()); + assertEquals(List.of("app-bootstrap"), parsed.modulesForBuild("main").stream().map(ModuleRegistry.Module::id).toList()); + assertEquals(List.of("grpc-client"), parsed.modulesForBuild("optional-grpc").stream().map(ModuleRegistry.Module::id).toList()); + } + + @Test + void blankBuildOwnershipIsRefused() { + String json = + """ + {"composition_roots":["app-bootstrap"],"modules":[ + {"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha", + "allowed_dependencies":[],"build":" "}]} + """; + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> read(json)); + assertTrue(failure.getMessage().contains("nonblank string 'build'"), failure.getMessage()); + } + + private File write(String json) { + try { + Path file = root.resolve("modules.json"); + Files.writeString(file, json); + return file.toFile(); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } + + private ModuleRegistry read(String json) { + return ModuleRegistry.read(write(json), root.toFile()); + } + + private String valid() { + return registry( + "[\"app-bootstrap\",\"sample-portfolio\"]", + entry("app-bootstrap", ":app-bootstrap", "src/alpha"), + entry("sample-portfolio", ":sample-portfolio", "src/beta")); + } + + private static String entry(String id, String path, String source) { + return entry(id, path, source, "[]"); + } + + private static String entry(String id, String path, String source, String deps) { + return "{\"id\":\"" + + id + + "\",\"gradle_path\":\"" + + path + + "\",\"source_path\":\"" + + source + + "\",\"allowed_dependencies\":" + + deps + + "}"; + } + + private static String registry(String roots, String... entries) { + return "{\"composition_roots\":" + roots + ",\"modules\":[" + String.join(",", entries) + "]}"; + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/RequiredTestExecutionTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/RequiredTestExecutionTest.java new file mode 100644 index 00000000..399159d9 --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/RequiredTestExecutionTest.java @@ -0,0 +1,39 @@ +package dev.caskeleton.buildlogic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class RequiredTestExecutionTest { + @Test void exactMatchCounts() { + assertEquals(List.of(), RequiredTestExecution.absent(List.of("com.example.FooTest"), List.of("com.example.FooTest"))); + } + @Test void nestedClassCountsForOuterClass() { + assertEquals(List.of(), RequiredTestExecution.absent(List.of("com.example.FooTest"), List.of("com.example.FooTest$WhenEmpty"))); + } + @Test void parameterizedInvocationCountsForMethod() { + assertEquals(List.of(), RequiredTestExecution.absent(List.of("com.example.FooTest.rejects"), List.of("com.example.FooTest.rejects(String)[1]"))); + assertEquals(List.of(), RequiredTestExecution.absent(List.of("com.example.FooTest.rejects"), List.of("com.example.FooTest.rejects[2]"))); + } + @Test void differentPrefixNameIsNotMatch() { + assertEquals(List.of("com.example.FooTest"), RequiredTestExecution.absent(List.of("com.example.FooTest"), List.of("com.example.FooTestHelper"))); + } + @Test void reportsEveryAbsentSelector() { + assertEquals(List.of("com.example.A", "com.example.C"), RequiredTestExecution.absent(List.of("com.example.A", "com.example.B", "com.example.C"), List.of("com.example.B"))); + } + @Test void handlesEmptyInputs() { + assertEquals(List.of(), RequiredTestExecution.absent(List.of(), List.of("com.example.A"))); + assertEquals(List.of(), RequiredTestExecution.absent(null, List.of("com.example.A"))); + assertEquals(List.of("com.example.A"), RequiredTestExecution.absent(List.of("com.example.A"), List.of())); + assertEquals(List.of("com.example.A"), RequiredTestExecution.absent(List.of("com.example.A"), null)); + } + @Test void satisfiesIsSinglePredicate() { + assertTrue(RequiredTestExecution.satisfies("com.example.FooTest$Inner", "com.example.FooTest")); + assertTrue(RequiredTestExecution.satisfies("com.example.FooTest.bar(int)", "com.example.FooTest.bar")); + assertFalse(RequiredTestExecution.satisfies(null, "com.example.FooTest")); + assertFalse(RequiredTestExecution.satisfies("com.example.FooTest", null)); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/apisurface/ApiSurfaceConventionTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/apisurface/ApiSurfaceConventionTest.java new file mode 100644 index 00000000..9bae44c1 --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/apisurface/ApiSurfaceConventionTest.java @@ -0,0 +1,124 @@ +package dev.caskeleton.buildlogic.apisurface; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ApiSurfaceConventionTest { + @TempDir Path projectDir; + + @BeforeEach + void setUp() throws Exception { + 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"); + writeConfiguredBuild("src/main/java"); + } + + @Test + void documentedFlagApprovesUpdateAndVerifyIsReadOnly() throws Exception { + BuildResult updated = + runner("updateFixtureApiSurface", "-PapproveFixtureApiSurfaceChange").build(); + assertTrue(updated.getOutput().contains("wrote"), updated.getOutput()); + assertTrue(Files.readString(projectDir.resolve("surface.txt")).contains("app.Visible")); + + BuildResult verify = + runner("verifyFixtureApiSurface", "-PapproveFixtureApiSurfaceChange").buildAndFail(); + assertTrue(verify.getOutput().contains("read-only"), verify.getOutput()); + } + + @Test + void unapprovedAndMisnamedApprovalFlagsAreRefused() { + BuildResult noFlag = runner("updateFixtureApiSurface").buildAndFail(); + assertTrue(noFlag.getOutput().contains("requires -PapproveFixtureApiSurfaceChange")); + + BuildResult wrongFlag = + runner("updateFixtureApiSurface", "-PapprovenullApiSurfaceChange").buildAndFail(); + assertTrue(wrongFlag.getOutput().contains("requires -PapproveFixtureApiSurfaceChange")); + } + + @Test + void grownSurfaceFailsAndNamesAddedType() throws Exception { + runner("updateFixtureApiSurface", "-PapproveFixtureApiSurfaceChange").build(); + Files.writeString( + projectDir.resolve("src/main/java/app/Added.java"), + "package app;\npublic interface Added {}\n"); + BuildResult result = runner("verifyFixtureApiSurface").buildAndFail(); + assertTrue(result.getOutput().contains("app.Added"), result.getOutput()); + } + + @Test + void parserHandlesStrictfpAndIgnoresCommentedType() throws Exception { + Files.writeString( + projectDir.resolve("src/main/java/app/Strict.java"), + "package app;\npublic strictfp class Strict {}\n"); + Files.writeString( + projectDir.resolve("src/main/java/app/Commented.java"), + "package app;\n/*\npublic class Ghost {}\n*/\npublic final class Commented {}\n"); + runner("updateFixtureApiSurface", "-PapproveFixtureApiSurfaceChange").build(); + String surface = Files.readString(projectDir.resolve("surface.txt")); + assertTrue(surface.contains("app.Strict")); + assertTrue(surface.contains("app.Commented")); + assertFalse(surface.contains("app.Ghost")); + } + + @Test + void emptyOrUnparseableSurfaceFailsClosed() throws Exception { + writeConfiguredBuild("src/main/moved-away"); + BuildResult empty = runner("verifyFixtureApiSurface").buildAndFail(); + assertTrue(empty.getOutput().contains("found no public types"), empty.getOutput()); + + writeConfiguredBuild("src/main/java"); + Files.writeString( + projectDir.resolve("src/main/java/app/Broken.java"), + "package app;\npublic class Broken {\n"); + BuildResult broken = runner("verifyFixtureApiSurface").buildAndFail(); + assertTrue(broken.getOutput().contains("could not be parsed"), broken.getOutput()); + } + + @Test + void undeclaredSurfaceRegistersNoApiSurfaceTasks() throws Exception { + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.api-surface' + } + """); + BuildResult result = runner("tasks", "--group=verification").build(); + assertFalse(result.getOutput().contains("ApiSurface"), result.getOutput()); + } + + private void writeConfiguredBuild(String sourceRoot) throws Exception { + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.api-surface' + } + apiSurface { + label = 'Fixture' + sourceRoot = '%s' + baseline = file('surface.txt') + description = 'The fixture leaf public surface.' + } + """.formatted(sourceRoot)); + } + + private GradleRunner runner(String... args) { + return GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments(args); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/archive/ArchiveHygienePolicyTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/archive/ArchiveHygienePolicyTest.java new file mode 100644 index 00000000..36039a1b --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/archive/ArchiveHygienePolicyTest.java @@ -0,0 +1,46 @@ +package dev.caskeleton.buildlogic.archive; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ArchiveHygienePolicyTest { + + @Test + void findsOnlyOldTraceableArchivesForTheSameJar(@TempDir Path temp) throws Exception { + Files.writeString(temp.resolve("module-1.0.0+1234567.jar"), "stale"); + Files.writeString(temp.resolve("module-1.0.0+abcdef1.jar"), "current"); + Files.writeString(temp.resolve("module-local.jar"), "local"); + Files.writeString(temp.resolve("other-1.0.0+1234567.jar"), "other"); + + ArchiveTarget target = + new ArchiveTarget( + ":module:jar", temp.toFile(), "module", "", "module-1.0.0+abcdef1.jar"); + + assertThat(ArchiveHygienePolicy.staleArchives(target)) + .extracting(file -> file.getName()) + .containsExactly("module-1.0.0+1234567.jar"); + } + + @Test + void classifierIsPartOfTraceableArchiveIdentity(@TempDir Path temp) throws Exception { + Files.writeString(temp.resolve("module-1.0.0+1234567-sources.jar"), "stale"); + Files.writeString(temp.resolve("module-1.0.0+abcdef1-sources.jar"), "current"); + Files.writeString(temp.resolve("module-1.0.0+1234567.jar"), "main"); + + ArchiveTarget target = + new ArchiveTarget( + ":module:sourcesJar", + temp.toFile(), + "module", + "sources", + "module-1.0.0+abcdef1-sources.jar"); + + assertThat(ArchiveHygienePolicy.staleArchives(target)) + .extracting(file -> file.getName()) + .containsExactly("module-1.0.0+1234567-sources.jar"); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetConventionTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetConventionTest.java new file mode 100644 index 00000000..c26c192e --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/auxiliary/AuxiliarySourceSetConventionTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.buildlogic.auxiliary; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AuxiliarySourceSetConventionTest { + @TempDir Path projectDir; + + @BeforeEach + void setUp() throws Exception { + Files.writeString(projectDir.resolve("settings.gradle"), "rootProject.name='fixture'\n"); + } + + @Test + void declaredSourceSetGetsVisibleOutputsAndSelectedTestConfigurations() throws Exception { + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.auxiliary-source-set' + } + auxiliarySourceSets { + sourceSet('contractTest') { + compilesAgainst 'main' + inherits 'implementation', 'runtimeOnly' + } + } + tasks.register('report') { + doLast { + logger.lifecycle('source=' + (sourceSets.findByName('contractTest') != null)) + logger.lifecycle('extends=' + configurations.contractTestImplementation.extendsFrom*.name.sort()) + } + } + """); + + BuildResult result = runner("report", "--console=plain").build(); + assertTrue(result.getOutput().contains("source=true"), result.getOutput()); + assertTrue(result.getOutput().contains("testImplementation"), result.getOutput()); + } + + @Test + void runtimeCanMirrorAnotherSourceSetWithoutLeakingCompileOnly() throws Exception { + Files.writeString(projectDir.resolve("compile-only-marker.jar"), "marker"); + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.auxiliary-source-set' + } + dependencies { testCompileOnly files('compile-only-marker.jar') } + auxiliarySourceSets { + sourceSet('architectureTest') { + compilesAgainst 'main', 'test' + inherits 'implementation', 'compileOnly', 'runtimeOnly' + runtimeFrom 'test' + } + } + tasks.register('reportRuntime') { + doLast { + def names = sourceSets.architectureTest.runtimeClasspath.files*.name + logger.lifecycle('contains-marker=' + names.contains('compile-only-marker.jar')) + logger.lifecycle('contains-test-output=' + sourceSets.architectureTest.runtimeClasspath.files.containsAll(sourceSets.test.output.files)) + } + } + """); + + BuildResult result = runner("reportRuntime", "--console=plain").build(); + assertTrue(result.getOutput().contains("contains-marker=false"), result.getOutput()); + assertTrue(result.getOutput().contains("contains-test-output=true"), result.getOutput()); + } + + @Test + void unknownVisibleSourceSetFailsWithBothNames() throws Exception { + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.auxiliary-source-set' + } + auxiliarySourceSets { + sourceSet('performanceTest') { compilesAgainst 'main', 'missingKit' } + } + """); + + BuildResult result = runner("tasks").buildAndFail(); + assertTrue( + result.getOutput().contains("'performanceTest'") + && result.getOutput().contains("'missingKit'"), + result.getOutput()); + } + + private GradleRunner runner(String... args) { + return GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments(args); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/bootstrap/BootRunDotenvPluginFunctionalTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/bootstrap/BootRunDotenvPluginFunctionalTest.java new file mode 100644 index 00000000..d798248d --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/bootstrap/BootRunDotenvPluginFunctionalTest.java @@ -0,0 +1,60 @@ +package dev.caskeleton.buildlogic.bootstrap; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BootRunDotenvPluginFunctionalTest { + @Test + void bootRunReadsRootDotenvWithoutOverwritingTaskEnvironment(@TempDir Path projectDir) + throws Exception { + Files.writeString( + projectDir.resolve("settings.gradle"), "rootProject.name='fixture'\ninclude 'app'\n"); + Files.writeString( + projectDir.resolve(".env"), + "# comment\nFROM_DOTENV=from-file\nEXISTING=from-file\nMALFORMED\n"); + Path appDir = projectDir.resolve("app"); + Files.createDirectories(appDir.resolve("src/main/java")); + Files.writeString( + appDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.bootrun-dotenv' + } + tasks.register('bootRun', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'EnvMain' + environment 'EXISTING', 'preconfigured' + } + """); + Files.writeString( + appDir.resolve("src/main/java/EnvMain.java"), + """ + public final class EnvMain { + public static void main(String[] args) { + System.out.println("FROM=" + System.getenv("FROM_DOTENV")); + System.out.println("EXISTING=" + System.getenv("EXISTING")); + System.out.println("WORKDIR=" + System.getProperty("user.dir")); + } + } + """); + + BuildResult result = + GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments(":app:bootRun", "--console=plain") + .build(); + + assertTrue(result.getOutput().contains("FROM=from-file"), result.getOutput()); + assertTrue(result.getOutput().contains("EXISTING=preconfigured"), result.getOutput()); + assertTrue( + result.getOutput().contains("WORKDIR=" + projectDir.toAbsolutePath()), result.getOutput()); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/bootstrap/DeveloperBootstrapPluginFunctionalTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/bootstrap/DeveloperBootstrapPluginFunctionalTest.java new file mode 100644 index 00000000..aa598754 --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/bootstrap/DeveloperBootstrapPluginFunctionalTest.java @@ -0,0 +1,74 @@ +package dev.caskeleton.buildlogic.bootstrap; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class DeveloperBootstrapPluginFunctionalTest { + + @Test + void registersTheFourStageBootstrapLifecycle(@TempDir Path temporaryDirectory) throws IOException { + Path projectDirectory = fixture(temporaryDirectory); + + BuildResult result = run(projectDirectory, "tasks", "--group=developer experience"); + + assertThat(result.getOutput()) + .contains("bootstrapCompile") + .contains("bootstrapDockerPreflight") + .contains("bootstrapDependencies") + .contains("bootstrapMigrateAndStart") + .contains("bootstrapSmoke") + .contains("bootstrap - Runs the complete four-stage local bootstrap contract."); + } + + @Test + void bootstrapTaskKeepsTheRequiredStageOrder(@TempDir Path temporaryDirectory) throws IOException { + Path projectDirectory = fixture(temporaryDirectory); + + BuildResult result = run(projectDirectory, "bootstrap", "--dry-run"); + String output = result.getOutput(); + + assertThat(output.indexOf(":bootstrapCompile SKIPPED")).isLessThan(output.indexOf(":bootstrapDockerPreflight SKIPPED")); + assertThat(output.indexOf(":bootstrapDockerPreflight SKIPPED")).isLessThan(output.indexOf(":bootstrapDependencies SKIPPED")); + assertThat(output.indexOf(":bootstrapDependencies SKIPPED")).isLessThan(output.indexOf(":bootstrapMigrateAndStart SKIPPED")); + assertThat(output.indexOf(":bootstrapMigrateAndStart SKIPPED")).isLessThan(output.indexOf(":bootstrapSmoke SKIPPED")); + assertThat(output.indexOf(":bootstrapSmoke SKIPPED")).isLessThan(output.indexOf(":bootstrap SKIPPED")); + } + + private static Path fixture(Path temporaryDirectory) throws IOException { + Path repositoryRoot = temporaryDirectory.resolve("repository"); + Path projectDirectory = repositoryRoot.resolve("src"); + Files.createDirectories(projectDirectory.resolve("app-bootstrap")); + Files.writeString( + projectDirectory.resolve("settings.gradle"), + "rootProject.name='fixture'\ninclude 'app-bootstrap'\n", + UTF_8); + Files.writeString( + projectDirectory.resolve("build.gradle"), + "plugins { id 'ca.developer-bootstrap' }\n", + UTF_8); + Files.writeString( + projectDirectory.resolve("app-bootstrap/build.gradle"), + "plugins { id 'java' }\n", + UTF_8); + return projectDirectory; + } + + private static BuildResult run(Path projectDirectory, String... arguments) { + String[] fullArguments = new String[arguments.length + 1]; + fullArguments[0] = "--console=plain"; + System.arraycopy(arguments, 0, fullArguments, 1, arguments.length); + return GradleRunner.create() + .withProjectDir(projectDirectory.toFile()) + .withPluginClasspath() + .withArguments(fullArguments) + .build(); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/convention/PlatformModuleConventionTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/convention/PlatformModuleConventionTest.java new file mode 100644 index 00000000..ad134057 --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/convention/PlatformModuleConventionTest.java @@ -0,0 +1,105 @@ +package dev.caskeleton.buildlogic.convention; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PlatformModuleConventionTest { + @TempDir Path projectDir; + + @BeforeEach + void setUp() throws Exception { + Files.createDirectories(projectDir.resolve("gradle")); + writeCatalog(true); + Files.writeString( + projectDir.resolve("settings.gradle"), + """ + dependencyResolutionManagement { repositories { mavenCentral() } } + rootProject.name = 'fixture' + """); + } + + @Test + void platformModuleProvidesJavaLibrary() throws Exception { + buildFile( + """ + plugins { id 'ca.platform-module' } + tasks.register('reportApiConfiguration') { + boolean present = configurations.findByName('api') != null + doLast { logger.lifecycle('api-configuration-present=' + present) } + } + """); + BuildResult result = runner("reportApiConfiguration").build(); + assertTrue(result.getOutput().contains("api-configuration-present=true"), result.getOutput()); + } + + @Test + void grpcConventionImportsBom() throws Exception { + buildFile( + """ + plugins { id 'ca.grpc-platform-module' } + tasks.register('reportManagedVersion') { + String managed = dependencyManagement.managedVersions['io.grpc:grpc-api'] + doLast { logger.lifecycle('managed-grpc-api=' + managed) } + } + """); + BuildResult result = runner("reportManagedVersion").build(); + assertTrue(result.getOutput().contains("managed-grpc-api=1.68.1"), result.getOutput()); + } + + @Test + void grpcConventionRefusesMissingVersion() throws Exception { + writeCatalog(false); + buildFile("plugins { id 'ca.grpc-platform-module' }\n"); + BuildResult result = runner("tasks").buildAndFail(); + assertTrue( + result.getOutput().contains("Version catalog 'libs' must define version 'grpc'"), + result.getOutput()); + } + + @Test + void grpcConventionBringsDependencyManagement() throws Exception { + buildFile( + """ + plugins { id 'ca.grpc-platform-module' } + tasks.register('reportDependencyManagement') { + boolean present = project.extensions.findByName('dependencyManagement') != null + doLast { logger.lifecycle('dependency-management-present=' + present) } + } + """); + BuildResult result = runner("reportDependencyManagement").build(); + assertTrue(result.getOutput().contains("dependency-management-present=true"), result.getOutput()); + } + + private void writeCatalog(boolean grpc) throws Exception { + Files.writeString( + projectDir.resolve("gradle/libs.versions.toml"), + """ + [versions] + springBoot = "4.0.8" + googleJavaFormat = "1.35.0" + checkstyle = "13.5.0" + spotbugs = "4.10.2" + findsecbugs = "1.14.0" + errorprone = "2.49.0" + %s + """.formatted(grpc ? "grpc = \"1.68.1\"" : "")); + } + + private void buildFile(String body) throws Exception { + Files.writeString(projectDir.resolve("build.gradle"), body); + } + + private GradleRunner runner(String... args) { + return GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments(args); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyPluginTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyPluginTest.java new file mode 100644 index 00000000..b8deeb16 --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/dependency/DependencyPolicyPluginTest.java @@ -0,0 +1,95 @@ +package dev.caskeleton.buildlogic.dependency; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarOutputStream; +import org.gradle.api.DefaultTask; +import org.gradle.api.Plugin; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DependencyPolicyPluginTest { + @Test + void pluginUsesTypedJavaModel() { + assertTrue(Plugin.class.isAssignableFrom(DependencyPolicyPlugin.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyDependencyPolicyTask.class)); + DependencyAbsence absence = new DependencyAbsence("g:m", "why", "runtimeClasspath"); + assertTrue(absence.coordinate().equals("g:m")); + } + + + @Test + void missingRequiredModuleFailsResolvedGraph(@TempDir Path projectDir) throws Exception { + fixture(projectDir, + """ + plugins { id 'java'; id 'ca.dependency-policy' } + dependencyPolicy { required 'g:m', 'runtime contract needs it' } + """); + + BuildResult result = runner(projectDir, "verifyDependencyPolicy").buildAndFail(); + + assertTrue(result.getOutput().contains("required g:m is missing"), result.getOutput()); + } + + @Test + void forbiddenModulePatternFailsResolvedGraph(@TempDir Path projectDir) throws Exception { + localModule(projectDir, "g", "yaml-tool", "1.0"); + fixture(projectDir, + """ + plugins { id 'java'; id 'ca.dependency-policy' } + repositories { maven { url = uri('repo') } } + dependencies { implementation 'g:yaml-tool:1.0' } + dependencyPolicy { absentMatching '(?i).*yaml.*', 'runtime must remain YAML-free' } + """); + + BuildResult result = runner(projectDir, "verifyDependencyPolicy").buildAndFail(); + + assertTrue(result.getOutput().contains("g:yaml-tool matches forbidden pattern"), result.getOutput()); + } + + @Test + void missingReasonFailsAtDeclarationTime(@TempDir Path projectDir) throws Exception { + fixture(projectDir, + """ + plugins { id 'java'; id 'ca.dependency-policy' } + dependencyPolicy { absent 'g:m', ' ' } + """); + BuildResult result = runner(projectDir, "tasks").buildAndFail(); + assertTrue(result.getOutput().contains("needs a reason"), result.getOutput()); + } + + + private static void localModule(Path projectDir, String group, String module, String version) + throws Exception { + Path moduleDirectory = + projectDir.resolve("repo").resolve(group.replace('.', '/')).resolve(module).resolve(version); + Files.createDirectories(moduleDirectory); + Files.writeString( + moduleDirectory.resolve(module + "-" + version + ".pom"), + """ + + 4.0.0 + %s + %s + %s + + """.formatted(group, module, version)); + try (JarOutputStream ignored = + new JarOutputStream(Files.newOutputStream(moduleDirectory.resolve(module + "-" + version + ".jar")))) { + // Empty artifact: the policy test only needs a resolvable module coordinate. + } + } + + private static void fixture(Path dir, String build) throws Exception { + Files.writeString(dir.resolve("settings.gradle"), "rootProject.name='fixture'\n"); + Files.writeString(dir.resolve("build.gradle"), build); + } + + private static GradleRunner runner(Path dir, String... args) { + return GradleRunner.create().withProjectDir(dir.toFile()).withPluginClasspath().withArguments(args); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/jmh/JmhBenchmarksPluginFunctionalTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/jmh/JmhBenchmarksPluginFunctionalTest.java new file mode 100644 index 00000000..b0cc782d --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/jmh/JmhBenchmarksPluginFunctionalTest.java @@ -0,0 +1,79 @@ +package dev.caskeleton.buildlogic.jmh; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class JmhBenchmarksPluginFunctionalTest { + + @Test + void customVisibleOutputsAndJsonReportAreOwnedByThePlugin(@TempDir Path temporaryDirectory) + throws IOException { + Path projectDirectory = temporaryDirectory.resolve("fixture"); + Files.createDirectories(projectDirectory.resolve("gradle")); + Files.copy(repositorySourceRoot().resolve("gradle/libs.versions.toml"), + projectDirectory.resolve("gradle/libs.versions.toml")); + Files.writeString(projectDirectory.resolve("settings.gradle"), "rootProject.name='fixture'\n", UTF_8); + Files.writeString( + projectDirectory.resolve("build.gradle"), + """ + plugins { + id 'java-test-fixtures' + id 'ca.jmh-benchmarks' + } + + jmhBenchmarks { + compilesAgainst 'main', 'testFixtures' + jsonReport 'reports/jmh/result.json' + } + + tasks.register('verifyJmhModel') { + doLast { + def buildDependencies = sourceSets.jmh.compileClasspath.buildDependencies + .getDependencies(null)*.name as Set + assert buildDependencies.contains('testFixturesClasses') + assert !buildDependencies.contains('testClasses') + + def jmhTask = tasks.named('jmh', JavaExec).get() + assert jmhTask.args == [ + '-rf', 'json', '-rff', + layout.buildDirectory.file('reports/jmh/result.json').get().asFile.absolutePath + ] + } + } + """, + UTF_8); + + BuildResult result = runner(projectDirectory, "verifyJmhModel").build(); + + assertThat(result.getOutput()).contains("BUILD SUCCESSFUL"); + } + + private static GradleRunner runner(Path projectDirectory, String... arguments) { + String[] fullArguments = new String[arguments.length + 1]; + fullArguments[0] = "--console=plain"; + System.arraycopy(arguments, 0, fullArguments, 1, arguments.length); + return GradleRunner.create() + .withProjectDir(projectDirectory.toFile()) + .withPluginClasspath() + .withArguments(fullArguments); + } + + private static Path repositorySourceRoot() { + Path candidate = Path.of("").toAbsolutePath(); + while (candidate != null && !Files.isRegularFile(candidate.resolve("gradle/libs.versions.toml"))) { + candidate = candidate.getParent(); + } + if (candidate == null) { + throw new IllegalStateException("could not locate repository source root"); + } + return candidate; + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/jpa/JpaTestLanesPluginFunctionalTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/jpa/JpaTestLanesPluginFunctionalTest.java new file mode 100644 index 00000000..83d09330 --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/jpa/JpaTestLanesPluginFunctionalTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.buildlogic.jpa; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JpaTestLanesPluginFunctionalTest { + @Test + void pluginOwnsTypedJpaLaneModelAndRuntimeProperties(@TempDir Path projectDir) throws Exception { + Files.writeString(projectDir.resolve("settings.gradle"), "rootProject.name='fixture'\n"); + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.jpa-test-lanes' + } + + sourceSets { + postgresqlIntegrationTest + jpaPlatformPerformanceTest + } + + tasks.register('verifyJpaLaneModel') { + doLast { + assert strictTestLanes.lanes.size() == 20 + + def security = strictTestLanes.lanes.getByName('postgresqlSecurityBaselineIntegrationTest') + assert security.requiredTests == [ + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest.runtimeRoleCannotCreateInApplicationSchemaOrTempAndUsesTrustedSearchPath' + ] + + def platform = strictTestLanes.lanes.getByName('jpaPlatformContractTest') + assert platform.tag == 'jpa-contract' + assert platform.sourceSet == 'postgresqlIntegrationTest' + + def readiness = strictTestLanes.lanes.getByName('postgresqlLifecycleIntegrationTest') + assert readiness.jvmArgs.contains('-Duser.timezone=UTC') + assert readiness.jvmArgs.contains('-Djpa.evidence.postgresql.image=postgres:17-alpine') + + def readinessTask = tasks.named('postgresqlLifecycleIntegrationTest', Test).get() + assert readinessTask.allJvmArgs.contains('-Duser.timezone=UTC') + assert readinessTask.allJvmArgs.contains('-Djpa.evidence.postgresql.image=postgres:17-alpine') + + assert platform.systemProperties['jpa.matrix.versions'] == '15,16' + def platformTask = tasks.named('jpaPlatformContractTest', Test).get() + assert platformTask.systemProperties['jpa.matrix.versions'] == '15,16' + + def pool = strictTestLanes.lanes.getByName('jpaPlatformPoolContractTest') + assert pool.sourceSet == 'jpaPlatformPerformanceTest' + assert pool.category.toString() == 'PERFORMANCE' + assert pool.jvmArgs.contains('-Duser.timezone=UTC') + } + } + """); + + BuildResult result = + GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments( + "verifyJpaLaneModel", + "-PjpaPostgreSqlEvidenceImage=postgres:17-alpine", + "-Pjpa.matrix.versions=15,16", + "--console=plain") + .build(); + + assertTrue(result.getOutput().contains("BUILD SUCCESSFUL"), result.getOutput()); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshotRendererTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshotRendererTest.java new file mode 100644 index 00000000..31155879 --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/publicpath/PublicPathSnapshotRendererTest.java @@ -0,0 +1,37 @@ +package dev.caskeleton.buildlogic.publicpath; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PublicPathSnapshotRendererTest { + + @Test + void resolvesNestedSpringDefaultsAndSortsPaths(@TempDir Path temp) throws Exception { + Path security = temp.resolve("security.yml"); + Files.writeString( + security, + "ca-skeleton:\n security:\n public-paths: ${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck,/actuator/info}\n"); + + PublicPathSnapshot rendered = PublicPathSnapshotRenderer.render(security.toFile()); + + assertThat(rendered.publicPaths()).containsExactly("/actuator/info", "/v1/healthcheck"); + assertThat(rendered.canonicalText()) + .contains("# feature-security-operational-baseline D5") + .endsWith("/actuator/info\n/v1/healthcheck\n"); + } + + @Test + void unresolvedPlaceholderFailsClosed(@TempDir Path temp) throws Exception { + Path security = temp.resolve("security.yml"); + Files.writeString(security, "public-paths: ${SECURITY_PUBLIC_PATHS}\n"); + + assertThatThrownBy(() -> PublicPathSnapshotRenderer.render(security.toFile())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("still holds a placeholder with no default"); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/redis/RedisTopologyContractTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/redis/RedisTopologyContractTest.java new file mode 100644 index 00000000..0e095953 --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/redis/RedisTopologyContractTest.java @@ -0,0 +1,69 @@ +package dev.caskeleton.buildlogic.redis; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RedisTopologyContractTest { + @Test + void tlsLaneUsesStandaloneDeploymentMode() { + assertEquals("standalone", RedisTopologyContract.deploymentMode("tls")); + assertTrue(RedisTopologyContract.tlsEnabled("tls")); + } + + @Test + void unknownModeFailsClosed() { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> RedisTopologyContract.validateInvocation("typo", Set.of())); + + assertTrue(failure.getMessage().contains("supported modes are cluster, sentinel, standalone, tls")); + } + + @Test + void sentinelRequiresMasterAlongsideEndpoint() { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + RedisTopologyContract.validateInvocation( + "sentinel", Set.of("redis.topology.host", "redis.topology.port"))); + + assertTrue(failure.getMessage().contains("redis.topology.master")); + } + + @Test + void tlsRequiresTrustMaterial() { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + RedisTopologyContract.validateInvocation( + "tls", Set.of("redis.topology.host", "redis.topology.port"))); + + assertTrue(failure.getMessage().contains("redis.topology.trust-material")); + } + + @Test + void executionRequiresEveryDeclaredClassAndNoSkips() { + IllegalStateException missingClass = + assertThrows( + IllegalStateException.class, + () -> + RedisTopologyContract.verifyExecution( + "tls", Set.of("SomeOtherClass"), 0)); + assertTrue(missingClass.getMessage().contains("LiveRedisTlsTest")); + + IllegalStateException skipped = + assertThrows( + IllegalStateException.class, + () -> + RedisTopologyContract.verifyExecution( + "tls", Set.of("LiveRedisTlsTest"), 1)); + assertTrue(skipped.getMessage().contains("skipped 1 test(s)")); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/redis/RedisTopologyLanePluginFunctionalTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/redis/RedisTopologyLanePluginFunctionalTest.java new file mode 100644 index 00000000..7cac08ed --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/redis/RedisTopologyLanePluginFunctionalTest.java @@ -0,0 +1,64 @@ +package dev.caskeleton.buildlogic.redis; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class RedisTopologyLanePluginFunctionalTest { + @TempDir Path projectDir; + + @BeforeEach + void setUp() throws Exception { + Files.writeString(projectDir.resolve("settings.gradle"), "rootProject.name = 'fixture'\n"); + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.redis-topology-lane' + } + repositories { mavenCentral() } + """); + } + + @Test + void pluginDeclaresRedisTopologyVerificationLane() { + BuildResult result = runner("tasks", "--group=verification", "--console=plain").build(); + + assertTrue(result.getOutput().contains("redisTopologyTest"), result.getOutput()); + assertTrue( + result.getOutput().contains("Runs the Redis SDK contracts against a real topology"), + result.getOutput()); + } + + @Test + void unknownTopologyModeFailsClosedBeforeTestExecution() throws Exception { + Path testSource = projectDir.resolve("src/test/java"); + Files.createDirectories(testSource); + Files.writeString(testSource.resolve("FixtureTest.java"), "class FixtureTest {}\n"); + + BuildResult result = + runner( + "redisTopologyTest", + "-Predis.topology.mode=typo", + "--console=plain") + .buildAndFail(); + + assertTrue( + result.getOutput().contains("the supported modes are cluster, sentinel, standalone, tls"), + result.getOutput()); + } + + private GradleRunner runner(String... arguments) { + return GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments(arguments); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/release/ReleaseProvenanceConventionTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/release/ReleaseProvenanceConventionTest.java new file mode 100644 index 00000000..050f1b42 --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/release/ReleaseProvenanceConventionTest.java @@ -0,0 +1,71 @@ +package dev.caskeleton.buildlogic.release; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ReleaseProvenanceConventionTest { + @TempDir Path projectDir; + + @BeforeEach + void setUp() throws Exception { + Files.writeString(projectDir.resolve("settings.gradle"), "rootProject.name='fixture'\n"); + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { id 'ca.release-provenance' } + tasks.register('reportRelease') { + doLast { + logger.lifecycle("release-version=${releaseProvenance.releaseVersion}") + logger.lifecycle("source-revision=${releaseProvenance.sourceRevision}") + logger.lifecycle("traceable-version=${releaseProvenance.traceableVersion}") + logger.lifecycle("release-complete=${releaseProvenance.complete}") + logger.lifecycle("project-version=${project.version}") + } + } + """); + } + + @Test + void missingRevisionConfiguresAsSnapshotButReleaseGateFails() { + BuildResult configured = runner("reportRelease", "--console=plain").build(); + assertTrue(configured.getOutput().contains("release-version=0.0.1")); + assertTrue(configured.getOutput().contains("source-revision=unknown")); + assertTrue(configured.getOutput().contains("traceable-version=0.0.1-SNAPSHOT")); + assertTrue(configured.getOutput().contains("release-complete=false")); + assertTrue(configured.getOutput().contains("project-version=0.0.1-SNAPSHOT")); + + BuildResult failed = runner("verifyReleaseProvenance", "--console=plain").buildAndFail(); + assertTrue(failed.getOutput().contains("verifyReleaseProvenance: no source revision")); + } + + @Test + void attestedRevisionProducesTraceableVersionAndPassesReleaseGate() { + BuildResult result = + runner( + "reportRelease", + "verifyReleaseProvenance", + "-PreleaseVersion=1.2.3", + "-PgitRevision=0123456789abcdef0123456789abcdef01234567", + "--console=plain") + .build(); + assertTrue(result.getOutput().contains("source-revision=0123456789ab")); + assertTrue(result.getOutput().contains("traceable-version=1.2.3+0123456789ab")); + assertTrue(result.getOutput().contains("release-complete=true")); + assertTrue(result.getOutput().contains("project-version=1.2.3+0123456789ab")); + assertTrue(result.getOutput().contains("verifyReleaseProvenance: OK")); + } + + private GradleRunner runner(String... args) { + return GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments(args); + } +} diff --git a/src/build-logic/src/test/java/dev/caskeleton/buildlogic/strictlane/StrictTestLaneConventionTest.java b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/strictlane/StrictTestLaneConventionTest.java new file mode 100644 index 00000000..38abdfdf --- /dev/null +++ b/src/build-logic/src/test/java/dev/caskeleton/buildlogic/strictlane/StrictTestLaneConventionTest.java @@ -0,0 +1,424 @@ +package dev.caskeleton.buildlogic.strictlane; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class StrictTestLaneConventionTest { + @TempDir Path projectDir; + + @BeforeEach + void setUp() throws Exception { + Files.writeString(projectDir.resolve("settings.gradle"), "rootProject.name = 'fixture'\n"); + } + + @Test + void declaredLaneBecomesVerificationTaskWithDescription() throws Exception { + buildFile( + """ + strictTestLanes { + lane('contractLane') { + tag = 'contract' + description = 'What this lane proves.' + } + } + """); + BuildResult result = runner("tasks", "--group=verification").build(); + assertTrue(result.getOutput().contains("contractLane"), result.getOutput()); + assertTrue(result.getOutput().contains("What this lane proves."), result.getOutput()); + } + + @Test + void laneWithNoSelectionFailsAtConfigurationTime() throws Exception { + buildFile( + """ + strictTestLanes { + lane('untagged') { description = 'Selects nothing.' } + } + """); + BuildResult result = runner("tasks").buildAndFail(); + assertTrue( + result.getOutput().contains("lane 'untagged'") + && result.getOutput().contains("selects nothing"), + result.getOutput()); + } + + @Test + void laneWithNoDescriptionFails() throws Exception { + buildFile( + """ + strictTestLanes { + lane('undescribed') { tag = 'contract' } + } + """); + BuildResult result = runner("tasks").buildAndFail(); + assertTrue(result.getOutput().contains("declares no description"), result.getOutput()); + } + + @Test + void emptySourceSetFailsInsteadOfNoSourceSuccess() throws Exception { + buildFile( + """ + strictTestLanes { + lane('emptyLane') { + tag = 'nothing-carries-this-tag' + description = 'Selects a tag no test declares.' + } + } + """); + BuildResult result = runner("emptyLane").buildAndFail(); + assertTrue(result.getOutput().contains("has no sources"), result.getOutput()); + } + + @Test + void tagMatchingNothingFailsWhenTestsExist() throws Exception { + buildFile( + junitDependencies() + + """ + strictTestLanes { + lane('mismatchedLane') { + tag = 'no-test-carries-this' + description = 'A tag nothing declares.' + } + } + """); + writeTest( + "PresentTest.java", + """ + import org.junit.jupiter.api.Tag; + import org.junit.jupiter.api.Test; + class PresentTest { + @Test @Tag("carried") void present() {} + } + """); + BuildResult result = runner("mismatchedLane").buildAndFail(); + assertTrue(result.getOutput().toLowerCase().contains("no test"), result.getOutput()); + } + + @Test + void laneOfOnlySkippedTestsFails() throws Exception { + buildFile( + junitDependencies() + + """ + strictTestLanes { + lane('skippedLane') { + tag = 'carried' + description = 'Every test it selects is skipped.' + } + } + """); + writeTest( + "SkippedTest.java", + """ + import org.junit.jupiter.api.Assumptions; + import org.junit.jupiter.api.Disabled; + import org.junit.jupiter.api.Tag; + import org.junit.jupiter.api.Test; + class SkippedTest { + @Test @Tag("carried") @Disabled("quarantined") void disabled() {} + @Test @Tag("carried") void assumedAway() { Assumptions.assumeTrue(false, "no docker here"); } + } + """); + BuildResult result = runner("skippedLane").buildAndFail(); + assertTrue( + result.getOutput().contains("executed no test") + && result.getOutput().contains("skipped"), + result.getOutput()); + } + + @Test + void oneExecutionAllowsOtherSkippedTests() throws Exception { + buildFile( + junitDependencies() + + """ + strictTestLanes { + lane('mixedLane') { + tag = 'carried' + description = 'One test runs, one is skipped.' + } + } + """); + writeTest( + "MixedTest.java", + """ + import org.junit.jupiter.api.Disabled; + import org.junit.jupiter.api.Tag; + import org.junit.jupiter.api.Test; + class MixedTest { + @Test @Tag("carried") @Disabled("quarantined") void disabled() {} + @Test @Tag("carried") void ran() {} + } + """); + BuildResult result = runner("mixedLane").build(); + assertTrue(result.getOutput().contains("BUILD SUCCESSFUL"), result.getOutput()); + } + + @Test + void exactTestSelectionRunsOnlyNamedTest() throws Exception { + buildFile( + junitDependencies() + + """ + strictTestLanes { + lane('namedLane') { + description = 'Runs exactly one named contract.' + requires 'SelectedTest.selected' + } + } + """); + writeTest( + "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"); } + } + """); + BuildResult result = runner("namedLane").build(); + assertTrue(result.getOutput().contains("BUILD SUCCESSFUL"), result.getOutput()); + } + + @Test + void missingRequiredTestFailsLane() throws Exception { + buildFile( + junitDependencies() + + """ + strictTestLanes { + lane('renamedLane') { + description = 'Names a test that was renamed away.' + requires 'SelectedTest.selected', 'SelectedTest.renamedAway' + } + } + """); + writeTest( + "SelectedTest.java", + """ + import org.junit.jupiter.api.Test; + class SelectedTest { @Test void selected() {} } + """); + BuildResult result = runner("renamedLane").buildAndFail(); + assertTrue( + result.getOutput().contains("renamedAway") + || result.getOutput().toLowerCase().contains("no tests found"), + result.getOutput()); + } + + @Test + void tagAndRequiredTestsCannotBothBeDeclared() throws Exception { + buildFile( + """ + strictTestLanes { + lane('doubleSelection') { + tag = 'contract' + description = 'Two selections at once.' + requires 'SomeTest.some' + } + } + """); + BuildResult result = runner("tasks").buildAndFail(); + assertTrue(result.getOutput().contains("pick one selection"), result.getOutput()); + } + + @Test + void duplicateLaneTaskNameFails() throws Exception { + buildFile( + """ + tasks.register('contractLane') { } + strictTestLanes { + lane('contractLane') { + tag = 'contract' + description = 'Collides with an existing task.' + } + } + """); + BuildResult result = runner("tasks").buildAndFail(); + assertTrue(result.getOutput().contains("contractLane"), result.getOutput()); + } + + @Test + void laneSupportsTypedRuntimeAndOrderingOptions() throws Exception { + Files.createDirectories(projectDir.resolve("contracts")); + Files.writeString(projectDir.resolve("contracts/contract.txt"), "contract"); + buildFile( + """ + sourceSets { architecture } + tasks.register('fixtureProducer') + strictTestLanes { + lane('architectureLane') { + sourceSet = 'architecture' + description = 'Architecture verification.' + group = 'architecture verification' + maxHeapSize = '512m' + jvmArgs '-Duser.timezone=UTC' + shouldRunAfter 'test' + dependsOn 'fixtureProducer' + systemProperty 'lane.mode', 'strict' + inputDirectory file('contracts') + } + } + tasks.register('reportLane') { + doLast { + def lane = tasks.named('architectureLane', Test).get() + logger.lifecycle('group=' + lane.group) + logger.lifecycle('heap=' + lane.maxHeapSize) + logger.lifecycle('utc=' + lane.allJvmArgs.contains('-Duser.timezone=UTC')) + logger.lifecycle('after-test=' + lane.shouldRunAfter.getDependencies(lane)*.name.contains('test')) + logger.lifecycle('depends-producer=' + lane.taskDependencies.getDependencies(lane)*.name.contains('fixtureProducer')) + logger.lifecycle('mode=' + lane.systemProperties['lane.mode']) + logger.lifecycle('input=' + lane.inputs.files.files.any { it.name == 'contract.txt' }) + } + } + """); + + BuildResult result = runner("reportLane", "--console=plain").build(); + assertTrue(result.getOutput().contains("group=architecture verification"), result.getOutput()); + assertTrue(result.getOutput().contains("heap=512m"), result.getOutput()); + assertTrue(result.getOutput().contains("utc=true"), result.getOutput()); + assertTrue(result.getOutput().contains("after-test=true"), result.getOutput()); + assertTrue(result.getOutput().contains("depends-producer=true"), result.getOutput()); + assertTrue(result.getOutput().contains("mode=strict"), result.getOutput()); + assertTrue(result.getOutput().contains("input=true"), result.getOutput()); + } + + @Test + void rejectSkippedFailsWhenAnySelectedTestIsSkipped() throws Exception { + buildFile( + junitDependencies() + + """ + strictTestLanes { + lane('strictNoSkip') { + tag = 'carried' + description = 'Every selected test must execute.' + rejectSkipped = true + } + } + """); + writeTest( + "StrictNoSkipTest.java", + """ + import org.junit.jupiter.api.Disabled; + import org.junit.jupiter.api.Tag; + import org.junit.jupiter.api.Test; + class StrictNoSkipTest { + @Test @Tag("carried") void ran() {} + @Test @Tag("carried") @Disabled("must fail lane") void skipped() {} + } + """); + + BuildResult result = runner("strictNoSkip").buildAndFail(); + assertTrue(result.getOutput().contains("forbids skipped tests"), result.getOutput()); + } + + @Test + void categoryAggregatesFollowRepositoryTestTaxonomy() throws Exception { + buildFile( + """ + sourceSets { ordinary; integration; system; architecture; performance } + strictTestLanes { + lane('ordinaryLane') { + sourceSet = 'ordinary' + description = 'Hermetic test.' + } + lane('integrationLane') { + sourceSet = 'integration' + description = 'External-infrastructure integration.' + integration() + } + lane('systemLane') { + sourceSet = 'system' + description = 'Full application/system test.' + system() + } + lane('architectureLane') { + sourceSet = 'architecture' + description = 'Architecture.' + architecture() + } + lane('performanceLane') { + sourceSet = 'performance' + description = 'Performance.' + performance() + } + } + tasks.register('reportCategories') { + doLast { + ['strictTestLaneCheck', 'testLaneCheck', 'integrationTestLaneCheck', 'systemTestLaneCheck', + 'architectureTestLaneCheck', 'performanceTestLaneCheck'].each { name -> + def aggregate = tasks.named(name).get() + logger.lifecycle(name + '=' + aggregate.taskDependencies.getDependencies(aggregate)*.name.sort()) + } + } + } + """); + + BuildResult result = runner("reportCategories", "--console=plain").build(); + assertTrue( + result.getOutput().contains( + "strictTestLaneCheck=[architectureLane, integrationLane, ordinaryLane, performanceLane, systemLane]"), + result.getOutput()); + assertTrue(result.getOutput().contains("testLaneCheck=[ordinaryLane]"), result.getOutput()); + assertTrue(result.getOutput().contains("integrationTestLaneCheck=[integrationLane]"), result.getOutput()); + assertTrue(result.getOutput().contains("systemTestLaneCheck=[systemLane]"), result.getOutput()); + assertTrue(result.getOutput().contains("architectureTestLaneCheck=[architectureLane]"), result.getOutput()); + assertTrue(result.getOutput().contains("performanceTestLaneCheck=[performanceLane]"), result.getOutput()); + } + + @Test + void dedicatedSourceSetNeedsNoTag() throws Exception { + buildFile( + """ + sourceSets { performance } + strictTestLanes { + lane('performanceLane') { + sourceSet = 'performance' + description = 'Its own source set is the selection.' + } + } + """); + BuildResult result = runner("tasks", "--group=verification").build(); + assertTrue(result.getOutput().contains("performanceLane"), result.getOutput()); + } + + private void buildFile(String laneBlock) throws Exception { + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.strict-test-lane' + } + repositories { mavenCentral() } + %s + """ + .formatted(laneBlock)); + } + + private void writeTest(String fileName, String source) throws Exception { + Path testSource = projectDir.resolve("src/test/java"); + Files.createDirectories(testSource); + Files.writeString(testSource.resolve(fileName), source); + } + + private static String junitDependencies() { + return """ + dependencies { + testImplementation platform('org.junit:junit-bom:5.11.3') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + } + """; + } + + private GradleRunner runner(String... args) { + return GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments(args); + } +} diff --git a/src/build-qualification/build.gradle b/src/build-qualification/build.gradle deleted file mode 100644 index b89ad1f6..00000000 --- a/src/build-qualification/build.gradle +++ /dev/null @@ -1,6 +0,0 @@ -plugins { - id 'groovy-gradle-plugin' -} - -// Release/certification logic lives here, physically separate from compilation conventions. -// These plugins only register qualification/evidence tasks on the application build that applies them. diff --git a/src/build-qualification/settings.gradle b/src/build-qualification/settings.gradle deleted file mode 100644 index f4c9c86e..00000000 --- a/src/build-qualification/settings.gradle +++ /dev/null @@ -1,8 +0,0 @@ -dependencyResolutionManagement { - repositories { - mavenCentral() - gradlePluginPortal() - } -} - -rootProject.name = 'build-qualification' diff --git a/src/build-qualification/src/main/groovy/ca.jpa-evidence.gradle b/src/build-qualification/src/main/groovy/ca.jpa-evidence.gradle deleted file mode 100644 index acf44d0c..00000000 --- a/src/build-qualification/src/main/groovy/ca.jpa-evidence.gradle +++ /dev/null @@ -1,665 +0,0 @@ -import groovy.json.JsonOutput -import groovy.json.JsonSlurper -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.time.Instant -import org.gradle.api.artifacts.component.ModuleComponentIdentifier -import org.gradle.api.tasks.testing.Test - -/* - * JPA readiness evidence producer. - * - * The registry owns the card/task/scenario mapping. This script only accepts evidence emitted by - * tasks in that registry, reads their JUnit XML, and writes one content-addressed manifest per - * active card. The candidate verifier deliberately permits incomplete R2 dimensions while the - * canonical primary-foundation task requires a clean CI R2 profile and a complete prerequisite - * manifest DAG. - */ - -File jpaEvidenceRegistryFile = rootProject.file('config/jpa/readiness-cards.yaml') -def jpaEvidenceOutputDirectory = layout.buildDirectory.dir('jpa-evidence/manifests') -String jpaEvidenceImage = providers.gradleProperty('jpaPostgreSqlEvidenceImage').getOrElse('postgres:16-alpine') - -Closure canonicalizeJpaEvidence -canonicalizeJpaEvidence = { Object value -> - if (value instanceof Map) { - Map sorted = new TreeMap<>() - (value as Map).each { Object key, Object child -> - sorted[key as String] = canonicalizeJpaEvidence(child) - } - return sorted - } - if (value instanceof List) { - return (value as List).collect { Object child -> canonicalizeJpaEvidence(child) } - } - value -} - -Closure canonicalJpaEvidenceJson = { Object value -> - JsonOutput.toJson(canonicalizeJpaEvidence(value)) -} - -Closure sha256JpaEvidence = { String value -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - digest.digest(value.getBytes(StandardCharsets.UTF_8)).encodeHex().toString() -} - -Closure jpaEvidenceTaskAtPath = { String absoluteTaskPath -> - int separator = absoluteTaskPath.lastIndexOf(':') - if (separator < 0 || separator == absoluteTaskPath.length() - 1) { - throw new GradleException("Invalid absolute Gradle task path '${absoluteTaskPath}'") - } - String projectPath = separator == 0 ? ':' : absoluteTaskPath.substring(0, separator) - String taskName = absoluteTaskPath.substring(separator + 1) - Project owner = rootProject.findProject(projectPath) - if (owner == null) { - throw new GradleException("Unknown project for JPA evidence task '${absoluteTaskPath}'") - } - Task task = owner.tasks.findByName(taskName) - if (task == null) { - throw new GradleException("Missing JPA evidence task '${absoluteTaskPath}'") - } - task -} - -// Two callers with opposite failure meanings, so the exit code is a parameter rather than an -// assumption. -// -// `git status --porcelain=v1` prints nothing when the worktree is clean, and a non-zero exit also -// yields no stdout — not a git repository, no git on PATH, a permission error. With the exit code -// ignored, both produced "" and the R2 gate read that as CLEAN: `source.worktreeDirty != false` -// (:347) and the `worktree-is-dirty` blocker both passed. The one condition R2 evidence has to -// prove was satisfied by failing to check it, so a manifest built from a dirty tree could be -// published as evidence that the released binary came from the committed source. git failures now -// fail the build. -// -// `docker image inspect` keeps the tolerant behaviour: a blank digest is caught downstream by the -// `.+@sha256:<64 hex>` assertion (:273 and :603), which is a failure either way, and the message -// there names the actual problem. -Closure runJpaEvidenceCommand = { List command, boolean requireSuccess = false -> - def execution = providers.exec { - commandLine command - ignoreExitValue = true - } - String output = execution.standardOutput.asText.get().trim() - if (requireSuccess) { - int exitCode = execution.result.get().exitValue - if (exitCode != 0) { - throw new GradleException( - "JPA evidence: `${command.join(' ')}` exited ${exitCode}. Its result is a " + - 'precondition of the evidence, not an optional detail — an unavailable ' + - 'command must not be read as a satisfied condition.') - } - } - output -} - -Closure> readJpaJUnitResult = { Test testTask -> - File resultDirectory = testTask.reports.junitXml.outputLocation.get().asFile - // Read through the shared reader rather than a second XmlSlurper. This one did not disable - // DOCTYPE processing and counted a skipped case as an executed class; junit-evidence.gradle did - // neither. Same format, two readers, two answers — the shared one keeps the stricter answer. - if (!rootProject.ext.has('readJUnitEvidence')) { - throw new GradleException('ca.jpa-evidence requires ca.evidence on the root project.') - } - Map results = rootProject.ext.readJUnitEvidence(testTask.path, resultDirectory) - int executed = results.tests as int - int skipped = results.skipped as int - int failures = results.failures as int - int errors = results.errors as int - Set selectors = new TreeSet<>(results.executedSelectors as Set) - - [ - tasks: [testTask.path], - resultDirectories: [rootProject.relativePath(resultDirectory)], - executedTestCount: executed, - skippedOrAbortedCount: skipped, - failureCount: failures, - errorCount: errors, - noSkipResult: executed > 0 && skipped == 0 && failures == 0 && errors == 0, - executedSelectors: selectors.toList() - ] as Map -} - -Closure> requiredJpaEvidence = { Map card -> - List required = (card['required-evidence'] as List) - .collect { Object item -> item as String } - if (card.migration instanceof Map) { - Object rawLifecycle = (card.migration as Map)['lifecycle-evidence'] - if (rawLifecycle instanceof List) { - (rawLifecycle as List).each { Object lifecycle -> - required << "migration-lifecycle:${lifecycle as String}".toString() - } - } - } - required.toSet().toSorted() -} - -Closure> resolvedJpaEvidenceVersions = { - Map versions = [:] - configurations.postgresqlIntegrationTestRuntimeClasspath - .incoming - .resolutionResult - .allComponents - .each { component -> - if (component.id instanceof ModuleComponentIdentifier) { - ModuleComponentIdentifier id = component.id as ModuleComponentIdentifier - versions["${id.group}:${id.module}".toString()] = id.version - } - } - [ - pgjdbc: versions['org.postgresql:postgresql'] ?: '', - hibernate: versions['org.hibernate.orm:hibernate-core'] ?: '', - flyway: versions['org.flywaydb:flyway-core'] ?: '' - ] as Map -} - -// Only the checks whose answer the writer does not already know. -// -// This validator used to assert thirty-odd properties of a manifest that -// `generateJpaEvidenceManifests` had written a few hundred lines earlier in the same process: that -// `schemaVersion` was the literal 1 the writer wrote, that the key set was the key set of its own -// map literal, that `missingEvidence` equalled `required - covered` — which is the expression the -// writer evaluates. No path in this repository accepts a manifest from anywhere else. The generator -// deletes the output directory and writes every file the verifier then reads, in the same build, so -// there is no hand-written manifest to reject and no forgery to detect. Those assertions could not -// fail, and a check that cannot fail proves nothing about the evidence while still having to be -// maintained, read and trusted. -// -// What is left is what the build learned from outside itself and could therefore be wrong about: -// the JUnit XML (executed and skipped counts), `docker image inspect`, the resolved dependency -// graph, and the R2 profile's provenance inputs. -Closure> validateJpaEvidenceManifest = { Map manifest -> - List violations = [] - String cardId = manifest.cardId as String - - // The reason this file exists. Gradle's `Test` fails a build on a failing test and passes it on - // a skipped one, so a PostgreSQL container that never started — every integration test skipped - // by an unmet assumption — is BUILD SUCCESSFUL. A card's evidence claims its scenarios ran, and - // a skip is not a result. - Map testResult = (manifest.testResult ?: [:]) as Map - if (((testResult.executedTestCount ?: 0) as int) <= 0) { - violations << "${cardId}: executed test count must be positive" - } - ['skippedOrAbortedCount', 'failureCount', 'errorCount'].each { String countKey -> - if (((testResult[countKey] ?: 0) as int) != 0) { - violations << "${cardId}: ${countKey} must be zero" - } - } - if (testResult.noSkipResult != true) { - violations << "${cardId}: no-skip sentinel must be true" - } - - // `docker image inspect` on an image that was never pulled by digest prints nothing, and a - // manifest that cannot name the image its tests ran against is not evidence about a PostgreSQL - // version. - Map postgresql = (manifest.postgresql ?: [:]) as Map - if (!((postgresql.imageDigest as String) ==~ /.+@sha256:[0-9a-f]{64}/)) { - violations << "${cardId}: PostgreSQL image digest must be immutable" - } - - // Resolved from the integration-test runtime classpath, so a renamed or dropped module leaves a - // blank here rather than a wrong version. - Map dependencies = (manifest.dependencies ?: [:]) as Map - ['pgjdbc', 'hibernate', 'flyway'].each { String component -> - if (((dependencies[component] ?: '') as String).isBlank()) { - violations << "${cardId}: ${component} version must be present" - } - } - - // R2 is the release claim, and every input below comes from the environment the lane ran in - // rather than from this build's own literals. - if (manifest.attainedReadiness == 'R2') { - Map source = (manifest.source ?: [:]) as Map - Map producer = (manifest.producer ?: [:]) as Map - List missing = manifest.missingEvidence instanceof List - ? (manifest.missingEvidence as List).collect { it as String } - : [] - if (manifest.profile != 'r2') { - violations << "${cardId}: R2 requires the r2 profile" - } - if (source.worktreeDirty != false) { - violations << "${cardId}: R2 requires a clean worktree" - } - if (!missing.isEmpty()) { - violations << "${cardId}: R2 has missing evidence ${missing}" - } - if (((producer.ciJob ?: '') as String).isBlank() || producer.ciJob == 'local-unpublished') { - violations << "${cardId}: R2 requires a real CI job identity" - } - if (!((manifest.artifactLocation as String) ==~ /(?i)(https|s3|gs):\/\/\S+/)) { - violations << "${cardId}: R2 requires an externally retained artifact location" - } - } - violations -} - -Closure> loadJpaEvidenceRegistry = { - new JsonSlurper().parse(jpaEvidenceRegistryFile) as Map -} - -// Reads back what generateJpaEvidenceManifests just wrote. It does not re-derive the content hash -// from the file name: the generator names each file after the hash it computed one statement -// earlier, so that comparison only ever proved that JsonOutput and JsonSlurper round-trip. The same -// goes for the prerequisite manifest-ID cross-check, whose two sides were both filled in from the -// generator's own `manifestIds` map. -Closure> verifyJpaEvidenceDirectory = { - File outputDirectory, - Map registry -> - List violations = [] - Map manifests = [:] - Set activeCardIds = (registry.cards as Map).findAll { - String ignored, Object rawCard -> - ((rawCard as Map).state as String) != 'not-implemented' - }.keySet() - - activeCardIds.each { String cardId -> - File cardDirectory = new File(outputDirectory, cardId) - List files = cardDirectory.isDirectory() - ? (cardDirectory.listFiles() ?: [] as File[]) - .findAll { File file -> file.name.endsWith('.json') } - : [] - if (files.size() != 1) { - violations << "${cardId}: expected exactly one content-addressed manifest; got ${files.size()}" - return - } - Map manifest = - new JsonSlurper().parse(files[0]) as Map - violations.addAll(validateJpaEvidenceManifest(manifest)) - manifests[cardId] = manifest - } - - [violations: violations, manifests: manifests] -} - -def generateJpaEvidenceManifests = tasks.register('generateJpaEvidenceManifests') { - group = 'verification' - description = 'Runs active JPA card producers and writes content-addressed candidate/R2 manifests.' - dependsOn rootProject.tasks.named('verifyJpaReadinessRegistry') - - Map configuredRegistry = loadJpaEvidenceRegistry() - Map configuredActiveCards = - (configuredRegistry.cards as Map).findAll { - String ignored, Object rawCard -> - ((rawCard as Map).state as String) != 'not-implemented' - } - configuredActiveCards.each { String cardId, Object rawCard -> - Map card = rawCard as Map - if (cardId != 'jpa-primary-foundation') { - dependsOn jpaEvidenceTaskAtPath(card['readiness-task'] as String) - } - ((card['support-tasks'] ?: []) as List).each { Object taskPath -> - dependsOn jpaEvidenceTaskAtPath(taskPath as String) - } - } - Map primaryCard = - configuredActiveCards['jpa-primary-foundation'] as Map - (primaryCard['support-tasks'] as List).each { Object taskPath -> - dependsOn jpaEvidenceTaskAtPath(taskPath as String) - } - - outputs.dir(jpaEvidenceOutputDirectory) - outputs.upToDateWhen { false } - - doLast { - Map registry = loadJpaEvidenceRegistry() - Map cards = registry.cards as Map - Map activeCards = cards.findAll { - String ignored, Object rawCard -> - ((rawCard as Map).state as String) != 'not-implemented' - } - - String profile = providers.gradleProperty('jpaEvidenceProfile') - .orElse(providers.environmentVariable('JPA_EVIDENCE_PROFILE')) - .getOrElse('candidate') - if (!(profile in ['candidate', 'r2'])) { - throw new GradleException( - "jpaEvidenceProfile must be candidate or r2; got '${profile}'") - } - String ciJob = providers.environmentVariable('JPA_EVIDENCE_CI_JOB') - .getOrElse(profile == 'candidate' ? 'local-unpublished' : '') - String configuredArtifactLocation = - providers.environmentVariable('JPA_EVIDENCE_ARTIFACT_LOCATION') - .getOrElse(profile == 'candidate' - ? rootProject.relativePath(jpaEvidenceOutputDirectory.get().asFile) - : '') - String topology = providers.environmentVariable('JPA_EVIDENCE_TOPOLOGY') - .getOrElse('single-postgresql-testcontainer') - - String worktreeStatus = runJpaEvidenceCommand( - ['git', 'status', '--porcelain=v1', '--untracked-files=all'], true) - boolean worktreeDirty = !worktreeStatus.isBlank() - String worktreeStatusDigest = sha256JpaEvidence(worktreeStatus) - String imageDigest = runJpaEvidenceCommand([ - 'docker', - 'image', - 'inspect', - '--format={{index .RepoDigests 0}}', - jpaEvidenceImage - ]) - Map dependencyVersions = resolvedJpaEvidenceVersions() - List productionMetadataBlockers = [] - if (profile == 'r2') { - if (worktreeDirty) { - productionMetadataBlockers << 'worktree-is-dirty' - } - if (ciJob.isBlank()) { - productionMetadataBlockers << 'missing-JPA_EVIDENCE_CI_JOB' - } - if (configuredArtifactLocation.isBlank()) { - productionMetadataBlockers << 'missing-JPA_EVIDENCE_ARTIFACT_LOCATION' - } else if (!(configuredArtifactLocation ==~ /(?i)(https|s3|gs):\/\/\S+/)) { - productionMetadataBlockers << 'artifact-location-is-not-externally-retained' - } - } - if (!(imageDigest ==~ /.+@sha256:[0-9a-f]{64}/)) { - productionMetadataBlockers << 'missing-immutable-postgresql-image-digest' - } - dependencyVersions.each { String component, String version -> - if (version.isBlank()) { - productionMetadataBlockers << "missing-${component}-version".toString() - } - } - - File outputDirectory = jpaEvidenceOutputDirectory.get().asFile - delete(outputDirectory) - outputDirectory.mkdirs() - - Map manifests = [:] - Map manifestIds = [:] - activeCards.each { String cardId, Object rawCard -> - Map card = rawCard as Map - List> testResults = [] - if (cardId == 'jpa-primary-foundation') { - (card.prerequisites as List).each { Object prerequisite -> - Map prerequisiteManifest = - manifests[prerequisite as String] as Map - if (prerequisiteManifest != null) { - testResults << (prerequisiteManifest.testResult as Map) - } - } - } else { - Task readinessTask = jpaEvidenceTaskAtPath(card['readiness-task'] as String) - if (!(readinessTask instanceof Test)) { - throw new GradleException( - "${cardId}: readiness task ${readinessTask.path} must be a Test task") - } - testResults << readJpaJUnitResult(readinessTask as Test) - ((card['support-tasks'] ?: []) as List).each { Object taskPath -> - Task supportTask = jpaEvidenceTaskAtPath(taskPath as String) - if (supportTask instanceof Test) { - testResults << readJpaJUnitResult(supportTask as Test) - } - } - } - - Set executedSelectors = testResults - .collectMany { Map result -> - result.executedSelectors as List - } - .toSet() - Set covered = new TreeSet<>() - List> scenarios = - ((card.evidence as Map).scenarios as List>) - scenarios.each { Map scenario -> - if (executedSelectors.contains(scenario.selector as String)) { - covered.addAll((scenario.covers as List).collect { it as String }) - } - } - List> taskClaims = - ((card.evidence as Map)['task-claims'] as List>) - taskClaims.each { Map taskClaim -> - Task evidenceTask = jpaEvidenceTaskAtPath(taskClaim.task as String) - if (evidenceTask.state.executed && - evidenceTask.state.failure == null && - !evidenceTask.state.skipped) { - covered.addAll((taskClaim.covers as List).collect { it as String }) - } - } - - int executedTestCount = testResults.sum { - Map result -> result.executedTestCount as int - } as int - int skippedOrAbortedCount = testResults.sum { - Map result -> result.skippedOrAbortedCount as int - } as int - int failureCount = testResults.sum { - Map result -> result.failureCount as int - } as int - int errorCount = testResults.sum { - Map result -> result.errorCount as int - } as int - boolean noSkipResult = executedTestCount > 0 && - skippedOrAbortedCount == 0 && - failureCount == 0 && - errorCount == 0 - if (noSkipResult) { - covered << 'no-skip' - } - if (cardId == 'jpa-primary-foundation' && - (card.prerequisites as List).every { - Object prerequisite -> manifestIds.containsKey(prerequisite as String) - }) { - covered << 'base-card-manifests' - } - - List required = requiredJpaEvidence(card) - List coveredList = covered.findAll { - String claim -> required.contains(claim) - }.toList().sort() - List missing = (required - coveredList).toSorted() - List> prerequisites = (card.prerequisites as List).collect { - Object rawPrerequisite -> - String prerequisiteId = rawPrerequisite as String - Map prerequisiteManifest = - manifests[prerequisiteId] as Map - if (prerequisiteManifest == null || manifestIds[prerequisiteId] == null) { - throw new GradleException( - "${cardId}: prerequisite manifest '${prerequisiteId}' was not produced first") - } - [ - cardId: prerequisiteId, - cardVersion: prerequisiteManifest.cardVersion, - manifestId: manifestIds[prerequisiteId], - attainedReadiness: prerequisiteManifest.attainedReadiness - ] as Map - } - - List readinessBlockers = [] - if (profile == 'candidate') { - readinessBlockers << 'candidate-profile-is-not-release-evidence' - } - readinessBlockers.addAll(productionMetadataBlockers) - missing.each { String requirement -> - readinessBlockers << "missing-evidence:${requirement}".toString() - } - prerequisites.findAll { - Map prerequisite -> - prerequisite.attainedReadiness != 'R2' - }.each { Map prerequisite -> - readinessBlockers << - "prerequisite-not-R2:${prerequisite.cardId}".toString() - } - - boolean attainedR2 = profile == 'r2' && - readinessBlockers.isEmpty() && - missing.isEmpty() - String generatedAt = Instant.now().toString() - String cardVersion = card.migration instanceof Map - ? ((card.migration as Map)['feature-revision'] as Integer).toString() - : rootProject.ext.traceableVersion as String - String evidenceGrade = cardId == 'jpa-primary-foundation' - ? 'E1' - : (covered.any { String claim -> - claim in [ - 'concurrency', - 'fault', - 'publish-fault', - 'migration', - 'query-plan', - 'optimistic-conflict' - ] - } ? 'E3' : 'E2') - Map migration = card.migration instanceof Map - ? [ - location: (card.migration as Map).location, - historyTable: (card.migration as Map)['history-table'], - requiredCoreEpoch: (card.migration as Map)['required-core-epoch'], - featureRevision: (card.migration as Map)['feature-revision'], - streamLifecycleEvidenceIds: - (card.migration as Map)['lifecycle-evidence'] - ] as Map - : null - - Map manifest = [ - schemaVersion: 1, - cardId: cardId, - cardVersion: cardVersion, - declaredState: card.state, - attainedReadiness: attainedR2 ? 'R2' : 'R1', - evidenceGrade: evidenceGrade, - profile: profile, - prerequisites: prerequisites, - source: [ - revision: rootProject.ext.sourceRevision as String, - worktreeDirty: worktreeDirty, - worktreeStatusDigest: worktreeStatusDigest - ], - producer: [ - gradleTask: card['readiness-task'], - ciJob: ciJob - ], - testResult: [ - tasks: testResults.collectMany { - Map result -> result.tasks as List - }.toSet().toList().sort(), - resultDirectories: testResults.collectMany { - Map result -> result.resultDirectories as List - }.toSet().toList().sort(), - executedTestCount: executedTestCount, - skippedOrAbortedCount: skippedOrAbortedCount, - failureCount: failureCount, - errorCount: errorCount, - noSkipResult: noSkipResult, - executedSelectors: executedSelectors.toSorted() - ], - requiredEvidence: required, - coveredEvidence: coveredList, - missingEvidence: missing, - readinessBlockers: readinessBlockers.toSet().toList().sort(), - postgresql: [ - image: jpaEvidenceImage, - imageDigest: imageDigest, - managedEngineVersion: '16' - ], - dependencies: dependencyVersions, - generatedAt: generatedAt, - date: generatedAt.substring(0, 10), - topology: topology, - artifactLocation: configuredArtifactLocation, - migration: migration, - dispatchModes: card['dispatch-modes'] instanceof List - ? card['dispatch-modes'] - : [] - ] as Map - - String contentHash = sha256JpaEvidence(canonicalJpaEvidenceJson(manifest)) - File cardDirectory = new File(outputDirectory, cardId) - cardDirectory.mkdirs() - File manifestFile = new File(cardDirectory, "${contentHash}.json") - manifestFile.setText(JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + '\n', 'UTF-8') - manifests[cardId] = manifest - manifestIds[cardId] = "sha256:${contentHash}".toString() - } - - logger.lifecycle( - "generateJpaEvidenceManifests: wrote ${manifests.size()} ${profile} " + - "content-addressed card manifests to ${outputDirectory}") - } -} - -gradle.taskGraph.whenReady { graph -> - if (graph.hasTask(generateJpaEvidenceManifests.get())) { - [ - tasks.named('test').get(), - project(':app-bootstrap').tasks.named('test').get() - ].each { Task testTask -> - testTask.outputs.upToDateWhen { false } - } - } -} - -def verifyJpaCandidateEvidence = tasks.register('verifyJpaCandidateEvidence') { - group = 'verification' - description = 'Validates hashes, schema, exact JUnit selectors, no-skip, and prerequisite links without claiming R2.' - dependsOn generateJpaEvidenceManifests - outputs.upToDateWhen { false } - - doLast { - Map result = verifyJpaEvidenceDirectory( - jpaEvidenceOutputDirectory.get().asFile, - loadJpaEvidenceRegistry()) - List violations = result.violations as List - if (!violations.isEmpty()) { - throw new GradleException( - "verifyJpaCandidateEvidence: ${violations.size()} violation(s):\n " + - violations.toSorted().join('\n ')) - } - Map manifests = result.manifests as Map - manifests.each { String cardId, Object rawManifest -> - Map manifest = rawManifest as Map - List missing = manifest.missingEvidence as List - logger.lifecycle( - "${cardId}: ${manifest.attainedReadiness}/${manifest.evidenceGrade}, " + - "${manifest.testResult.executedTestCount} tests, " + - "missing=${missing.isEmpty() ? 'none' : missing.join(',')}") - } - logger.lifecycle( - "verifyJpaCandidateEvidence: OK — ${manifests.size()} manifests are " + - 'content-addressed, linked, zero-skip candidate evidence; no R2 claim was made.') - } -} - -tasks.register('verifyJpaPrimaryFoundationEvidence') { - group = 'verification' - description = 'Requires complete immutable base-card manifests from a clean, retained CI R2 evidence lane.' - dependsOn generateJpaEvidenceManifests - outputs.upToDateWhen { false } - - doLast { - Map result = verifyJpaEvidenceDirectory( - jpaEvidenceOutputDirectory.get().asFile, - loadJpaEvidenceRegistry()) - List violations = result.violations as List - Map manifests = result.manifests as Map - Map primary = - manifests['jpa-primary-foundation'] as Map - if ((primary?.profile as String) != 'r2') { - violations << 'jpa-primary-foundation: run with -PjpaEvidenceProfile=r2 in the dedicated CI lane' - } - [ - 'jpa-observability-lifecycle', - 'jpa-security-baseline', - 'jpa-flyway-migration', - 'jpa-transaction-runtime', - 'jpa-aggregate-store', - 'jpa-query-model', - 'jpa-primary-foundation' - ].each { String cardId -> - Map manifest = manifests[cardId] as Map - if (manifest == null) { - violations << "${cardId}: manifest is missing" - } else if (manifest.attainedReadiness != 'R2') { - violations << "${cardId}: attained ${manifest.attainedReadiness}; blockers=" + - "${(manifest.readinessBlockers as List).join(',')}" - } - } - if (!violations.isEmpty()) { - throw new GradleException( - "verifyJpaPrimaryFoundationEvidence: ${violations.size()} violation(s):\n " + - violations.toSorted().join('\n ')) - } - logger.lifecycle( - 'verifyJpaPrimaryFoundationEvidence: OK — six immutable R2 base manifests and the primary DAG are verified.') - } -} diff --git a/src/build-qualification/src/main/groovy/ca.jpa-qualification.gradle b/src/build-qualification/src/main/groovy/ca.jpa-qualification.gradle deleted file mode 100644 index 055234a4..00000000 --- a/src/build-qualification/src/main/groovy/ca.jpa-qualification.gradle +++ /dev/null @@ -1,701 +0,0 @@ -import groovy.json.JsonSlurper -import groovy.json.JsonOutput - -// JPA persistence platform qualification — the readiness card registry and the release gate. -// -// Not build policy. This is a certification system for one adapter: which readiness cards exist, -// which migration streams they own, which Gradle task produces each card's evidence, and which lanes -// a release of that platform must clear. It lived in the root build file for months, where it was -// roughly a fifth of everything the repository knew about how to build itself, and where a reader -// looking for "what does this project compile with" found a DAG validator for migration cards. -// -// Applied from the root build so the task names CI already calls — `jpaReleaseGate`, -// `verifyJpaReadinessRegistry` — keep resolving, and off every `check` but the JPA platform's own. -// Nothing here runs unless somebody names it or runs `:adapter:outbound:persistence-jpa:check`. - -// Every gate the release registry declares names the Gradle task that produces its evidence, and -// nothing resolved those names. A gate could name a task that had been renamed, moved to another -// project, or never existed: the registry still listed it, JpaReleaseManifestTest still confirmed -// the gate was declared and named a task, and the release lane ran without ever executing it. -// -// Resolving the path against the real project/task graph is what turns "declares a task" into -// "the task exists". Registering a Test-typed check is deliberate too — a gate whose evidence comes -// from something that never runs tests produces an artifact with no assertions behind it. -tasks.register('verifyJpaReleaseGateTasks') { - group = 'verification' - description = 'Resolves every release-registry gate task against the real Gradle task graph.' - - File registryFile = rootProject.file('config/jpa/release-registry.json') - inputs.file(registryFile) - - doLast { - def registry = new groovy.json.JsonSlurper().parse(registryFile) as Map - List violations = [] - (registry.gates as List).each { Object entry -> - Map gate = entry as Map - String name = gate.name as String - String path = gate.task as String - if (path == null || !path.startsWith(':')) { - violations << "${name}: gate task must be an absolute Gradle path, was '${path}'" - return - } - int separator = path.lastIndexOf(':') - String projectPath = separator == 0 ? ':' : path.substring(0, separator) - String taskName = path.substring(separator + 1) - Project owner = rootProject.findProject(projectPath) - if (owner == null) { - violations << "${name}: no project at '${projectPath}' for gate task '${path}'" - return - } - Task task = owner.tasks.findByName(taskName) - if (task == null) { - violations << "${name}: no task '${taskName}' in '${projectPath}'" - return - } - if (!(task instanceof Test)) { - violations << "${name}: '${path}' is not a Test task, so it produces no JUnit evidence" - } - } - if (!violations.isEmpty()) { - throw new GradleException( - "verifyJpaReleaseGateTasks: ${violations.size()} violation(s):\n " + - violations.join('\n ')) - } - logger.lifecycle( - "verifyJpaReleaseGateTasks: OK — ${(registry.gates as List).size()} gate task(s) resolve to real Test tasks.") - } -} - -// JPA persistence platform release gate (design §41, docs/jpa/support-matrix.md). -// -// Aggregated at the root because a release is a repository-wide event and the gate spans two -// leaves: the platform's own lanes, and the architecture rules in app-bootstrap that keep the -// platform inside its boundary. Every entry corresponds to a gate in -// config/jpa/release-registry.json; JpaReleaseRenderingTest holds the support document and the -// release workflow to that registry, and verifyJpaReleaseGateTasks holds the registry to the task -// graph — so a gate deleted from the registry, demoted in the document, or pointed at a task that -// no longer exists fails the build rather than quietly ceasing to be checked. -tasks.register('jpaReleaseGate') { - group = 'verification' - description = 'Runs every JPA persistence platform lane required for a release (design §41).' - dependsOn ':adapter:outbound:persistence-jpa:jpaPlatformReleaseGate' - dependsOn 'verifyCleanArchitectureDependencies' - // Was `verifyOneTypePerFile`, a root task whose entire body was - // `dependsOn every leaf's checkstyleMain`. Naming the real task removes the indirection and the - // misleading name — Checkstyle's OneTopLevelClass is one rule in the D2 ruleset this runs. - dependsOn subprojects.findAll { it.childProjects.isEmpty() } - .collect { "${it.path}:checkstyleMain" } - dependsOn 'verifyJpaReleaseGateTasks' - dependsOn ':app-bootstrap:test' -} - -Set expectedJpaReadinessCardIds = [ - 'jpa-observability-lifecycle', - 'jpa-security-baseline', - 'jpa-flyway-migration', - 'jpa-transaction-runtime', - 'jpa-aggregate-store', - 'jpa-query-model', - 'jpa-primary-foundation', - 'jpa-idempotency-owner-safe-v2', - 'jpa-outbox-storage-v2', - 'jpa-outbox-polling-delivery-v2', - 'jpa-outbox-cdc-retention-v1', - 'jpa-inbox-same-store-v1', - 'jpa-fileserver-metadata-v1', - 'jpa-notification-platform-v4', - 'jpa-primary-replica', - 'jpa-tenant-discriminator-rls', - 'jpa-jdbc-efficiency-coordination' -] as Set - -Set expectedJpaOwnedMigrationCardIds = [ - 'jpa-flyway-migration', - 'jpa-idempotency-owner-safe-v2', - 'jpa-outbox-storage-v2', - 'jpa-outbox-polling-delivery-v2', - 'jpa-inbox-same-store-v1', - 'jpa-fileserver-metadata-v1', - 'jpa-notification-platform-v4', - 'jpa-tenant-discriminator-rls', - 'jpa-jdbc-efficiency-coordination' -] as Set - -Closure> validateJpaReadinessRegistry = { - Map registry, - String rawRegistry, - Closure taskExists -> - List violations = [] - Set rootKeys = registry.keySet().collect { it as String }.toSet() - Set expectedRootKeys = ['schema-version', 'legacy-adoption', 'cards'] as Set - if (rootKeys != expectedRootKeys) { - violations << "root keys must be exactly ${expectedRootKeys}; got ${rootKeys}" - } - if (registry['schema-version'] != 1) { - violations << "schema-version must be integer 1; got ${registry['schema-version']}" - } - - Map legacy = registry['legacy-adoption'] instanceof Map - ? registry['legacy-adoption'] as Map - : [:] - Set expectedLegacyKeys = [ - 'state', - 'location', - 'history-table', - 'immutable-applied-versions', - 'allowed-origin' - ] as Set - if (legacy.keySet().collect { it as String }.toSet() != expectedLegacyKeys) { - violations << "legacy-adoption keys must be exactly ${expectedLegacyKeys}" - } - if (legacy.state != 'transition-only') { - violations << "legacy-adoption.state must be transition-only" - } - if (legacy.location != 'db/migration/postgresql') { - violations << "legacy-adoption.location must be db/migration/postgresql" - } - if (legacy['history-table'] != 'flyway_schema_history') { - violations << "legacy-adoption.history-table must be flyway_schema_history" - } - if (legacy['immutable-applied-versions'] != [1, 3, 4, 5]) { - violations << "legacy-adoption immutable versions must be exactly [1, 3, 4, 5]" - } - if (legacy['allowed-origin'] != 'LEGACY_ADOPTED') { - violations << "legacy-adoption.allowed-origin must be LEGACY_ADOPTED" - } - - Map cards = registry.cards instanceof Map - ? registry.cards as Map - : [:] - Set actualCardIds = cards.keySet().collect { it as String }.toSet() - Set missingCards = expectedJpaReadinessCardIds - actualCardIds - Set unknownCards = actualCardIds - expectedJpaReadinessCardIds - if (!missingCards.isEmpty()) { - violations << "missing card ids ${missingCards.toSorted()}" - } - if (!unknownCards.isEmpty()) { - violations << "unknown card ids ${unknownCards.toSorted()}" - } - - List rawCardKeys = [] - def rawCardKeyMatcher = rawRegistry =~ /"(?jpa-[a-z0-9.-]+)"\s*:/ - while (rawCardKeyMatcher.find()) { - rawCardKeys << rawCardKeyMatcher.group('card') - } - Set duplicateRawCardKeys = rawCardKeys.countBy { it }.findAll { - String ignored, Integer count -> count > 1 - }.keySet() - if (!duplicateRawCardKeys.isEmpty()) { - violations << "duplicate raw card keys ${duplicateRawCardKeys.toSorted()}" - } - - Set allowedCardKeys = [ - 'state', - 'schema-stream', - 'prerequisites', - 'external-prerequisites', - 'readiness-task', - 'support-tasks', - 'required-evidence', - 'evidence', - 'dispatch-modes', - 'migration' - ] as Set - Set allowedStates = ['selected', 'implemented-candidate', 'not-implemented'] as Set - Set allowedSchemaStreams = ['none', 'owned', 'contributes-to-core'] as Set - Map taskOwners = [:] - Map migrationLocationOwners = [:] - Map migrationHistoryOwners = [:] - Map evidenceSelectorOwners = [:] - Set actualOwnedMigrationCards = [] - - cards.each { String cardId, Object rawCard -> - if (!(rawCard instanceof Map)) { - violations << "${cardId}: card value must be an object" - return - } - Map card = rawCard as Map - Set unknownKeys = card.keySet().collect { it as String }.toSet() - allowedCardKeys - if (!unknownKeys.isEmpty()) { - violations << "${cardId}: unknown keys ${unknownKeys.toSorted()}" - } - - String state = card.state as String - String schemaStream = card['schema-stream'] as String - if (!allowedStates.contains(state)) { - violations << "${cardId}: invalid state '${state}'" - } - if (!allowedSchemaStreams.contains(schemaStream)) { - violations << "${cardId}: invalid schema-stream '${schemaStream}'" - } - - if (!(card.prerequisites instanceof List)) { - violations << "${cardId}: prerequisites must be a list" - } - List prerequisites = card.prerequisites instanceof List - ? (card.prerequisites as List).collect { it as String } - : [] - if (prerequisites.toSet().size() != prerequisites.size()) { - violations << "${cardId}: duplicate prerequisites ${prerequisites}" - } - prerequisites.each { String prerequisite -> - if (!cards.containsKey(prerequisite)) { - violations << "${cardId}: unknown prerequisite '${prerequisite}'" - } else if (state == 'selected' && - ((cards[prerequisite] as Map).state as String) != 'selected') { - violations << "${cardId}: selected card requires non-selected '${prerequisite}'" - } - } - - String readinessTask = card['readiness-task'] as String - if (readinessTask == null || !readinessTask.startsWith(':')) { - violations << "${cardId}: readiness-task must be an absolute Gradle task path" - } - List supportTasks = card['support-tasks'] instanceof List - ? (card['support-tasks'] as List).collect { it as String } - : [] - if (supportTasks.toSet().size() != supportTasks.size()) { - violations << "${cardId}: duplicate support-tasks ${supportTasks}" - } - ([readinessTask] + supportTasks).findAll { it != null }.each { String taskPath -> - if (!taskPath.startsWith(':')) { - violations << "${cardId}: task '${taskPath}' must be an absolute Gradle task path" - return - } - String previousOwner = taskOwners.putIfAbsent(taskPath, cardId) - if (previousOwner != null) { - violations << "duplicate task '${taskPath}' owned by ${previousOwner} and ${cardId}" - } - if (state == 'selected' && !taskExists(taskPath)) { - violations << "${cardId}: selected task does not exist '${taskPath}'" - } - } - - List requiredEvidence = card['required-evidence'] instanceof List - ? (card['required-evidence'] as List).collect { it as String } - : [] - if (requiredEvidence.isEmpty()) { - violations << "${cardId}: required-evidence must be a non-empty list" - } else { - if (requiredEvidence.toSet().size() != requiredEvidence.size()) { - violations << "${cardId}: duplicate required-evidence ${requiredEvidence}" - } - if (!requiredEvidence.contains('no-skip')) { - violations << "${cardId}: required-evidence must include no-skip" - } - } - - Object migrationNode = card.migration - Set allowedEvidenceClaims = requiredEvidence - .findAll { String requirement -> requirement != 'no-skip' } - .toSet() - Map migrationForEvidence = migrationNode instanceof Map - ? migrationNode as Map - : [:] - Object lifecycleEvidenceNode = migrationForEvidence['lifecycle-evidence'] - if (lifecycleEvidenceNode instanceof List) { - (lifecycleEvidenceNode as List).each { - Object lifecycle -> - allowedEvidenceClaims << - "migration-lifecycle:${lifecycle as String}".toString() - } - } - - Object evidenceNode = card.evidence - if (state == 'not-implemented') { - if (evidenceNode != null) { - violations << "${cardId}: not-implemented card forbids evidence" - } - } else if (!(evidenceNode instanceof Map)) { - violations << "${cardId}: active card requires evidence" - } else { - Map evidence = evidenceNode as Map - Set evidenceKeys = evidence.keySet().collect { it as String }.toSet() - Set expectedEvidenceKeys = ['scenarios', 'task-claims'] as Set - if (evidenceKeys != expectedEvidenceKeys) { - violations << "${cardId}: evidence keys must be exactly ${expectedEvidenceKeys}" - } - - List scenarios = evidence.scenarios instanceof List - ? evidence.scenarios as List - : [] - if (!(evidence.scenarios instanceof List)) { - violations << "${cardId}: evidence scenarios must be a list" - } - List taskClaims = evidence['task-claims'] instanceof List - ? evidence['task-claims'] as List - : [] - if (!(evidence['task-claims'] instanceof List)) { - violations << "${cardId}: evidence task-claims must be a list" - } - if (scenarios.isEmpty() && taskClaims.isEmpty()) { - violations << "${cardId}: evidence must contain a scenario or task claim" - } - - scenarios.eachWithIndex { Object rawScenario, int index -> - if (!(rawScenario instanceof Map)) { - violations << "${cardId}: evidence scenario ${index} must be an object" - return - } - Map scenario = rawScenario as Map - Set scenarioKeys = - scenario.keySet().collect { it as String }.toSet() - if (scenarioKeys != ['selector', 'covers'] as Set) { - violations << "${cardId}: evidence scenario ${index} has invalid keys ${scenarioKeys}" - } - String selector = scenario.selector as String - if (selector == null || - !(selector ==~ /dev\.caskeleton\.[A-Za-z0-9_.]+\#[A-Za-z][A-Za-z0-9_]*/)) { - violations << "${cardId}: invalid evidence selector '${selector}'" - } else { - String previousOwner = evidenceSelectorOwners.putIfAbsent(selector, cardId) - if (previousOwner != null) { - violations << "duplicate evidence selector '${selector}' owned by " + - "${previousOwner} and ${cardId}" - } - } - List covers = scenario.covers instanceof List - ? (scenario.covers as List).collect { it as String } - : [] - if (covers.isEmpty()) { - violations << "${cardId}: evidence scenario ${index} covers must be non-empty" - } - if (covers.toSet().size() != covers.size()) { - violations << "${cardId}: evidence scenario ${index} has duplicate covers ${covers}" - } - covers.each { String claim -> - if (!allowedEvidenceClaims.contains(claim)) { - violations << "${cardId}: evidence covers unknown requirement '${claim}'" - } - } - } - - Set ownedTasks = ([readinessTask] + supportTasks) - .findAll { it != null } - .toSet() - taskClaims.eachWithIndex { Object rawClaim, int index -> - if (!(rawClaim instanceof Map)) { - violations << "${cardId}: evidence task claim ${index} must be an object" - return - } - Map claim = rawClaim as Map - Set claimKeys = claim.keySet().collect { it as String }.toSet() - if (claimKeys != ['task', 'covers'] as Set) { - violations << "${cardId}: evidence task claim ${index} has invalid keys ${claimKeys}" - } - String taskPath = claim.task as String - if (!ownedTasks.contains(taskPath)) { - violations << "${cardId}: evidence task claim is not owned by card '${taskPath}'" - } - List covers = claim.covers instanceof List - ? (claim.covers as List).collect { it as String } - : [] - if (covers.isEmpty()) { - violations << "${cardId}: evidence task claim ${index} covers must be non-empty" - } - if (covers.toSet().size() != covers.size()) { - violations << "${cardId}: evidence task claim ${index} has duplicate covers ${covers}" - } - covers.each { String evidenceClaim -> - if (!allowedEvidenceClaims.contains(evidenceClaim)) { - violations << "${cardId}: evidence covers unknown requirement '${evidenceClaim}'" - } - } - } - } - - if (schemaStream == 'owned') { - actualOwnedMigrationCards << cardId - if (!(migrationNode instanceof Map)) { - violations << "${cardId}: owned schema-stream requires migration" - } - } else if (migrationNode != null) { - violations << "${cardId}: schema-stream ${schemaStream} forbids migration" - } - - if (migrationNode instanceof Map) { - Map migration = migrationNode as Map - Set expectedMigrationKeys = [ - 'location', - 'history-table', - 'required-core-epoch', - 'feature-revision', - 'lifecycle-evidence' - ] as Set - Set migrationKeys = migration.keySet().collect { it as String }.toSet() - if (migrationKeys != expectedMigrationKeys) { - violations << "${cardId}: migration keys must be exactly ${expectedMigrationKeys}" - } - - String location = migration.location as String - String historyTable = migration['history-table'] as String - if (location == null || !(location ==~ /db\/migration\/jpa\/[a-z0-9-]+/)) { - violations << "${cardId}: invalid migration location '${location}'" - } else { - String previousOwner = migrationLocationOwners.putIfAbsent(location, cardId) - if (previousOwner != null) { - violations << "duplicate migration location '${location}' for ${previousOwner} and ${cardId}" - } - } - if (historyTable == null || !(historyTable ==~ /flyway_jpa_[a-z0-9_]+_history/)) { - violations << "${cardId}: invalid migration history-table '${historyTable}'" - } else { - String previousOwner = migrationHistoryOwners.putIfAbsent(historyTable, cardId) - if (previousOwner != null) { - violations << "duplicate migration history-table '${historyTable}' for ${previousOwner} and ${cardId}" - } - } - - Object coreEpoch = migration['required-core-epoch'] - Object featureRevision = migration['feature-revision'] - if (!(coreEpoch instanceof Integer) || (coreEpoch as Integer) < 0) { - violations << "${cardId}: required-core-epoch must be a non-negative integer" - } - if (!(featureRevision instanceof Integer) || (featureRevision as Integer) <= 0) { - violations << "${cardId}: feature-revision must be a positive integer" - } - List lifecycleEvidence = migration['lifecycle-evidence'] instanceof List - ? (migration['lifecycle-evidence'] as List).collect { it as String } - : [] - if (lifecycleEvidence.isEmpty()) { - violations << "${cardId}: lifecycle-evidence must be a non-empty list" - } else if (lifecycleEvidence.toSet().size() != lifecycleEvidence.size()) { - violations << "${cardId}: duplicate lifecycle-evidence ${lifecycleEvidence}" - } - } - - if (card['external-prerequisites'] != null) { - if (!(card['external-prerequisites'] instanceof List)) { - violations << "${cardId}: external-prerequisites must be a list" - } else { - (card['external-prerequisites'] as List).eachWithIndex { - Object rawExternal, int index -> - if (!(rawExternal instanceof Map)) { - violations << "${cardId}: external prerequisite ${index} must be an object" - return - } - Map external = rawExternal as Map - Set externalKeys = external.keySet() - .collect { it as String } - .toSet() - if (externalKeys != ['registry', 'card-id', 'minimum-readiness'] as Set) { - violations << "${cardId}: external prerequisite ${index} has invalid keys ${externalKeys}" - } - if (!((external.registry as String)?.startsWith('src/config/'))) { - violations << "${cardId}: external prerequisite ${index} has invalid registry" - } - if (!((external['card-id'] as String) ==~ /[a-z0-9.-]+/)) { - violations << "${cardId}: external prerequisite ${index} has invalid card-id" - } - if (!((external['minimum-readiness'] as String) ==~ /R[0-3]/)) { - violations << "${cardId}: external prerequisite ${index} has invalid minimum-readiness" - } - } - } - } - } - - if (actualOwnedMigrationCards != expectedJpaOwnedMigrationCardIds) { - violations << "owned migration cards must be exactly ${expectedJpaOwnedMigrationCardIds}; " + - "got ${actualOwnedMigrationCards}" - } - - Map visitState = [:].withDefault { 0 } - Closure visitCard - visitCard = { String cardId -> - if (visitState[cardId] == 1) { - violations << "readiness prerequisite cycle includes '${cardId}'" - return - } - if (visitState[cardId] == 2 || !cards.containsKey(cardId)) { - return - } - visitState[cardId] = 1 - Map card = cards[cardId] as Map - if (card.prerequisites instanceof List) { - (card.prerequisites as List).each { Object prerequisite -> - visitCard(prerequisite as String) - } - } - visitState[cardId] = 2 - } - cards.keySet().each { Object cardId -> visitCard(cardId as String) } - - boolean pollingSelected = - ((cards['jpa-outbox-polling-delivery-v2'] as Map)?.state as String) == 'selected' - boolean cdcSelected = - ((cards['jpa-outbox-cdc-retention-v1'] as Map)?.state as String) == 'selected' - if (pollingSelected && cdcSelected) { - violations << 'polling and CDC outbox delivery cards cannot both be selected' - } - - violations -} - -Closure jpaTaskExists = { String absoluteTaskPath -> - int separator = absoluteTaskPath.lastIndexOf(':') - if (separator < 0 || separator == absoluteTaskPath.length() - 1) { - return false - } - String projectPath = separator == 0 ? ':' : absoluteTaskPath.substring(0, separator) - String taskName = absoluteTaskPath.substring(separator + 1) - Project targetProject = rootProject.findProject(projectPath) - targetProject != null && targetProject.tasks.findByName(taskName) != null -} - -def verifyJpaReadinessRegistryContract = tasks.register('verifyJpaReadinessRegistryContract') { - group = 'verification' - description = 'Mutation-tests the fail-closed JPA readiness registry validator.' - - File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml") - inputs.file(registryFile) - - doLast { - String raw = registryFile.getText('UTF-8') - Map baseline = new JsonSlurper().parseText(raw) as Map - - Closure> copyRegistry = { - new JsonSlurper().parseText(JsonOutput.toJson(baseline)) as Map - } - Closure expectViolation = { - String scenario, - String expectedText, - Closure mutation, - Closure taskExists = { String ignored -> true } -> - Map candidate = copyRegistry() - mutation(candidate) - List candidateViolations = validateJpaReadinessRegistry( - candidate, - JsonOutput.toJson(candidate), - taskExists) - if (!candidateViolations.any { String violation -> - violation.contains(expectedText) - }) { - throw new GradleException( - "verifyJpaReadinessRegistryContract: scenario '${scenario}' did not " + - "produce '${expectedText}'; got ${candidateViolations}") - } - } - - expectViolation('unknown-card', 'unknown card ids', { Map candidate -> - (candidate.cards as Map)['jpa-primary-foundation-alias'] = - (candidate.cards as Map)['jpa-primary-foundation'] - }) - expectViolation('duplicate-task', 'duplicate task', { Map candidate -> - ((candidate.cards as Map)['jpa-security-baseline'] as Map)['readiness-task'] = - ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)['readiness-task'] - }) - expectViolation('missing-prerequisite', 'unknown prerequisite', { - Map candidate -> - ((candidate.cards as Map)['jpa-security-baseline'] as Map).prerequisites = - ['jpa-does-not-exist'] - }) - expectViolation('cycle', 'prerequisite cycle', { Map candidate -> - ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).prerequisites = - ['jpa-security-baseline'] - }) - expectViolation('duplicate-location', 'duplicate migration location', { - Map candidate -> - (((candidate.cards as Map)['jpa-idempotency-owner-safe-v2'] as Map).migration - as Map).location = 'db/migration/jpa/core' - }) - expectViolation( - 'missing-selected-task', - 'selected task does not exist', - { Map ignored -> }, - { String taskPath -> - taskPath != - ':adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest' - }) - expectViolation('missing-active-evidence', 'active card requires evidence', { - Map candidate -> - ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map) - .remove('evidence') - }) - expectViolation('unknown-evidence-requirement', 'evidence covers unknown requirement', { - Map candidate -> - ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).evidence = [ - scenarios: [[ - selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql', - covers: ['not-a-card-requirement'] - ]], - 'task-claims': [] - ] - }) - expectViolation('duplicate-evidence-selector', 'duplicate evidence selector', { - Map candidate -> - Map card = - (candidate.cards as Map)['jpa-observability-lifecycle'] as Map - card.evidence = [ - scenarios: [ - [ - selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql', - covers: ['real-postgresql'] - ], - [ - selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql', - covers: ['lifecycle'] - ] - ], - 'task-claims': [] - ] - }) - expectViolation('unknown-evidence-task', 'evidence task claim is not owned by card', { - Map candidate -> - ((candidate.cards as Map)['jpa-primary-foundation'] as Map).evidence = [ - scenarios: [], - 'task-claims': [[ - task: ':test', - covers: ['architecture'] - ]] - ] - }) - - logger.lifecycle( - 'verifyJpaReadinessRegistryContract: OK — unknown card, duplicate task, ' + - 'missing prerequisite, cycle, duplicate migration ownership, missing ' + - 'selected task, and malformed evidence ownership all fail closed.') - } -} - -def verifyJpaReadinessRegistry = tasks.register('verifyJpaReadinessRegistry') { - group = 'verification' - description = 'Validates the JPA readiness card, prerequisite, task, and migration registry.' - dependsOn verifyJpaReadinessRegistryContract - - File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml") - inputs.file(registryFile) - - doLast { - if (!registryFile.isFile()) { - throw new GradleException( - "verifyJpaReadinessRegistry: missing registry ${registryFile}") - } - String raw = registryFile.getText('UTF-8') - Map registry - try { - registry = new JsonSlurper().parseText(raw) as Map - } catch (RuntimeException ex) { - throw new GradleException( - "verifyJpaReadinessRegistry: registry is not valid JSON-compatible YAML", - ex) - } - - List violations = - validateJpaReadinessRegistry(registry, raw, jpaTaskExists) - if (!violations.isEmpty()) { - throw new GradleException( - "verifyJpaReadinessRegistry: ${violations.size()} violation(s):\n " + - violations.toSorted().join('\n ')) - } - logger.lifecycle( - "verifyJpaReadinessRegistry: OK — ${expectedJpaReadinessCardIds.size()} exact " + - "cards, ${expectedJpaOwnedMigrationCardIds.size()} owned migration " + - 'streams, acyclic prerequisites, unique tasks/locations/history tables, ' + - 'and selected task existence verified.') - } -} - -// The registry runs with the JPA platform's own `check`, and that wiring is declared in -// adapter/outbound/persistence-jpa/build.gradle rather than reached into from here: the leaf owns -// its plugins now, so its `check` does not exist yet while this script is being evaluated. diff --git a/src/build-qualification/src/main/groovy/ca.messaging-qualification.gradle b/src/build-qualification/src/main/groovy/ca.messaging-qualification.gradle deleted file mode 100644 index fef8f4b1..00000000 --- a/src/build-qualification/src/main/groovy/ca.messaging-qualification.gradle +++ /dev/null @@ -1,310 +0,0 @@ -import groovy.json.JsonOutput -import java.time.Instant -import java.security.MessageDigest - -// Messaging contract/schema qualification — the payload-free evidence manifests. -// -// Qualification, not build policy, for the same reason the JPA registry is: it answers "may this -// messaging capability be advertised at R1", which is a release question about one platform, and it -// answers it by writing content-addressed evidence that a workflow uploads. -// -// The nine fail-closed R2 skeleton tasks that used to sit beside this are gone. They registered task -// names for work that has no producer and then threw unconditionally, so `verifyMessagingSecurityR2` -// could not pass on any input — a TODO wearing the Gradle task API. MSG-015 tracks the real work; -// docs/roadmap is where an unimplemented capability belongs. - -// Task 6 replaces only the contract/schema skeletons with real, no-match-failing Test lanes. -// The manifest is payload-free and is rebuilt only after exact source/artifact/profile properties -// and every selected Task 3-6 test have passed in the current invocation. -def messagingEvidenceResultRoot = layout.buildDirectory.dir('test-results/messaging-evidence') - -def messagingEvidenceFile = layout.buildDirectory.file( - 'messaging-evidence/contracts-schema/manifest.json') -def messagingProfileFile = file('config/messaging/profile-compatibility.yaml') -def messagingDigestProperty = { String propertyName -> - String value = providers.gradleProperty(propertyName).getOrElse('') - if (!(value ==~ /sha256:[a-f0-9]{64}/)) { - throw new GradleException( - "-P${propertyName}=sha256:<64-lowercase-hex> is required for Messaging evidence.") - } - value -} -def messagingSha256Bytes = { byte[] bytes -> - 'sha256:' + java.util.HexFormat.of().formatHex( - MessageDigest.getInstance('SHA-256').digest(bytes)) -} -def messagingSha256FileSet = { String domain, List files -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - digest.update(domain.getBytes(java.nio.charset.StandardCharsets.UTF_8)) - digest.update((byte) 0) - files.sort { rootProject.relativePath(it) }.each { File input -> - if (!input.isFile()) { - throw new GradleException( - "Messaging evidence input is missing: ${rootProject.relativePath(input)}") - } - byte[] path = rootProject.relativePath(input) - .getBytes(java.nio.charset.StandardCharsets.UTF_8) - byte[] content = input.bytes - digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(path.length).array()) - digest.update(path) - digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(content.length).array()) - digest.update(content) - } - 'sha256:' + java.util.HexFormat.of().formatHex(digest.digest()) -} - -def prepareMessagingContractEvidence = tasks.register('prepareMessagingContractEvidence') { - group = 'verification' - outputs.upToDateWhen { false } - doLast { - File output = messagingEvidenceFile.get().asFile - if (output.exists() && !output.delete()) { - throw new GradleException("Could not delete stale Messaging evidence ${output}") - } - messagingDigestProperty('messagingSourceDigest') - messagingDigestProperty('messagingArtifactDigest') - String suppliedProfile = messagingDigestProperty('messagingProfileHash') - String exactProfile = messagingSha256Bytes(messagingProfileFile.bytes) - if (suppliedProfile != exactProfile) { - throw new GradleException( - "messagingProfileHash does not match exact config/messaging/profile-compatibility.yaml bytes.") - } - } -} - -// JUnit XML through the shared reader, not a second XmlSlurper. -// -// This closure used to parse TEST-*.xml itself with `new XmlSlurper(false, false)`. That is the -// same construction the historical JPA evidence script removed, and it left the reason in a comment: -// the shared reader additionally sets `disallow-doctype-decl`, so two readers of the same files did -// not agree on how to read them, and only one of them could be what the author meant. It is also -// where the counts come from — dev.caskeleton.buildlogic.JUnitEvidence takes them from the suite -// attributes rather than by counting elements, so a suite that failed to initialise -// (one error in the header, no test cases at all) counts as a failure instead of as nothing. -// -// The class is called directly rather than through rootProject.ext.readJUnitEvidence because the -// scenario IDs below need executedSelectors, which that closure does not return. -def messagingEvidenceFromXml = { List resultDirectories -> - int executed = 0 - int failed = 0 - int skipped = 0 - Set selectors = new TreeSet<>() - resultDirectories.each { String directory -> - File resultDirectory = messagingEvidenceResultRoot.get().dir(directory).asFile - if (!rootProject.ext.has('readJUnitEvidence')) { - throw new GradleException('ca.messaging-qualification requires ca.evidence on the root project.') - } - Map results = rootProject.ext.readJUnitEvidence( - "messaging-evidence/${directory}", resultDirectory) - executed += results.tests as int - failed += (results.failures as int) + (results.errors as int) - skipped += results.skipped as int - selectors.addAll(results.executedSelectors as Set) - } - if (executed <= 0) { - throw new GradleException('Messaging qualification XML contains no discovered test cases.') - } - // `pkg.ClassName#method` -> `ClassName.method`, then sanitised to the manifest's identifier - // grammar. The uniqueness check is on the simple-name form on purpose: two classes with the same - // simple name in different packages produce one scenario ID between them, and a manifest whose - // scenario list silently merges two scenarios is the failure this refuses. - List scenarioIds = selectors.collect { String selector -> - selector.replaceFirst(/^.*\./, '') - .replace('#', '.') - .replaceAll('[^A-Za-z0-9._:-]', '-') - .replaceAll('-+', '-') - }.sort() - if (scenarioIds.toSet().size() != scenarioIds.size()) { - throw new GradleException('Messaging qualification scenario IDs are not unique.') - } - [ - scenarioIds: scenarioIds, - counts: [ - executed: executed, - passed: executed - failed - skipped, - failed: failed, - skipped: skipped - ] - ] -} - -// What the JSON Schema cannot say, and nothing else. -// -// The manifest used to be validated three times: this closure before the write, this closure again -// on the bytes it had just written, and MessagingEvidenceManifestSchemaValidator over the same bytes -// as a finalizer. Three validators is three definitions of "valid evidence", and the day they -// disagree there is no way to say which one is the schema. -// config/messaging/evidence/build-evidence-manifest-v1.schema.json is now the only structural -// answer — field set, types, SHA-256 patterns, identifier grammar, counts' bounds — and the second -// pass over the written bytes is gone because the finalizer already reads exactly those bytes. -// -// Four rules are kept here because the schema genuinely does not express them: -// 1. the manifest names the task that produced it (the schema lists all eleven legal producers); -// 2. executed == passed + failed + skipped (a schema cannot relate two numbers); -// 3. a run with a failure or a skip cannot be PASS evidence (the whole point of the artifact); -// 4. generatedAt parses as an instant — `format: date-time` is an annotation, not an assertion, -// unless a validator is configured to assert it. -def validateMessagingEvidenceStructure = { Map manifest, String expectedProducer -> - List violations = [] - if (manifest.producerTask != expectedProducer) { - violations << "producerTask is '${manifest.producerTask}', not '${expectedProducer}'" - } - if (manifest.counts?.executed != - (manifest.counts?.passed ?: 0) + (manifest.counts?.failed ?: 0) + - (manifest.counts?.skipped ?: 0)) { - violations << "counts do not add up: ${manifest.counts}" - } - if (manifest.counts?.failed != 0 || manifest.counts?.skipped != 0 || - manifest.failures != [] || manifest.skips != []) { - violations << 'failed or skipped qualification cannot produce PASS evidence' - } - try { - Instant.parse(manifest.generatedAt as String) - } catch (RuntimeException ignored) { - violations << "generatedAt '${manifest.generatedAt}' is not UTC date-time evidence" - } - if (!violations.isEmpty()) { - throw new GradleException( - "Messaging evidence fails the rules the manifest schema cannot express:\n " + - violations.join('\n ')) - } -} - -def writeMessagingEvidence = { - String producerTask, List resultDirectories, List commandTasks -> - Map result = messagingEvidenceFromXml(resultDirectories) - Map manifest = [ - schemaVersion: 1, - sourceDigest: messagingDigestProperty('messagingSourceDigest'), - artifactDigest: messagingDigestProperty('messagingArtifactDigest'), - producerTask: producerTask, - scenarioIds: result.scenarioIds, - counts: result.counts, - command: './gradlew ' + commandTasks.join(' ') + - ' -PmessagingSourceDigest= -PmessagingArtifactDigest= ' + - '-PmessagingProfileHash= --console=plain', - generatedAt: Instant.now().toString(), - hashes: [ - profile: messagingSha256Bytes(messagingProfileFile.bytes), - catalog: messagingSha256FileSet( - 'ca-skeleton.messaging.evidence.catalog.v1', - [file('config/messaging/readiness-cards.yaml')]), - schema: messagingSha256FileSet( - 'ca-skeleton.messaging.evidence.schema-set.v1', - [ - file('shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json') - ] + fileTree( - 'adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12' - ).files.toList()), - settings: messagingSha256FileSet( - 'ca-skeleton.messaging.evidence.settings.v1', - [ - file('adapter/outbound/messaging/build.gradle'), - file('adapter/outbound/messaging/gradle.lockfile') - ]) - ], - failures: [], - skips: [], - unsupportedClaims: [ - 'consumer-compatibility-full-suite', - 'durable-outbox-r2', - 'kafka-acknowledged-r2', - 'regex-engine-timeout', - 'remote-schema-resolution' - ] - ] - validateMessagingEvidenceStructure(manifest, producerTask) - File commonSchema = - file('config/messaging/evidence/build-evidence-manifest-v1.schema.json') - if (!commonSchema.isFile()) { - throw new GradleException('Common Messaging evidence schema is missing.') - } - File output = messagingEvidenceFile.get().asFile - output.parentFile.mkdirs() - output.text = JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + System.lineSeparator() - logger.lifecycle( - "${producerTask}: wrote payload-free evidence with ${result.counts.executed} scenarios.") -} - -def verifyMessagingJsonSchemaV1 = tasks.register('verifyMessagingJsonSchemaV1') { - group = 'verification' - description = 'Qualifies the deterministic local Draft 2020-12 envelope candidate.' - dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest' - dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph' - outputs.file(messagingEvidenceFile) - outputs.upToDateWhen { false } - doLast { - writeMessagingEvidence( - 'verifyMessagingJsonSchemaV1', - ['json-schema'], - [':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest', - 'verifyMessagingJsonSchemaV1']) - } -} - -def validateMessagingJsonSchemaV1EvidenceManifestSchema = - tasks.register('validateMessagingJsonSchemaV1EvidenceManifestSchema', JavaExec) { - group = 'verification' - description = - 'Validates the exact generated JSON qualification manifest bytes against the common Draft 2020-12 schema.' - dependsOn verifyMessagingJsonSchemaV1 - classpath = - project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath - mainClass = - 'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator' - args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json') - .absolutePath, - messagingEvidenceFile.get().asFile.absolutePath - inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')) - inputs.file(messagingEvidenceFile) - outputs.upToDateWhen { false } - } -verifyMessagingJsonSchemaV1.configure { - finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema -} - -def verifyMessagingContracts = tasks.register('verifyMessagingContracts') { - group = 'verification' - description = 'Qualifies the closed Task 3-6 contract, catalog, binding and schema candidate.' - dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema - dependsOn ':application-core:messagingApplicationContractQualificationTest' - dependsOn ':shared-contract:messagingSharedSchemaQualificationTest' - dependsOn ':adapter:outbound:messaging:messagingCompiledContractsQualificationTest' - dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest' - dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph' - outputs.file(messagingEvidenceFile) - outputs.upToDateWhen { false } - doLast { - writeMessagingEvidence( - 'verifyMessagingContracts', - ['application', 'shared', 'compiled', 'json-schema'], - [ - ':application-core:messagingApplicationContractQualificationTest', - ':shared-contract:messagingSharedSchemaQualificationTest', - ':adapter:outbound:messaging:messagingCompiledContractsQualificationTest', - ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest', - 'verifyMessagingContracts' - ]) - } -} - -def validateMessagingContractsEvidenceManifestSchema = - tasks.register('validateMessagingContractsEvidenceManifestSchema', JavaExec) { - group = 'verification' - description = - 'Validates the exact generated combined qualification manifest bytes against the common Draft 2020-12 schema.' - dependsOn verifyMessagingContracts - classpath = - project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath - mainClass = - 'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator' - args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json') - .absolutePath, - messagingEvidenceFile.get().asFile.absolutePath - inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')) - inputs.file(messagingEvidenceFile) - outputs.upToDateWhen { false } - } -verifyMessagingContracts.configure { - finalizedBy validateMessagingContractsEvidenceManifestSchema -} diff --git a/src/build-tools/build.gradle b/src/build-tools/build.gradle new file mode 100644 index 00000000..8f444b01 --- /dev/null +++ b/src/build-tools/build.gradle @@ -0,0 +1,56 @@ +plugins { + id 'java-gradle-plugin' +} + +// Repository verification tooling lives here, physically separate from compilation conventions. +// Precompiled plugins in this build should stay thin: task wiring here, verification algorithms in normal classes. + +dependencies { + implementation libs.jackson3.databind + testImplementation libs.junit.jupiter + testRuntimeOnly libs.junit.platform.launcher +} + + +gradlePlugin { + plugins { + notificationConfiguration { + id = 'ca.notification-configuration' + implementationClass = 'dev.caskeleton.buildtools.notificationconfig.NotificationConfigurationPlugin' + } + jpaEvidence { + id = 'ca.jpa-evidence' + implementationClass = 'dev.caskeleton.buildtools.jpa.JpaEvidencePlugin' + } + configContract { + id = 'ca.config-contract' + implementationClass = 'dev.caskeleton.buildtools.config.ConfigContractPlugin' + } + notificationEvidence { + id = 'ca.notification-evidence' + implementationClass = 'dev.caskeleton.buildtools.notification.NotificationEvidencePlugin' + } + notificationApiSurface { + id = 'ca.notification-api-surface' + implementationClass = 'dev.caskeleton.buildtools.notification.NotificationApiSurfacePlugin' + } + jpaQualification { + id = 'ca.jpa-qualification' + implementationClass = 'dev.caskeleton.buildtools.jpa.JpaQualificationPlugin' + } + messagingQualification { + id = 'ca.messaging-qualification' + implementationClass = 'dev.caskeleton.buildtools.messaging.MessagingQualificationPlugin' + } + messagingCertification { + id = 'ca.messaging-certification' + implementationClass = 'dev.caskeleton.buildtools.messaging.MessagingCertificationPlugin' + } + mongoVerification { + id = 'ca.mongo-verification' + implementationClass = 'dev.caskeleton.buildtools.mongo.MongoVerificationPlugin' + } + } +} + +tasks.named('test') { useJUnitPlatform() } diff --git a/src/build-tools/settings.gradle b/src/build-tools/settings.gradle new file mode 100644 index 00000000..93420bae --- /dev/null +++ b/src/build-tools/settings.gradle @@ -0,0 +1,14 @@ + +dependencyResolutionManagement { + versionCatalogs { + libs { + from(files('../gradle/libs.versions.toml')) + } + } + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'build-tools' diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/ConfigContractPlugin.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/ConfigContractPlugin.java new file mode 100644 index 00000000..d06f0d50 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/ConfigContractPlugin.java @@ -0,0 +1,42 @@ +package dev.caskeleton.buildtools.config; + +import java.io.File; +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class ConfigContractPlugin implements Plugin { + @Override + public void apply(Project project) { + Project root = project.getRootProject(); + project + .getTasks() + .register( + "verifyEnvKeys", + VerifyEnvKeysTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Verifies application.yml APP_ references, src/.env.example, and env-keys.yaml stay registered."); + task.dependsOn(":adapter:outbound:cache-redis:compileJava"); + task.getEnvFile().fileValue(root.file(".env.example")); + task.getApplicationYaml() + .fileValue(root.file("app-bootstrap/src/main/resources/application.yml")); + task.getRegistryFile().fileValue(root.file("../docs/registries/env-keys.yaml")); + task.getRedisSdkMetadata() + .fileValue( + root.file( + "adapter/outbound/cache-redis/build/classes/java/main/" + + "META-INF/spring-configuration-metadata.json")); + task.getProductionSources() + .from( + root.fileTree( + root.getProjectDir(), + tree -> { + tree.include("**/src/main/**/*.java"); + tree.include("**/src/main/**/application.yml"); + tree.exclude("sample-portfolio/**"); + tree.exclude("**/build/**"); + })); + }); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/EnvContractResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/EnvContractResult.java new file mode 100644 index 00000000..1dbacd46 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/EnvContractResult.java @@ -0,0 +1,15 @@ +package dev.caskeleton.buildtools.config; + +import java.util.List; + +public record EnvContractResult( + int envKeyCount, + int requiredPlaceholderCount, + int applicationReferenceCount, + int typedPropertyCount, + int consumedOrDeprecatedRegistryRowCount, + List warnings) { + public EnvContractResult { + warnings = List.copyOf(warnings); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/EnvContractVerifier.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/EnvContractVerifier.java new file mode 100644 index 00000000..a3a50602 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/EnvContractVerifier.java @@ -0,0 +1,306 @@ +package dev.caskeleton.buildtools.config; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** Repository policy for the operator-facing environment contract. No Gradle API. */ +public final class EnvContractVerifier { + private static final Pattern ENV_LINE = Pattern.compile("^([A-Z][A-Z0-9_]*)=.*"); + private static final Pattern PLACEHOLDER = + Pattern.compile("\\$\\{([A-Z][A-Z0-9_]*)(:[^}]*)?}"); + private static final Pattern SECRET_REFERENCE = + Pattern.compile("secret://environment/(APP_[A-Z][A-Z0-9_]*)"); + private static final Pattern REGISTRY_NAME = + Pattern.compile("^\\s*- name: (APP_[A-Z0-9_]+)"); + private static final Pattern REGISTRY_FIELD = + Pattern.compile("^\\s*([a-z_]+):\\s*(\\S.*)?$"); + private static final Pattern APP_KEY = Pattern.compile("APP_[A-Z][A-Z0-9_]*"); + private static final ObjectMapper JSON = + JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build(); + + private EnvContractVerifier() {} + + public static EnvContractResult verify( + File envFile, + File applicationYaml, + File registryFile, + Collection productionSources, + Collection metadataScopes, + Collection enforcedPrefixes) { + requireFile( + envFile, + "missing " + + envFile + + ". The tracked example is the contract an adopter copies; a real .env is operator " + + "input and is never read here."); + requireFile(applicationYaml, "missing " + applicationYaml); + requireFile(registryFile, "missing " + registryFile); + + Set envKeys = new TreeSet<>(); + for (String line : readLines(envFile)) { + Matcher matcher = ENV_LINE.matcher(line); + if (matcher.matches()) { + envKeys.add(matcher.group(1)); + } + } + + Set requiredPlaceholders = new TreeSet<>(); + Set allPlaceholders = new TreeSet<>(); + Matcher placeholderMatcher = PLACEHOLDER.matcher(read(applicationYaml)); + while (placeholderMatcher.find()) { + allPlaceholders.add(placeholderMatcher.group(1)); + if (placeholderMatcher.group(2) == null) { + requiredPlaceholders.add(placeholderMatcher.group(1)); + } + } + + Set environmentSecretReferences = new TreeSet<>(); + Matcher secretMatcher = SECRET_REFERENCE.matcher(read(applicationYaml)); + while (secretMatcher.find()) { + environmentSecretReferences.add(secretMatcher.group(1)); + } + + Set applicationAppReferences = new TreeSet<>(); + allPlaceholders.stream() + .filter(name -> name.startsWith("APP_")) + .forEach(applicationAppReferences::add); + applicationAppReferences.addAll(environmentSecretReferences); + + failUnlessEmpty( + difference(requiredPlaceholders, envKeys), + "application.yml references required env absent from src/.env.example: "); + + List registryRows = parseRegistryRows(registryFile); + Set registryAppKeys = new TreeSet<>(); + Set registryProperties = new TreeSet<>(); + for (EnvRegistryRow row : registryRows) { + registryAppKeys.add(row.name()); + row.property().ifPresent(registryProperties::add); + } + + failUnlessEmpty( + difference(registryAppKeys, envKeys), + "docs/registries/env-keys.yaml registers APP_ keys absent from src/.env.example, so an " + + "adopter copying the example never sees them: "); + + Set envAppKeys = new TreeSet<>(); + envKeys.stream().filter(name -> name.startsWith("APP_")).forEach(envAppKeys::add); + failUnlessEmpty( + difference(envAppKeys, registryAppKeys), + "src/.env.example declares APP_ keys absent from docs/registries/env-keys.yaml " + + "(registry is the SSOT for APP_ keys): "); + failUnlessEmpty( + difference(applicationAppReferences, registryAppKeys), + "application.yml references APP_ keys absent from docs/registries/env-keys.yaml " + + "(optional defaults and environment secret references are included): "); + + Set typedProperties = new TreeSet<>(); + Set missingMetadata = new TreeSet<>(); + for (MetadataScope scope : metadataScopes) { + if (!scope.metadataFile().exists()) { + missingMetadata.add(scope.propertyPrefix() + " (" + scope.metadataFile() + ")"); + continue; + } + SpringConfigurationMetadata metadata = parseMetadata(scope.metadataFile()); + metadata.properties().stream() + .map(SpringConfigurationProperty::name) + .filter(name -> name.startsWith(scope.propertyPrefix())) + .forEach(typedProperties::add); + } + if (!missingMetadata.isEmpty()) { + throw new IllegalStateException( + "verifyEnvKeys: configuration metadata is missing for " + + missingMetadata + + " — run the owning module's compileJava first (the annotation processor writes it), " + + "or the typed-property check silently covers nothing."); + } + + failUnlessEmpty( + difference(typedProperties, registryProperties), + "typed configuration properties absent from docs/registries/env-keys.yaml: ", + " — every bindable property needs a registry row carrying its official env name, type, " + + "default, secret classification and required_when."); + + Set scopedRegistryProperties = new TreeSet<>(); + for (String property : registryProperties) { + if (metadataScopes.stream().anyMatch(scope -> property.startsWith(scope.propertyPrefix()))) { + scopedRegistryProperties.add(property); + } + } + failUnlessEmpty( + difference(scopedRegistryProperties, typedProperties), + "docs/registries/env-keys.yaml declares properties that no typed settings class binds any more: ", + " — remove the row or restore the property."); + + Set consumed = new TreeSet<>(applicationAppReferences); + consumed.addAll(envAppKeys); + consumed.addAll(scanProductionSourceConsumers(productionSources)); + + Set unconsumed = new TreeSet<>(); + for (EnvRegistryRow row : registryRows) { + if (!consumed.contains(row.name()) && row.property().isEmpty() && !row.deprecatedOrphaned()) { + unconsumed.add(row.name()); + } + } + Set unconsumedOwned = new TreeSet<>(); + for (String name : unconsumed) { + if (enforcedPrefixes.stream().anyMatch(name::startsWith)) { + unconsumedOwned.add(name); + } + } + if (!unconsumedOwned.isEmpty()) { + throw new IllegalStateException( + "verifyEnvKeys: registered Redis keys that nothing reads — no typed property, no " + + "application.yml reference, no src/.env entry, no Java consumer, and not marked " + + "deprecated_orphaned: " + + unconsumedOwned + + ". Wire the key to a consumer, or mark the row deprecated_orphaned with a " + + "removal_deadline so a deployment still setting it is told rather than silently ignored."); + } + + Set unconsumedElsewhere = difference(unconsumed, unconsumedOwned); + List warnings = new ArrayList<>(); + if (!unconsumedElsewhere.isEmpty()) { + warnings.add( + "registered keys outside the Redis surface that nothing reads yet: " + + unconsumedElsewhere + + " — owned by the branch that registered them."); + } + + return new EnvContractResult( + envKeys.size(), + requiredPlaceholders.size(), + applicationAppReferences.size(), + typedProperties.size(), + registryRows.size() - unconsumed.size(), + warnings); + } + + private static SpringConfigurationMetadata parseMetadata(File file) { + SpringConfigurationMetadata metadata = + JSON.readValue(file, SpringConfigurationMetadata.class); + return metadata.properties() == null + ? new SpringConfigurationMetadata(List.of()) + : metadata; + } + + private static List parseRegistryRows(File registryFile) { + List rows = new ArrayList<>(); + RowBuilder current = null; + for (String line : readLines(registryFile)) { + Matcher nameMatcher = REGISTRY_NAME.matcher(line); + if (nameMatcher.find()) { + if (current != null) { + rows.add(current.build()); + } + current = new RowBuilder(nameMatcher.group(1)); + continue; + } + if (current == null) { + continue; + } + Matcher fieldMatcher = REGISTRY_FIELD.matcher(line); + if (!fieldMatcher.find()) { + continue; + } + String key = fieldMatcher.group(1); + String value = fieldMatcher.group(2) == null ? "" : fieldMatcher.group(2).trim(); + if (key.equals("property")) { + current.property = value; + } else if (key.equals("deprecated_orphaned")) { + current.deprecatedOrphaned = Boolean.parseBoolean(value); + } + } + if (current != null) { + rows.add(current.build()); + } + return List.copyOf(rows); + } + + private static Set scanProductionSourceConsumers(Collection productionSources) { + Set consumed = new TreeSet<>(); + for (File source : productionSources) { + if (!source.isFile()) { + continue; + } + Matcher matcher = APP_KEY.matcher(read(source)); + while (matcher.find()) { + consumed.add(matcher.group()); + } + } + return consumed; + } + + private static void requireFile(File file, String message) { + if (!file.exists()) { + throw new IllegalStateException("verifyEnvKeys: " + message); + } + } + + private static void failUnlessEmpty( + Collection values, String prefix) { + failUnlessEmpty(values, prefix, ""); + } + + private static void failUnlessEmpty( + Collection values, String prefix, String suffix) { + if (!values.isEmpty()) { + throw new IllegalStateException("verifyEnvKeys: " + prefix + values + suffix); + } + } + + private static Set difference(Collection left, Collection right) { + Set result = new TreeSet<>(left); + result.removeAll(new HashSet<>(right)); + return result; + } + + private static String read(File file) { + try { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } + + private static List readLines(File file) { + try { + return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } + + private record SpringConfigurationMetadata(List properties) {} + + private record SpringConfigurationProperty(String name) {} + + private static final class RowBuilder { + private final String name; + private String property; + private boolean deprecatedOrphaned; + + private RowBuilder(String name) { + this.name = name; + } + + private EnvRegistryRow build() { + return new EnvRegistryRow(name, Optional.ofNullable(property), deprecatedOrphaned); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/EnvRegistryRow.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/EnvRegistryRow.java new file mode 100644 index 00000000..2be793bd --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/EnvRegistryRow.java @@ -0,0 +1,12 @@ +package dev.caskeleton.buildtools.config; + +import java.util.Objects; +import java.util.Optional; + +public record EnvRegistryRow( + String name, Optional property, boolean deprecatedOrphaned) { + public EnvRegistryRow { + Objects.requireNonNull(name, "name"); + property = property == null ? Optional.empty() : property; + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/MetadataScope.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/MetadataScope.java new file mode 100644 index 00000000..d4e54554 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/MetadataScope.java @@ -0,0 +1,11 @@ +package dev.caskeleton.buildtools.config; + +import java.io.File; +import java.util.Objects; + +public record MetadataScope(String propertyPrefix, File metadataFile) { + public MetadataScope { + Objects.requireNonNull(propertyPrefix, "propertyPrefix"); + Objects.requireNonNull(metadataFile, "metadataFile"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/VerifyEnvKeysTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/VerifyEnvKeysTask.java new file mode 100644 index 00000000..ec72a7df --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/config/VerifyEnvKeysTask.java @@ -0,0 +1,72 @@ +package dev.caskeleton.buildtools.config; + +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Repository contract verification emits no reusable output") +public abstract class VerifyEnvKeysTask extends DefaultTask { + private static final List ENFORCED_PREFIXES = + List.of( + "APP_REDIS_", + "APP_CACHE_REDIS_", + "APP_RATE_LIMIT_REDIS_", + "APP_IDEMPOTENCY_REDIS_", + "APP_LEASE_REDIS_", + "APP_SESSION_REDIS_"); + + @InputFile + public abstract RegularFileProperty getEnvFile(); + + @InputFile + public abstract RegularFileProperty getApplicationYaml(); + + @InputFile + public abstract RegularFileProperty getRegistryFile(); + + @Optional + @InputFile + public abstract RegularFileProperty getRedisSdkMetadata(); + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getProductionSources(); + + @TaskAction + public void verifyContract() { + EnvContractResult result; + try { + result = + EnvContractVerifier.verify( + getEnvFile().get().getAsFile(), + getApplicationYaml().get().getAsFile(), + getRegistryFile().get().getAsFile(), + getProductionSources().getFiles(), + List.of( + new MetadataScope( + "app.redis.", getRedisSdkMetadata().get().getAsFile())), + ENFORCED_PREFIXES); + } catch (IllegalStateException invalidContract) { + throw new GradleException(invalidContract.getMessage(), invalidContract); + } + + result.warnings().forEach(warning -> getLogger().warn("verifyEnvKeys: {}", warning)); + getLogger() + .lifecycle( + "verifyEnvKeys: OK — {} env keys, {} required placeholders covered, {} application APP_ references registered, {} typed properties registered, {} rows with a consumer or a deprecation.", + result.envKeyCount(), + result.requiredPlaceholderCount(), + result.applicationReferenceCount(), + result.typedPropertyCount(), + result.consumedOrDeprecatedRegistryRowCount()); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/GenerateJpaEvidenceManifestsTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/GenerateJpaEvidenceManifestsTask.java new file mode 100644 index 00000000..bd6857e3 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/GenerateJpaEvidenceManifestsTask.java @@ -0,0 +1,372 @@ +package dev.caskeleton.buildtools.jpa; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import javax.inject.Inject; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.FileSystemOperations; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.TaskAction; +import org.gradle.api.services.ServiceReference; +import org.gradle.process.ExecOperations; +import org.gradle.process.ExecResult; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Evidence contains execution state and a generation timestamp") +public abstract class GenerateJpaEvidenceManifestsTask extends DefaultTask { + private final ExecOperations execOperations; + private final FileSystemOperations fileSystemOperations; + + @Inject + public GenerateJpaEvidenceManifestsTask( + ExecOperations execOperations, FileSystemOperations fileSystemOperations) { + this.execOperations = execOperations; + this.fileSystemOperations = fileSystemOperations; + } + + @InputFile + public abstract RegularFileProperty getRegistryFile(); + + @OutputDirectory + public abstract DirectoryProperty getEvidenceOutputDirectory(); + + @Input + public abstract Property getPostgreSqlImage(); + + @Input + public abstract Property getEvidenceProfile(); + + @Input + public abstract Property getCiJob(); + + @Input + public abstract Property getArtifactLocation(); + + @Input + public abstract Property getTopology(); + + @Input + public abstract Property getSourceRevision(); + + @Input + public abstract Property getTraceableVersion(); + + @Input + public abstract Property getPgjdbcVersion(); + + @Input + public abstract Property getHibernateVersion(); + + @Input + public abstract Property getFlywayVersion(); + + @Input + public abstract Property getRepositoryRootPath(); + + @Input + public abstract MapProperty getJUnitResultDirectories(); + + @ServiceReference("jpaEvidenceExecution") + public abstract Property getExecutionService(); + + @TaskAction + public void generate() { + JpaReadinessRegistry registry = + JpaEvidenceTaskSupport.loadRegistry(getRegistryFile().get().getAsFile()); + List activeCards = registry.activeCards(); + + String profile = getEvidenceProfile().get(); + if (!profile.equals("candidate") && !profile.equals("r2")) { + throw new GradleException( + "jpaEvidenceProfile must be candidate or r2; got '" + profile + "'"); + } + String ciJob = getCiJob().get(); + String configuredArtifactLocation = getArtifactLocation().get(); + String topology = getTopology().get(); + + String worktreeStatus = + runCommand( + List.of("git", "status", "--porcelain=v1", "--untracked-files=all"), true); + boolean worktreeDirty = !worktreeStatus.isBlank(); + String worktreeStatusDigest = JpaEvidenceVerifier.sha256(worktreeStatus); + String image = getPostgreSqlImage().get(); + String imageDigest = + runCommand( + List.of( + "docker", "image", "inspect", "--format={{index .RepoDigests 0}}", image), + false); + JpaDependencyVersions dependencyVersions = + new JpaDependencyVersions( + getPgjdbcVersion().get(), getHibernateVersion().get(), getFlywayVersion().get()); + JpaEvidenceTestResultLocator testResultsByTask = + new JpaEvidenceTestResultLocator( + java.nio.file.Path.of(getRepositoryRootPath().get()), getJUnitResultDirectories().get()); + JpaEvidenceExecutionService executionService = getExecutionService().get(); + + List productionMetadataBlockers = new ArrayList<>(); + if (profile.equals("r2")) { + if (worktreeDirty) { + productionMetadataBlockers.add("worktree-is-dirty"); + } + if (ciJob.isBlank()) { + productionMetadataBlockers.add("missing-JPA_EVIDENCE_CI_JOB"); + } + if (configuredArtifactLocation.isBlank()) { + productionMetadataBlockers.add("missing-JPA_EVIDENCE_ARTIFACT_LOCATION"); + } else if (!configuredArtifactLocation.matches("(?i)(https|s3|gs)://\\S+")) { + productionMetadataBlockers.add("artifact-location-is-not-externally-retained"); + } + } + if (!imageDigest.matches(".+@sha256:[0-9a-f]{64}")) { + productionMetadataBlockers.add("missing-immutable-postgresql-image-digest"); + } + if (dependencyVersions.pgjdbc().isBlank()) { + productionMetadataBlockers.add("missing-pgjdbc-version"); + } + if (dependencyVersions.hibernate().isBlank()) { + productionMetadataBlockers.add("missing-hibernate-version"); + } + if (dependencyVersions.flyway().isBlank()) { + productionMetadataBlockers.add("missing-flyway-version"); + } + + File outputDirectory = getEvidenceOutputDirectory().get().getAsFile(); + fileSystemOperations.delete(spec -> spec.delete(outputDirectory)); + if (!outputDirectory.mkdirs() && !outputDirectory.isDirectory()) { + throw new GradleException("Could not create JPA evidence directory " + outputDirectory); + } + + Map manifests = new HashMap<>(); + Map manifestIds = new HashMap<>(); + for (JpaReadinessCard card : activeCards) { + String cardId = card.id(); + List testResults = new ArrayList<>(); + if (cardId.equals("jpa-primary-foundation")) { + for (String prerequisite : card.prerequisites()) { + JpaGeneratedEvidenceManifest prerequisiteManifest = manifests.get(prerequisite); + if (prerequisiteManifest != null) { + testResults.add(prerequisiteManifest.testResult()); + } + } + } else { + testResults.add(testResultsByTask.read(card.readinessTask())); + for (String supportPath : card.supportTasks()) { + if (getJUnitResultDirectories().get().containsKey(supportPath)) { + testResults.add(testResultsByTask.read(supportPath)); + } + } + } + + Set executedSelectors = new TreeSet<>(); + testResults.forEach(result -> executedSelectors.addAll(result.executedSelectors())); + Set covered = new TreeSet<>(); + JpaCardEvidence cardEvidence = + card.evidence() + .orElseThrow( + () -> new GradleException(cardId + ": active card has no typed evidence declaration")); + for (JpaEvidenceScenario scenario : cardEvidence.scenarios()) { + if (executedSelectors.contains(scenario.selector())) { + covered.addAll(scenario.covers()); + } + } + for (JpaEvidenceTaskClaim taskClaim : cardEvidence.taskClaims()) { + if (executionService.completedSuccessfully(taskClaim.task())) { + covered.addAll(taskClaim.covers()); + } + } + + int executedTestCount = JpaEvidenceTaskSupport.sumExecuted(testResults); + int skippedOrAbortedCount = JpaEvidenceTaskSupport.sumSkipped(testResults); + int failureCount = JpaEvidenceTaskSupport.sumFailures(testResults); + int errorCount = JpaEvidenceTaskSupport.sumErrors(testResults); + boolean noSkipResult = + executedTestCount > 0 + && skippedOrAbortedCount == 0 + && failureCount == 0 + && errorCount == 0; + if (noSkipResult) { + covered.add("no-skip"); + } + if (cardId.equals("jpa-primary-foundation") + && card.prerequisites().stream().allMatch(manifestIds::containsKey)) { + covered.add("base-card-manifests"); + } + + List required = JpaEvidenceVerifier.requiredEvidence(card); + List coveredList = + covered.stream().filter(required::contains).sorted().toList(); + List missing = new ArrayList<>(required); + missing.removeAll(coveredList); + missing.sort(String::compareTo); + + List prerequisites = new ArrayList<>(); + for (String prerequisiteId : card.prerequisites()) { + JpaGeneratedEvidenceManifest prerequisiteManifest = manifests.get(prerequisiteId); + String prerequisiteManifestId = manifestIds.get(prerequisiteId); + if (prerequisiteManifest == null || prerequisiteManifestId == null) { + throw new GradleException( + cardId + + ": prerequisite manifest '" + + prerequisiteId + + "' was not produced first"); + } + prerequisites.add( + new JpaPrerequisiteEvidence( + prerequisiteId, + prerequisiteManifest.cardVersion(), + prerequisiteManifestId, + prerequisiteManifest.attainedReadiness())); + } + + List readinessBlockers = new ArrayList<>(); + if (profile.equals("candidate")) { + readinessBlockers.add("candidate-profile-is-not-release-evidence"); + } + readinessBlockers.addAll(productionMetadataBlockers); + for (String requirement : missing) { + readinessBlockers.add("missing-evidence:" + requirement); + } + for (JpaPrerequisiteEvidence prerequisite : prerequisites) { + if (!prerequisite.attainedReadiness().equals("R2")) { + readinessBlockers.add("prerequisite-not-R2:" + prerequisite.cardId()); + } + } + List sortedReadinessBlockers = + JpaEvidenceTaskSupport.sortedDistinct(readinessBlockers); + + boolean attainedR2 = + profile.equals("r2") && sortedReadinessBlockers.isEmpty() && missing.isEmpty(); + String generatedAt = Instant.now().toString(); + String cardVersion = + card.migration() + .map(migration -> Integer.toString(migration.featureRevision())) + .orElse(getTraceableVersion().get()); + String evidenceGrade = + cardId.equals("jpa-primary-foundation") + ? "E1" + : covered.stream() + .anyMatch( + claim -> + Set.of( + "concurrency", + "fault", + "publish-fault", + "migration", + "query-plan", + "optimistic-conflict") + .contains(claim)) + ? "E3" + : "E2"; + JpaGeneratedMigrationEvidence migration = + card.migration() + .map( + spec -> + new JpaGeneratedMigrationEvidence( + spec.location(), + spec.historyTable(), + spec.requiredCoreEpoch(), + spec.featureRevision(), + spec.lifecycleEvidence())) + .orElse(null); + JpaGeneratedTestResult combinedTestResult = + new JpaGeneratedTestResult( + JpaEvidenceTaskSupport.allTasks(testResults), + JpaEvidenceTaskSupport.allResultDirectories(testResults), + executedTestCount, + skippedOrAbortedCount, + failureCount, + errorCount, + noSkipResult, + List.copyOf(executedSelectors)); + + JpaGeneratedEvidenceManifest manifest = + new JpaGeneratedEvidenceManifest( + 1, + cardId, + cardVersion, + card.state().externalValue(), + attainedR2 ? "R2" : "R1", + evidenceGrade, + profile, + prerequisites, + new JpaGeneratedSourceEvidence( + getSourceRevision().get(), worktreeDirty, worktreeStatusDigest), + new JpaGeneratedProducerEvidence(card.readinessTask(), ciJob), + combinedTestResult, + required, + coveredList, + missing, + sortedReadinessBlockers, + new JpaGeneratedPostgresqlEvidence(image, imageDigest, "16"), + dependencyVersions, + generatedAt, + generatedAt.substring(0, 10), + topology, + configuredArtifactLocation, + migration, + card.dispatchModes()); + + String contentHash = + JpaEvidenceVerifier.sha256(JpaEvidenceVerifier.canonicalJson(manifest)); + File cardDirectory = new File(outputDirectory, cardId); + if (!cardDirectory.mkdirs() && !cardDirectory.isDirectory()) { + throw new GradleException("Could not create JPA evidence card directory " + cardDirectory); + } + File manifestFile = new File(cardDirectory, contentHash + ".json"); + try { + Files.writeString( + manifestFile.toPath(), JpaEvidenceVerifier.prettyJson(manifest), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to write " + manifestFile, exception); + } + manifests.put(cardId, manifest); + manifestIds.put(cardId, "sha256:" + contentHash); + } + + getLogger() + .lifecycle( + "generateJpaEvidenceManifests: wrote {} {} content-addressed card manifests to {}", + manifests.size(), + profile, + outputDirectory); + } + + private String runCommand(List command, boolean requireSuccess) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ExecResult result = + execOperations.exec( + spec -> { + spec.setCommandLine(command); + spec.setIgnoreExitValue(true); + spec.setStandardOutput(output); + }); + String text = output.toString(StandardCharsets.UTF_8).trim(); + if (requireSuccess && result.getExitValue() != 0) { + throw new GradleException( + "JPA evidence: `" + + String.join(" ", command) + + "` exited " + + result.getExitValue() + + ". Its result is a precondition of the evidence, not an optional detail — an unavailable command must not be read as a satisfied condition."); + } + return text; + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaCardEvidence.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaCardEvidence.java new file mode 100644 index 00000000..2d3ff0c5 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaCardEvidence.java @@ -0,0 +1,11 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; + +public record JpaCardEvidence( + List scenarios, List taskClaims) { + public JpaCardEvidence { + scenarios = List.copyOf(scenarios); + taskClaims = List.copyOf(taskClaims); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaCardState.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaCardState.java new file mode 100644 index 00000000..5a703df3 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaCardState.java @@ -0,0 +1,26 @@ +package dev.caskeleton.buildtools.jpa; + +public enum JpaCardState { + SELECTED("selected"), + IMPLEMENTED_CANDIDATE("implemented-candidate"), + NOT_IMPLEMENTED("not-implemented"); + + private final String externalValue; + + JpaCardState(String externalValue) { + this.externalValue = externalValue; + } + + public String externalValue() { + return externalValue; + } + + public static JpaCardState parse(String value) { + for (JpaCardState state : values()) { + if (state.externalValue.equals(value)) { + return state; + } + } + throw new IllegalStateException("invalid JPA card state '" + value + "'"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaDependencyVersions.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaDependencyVersions.java new file mode 100644 index 00000000..645993eb --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaDependencyVersions.java @@ -0,0 +1,11 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaDependencyVersions(String pgjdbc, String hibernate, String flyway) { + public JpaDependencyVersions { + Objects.requireNonNull(pgjdbc, "pgjdbc"); + Objects.requireNonNull(hibernate, "hibernate"); + Objects.requireNonNull(flyway, "flyway"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceExecutionService.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceExecutionService.java new file mode 100644 index 00000000..91dc24b7 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceExecutionService.java @@ -0,0 +1,45 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.gradle.api.services.BuildService; +import org.gradle.api.services.BuildServiceParameters; +import org.gradle.tooling.events.FinishEvent; +import org.gradle.tooling.events.OperationCompletionListener; +import org.gradle.tooling.events.task.TaskFailureResult; +import org.gradle.tooling.events.task.TaskFinishEvent; +import org.gradle.tooling.events.task.TaskSkippedResult; +import org.gradle.tooling.events.task.TaskSuccessResult; + +public abstract class JpaEvidenceExecutionService + implements BuildService, OperationCompletionListener { + private final Map outcomes = new ConcurrentHashMap<>(); + + @Override + public void onFinish(FinishEvent event) { + if (!(event instanceof TaskFinishEvent taskEvent)) { + return; + } + String taskPath = taskEvent.getDescriptor().getTaskPath(); + if (taskEvent.getResult() instanceof TaskSuccessResult) { + record(taskPath, JpaEvidenceTaskOutcome.SUCCESS); + } else if (taskEvent.getResult() instanceof TaskFailureResult) { + record(taskPath, JpaEvidenceTaskOutcome.FAILED); + } else if (taskEvent.getResult() instanceof TaskSkippedResult) { + record(taskPath, JpaEvidenceTaskOutcome.SKIPPED); + } + } + + void record(String taskPath, JpaEvidenceTaskOutcome outcome) { + outcomes.put(taskPath, outcome); + } + + Optional outcome(String taskPath) { + return Optional.ofNullable(outcomes.get(taskPath)); + } + + public boolean completedSuccessfully(String taskPath) { + return outcomes.get(taskPath) == JpaEvidenceTaskOutcome.SUCCESS; + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceManifest.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceManifest.java new file mode 100644 index 00000000..d1be0473 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceManifest.java @@ -0,0 +1,33 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; +import java.util.Objects; + +public record JpaEvidenceManifest( + String cardId, + String attainedReadiness, + String profile, + String evidenceGrade, + List missingEvidence, + List readinessBlockers, + JpaTestResult testResult, + JpaPostgresqlEvidence postgresql, + JpaDependencyVersions dependencies, + JpaSourceEvidence source, + JpaProducerEvidence producer, + String artifactLocation) { + public JpaEvidenceManifest { + Objects.requireNonNull(cardId, "cardId"); + Objects.requireNonNull(attainedReadiness, "attainedReadiness"); + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(evidenceGrade, "evidenceGrade"); + missingEvidence = List.copyOf(missingEvidence); + readinessBlockers = List.copyOf(readinessBlockers); + Objects.requireNonNull(testResult, "testResult"); + Objects.requireNonNull(postgresql, "postgresql"); + Objects.requireNonNull(dependencies, "dependencies"); + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(producer, "producer"); + Objects.requireNonNull(artifactLocation, "artifactLocation"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidencePlugin.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidencePlugin.java new file mode 100644 index 00000000..8fa78cd3 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidencePlugin.java @@ -0,0 +1,229 @@ +package dev.caskeleton.buildtools.jpa; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.inject.Inject; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.testing.Test; +import org.gradle.build.event.BuildEventsListenerRegistry; + +public final class JpaEvidencePlugin implements Plugin { + private final BuildEventsListenerRegistry buildEventsListenerRegistry; + + @Inject + public JpaEvidencePlugin(BuildEventsListenerRegistry buildEventsListenerRegistry) { + this.buildEventsListenerRegistry = buildEventsListenerRegistry; + } + + @Override + public void apply(Project project) { + Project root = project.getRootProject(); + File registryFile = root.file("config/jpa/readiness-cards.yaml"); + var outputDirectory = project.getLayout().getBuildDirectory().dir("jpa-evidence/manifests"); + + Provider executionService = + project + .getGradle() + .getSharedServices() + .registerIfAbsent( + "jpaEvidenceExecution", JpaEvidenceExecutionService.class, ignored -> {}); + buildEventsListenerRegistry.onTaskCompletion(executionService); + + project + .getTasks() + .register( + "verifyJpaSqlConstructionSafety", + VerifyJpaSqlConstructionSafetyTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Rejects non-parameterized PostgreSQL set_config values anywhere in this leaf."); + task.getMainSource() + .set(project.getLayout().getProjectDirectory().dir("src/main/java")); + }); + + TaskProvider generate = + project + .getTasks() + .register( + "generateJpaEvidenceManifests", + GenerateJpaEvidenceManifestsTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Runs active JPA card producers and writes content-addressed candidate/R2 manifests."); + task.dependsOn(root.getTasks().named("verifyJpaReadinessRegistry")); + task.getRegistryFile().fileValue(registryFile); + task.getEvidenceOutputDirectory().set(outputDirectory); + task.getPostgreSqlImage() + .convention( + project + .getProviders() + .gradleProperty("jpaPostgreSqlEvidenceImage") + .orElse("postgres:16-alpine")); + + var profile = + project + .getProviders() + .gradleProperty("jpaEvidenceProfile") + .orElse(project.getProviders().environmentVariable("JPA_EVIDENCE_PROFILE")) + .orElse("candidate"); + task.getEvidenceProfile().convention(profile); + task.getCiJob() + .convention( + project + .getProviders() + .environmentVariable("JPA_EVIDENCE_CI_JOB") + .orElse( + profile.map( + value -> + value.equals("candidate") + ? "local-unpublished" + : ""))); + String candidateArtifactLocation = + root.relativePath(outputDirectory.get().getAsFile()); + task.getArtifactLocation() + .convention( + project + .getProviders() + .environmentVariable("JPA_EVIDENCE_ARTIFACT_LOCATION") + .orElse( + profile.map( + value -> + value.equals("candidate") + ? candidateArtifactLocation + : ""))); + task.getTopology() + .convention( + project + .getProviders() + .environmentVariable("JPA_EVIDENCE_TOPOLOGY") + .orElse("single-postgresql-testcontainer")); + task.getSourceRevision().set(configurationString(root, "sourceRevision")); + task.getTraceableVersion().set(configurationString(root, "traceableVersion")); + task.getRepositoryRootPath().set(root.getProjectDir().getAbsolutePath()); + task.getExecutionService().set(executionService); + task.usesService(executionService); + + var runtimeClasspath = + project + .getConfigurations() + .getByName("postgresqlIntegrationTestRuntimeClasspath"); + Provider dependencyVersions = + project + .getProviders() + .provider( + () -> JpaEvidenceTaskSupport.resolvedVersions(runtimeClasspath)); + task.getPgjdbcVersion().set(dependencyVersions.map(JpaDependencyVersions::pgjdbc)); + task.getHibernateVersion() + .set(dependencyVersions.map(JpaDependencyVersions::hibernate)); + task.getFlywayVersion().set(dependencyVersions.map(JpaDependencyVersions::flyway)); + + Map junitResultDirectories = new LinkedHashMap<>(); + JpaReadinessRegistry registry = + JpaEvidenceTaskSupport.loadRegistry(registryFile); + for (JpaReadinessCard card : registry.activeCards()) { + if (!card.id().equals("jpa-primary-foundation")) { + Task readinessTask = + JpaEvidenceTaskSupport.taskAtPath(root, card.readinessTask()); + task.dependsOn(readinessTask); + if (!(readinessTask instanceof Test readinessTest)) { + throw new GradleException( + card.id() + + ": readiness task " + + readinessTask.getPath() + + " must be a Test task"); + } + junitResultDirectories.put( + readinessTask.getPath(), junitResultDirectory(readinessTest)); + } + for (String supportPath : card.supportTasks()) { + Task supportTask = JpaEvidenceTaskSupport.taskAtPath(root, supportPath); + task.dependsOn(supportTask); + if (supportTask instanceof Test supportTest) { + junitResultDirectories.put( + supportTask.getPath(), junitResultDirectory(supportTest)); + } + } + } + task.getJUnitResultDirectories().set(junitResultDirectories); + task.getOutputs().upToDateWhen(ignored -> false); + }); + + project + .getGradle() + .getTaskGraph() + .whenReady( + graph -> { + if (!graph.hasTask(generate.get())) { + return; + } + project + .getTasks() + .named("test", Test.class) + .get() + .getOutputs() + .upToDateWhen(ignored -> false); + Project appBootstrap = root.findProject(":app-bootstrap"); + if (appBootstrap != null) { + appBootstrap + .getTasks() + .named("test", Test.class) + .get() + .getOutputs() + .upToDateWhen(ignored -> false); + } + }); + + project + .getTasks() + .register( + "verifyJpaCandidateEvidence", + VerifyJpaCandidateEvidenceTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Validates hashes, schema, exact JUnit selectors, no-skip, and prerequisite links without claiming R2."); + task.dependsOn(generate); + task.getRegistryFile().fileValue(registryFile); + task.getEvidenceOutputDirectory().set(outputDirectory); + task.getOutputs().upToDateWhen(ignored -> false); + }); + + project + .getTasks() + .register( + "verifyJpaPrimaryFoundationEvidence", + VerifyJpaPrimaryFoundationEvidenceTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Requires complete immutable base-card manifests from a clean, retained CI R2 evidence lane."); + task.dependsOn(generate); + task.getRegistryFile().fileValue(registryFile); + task.getEvidenceOutputDirectory().set(outputDirectory); + task.getOutputs().upToDateWhen(ignored -> false); + }); + } + + private static String configurationString(Project project, String name) { + var extra = project.getExtensions().getExtraProperties(); + return extra.has(name) ? String.valueOf(extra.get(name)) : ""; + } + + private static String junitResultDirectory(Test test) { + return test + .getReports() + .getJunitXml() + .getOutputLocation() + .get() + .getAsFile() + .getAbsolutePath(); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceScenario.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceScenario.java new file mode 100644 index 00000000..93770f53 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceScenario.java @@ -0,0 +1,11 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; +import java.util.Objects; + +public record JpaEvidenceScenario(String selector, List covers) { + public JpaEvidenceScenario { + Objects.requireNonNull(selector, "selector"); + covers = List.copyOf(covers); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskClaim.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskClaim.java new file mode 100644 index 00000000..5c7d952c --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskClaim.java @@ -0,0 +1,11 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; +import java.util.Objects; + +public record JpaEvidenceTaskClaim(String task, List covers) { + public JpaEvidenceTaskClaim { + Objects.requireNonNull(task, "task"); + covers = List.copyOf(covers); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskOutcome.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskOutcome.java new file mode 100644 index 00000000..ff8d5938 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskOutcome.java @@ -0,0 +1,7 @@ +package dev.caskeleton.buildtools.jpa; + +enum JpaEvidenceTaskOutcome { + SUCCESS, + FAILED, + SKIPPED +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskSupport.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskSupport.java new file mode 100644 index 00000000..343b00c1 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTaskSupport.java @@ -0,0 +1,100 @@ +package dev.caskeleton.buildtools.jpa; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.result.ResolvedComponentResult; + +final class JpaEvidenceTaskSupport { + private JpaEvidenceTaskSupport() {} + + static JpaReadinessRegistry loadRegistry(File registryFile) { + try { + return JpaReadinessRegistryParser.parse( + Files.readString(registryFile.toPath(), StandardCharsets.UTF_8)); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + registryFile, exception); + } + } + + static Task taskAtPath(Project rootProject, String absoluteTaskPath) { + int separator = absoluteTaskPath.lastIndexOf(':'); + if (separator < 0 || separator == absoluteTaskPath.length() - 1) { + throw new GradleException("Invalid absolute Gradle task path '" + absoluteTaskPath + "'"); + } + String projectPath = separator == 0 ? ":" : absoluteTaskPath.substring(0, separator); + String taskName = absoluteTaskPath.substring(separator + 1); + Project owner = rootProject.findProject(projectPath); + if (owner == null) { + throw new GradleException("Unknown project for JPA evidence task '" + absoluteTaskPath + "'"); + } + Task task = owner.getTasks().findByName(taskName); + if (task == null) { + throw new GradleException("Missing JPA evidence task '" + absoluteTaskPath + "'"); + } + return task; + } + + static JpaDependencyVersions resolvedVersions(Configuration configuration) { + String pgjdbc = ""; + String hibernate = ""; + String flyway = ""; + for (ResolvedComponentResult component : + configuration.getIncoming().getResolutionResult().getAllComponents()) { + if (!(component.getId() instanceof ModuleComponentIdentifier id)) { + continue; + } + String coordinate = id.getGroup() + ":" + id.getModule(); + if (coordinate.equals("org.postgresql:postgresql")) { + pgjdbc = id.getVersion(); + } else if (coordinate.equals("org.hibernate.orm:hibernate-core")) { + hibernate = id.getVersion(); + } else if (coordinate.equals("org.flywaydb:flyway-core")) { + flyway = id.getVersion(); + } + } + return new JpaDependencyVersions(pgjdbc, hibernate, flyway); + } + + static List sortedDistinct(List values) { + return new TreeSet<>(values).stream().toList(); + } + + static int sumExecuted(List values) { + return values.stream().mapToInt(JpaGeneratedTestResult::executedTestCount).sum(); + } + + static int sumSkipped(List values) { + return values.stream().mapToInt(JpaGeneratedTestResult::skippedOrAbortedCount).sum(); + } + + static int sumFailures(List values) { + return values.stream().mapToInt(JpaGeneratedTestResult::failureCount).sum(); + } + + static int sumErrors(List values) { + return values.stream().mapToInt(JpaGeneratedTestResult::errorCount).sum(); + } + + static List allTasks(List results) { + List values = new ArrayList<>(); + results.forEach(result -> values.addAll(result.tasks())); + return sortedDistinct(values); + } + + static List allResultDirectories(List results) { + List values = new ArrayList<>(); + results.forEach(result -> values.addAll(result.resultDirectories())); + return sortedDistinct(values); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTestResultLocator.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTestResultLocator.java new file mode 100644 index 00000000..f384986c --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTestResultLocator.java @@ -0,0 +1,48 @@ +package dev.caskeleton.buildtools.jpa; + +import dev.caskeleton.buildtools.junit.JUnitEvidenceReader; +import dev.caskeleton.buildtools.junit.JUnitEvidenceResult; +import java.io.File; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +final class JpaEvidenceTestResultLocator { + private final Path repositoryRoot; + private final Map resultDirectories; + + JpaEvidenceTestResultLocator(Path repositoryRoot, Map resultDirectories) { + this.repositoryRoot = repositoryRoot.toAbsolutePath().normalize(); + this.resultDirectories = Map.copyOf(resultDirectories); + } + + JpaGeneratedTestResult read(String taskPath) { + String configuredDirectory = resultDirectories.get(taskPath); + if (configuredDirectory == null || configuredDirectory.isBlank()) { + throw new IllegalStateException( + taskPath + ": no configured JUnit result directory for JPA evidence"); + } + + Path resultDirectory = Path.of(configuredDirectory).toAbsolutePath().normalize(); + JUnitEvidenceResult result = JUnitEvidenceReader.read(taskPath, resultDirectory.toFile()); + String renderedDirectory = renderDirectory(resultDirectory); + + return new JpaGeneratedTestResult( + List.of(taskPath), + List.of(renderedDirectory), + result.tests(), + result.skipped(), + result.failures(), + result.errors(), + result.isClean(), + new TreeSet<>(result.executedSelectors()).stream().toList()); + } + + private String renderDirectory(Path resultDirectory) { + if (resultDirectory.startsWith(repositoryRoot)) { + return repositoryRoot.relativize(resultDirectory).toString().replace(File.separatorChar, '/'); + } + return resultDirectory.toString(); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceVerificationResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceVerificationResult.java new file mode 100644 index 00000000..3ae41a91 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceVerificationResult.java @@ -0,0 +1,18 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; + +public record JpaEvidenceVerificationResult( + List violations, List manifests) { + public JpaEvidenceVerificationResult { + violations = List.copyOf(violations); + manifests = List.copyOf(manifests); + } + + public JpaEvidenceManifest manifest(String cardId) { + return manifests.stream() + .filter(manifest -> manifest.cardId().equals(cardId)) + .findFirst() + .orElse(null); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceVerifier.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceVerifier.java new file mode 100644 index 00000000..d614a0f6 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaEvidenceVerifier.java @@ -0,0 +1,251 @@ +package dev.caskeleton.buildtools.jpa; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** Pure evidence semantics for JPA readiness manifests. No Gradle API. */ +public final class JpaEvidenceVerifier { + private static final ObjectMapper JSON = JsonMapper.builder().build(); + + private JpaEvidenceVerifier() {} + + public static String canonicalJson(String json) { + return canonicalJson(JSON.readTree(json)); + } + + public static String canonicalJson(JsonNode node) { + StringBuilder result = new StringBuilder(); + appendCanonical(node, result); + return result.toString(); + } + + public static String prettyJson(JpaEvidenceManifest manifest) { + return JSON.writerWithDefaultPrettyPrinter().writeValueAsString(manifest) + System.lineSeparator(); + } + + public static String canonicalJson(JpaGeneratedEvidenceManifest manifest) { + return canonicalJson(JSON.writeValueAsString(manifest)); + } + + public static String prettyJson(JpaGeneratedEvidenceManifest manifest) { + return JSON.writerWithDefaultPrettyPrinter().writeValueAsString(manifest) + System.lineSeparator(); + } + + public static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + public static List requiredEvidence(JpaReadinessCard card) { + return card.allRequiredEvidence(); + } + + public static List validateManifest(JpaEvidenceManifest manifest) { + List violations = new ArrayList<>(); + String cardId = manifest.cardId(); + JpaTestResult testResult = manifest.testResult(); + if (testResult.executedTestCount() <= 0) { + violations.add(cardId + ": executed test count must be positive"); + } + if (testResult.skippedOrAbortedCount() != 0) { + violations.add(cardId + ": skippedOrAbortedCount must be zero"); + } + if (testResult.failureCount() != 0) { + violations.add(cardId + ": failureCount must be zero"); + } + if (testResult.errorCount() != 0) { + violations.add(cardId + ": errorCount must be zero"); + } + if (!testResult.noSkipResult()) { + violations.add(cardId + ": no-skip sentinel must be true"); + } + + if (!manifest.postgresql().imageDigest().matches(".+@sha256:[0-9a-f]{64}")) { + violations.add(cardId + ": PostgreSQL image digest must be immutable"); + } + if (manifest.dependencies().pgjdbc().isBlank()) { + violations.add(cardId + ": pgjdbc version must be present"); + } + if (manifest.dependencies().hibernate().isBlank()) { + violations.add(cardId + ": hibernate version must be present"); + } + if (manifest.dependencies().flyway().isBlank()) { + violations.add(cardId + ": flyway version must be present"); + } + + if (manifest.attainedReadiness().equals("R2")) { + if (!manifest.profile().equals("r2")) { + violations.add(cardId + ": R2 requires the r2 profile"); + } + if (manifest.source().worktreeDirty()) { + violations.add(cardId + ": R2 requires a clean worktree"); + } + if (!manifest.missingEvidence().isEmpty()) { + violations.add(cardId + ": R2 has missing evidence " + manifest.missingEvidence()); + } + if (manifest.producer().ciJob().isBlank() + || manifest.producer().ciJob().equals("local-unpublished")) { + violations.add(cardId + ": R2 requires a real CI job identity"); + } + if (!manifest.artifactLocation().matches("(?i)(https|s3|gs)://\\S+")) { + violations.add(cardId + ": R2 requires an externally retained artifact location"); + } + } + return List.copyOf(violations); + } + + public static JpaEvidenceVerificationResult verifyDirectory( + File outputDirectory, JpaReadinessRegistry registry) { + List violations = new ArrayList<>(); + List manifests = new ArrayList<>(); + for (JpaReadinessCard card : registry.activeCards()) { + File cardDirectory = new File(outputDirectory, card.id()); + File[] jsonFiles = + cardDirectory.isDirectory() + ? cardDirectory.listFiles(file -> file.isFile() && file.getName().endsWith(".json")) + : null; + List files = + jsonFiles == null + ? List.of() + : java.util.Arrays.stream(jsonFiles) + .sorted(Comparator.comparing(File::getName)) + .toList(); + if (files.size() != 1) { + violations.add( + card.id() + + ": expected exactly one content-addressed manifest; got " + + files.size()); + continue; + } + JpaEvidenceManifest manifest = parseManifest(files.getFirst()); + violations.addAll(validateManifest(manifest)); + manifests.add(manifest); + } + return new JpaEvidenceVerificationResult(violations, manifests); + } + + public static JpaEvidenceManifest parseManifest(File manifestFile) { + JsonNode root = JSON.readTree(read(manifestFile)); + return new JpaEvidenceManifest( + text(root, "cardId", ""), + text(root, "attainedReadiness", ""), + text(root, "profile", ""), + text(root, "evidenceGrade", ""), + strings(root.get("missingEvidence")), + strings(root.get("readinessBlockers")), + parseTestResult(root.get("testResult")), + new JpaPostgresqlEvidence(text(root.get("postgresql"), "imageDigest", "")), + parseDependencies(root.get("dependencies")), + new JpaSourceEvidence(booleanValue(root.get("source"), "worktreeDirty", true)), + new JpaProducerEvidence(text(root.get("producer"), "ciJob", "")), + text(root, "artifactLocation", "")); + } + + private static JpaTestResult parseTestResult(JsonNode node) { + return new JpaTestResult( + integer(node, "executedTestCount", 0), + integer(node, "skippedOrAbortedCount", 0), + integer(node, "failureCount", 0), + integer(node, "errorCount", 0), + booleanValue(node, "noSkipResult", false)); + } + + private static JpaDependencyVersions parseDependencies(JsonNode node) { + return new JpaDependencyVersions( + text(node, "pgjdbc", ""), text(node, "hibernate", ""), text(node, "flyway", "")); + } + + private static void appendCanonical(JsonNode node, StringBuilder into) { + if (node == null || node.isNull()) { + into.append("null"); + return; + } + if (node.isObject()) { + List names = new ArrayList<>(); + node.properties().forEach(entry -> names.add(entry.getKey())); + names.sort(String::compareTo); + into.append('{'); + for (int index = 0; index < names.size(); index++) { + if (index > 0) { + into.append(','); + } + String name = names.get(index); + into.append(JSON.writeValueAsString(name)).append(':'); + appendCanonical(node.get(name), into); + } + into.append('}'); + return; + } + if (node.isArray()) { + into.append('['); + for (int index = 0; index < node.size(); index++) { + if (index > 0) { + into.append(','); + } + appendCanonical(node.get(index), into); + } + into.append(']'); + return; + } + into.append(node.toString()); + } + + private static List strings(JsonNode node) { + if (node == null || !node.isArray()) { + return List.of(); + } + List values = new ArrayList<>(); + for (JsonNode value : node) { + values.add(value.asText()); + } + return List.copyOf(values); + } + + private static String text(JsonNode node, String field, String fallback) { + if (node == null || !node.isObject()) { + return fallback; + } + JsonNode value = node.get(field); + return value == null || value.isNull() ? fallback : value.asText(); + } + + private static int integer(JsonNode node, String field, int fallback) { + if (node == null || !node.isObject()) { + return fallback; + } + JsonNode value = node.get(field); + return value == null || !value.isIntegralNumber() ? fallback : value.asInt(); + } + + private static boolean booleanValue(JsonNode node, String field, boolean fallback) { + if (node == null || !node.isObject()) { + return fallback; + } + JsonNode value = node.get(field); + return value == null || !value.isBoolean() ? fallback : value.asBoolean(); + } + + private static String read(File file) { + try { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaExternalPrerequisite.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaExternalPrerequisite.java new file mode 100644 index 00000000..95b634b2 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaExternalPrerequisite.java @@ -0,0 +1,12 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaExternalPrerequisite( + String registry, String cardId, String minimumReadiness) { + public JpaExternalPrerequisite { + Objects.requireNonNull(registry, "registry"); + Objects.requireNonNull(cardId, "cardId"); + Objects.requireNonNull(minimumReadiness, "minimumReadiness"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedEvidenceManifest.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedEvidenceManifest.java new file mode 100644 index 00000000..6f683ff4 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedEvidenceManifest.java @@ -0,0 +1,69 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; +import java.util.Objects; + +public record JpaGeneratedEvidenceManifest( + int schemaVersion, + String cardId, + String cardVersion, + String declaredState, + String attainedReadiness, + String evidenceGrade, + String profile, + List prerequisites, + JpaGeneratedSourceEvidence source, + JpaGeneratedProducerEvidence producer, + JpaGeneratedTestResult testResult, + List requiredEvidence, + List coveredEvidence, + List missingEvidence, + List readinessBlockers, + JpaGeneratedPostgresqlEvidence postgresql, + JpaDependencyVersions dependencies, + String generatedAt, + String date, + String topology, + String artifactLocation, + JpaGeneratedMigrationEvidence migration, + List dispatchModes) { + public JpaGeneratedEvidenceManifest { + Objects.requireNonNull(cardId, "cardId"); + Objects.requireNonNull(cardVersion, "cardVersion"); + Objects.requireNonNull(declaredState, "declaredState"); + Objects.requireNonNull(attainedReadiness, "attainedReadiness"); + Objects.requireNonNull(evidenceGrade, "evidenceGrade"); + Objects.requireNonNull(profile, "profile"); + prerequisites = List.copyOf(prerequisites); + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(producer, "producer"); + Objects.requireNonNull(testResult, "testResult"); + requiredEvidence = List.copyOf(requiredEvidence); + coveredEvidence = List.copyOf(coveredEvidence); + missingEvidence = List.copyOf(missingEvidence); + readinessBlockers = List.copyOf(readinessBlockers); + Objects.requireNonNull(postgresql, "postgresql"); + Objects.requireNonNull(dependencies, "dependencies"); + Objects.requireNonNull(generatedAt, "generatedAt"); + Objects.requireNonNull(date, "date"); + Objects.requireNonNull(topology, "topology"); + Objects.requireNonNull(artifactLocation, "artifactLocation"); + dispatchModes = List.copyOf(dispatchModes); + } + + public JpaEvidenceManifest verificationView() { + return new JpaEvidenceManifest( + cardId, + attainedReadiness, + profile, + evidenceGrade, + missingEvidence, + readinessBlockers, + testResult.verificationView(), + new JpaPostgresqlEvidence(postgresql.imageDigest()), + dependencies, + new JpaSourceEvidence(source.worktreeDirty()), + new JpaProducerEvidence(producer.ciJob()), + artifactLocation); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedMigrationEvidence.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedMigrationEvidence.java new file mode 100644 index 00000000..1804490c --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedMigrationEvidence.java @@ -0,0 +1,17 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; +import java.util.Objects; + +public record JpaGeneratedMigrationEvidence( + String location, + String historyTable, + int requiredCoreEpoch, + int featureRevision, + List streamLifecycleEvidenceIds) { + public JpaGeneratedMigrationEvidence { + Objects.requireNonNull(location, "location"); + Objects.requireNonNull(historyTable, "historyTable"); + streamLifecycleEvidenceIds = List.copyOf(streamLifecycleEvidenceIds); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedPostgresqlEvidence.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedPostgresqlEvidence.java new file mode 100644 index 00000000..e14e76a7 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedPostgresqlEvidence.java @@ -0,0 +1,12 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaGeneratedPostgresqlEvidence( + String image, String imageDigest, String managedEngineVersion) { + public JpaGeneratedPostgresqlEvidence { + Objects.requireNonNull(image, "image"); + Objects.requireNonNull(imageDigest, "imageDigest"); + Objects.requireNonNull(managedEngineVersion, "managedEngineVersion"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedProducerEvidence.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedProducerEvidence.java new file mode 100644 index 00000000..0e4157bb --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedProducerEvidence.java @@ -0,0 +1,10 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaGeneratedProducerEvidence(String gradleTask, String ciJob) { + public JpaGeneratedProducerEvidence { + Objects.requireNonNull(gradleTask, "gradleTask"); + Objects.requireNonNull(ciJob, "ciJob"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedSourceEvidence.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedSourceEvidence.java new file mode 100644 index 00000000..847eae82 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedSourceEvidence.java @@ -0,0 +1,11 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaGeneratedSourceEvidence( + String revision, boolean worktreeDirty, String worktreeStatusDigest) { + public JpaGeneratedSourceEvidence { + Objects.requireNonNull(revision, "revision"); + Objects.requireNonNull(worktreeStatusDigest, "worktreeStatusDigest"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedTestResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedTestResult.java new file mode 100644 index 00000000..1aa24a2e --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGeneratedTestResult.java @@ -0,0 +1,24 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; + +public record JpaGeneratedTestResult( + List tasks, + List resultDirectories, + int executedTestCount, + int skippedOrAbortedCount, + int failureCount, + int errorCount, + boolean noSkipResult, + List executedSelectors) { + public JpaGeneratedTestResult { + tasks = List.copyOf(tasks); + resultDirectories = List.copyOf(resultDirectories); + executedSelectors = List.copyOf(executedSelectors); + } + + public JpaTestResult verificationView() { + return new JpaTestResult( + executedTestCount, skippedOrAbortedCount, failureCount, errorCount, noSkipResult); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGradleTaskPath.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGradleTaskPath.java new file mode 100644 index 00000000..d24f7b8b --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGradleTaskPath.java @@ -0,0 +1,31 @@ +package dev.caskeleton.buildtools.jpa; + +import org.gradle.api.Project; +import org.gradle.api.Task; + +final class JpaGradleTaskPath { + private JpaGradleTaskPath() {} + + static Task find(Project rootProject, String absoluteTaskPath) { + if (absoluteTaskPath == null || !absoluteTaskPath.startsWith(":")) { + return null; + } + int separator = absoluteTaskPath.lastIndexOf(':'); + if (separator < 0 || separator == absoluteTaskPath.length() - 1) { + return null; + } + String projectPath = separator == 0 ? ":" : absoluteTaskPath.substring(0, separator); + String taskName = absoluteTaskPath.substring(separator + 1); + Project owner = rootProject.findProject(projectPath); + return owner == null ? null : owner.getTasks().findByName(taskName); + } + + static String projectPath(String absoluteTaskPath) { + int separator = absoluteTaskPath.lastIndexOf(':'); + return separator == 0 ? ":" : absoluteTaskPath.substring(0, separator); + } + + static String taskName(String absoluteTaskPath) { + return absoluteTaskPath.substring(absoluteTaskPath.lastIndexOf(':') + 1); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGradleTaskSnapshot.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGradleTaskSnapshot.java new file mode 100644 index 00000000..4de43768 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaGradleTaskSnapshot.java @@ -0,0 +1,37 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Set; +import java.util.TreeSet; +import org.gradle.api.Project; +import org.gradle.api.tasks.testing.Test; + +record JpaGradleTaskSnapshot( + Set projectPaths, Set taskPaths, Set testTaskPaths) { + JpaGradleTaskSnapshot { + projectPaths = Set.copyOf(projectPaths); + taskPaths = Set.copyOf(taskPaths); + testTaskPaths = Set.copyOf(testTaskPaths); + } + + static JpaGradleTaskSnapshot capture(Project rootProject) { + Set projects = new TreeSet<>(); + Set tasks = new TreeSet<>(); + Set tests = new TreeSet<>(); + + for (Project project : rootProject.getAllprojects()) { + String projectPath = project.getPath(); + projects.add(projectPath); + for (String taskName : project.getTasks().getNames()) { + tasks.add(taskPath(projectPath, taskName)); + } + for (String taskName : project.getTasks().withType(Test.class).getNames()) { + tests.add(taskPath(projectPath, taskName)); + } + } + return new JpaGradleTaskSnapshot(projects, tasks, tests); + } + + private static String taskPath(String projectPath, String taskName) { + return projectPath.equals(":") ? ":" + taskName : projectPath + ":" + taskName; + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaLegacyAdoption.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaLegacyAdoption.java new file mode 100644 index 00000000..56cd09fe --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaLegacyAdoption.java @@ -0,0 +1,19 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; +import java.util.Objects; + +public record JpaLegacyAdoption( + String state, + String location, + String historyTable, + List immutableAppliedVersions, + String allowedOrigin) { + public JpaLegacyAdoption { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(location, "location"); + Objects.requireNonNull(historyTable, "historyTable"); + immutableAppliedVersions = List.copyOf(immutableAppliedVersions); + Objects.requireNonNull(allowedOrigin, "allowedOrigin"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaMigrationSpec.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaMigrationSpec.java new file mode 100644 index 00000000..5a05da42 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaMigrationSpec.java @@ -0,0 +1,17 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; +import java.util.Objects; + +public record JpaMigrationSpec( + String location, + String historyTable, + int requiredCoreEpoch, + int featureRevision, + List lifecycleEvidence) { + public JpaMigrationSpec { + Objects.requireNonNull(location, "location"); + Objects.requireNonNull(historyTable, "historyTable"); + lifecycleEvidence = List.copyOf(lifecycleEvidence); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaPostgresqlEvidence.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaPostgresqlEvidence.java new file mode 100644 index 00000000..60541d53 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaPostgresqlEvidence.java @@ -0,0 +1,9 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaPostgresqlEvidence(String imageDigest) { + public JpaPostgresqlEvidence { + Objects.requireNonNull(imageDigest, "imageDigest"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaPrerequisiteEvidence.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaPrerequisiteEvidence.java new file mode 100644 index 00000000..d7fe2256 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaPrerequisiteEvidence.java @@ -0,0 +1,13 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaPrerequisiteEvidence( + String cardId, String cardVersion, String manifestId, String attainedReadiness) { + public JpaPrerequisiteEvidence { + Objects.requireNonNull(cardId, "cardId"); + Objects.requireNonNull(cardVersion, "cardVersion"); + Objects.requireNonNull(manifestId, "manifestId"); + Objects.requireNonNull(attainedReadiness, "attainedReadiness"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaProducerEvidence.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaProducerEvidence.java new file mode 100644 index 00000000..d4b74ce6 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaProducerEvidence.java @@ -0,0 +1,9 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaProducerEvidence(String ciJob) { + public JpaProducerEvidence { + Objects.requireNonNull(ciJob, "ciJob"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaQualificationPlugin.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaQualificationPlugin.java new file mode 100644 index 00000000..dcf43b4a --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaQualificationPlugin.java @@ -0,0 +1,77 @@ +package dev.caskeleton.buildtools.jpa; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.tasks.TaskProvider; + +public final class JpaQualificationPlugin implements Plugin { + @Override + public void apply(Project project) { + Project root = project.getRootProject(); + TaskProvider releaseGateTasks = + project.getTasks().register( + "verifyJpaReleaseGateTasks", + VerifyJpaReleaseGateTasksTask.class, + task -> { + task.setGroup("verification"); + task.setDescription("Resolves every release-registry gate task against the real Gradle task graph."); + task.getRegistryFile().fileValue(root.file("config/jpa/release-registry.json")); + }); + + TaskProvider releaseQualification = + project.getTasks().register( + "jpaReleaseQualification", + task -> { + task.setGroup("verification"); + task.setDescription( + "Runs exactly the blocking JPA release gates declared by config/jpa/release-registry.json."); + task.dependsOn(releaseGateTasks); + }); + + TaskProvider readinessRegistry = + project.getTasks().register( + "verifyJpaReadinessRegistry", + VerifyJpaReadinessRegistryTask.class, + task -> { + task.setGroup("verification"); + task.setDescription("Validates the JPA readiness card, prerequisite, task, and migration registry."); + task.getRegistryFile().fileValue(root.file("config/jpa/readiness-cards.yaml")); + }); + + project.getGradle().projectsEvaluated( + ignored -> { + JpaGradleTaskSnapshot snapshot = JpaGradleTaskSnapshot.capture(root); + releaseGateTasks.configure(task -> { + task.getAvailableProjectPaths().set(snapshot.projectPaths()); + task.getAvailableTaskPaths().set(snapshot.taskPaths()); + task.getTestTaskPaths().set(snapshot.testTaskPaths()); + }); + readinessRegistry.configure( + task -> task.getAvailableTaskPaths().set(snapshot.taskPaths())); + + JpaReleaseRegistry registry = readReleaseRegistry(root); + releaseQualification.configure( + task -> + task.dependsOn( + registry.gates().stream() + .filter(JpaReleaseGate::blocking) + .map(JpaReleaseGate::task) + .toList())); + }); + } + + private static JpaReleaseRegistry readReleaseRegistry(Project root) { + try { + return JpaReleaseRegistryParser.parse( + Files.readString( + root.file("config/jpa/release-registry.json").toPath(), StandardCharsets.UTF_8)); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read JPA release registry", exception); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessCard.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessCard.java new file mode 100644 index 00000000..27a4e7e6 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessCard.java @@ -0,0 +1,49 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeSet; + +public record JpaReadinessCard( + String id, + JpaCardState state, + JpaSchemaStream schemaStream, + List prerequisites, + List externalPrerequisites, + String readinessTask, + List supportTasks, + List requiredEvidence, + Optional evidence, + List dispatchModes, + Optional migration) { + public JpaReadinessCard { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(schemaStream, "schemaStream"); + prerequisites = List.copyOf(prerequisites); + externalPrerequisites = List.copyOf(externalPrerequisites); + Objects.requireNonNull(readinessTask, "readinessTask"); + supportTasks = List.copyOf(supportTasks); + requiredEvidence = List.copyOf(requiredEvidence); + evidence = evidence == null ? Optional.empty() : evidence; + dispatchModes = List.copyOf(dispatchModes); + migration = migration == null ? Optional.empty() : migration; + } + + public List allOwnedTasks() { + TreeSet tasks = new TreeSet<>(); + tasks.add(readinessTask); + tasks.addAll(supportTasks); + return List.copyOf(tasks); + } + + public List allRequiredEvidence() { + TreeSet required = new TreeSet<>(requiredEvidence); + migration.ifPresent( + spec -> + spec.lifecycleEvidence() + .forEach(lifecycle -> required.add("migration-lifecycle:" + lifecycle))); + return List.copyOf(required); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistry.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistry.java new file mode 100644 index 00000000..dc9b36dd --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistry.java @@ -0,0 +1,26 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public record JpaReadinessRegistry( + int schemaVersion, JpaLegacyAdoption legacyAdoption, List cards) { + public JpaReadinessRegistry { + Objects.requireNonNull(legacyAdoption, "legacyAdoption"); + cards = List.copyOf(cards); + } + + public Optional findCard(String id) { + return cards.stream().filter(card -> card.id().equals(id)).findFirst(); + } + + public JpaReadinessCard card(String id) { + return findCard(id) + .orElseThrow(() -> new IllegalArgumentException("unknown JPA readiness card '" + id + "'")); + } + + public List activeCards() { + return cards.stream().filter(card -> card.state() != JpaCardState.NOT_IMPLEMENTED).toList(); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistryParser.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistryParser.java new file mode 100644 index 00000000..6dd6640f --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistryParser.java @@ -0,0 +1,285 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +public final class JpaReadinessRegistryParser { + private static final ObjectMapper JSON = JsonMapper.builder().build(); + private static final Set ROOT_KEYS = Set.of("schema-version", "legacy-adoption", "cards"); + private static final Set LEGACY_KEYS = + Set.of("state", "location", "history-table", "immutable-applied-versions", "allowed-origin"); + private static final Set CARD_KEYS = + Set.of( + "state", + "schema-stream", + "prerequisites", + "external-prerequisites", + "readiness-task", + "support-tasks", + "required-evidence", + "evidence", + "dispatch-modes", + "migration"); + private static final Set EVIDENCE_KEYS = Set.of("scenarios", "task-claims"); + private static final Set SCENARIO_KEYS = Set.of("selector", "covers"); + private static final Set TASK_CLAIM_KEYS = Set.of("task", "covers"); + private static final Set MIGRATION_KEYS = + Set.of( + "location", + "history-table", + "required-core-epoch", + "feature-revision", + "lifecycle-evidence"); + private static final Set EXTERNAL_KEYS = + Set.of("registry", "card-id", "minimum-readiness"); + + private JpaReadinessRegistryParser() {} + + public static JpaReadinessRegistry parse(String raw) { + detectDuplicateRawCardKeys(raw); + JsonNode root = JSON.readTree(raw); + requireObject(root, "root"); + requireExactKeys(root, ROOT_KEYS, "root"); + + int schemaVersion = requiredInt(root, "schema-version"); + JsonNode legacyNode = requiredObject(root, "legacy-adoption"); + requireExactKeys(legacyNode, LEGACY_KEYS, "legacy-adoption"); + JpaLegacyAdoption legacy = + new JpaLegacyAdoption( + requiredText(legacyNode, "state"), + requiredText(legacyNode, "location"), + requiredText(legacyNode, "history-table"), + integerArray(legacyNode.get("immutable-applied-versions"), "immutable-applied-versions"), + requiredText(legacyNode, "allowed-origin")); + + JsonNode cardsNode = requiredObject(root, "cards"); + List cards = new ArrayList<>(); + cardsNode.properties().forEach(entry -> cards.add(parseCard(entry.getKey(), entry.getValue()))); + return new JpaReadinessRegistry(schemaVersion, legacy, cards); + } + + private static JpaReadinessCard parseCard(String id, JsonNode node) { + requireObject(node, id); + Set actualKeys = keys(node); + Set unknown = new HashSet<>(actualKeys); + unknown.removeAll(CARD_KEYS); + if (!unknown.isEmpty()) { + throw new IllegalStateException(id + ": unknown card keys " + new java.util.TreeSet<>(unknown)); + } + + OptionalNode evidence = optionalEvidence(id, node.get("evidence")); + OptionalNode migration = optionalMigration(id, node.get("migration")); + + return new JpaReadinessCard( + id, + JpaCardState.parse(requiredText(node, "state")), + JpaSchemaStream.parse(requiredText(node, "schema-stream")), + stringArray(requiredNode(node, "prerequisites"), id + ".prerequisites"), + externalPrerequisites(id, node.get("external-prerequisites")), + requiredText(node, "readiness-task"), + optionalStringArray(node.get("support-tasks"), id + ".support-tasks"), + stringArray(requiredNode(node, "required-evidence"), id + ".required-evidence"), + evidence.value(), + optionalStringArray(node.get("dispatch-modes"), id + ".dispatch-modes"), + migration.value()); + } + + private static OptionalNode optionalEvidence(String cardId, JsonNode node) { + if (node == null || node.isNull()) { + return OptionalNode.empty(); + } + requireObject(node, cardId + ".evidence"); + requireExactKeys(node, EVIDENCE_KEYS, cardId + ".evidence"); + List scenarios = new ArrayList<>(); + JsonNode scenariosNode = requiredNode(node, "scenarios"); + requireArray(scenariosNode, cardId + ".evidence.scenarios"); + int scenarioIndex = 0; + for (JsonNode scenario : scenariosNode) { + requireObject(scenario, cardId + ".evidence.scenarios[" + scenarioIndex + "]"); + requireExactKeys( + scenario, SCENARIO_KEYS, cardId + ".evidence.scenarios[" + scenarioIndex + "]"); + scenarios.add( + new JpaEvidenceScenario( + requiredText(scenario, "selector"), + stringArray( + requiredNode(scenario, "covers"), + cardId + ".evidence.scenarios[" + scenarioIndex + "].covers"))); + scenarioIndex++; + } + + List taskClaims = new ArrayList<>(); + JsonNode taskClaimsNode = requiredNode(node, "task-claims"); + requireArray(taskClaimsNode, cardId + ".evidence.task-claims"); + int claimIndex = 0; + for (JsonNode claim : taskClaimsNode) { + requireObject(claim, cardId + ".evidence.task-claims[" + claimIndex + "]"); + requireExactKeys( + claim, TASK_CLAIM_KEYS, cardId + ".evidence.task-claims[" + claimIndex + "]"); + taskClaims.add( + new JpaEvidenceTaskClaim( + requiredText(claim, "task"), + stringArray( + requiredNode(claim, "covers"), + cardId + ".evidence.task-claims[" + claimIndex + "].covers"))); + claimIndex++; + } + return OptionalNode.of(new JpaCardEvidence(scenarios, taskClaims)); + } + + private static OptionalNode optionalMigration(String cardId, JsonNode node) { + if (node == null || node.isNull()) { + return OptionalNode.empty(); + } + requireObject(node, cardId + ".migration"); + requireExactKeys(node, MIGRATION_KEYS, cardId + ".migration"); + return OptionalNode.of( + new JpaMigrationSpec( + requiredText(node, "location"), + requiredText(node, "history-table"), + requiredInt(node, "required-core-epoch"), + requiredInt(node, "feature-revision"), + stringArray( + requiredNode(node, "lifecycle-evidence"), + cardId + ".migration.lifecycle-evidence"))); + } + + private static List externalPrerequisites(String cardId, JsonNode node) { + if (node == null || node.isNull()) { + return List.of(); + } + requireArray(node, cardId + ".external-prerequisites"); + List result = new ArrayList<>(); + int index = 0; + for (JsonNode external : node) { + requireObject(external, cardId + ".external-prerequisites[" + index + "]"); + requireExactKeys( + external, EXTERNAL_KEYS, cardId + ".external-prerequisites[" + index + "]"); + result.add( + new JpaExternalPrerequisite( + requiredText(external, "registry"), + requiredText(external, "card-id"), + requiredText(external, "minimum-readiness"))); + index++; + } + return List.copyOf(result); + } + + private static void detectDuplicateRawCardKeys(String raw) { + java.util.regex.Matcher matcher = + java.util.regex.Pattern.compile("\\\"(?jpa-[a-z0-9.-]+)\\\"\\s*:").matcher(raw); + Set seen = new HashSet<>(); + Set duplicates = new java.util.TreeSet<>(); + while (matcher.find()) { + String id = matcher.group("card"); + if (!seen.add(id)) { + duplicates.add(id); + } + } + if (!duplicates.isEmpty()) { + throw new IllegalStateException("duplicate raw card keys " + duplicates); + } + } + + private static JsonNode requiredObject(JsonNode parent, String field) { + JsonNode node = requiredNode(parent, field); + requireObject(node, field); + return node; + } + + private static JsonNode requiredNode(JsonNode parent, String field) { + JsonNode node = parent == null ? null : parent.get(field); + if (node == null || node.isNull()) { + throw new IllegalStateException("missing required field '" + field + "'"); + } + return node; + } + + private static String requiredText(JsonNode parent, String field) { + JsonNode node = requiredNode(parent, field); + if (!node.isString()) { + throw new IllegalStateException("field '" + field + "' must be a string"); + } + return node.asText(); + } + + private static int requiredInt(JsonNode parent, String field) { + JsonNode node = requiredNode(parent, field); + if (!node.isIntegralNumber()) { + throw new IllegalStateException("field '" + field + "' must be an integer"); + } + return node.asInt(); + } + + private static List optionalStringArray(JsonNode node, String field) { + return node == null || node.isNull() ? List.of() : stringArray(node, field); + } + + private static List stringArray(JsonNode node, String field) { + requireArray(node, field); + List values = new ArrayList<>(); + for (JsonNode value : node) { + if (!value.isString()) { + throw new IllegalStateException(field + " values must be strings"); + } + values.add(value.asText()); + } + return List.copyOf(values); + } + + private static List integerArray(JsonNode node, String field) { + requireArray(node, field); + List values = new ArrayList<>(); + for (JsonNode value : node) { + if (!value.isIntegralNumber()) { + throw new IllegalStateException(field + " values must be integers"); + } + values.add(value.asInt()); + } + return List.copyOf(values); + } + + private static void requireObject(JsonNode node, String name) { + if (node == null || !node.isObject()) { + throw new IllegalStateException(name + " must be an object"); + } + } + + private static void requireArray(JsonNode node, String name) { + if (node == null || !node.isArray()) { + throw new IllegalStateException(name + " must be a list"); + } + } + + private static void requireExactKeys(JsonNode node, Set expected, String name) { + Set actual = keys(node); + if (!actual.equals(expected)) { + Set unknown = new java.util.TreeSet<>(actual); + unknown.removeAll(expected); + Set missing = new java.util.TreeSet<>(expected); + missing.removeAll(actual); + String detail = !unknown.isEmpty() ? "unknown " + name + " keys " + unknown : name + " missing keys " + missing; + throw new IllegalStateException(detail); + } + } + + private static Set keys(JsonNode node) { + Set result = new HashSet<>(); + node.properties().forEach(entry -> result.add(entry.getKey())); + return result; + } + + private record OptionalNode(java.util.Optional value) { + private static OptionalNode empty() { + return new OptionalNode<>(java.util.Optional.empty()); + } + + private static OptionalNode of(T value) { + return new OptionalNode<>(java.util.Optional.of(value)); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistryValidator.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistryValidator.java new file mode 100644 index 00000000..61ad8314 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistryValidator.java @@ -0,0 +1,318 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Predicate; + +/** Repository-policy validator for the typed JPA readiness registry. */ +public final class JpaReadinessRegistryValidator { + private JpaReadinessRegistryValidator() {} + + public static List validate( + JpaReadinessRegistry registry, Predicate taskExists) { + List violations = new ArrayList<>(); + if (registry.schemaVersion() != 1) { + violations.add("schema-version must be integer 1; got " + registry.schemaVersion()); + } + validateLegacy(registry.legacyAdoption(), violations); + + Map cardsById = new HashMap<>(); + for (JpaReadinessCard card : registry.cards()) { + cardsById.putIfAbsent(card.id(), card); + } + Map taskOwners = new HashMap<>(); + Map migrationLocationOwners = new HashMap<>(); + Map migrationHistoryOwners = new HashMap<>(); + Map evidenceSelectorOwners = new HashMap<>(); + + for (JpaReadinessCard card : registry.cards()) { + validateCard( + card, + cardsById, + taskExists, + taskOwners, + migrationLocationOwners, + migrationHistoryOwners, + evidenceSelectorOwners, + violations); + } + + detectCycles(cardsById, violations); + JpaReadinessCard polling = cardsById.get("jpa-outbox-polling-delivery-v2"); + JpaReadinessCard cdc = cardsById.get("jpa-outbox-cdc-retention-v1"); + if (polling != null + && cdc != null + && polling.state() == JpaCardState.SELECTED + && cdc.state() == JpaCardState.SELECTED) { + violations.add("polling and CDC outbox delivery cards cannot both be selected"); + } + return List.copyOf(violations); + } + + private static void validateLegacy(JpaLegacyAdoption legacy, List violations) { + if (!legacy.state().equals("transition-only")) { + violations.add("legacy-adoption.state must be transition-only"); + } + if (!legacy.location().equals("db/migration/postgresql")) { + violations.add("legacy-adoption.location must be db/migration/postgresql"); + } + if (!legacy.historyTable().equals("flyway_schema_history")) { + violations.add("legacy-adoption.history-table must be flyway_schema_history"); + } + if (!legacy.immutableAppliedVersions().equals(List.of(1, 3, 4, 5))) { + violations.add("legacy-adoption immutable versions must be exactly [1, 3, 4, 5]"); + } + if (!legacy.allowedOrigin().equals("LEGACY_ADOPTED")) { + violations.add("legacy-adoption.allowed-origin must be LEGACY_ADOPTED"); + } + } + + private static void validateCard( + JpaReadinessCard card, + Map cardsById, + Predicate taskExists, + Map taskOwners, + Map migrationLocationOwners, + Map migrationHistoryOwners, + Map evidenceSelectorOwners, + List violations) { + if (hasDuplicates(card.prerequisites())) { + violations.add(card.id() + ": duplicate prerequisites " + card.prerequisites()); + } + for (String prerequisite : card.prerequisites()) { + JpaReadinessCard target = cardsById.get(prerequisite); + if (target == null) { + violations.add(card.id() + ": unknown prerequisite '" + prerequisite + "'"); + } else if (card.state() == JpaCardState.SELECTED + && target.state() != JpaCardState.SELECTED) { + violations.add(card.id() + ": selected card requires non-selected '" + prerequisite + "'"); + } + } + + if (!card.readinessTask().startsWith(":")) { + violations.add(card.id() + ": readiness-task must be an absolute Gradle task path"); + } + if (hasDuplicates(card.supportTasks())) { + violations.add(card.id() + ": duplicate support-tasks " + card.supportTasks()); + } + for (String task : card.allOwnedTasks()) { + if (!task.startsWith(":")) { + violations.add(card.id() + ": task '" + task + "' must be an absolute Gradle task path"); + continue; + } + String previousOwner = taskOwners.putIfAbsent(task, card.id()); + if (previousOwner != null && !previousOwner.equals(card.id())) { + violations.add("duplicate task '" + task + "' owned by " + previousOwner + " and " + card.id()); + } + if (card.state() == JpaCardState.SELECTED && !taskExists.test(task)) { + violations.add(card.id() + ": selected task does not exist '" + task + "'"); + } + } + + if (card.requiredEvidence().isEmpty()) { + violations.add(card.id() + ": required-evidence must be a non-empty list"); + } else { + if (hasDuplicates(card.requiredEvidence())) { + violations.add(card.id() + ": duplicate required-evidence " + card.requiredEvidence()); + } + if (!card.requiredEvidence().contains("no-skip")) { + violations.add(card.id() + ": required-evidence must include no-skip"); + } + } + + Set allowedEvidenceClaims = new HashSet<>(card.requiredEvidence()); + allowedEvidenceClaims.remove("no-skip"); + card.migration() + .ifPresent( + migration -> + migration.lifecycleEvidence().forEach( + lifecycle -> allowedEvidenceClaims.add("migration-lifecycle:" + lifecycle))); + + if (card.state() == JpaCardState.NOT_IMPLEMENTED) { + if (card.evidence().isPresent()) { + violations.add(card.id() + ": not-implemented card forbids evidence"); + } + } else if (card.evidence().isEmpty()) { + violations.add(card.id() + ": active card requires evidence"); + } else { + validateEvidence( + card, + card.evidence().orElseThrow(), + allowedEvidenceClaims, + evidenceSelectorOwners, + violations); + } + + if (card.schemaStream() == JpaSchemaStream.OWNED) { + if (card.migration().isEmpty()) { + violations.add(card.id() + ": owned schema-stream requires migration"); + } + } else if (card.migration().isPresent()) { + violations.add( + card.id() + ": schema-stream " + card.schemaStream().externalValue() + " forbids migration"); + } + card.migration() + .ifPresent( + migration -> + validateMigration( + card.id(), + migration, + migrationLocationOwners, + migrationHistoryOwners, + violations)); + + for (int index = 0; index < card.externalPrerequisites().size(); index++) { + JpaExternalPrerequisite external = card.externalPrerequisites().get(index); + if (!external.registry().startsWith("src/config/")) { + violations.add(card.id() + ": external prerequisite " + index + " has invalid registry"); + } + if (!external.cardId().matches("[a-z0-9.-]+")) { + violations.add(card.id() + ": external prerequisite " + index + " has invalid card-id"); + } + if (!external.minimumReadiness().matches("R[0-3]")) { + violations.add(card.id() + ": external prerequisite " + index + " has invalid minimum-readiness"); + } + } + } + + private static void validateEvidence( + JpaReadinessCard card, + JpaCardEvidence evidence, + Set allowedEvidenceClaims, + Map evidenceSelectorOwners, + List violations) { + if (evidence.scenarios().isEmpty() && evidence.taskClaims().isEmpty()) { + violations.add(card.id() + ": evidence must contain a scenario or task claim"); + } + for (int index = 0; index < evidence.scenarios().size(); index++) { + JpaEvidenceScenario scenario = evidence.scenarios().get(index); + if (!scenario.selector().matches("dev\\.caskeleton\\.[A-Za-z0-9_.]+#[A-Za-z][A-Za-z0-9_]*")) { + violations.add(card.id() + ": invalid evidence selector '" + scenario.selector() + "'"); + } else { + String previousOwner = evidenceSelectorOwners.putIfAbsent(scenario.selector(), card.id()); + if (previousOwner != null) { + violations.add( + "duplicate evidence selector '" + + scenario.selector() + + "' owned by " + + previousOwner + + " and " + + card.id()); + } + } + validateCovers(card.id(), "evidence scenario " + index, scenario.covers(), allowedEvidenceClaims, violations); + } + + Set ownedTasks = new HashSet<>(card.allOwnedTasks()); + for (int index = 0; index < evidence.taskClaims().size(); index++) { + JpaEvidenceTaskClaim claim = evidence.taskClaims().get(index); + if (!ownedTasks.contains(claim.task())) { + violations.add(card.id() + ": evidence task claim is not owned by card '" + claim.task() + "'"); + } + validateCovers(card.id(), "evidence task claim " + index, claim.covers(), allowedEvidenceClaims, violations); + } + } + + private static void validateCovers( + String cardId, + String label, + List covers, + Set allowedEvidenceClaims, + List violations) { + if (covers.isEmpty()) { + violations.add(cardId + ": " + label + " covers must be non-empty"); + } + if (hasDuplicates(covers)) { + violations.add(cardId + ": " + label + " has duplicate covers " + covers); + } + for (String claim : covers) { + if (!allowedEvidenceClaims.contains(claim)) { + violations.add(cardId + ": evidence covers unknown requirement '" + claim + "'"); + } + } + } + + private static void validateMigration( + String cardId, + JpaMigrationSpec migration, + Map migrationLocationOwners, + Map migrationHistoryOwners, + List violations) { + if (!migration.location().matches("db/migration/jpa/[a-z0-9-]+")) { + violations.add(cardId + ": invalid migration location '" + migration.location() + "'"); + } else { + String previousOwner = migrationLocationOwners.putIfAbsent(migration.location(), cardId); + if (previousOwner != null) { + violations.add( + "duplicate migration location '" + + migration.location() + + "' for " + + previousOwner + + " and " + + cardId); + } + } + if (!migration.historyTable().matches("flyway_jpa_[a-z0-9_]+_history")) { + violations.add(cardId + ": invalid migration history-table '" + migration.historyTable() + "'"); + } else { + String previousOwner = migrationHistoryOwners.putIfAbsent(migration.historyTable(), cardId); + if (previousOwner != null) { + violations.add( + "duplicate migration history-table '" + + migration.historyTable() + + "' for " + + previousOwner + + " and " + + cardId); + } + } + if (migration.requiredCoreEpoch() < 0) { + violations.add(cardId + ": required-core-epoch must be a non-negative integer"); + } + if (migration.featureRevision() <= 0) { + violations.add(cardId + ": feature-revision must be a positive integer"); + } + if (migration.lifecycleEvidence().isEmpty()) { + violations.add(cardId + ": lifecycle-evidence must be a non-empty list"); + } else if (hasDuplicates(migration.lifecycleEvidence())) { + violations.add(cardId + ": duplicate lifecycle-evidence " + migration.lifecycleEvidence()); + } + } + + private static void detectCycles( + Map cardsById, List violations) { + Map visitState = new HashMap<>(); + for (String cardId : cardsById.keySet()) { + visit(cardId, cardsById, visitState, violations); + } + } + + private static void visit( + String cardId, + Map cardsById, + Map visitState, + List violations) { + int state = visitState.getOrDefault(cardId, 0); + if (state == 1) { + violations.add("readiness prerequisite cycle includes '" + cardId + "'"); + return; + } + if (state == 2 || !cardsById.containsKey(cardId)) { + return; + } + visitState.put(cardId, 1); + for (String prerequisite : cardsById.get(cardId).prerequisites()) { + visit(prerequisite, cardsById, visitState, violations); + } + visitState.put(cardId, 2); + } + + private static boolean hasDuplicates(List values) { + return new HashSet<>(values).size() != values.size(); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseDatabase.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseDatabase.java new file mode 100644 index 00000000..1193e21a --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseDatabase.java @@ -0,0 +1,10 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaReleaseDatabase(int major, String supportLevel, String image) { + public JpaReleaseDatabase { + Objects.requireNonNull(supportLevel, "supportLevel"); + Objects.requireNonNull(image, "image"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseGate.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseGate.java new file mode 100644 index 00000000..6bc64845 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseGate.java @@ -0,0 +1,10 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaReleaseGate(String name, String task, boolean blocking) { + public JpaReleaseGate { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(task, "task"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseProvider.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseProvider.java new file mode 100644 index 00000000..9e3aeb1a --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseProvider.java @@ -0,0 +1,12 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.Objects; + +public record JpaReleaseProvider( + String name, String stableTestedBaseline, String compatibilityTarget) { + public JpaReleaseProvider { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(stableTestedBaseline, "stableTestedBaseline"); + Objects.requireNonNull(compatibilityTarget, "compatibilityTarget"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseRegistry.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseRegistry.java new file mode 100644 index 00000000..20ab21a9 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseRegistry.java @@ -0,0 +1,16 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; +import java.util.Objects; + +public record JpaReleaseRegistry( + int schemaVersion, + List databases, + JpaReleaseProvider provider, + List gates) { + public JpaReleaseRegistry { + databases = List.copyOf(databases); + Objects.requireNonNull(provider, "provider"); + gates = List.copyOf(gates); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseRegistryParser.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseRegistryParser.java new file mode 100644 index 00000000..6ff5ed91 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaReleaseRegistryParser.java @@ -0,0 +1,133 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +public final class JpaReleaseRegistryParser { + private static final ObjectMapper JSON = JsonMapper.builder().build(); + private static final Set ROOT_KEYS = + Set.of("schema-version", "_comment", "databases", "provider", "gates"); + private static final Set DATABASE_KEYS = + Set.of("major", "support-level", "image"); + private static final Set PROVIDER_KEYS = + Set.of("_comment", "name", "stable-tested-baseline", "compatibility-target"); + private static final Set GATE_KEYS = Set.of("name", "task", "blocking"); + + private JpaReleaseRegistryParser() {} + + public static JpaReleaseRegistry parse(String raw) { + JsonNode root = JSON.readTree(raw); + requireObject(root, "root"); + requireExactKeys(root, ROOT_KEYS, "root"); + + List databases = new ArrayList<>(); + JsonNode databasesNode = requiredArray(root, "databases"); + int databaseIndex = 0; + for (JsonNode database : databasesNode) { + String name = "databases[" + databaseIndex + "]"; + requireObject(database, name); + requireExactKeys(database, DATABASE_KEYS, name); + databases.add( + new JpaReleaseDatabase( + requiredInt(database, "major"), + requiredText(database, "support-level"), + requiredText(database, "image"))); + databaseIndex++; + } + + JsonNode providerNode = requiredObject(root, "provider"); + requireExactKeys(providerNode, PROVIDER_KEYS, "provider"); + JpaReleaseProvider provider = + new JpaReleaseProvider( + requiredText(providerNode, "name"), + requiredText(providerNode, "stable-tested-baseline"), + requiredText(providerNode, "compatibility-target")); + + List gates = new ArrayList<>(); + JsonNode gatesNode = requiredArray(root, "gates"); + int gateIndex = 0; + for (JsonNode gate : gatesNode) { + String name = "gates[" + gateIndex + "]"; + requireObject(gate, name); + requireExactKeys(gate, GATE_KEYS, name); + gates.add( + new JpaReleaseGate( + requiredText(gate, "name"), + requiredText(gate, "task"), + requiredBoolean(gate, "blocking"))); + gateIndex++; + } + + return new JpaReleaseRegistry(requiredInt(root, "schema-version"), databases, provider, gates); + } + + private static JsonNode requiredObject(JsonNode parent, String field) { + JsonNode node = requiredNode(parent, field); + requireObject(node, field); + return node; + } + + private static JsonNode requiredArray(JsonNode parent, String field) { + JsonNode node = requiredNode(parent, field); + if (!node.isArray()) { + throw new IllegalStateException(field + " must be a list"); + } + return node; + } + + private static JsonNode requiredNode(JsonNode parent, String field) { + JsonNode node = parent == null ? null : parent.get(field); + if (node == null || node.isNull()) { + throw new IllegalStateException("missing required field '" + field + "'"); + } + return node; + } + + private static String requiredText(JsonNode parent, String field) { + JsonNode node = requiredNode(parent, field); + if (!node.isString() || node.asText().isBlank()) { + throw new IllegalStateException("field '" + field + "' must be non-blank text"); + } + return node.asText(); + } + + private static int requiredInt(JsonNode parent, String field) { + JsonNode node = requiredNode(parent, field); + if (!node.isIntegralNumber()) { + throw new IllegalStateException("field '" + field + "' must be an integer"); + } + return node.asInt(); + } + + private static boolean requiredBoolean(JsonNode parent, String field) { + JsonNode node = requiredNode(parent, field); + if (!node.isBoolean()) { + throw new IllegalStateException("field '" + field + "' must be boolean"); + } + return node.asBoolean(); + } + + private static void requireObject(JsonNode node, String name) { + if (node == null || !node.isObject()) { + throw new IllegalStateException(name + " must be an object"); + } + } + + private static void requireExactKeys(JsonNode node, Set expected, String name) { + Set actual = new HashSet<>(); + node.properties().forEach(entry -> actual.add(entry.getKey())); + if (!actual.equals(expected)) { + Set unknown = new java.util.TreeSet<>(actual); + unknown.removeAll(expected); + Set missing = new java.util.TreeSet<>(expected); + missing.removeAll(actual); + throw new IllegalStateException( + name + " keys mismatch; unknown=" + unknown + ", missing=" + missing); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSchemaStream.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSchemaStream.java new file mode 100644 index 00000000..dd33fdbd --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSchemaStream.java @@ -0,0 +1,26 @@ +package dev.caskeleton.buildtools.jpa; + +public enum JpaSchemaStream { + NONE("none"), + OWNED("owned"), + CONTRIBUTES_TO_CORE("contributes-to-core"); + + private final String externalValue; + + JpaSchemaStream(String externalValue) { + this.externalValue = externalValue; + } + + public String externalValue() { + return externalValue; + } + + public static JpaSchemaStream parse(String value) { + for (JpaSchemaStream stream : values()) { + if (stream.externalValue.equals(value)) { + return stream; + } + } + throw new IllegalStateException("invalid JPA schema-stream '" + value + "'"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSourceEvidence.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSourceEvidence.java new file mode 100644 index 00000000..5cbbf2f5 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSourceEvidence.java @@ -0,0 +1,3 @@ +package dev.caskeleton.buildtools.jpa; + +public record JpaSourceEvidence(boolean worktreeDirty) {} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyResult.java new file mode 100644 index 00000000..279b6b4b --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyResult.java @@ -0,0 +1,9 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.List; + +public record JpaSqlConstructionSafetyResult(List violations) { + public JpaSqlConstructionSafetyResult { + violations = List.copyOf(violations); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyVerifier.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyVerifier.java new file mode 100644 index 00000000..075e38c0 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyVerifier.java @@ -0,0 +1,45 @@ +package dev.caskeleton.buildtools.jpa; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public final class JpaSqlConstructionSafetyVerifier { + private JpaSqlConstructionSafetyVerifier() {} + + public static JpaSqlConstructionSafetyResult verify(File mainSource) { + List violations = new ArrayList<>(); + if (!mainSource.isDirectory()) { + return new JpaSqlConstructionSafetyResult(violations); + } + try (var paths = Files.walk(mainSource.toPath())) { + for (var source : + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".java")) + .sorted(Comparator.naturalOrder()) + .toList()) { + List lines = Files.readAllLines(source); + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index); + String trimmed = line.trim(); + if (trimmed.startsWith("//") + || trimmed.startsWith("*") + || trimmed.startsWith("/*")) { + continue; + } + if (line.contains("set_config('") && !line.contains("?")) { + violations.add( + source.toFile() + ":" + (index + 1) + ": set_config value is not parameterized"); + } + } + } + } catch (IOException exception) { + throw new UncheckedIOException("cannot scan JPA source tree " + mainSource, exception); + } + return new JpaSqlConstructionSafetyResult(violations); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaTestResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaTestResult.java new file mode 100644 index 00000000..28e3b0e4 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/JpaTestResult.java @@ -0,0 +1,8 @@ +package dev.caskeleton.buildtools.jpa; + +public record JpaTestResult( + int executedTestCount, + int skippedOrAbortedCount, + int failureCount, + int errorCount, + boolean noSkipResult) {} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaCandidateEvidenceTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaCandidateEvidenceTask.java new file mode 100644 index 00000000..e999db05 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaCandidateEvidenceTask.java @@ -0,0 +1,53 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification emits no reusable output") +public abstract class VerifyJpaCandidateEvidenceTask extends DefaultTask { + @InputFile + public abstract RegularFileProperty getRegistryFile(); + + @InputDirectory + public abstract DirectoryProperty getEvidenceOutputDirectory(); + + @TaskAction + public void verifyEvidence() { + JpaEvidenceVerificationResult result = + JpaEvidenceVerifier.verifyDirectory( + getEvidenceOutputDirectory().get().getAsFile(), + JpaEvidenceTaskSupport.loadRegistry(getRegistryFile().get().getAsFile())); + List violations = new ArrayList<>(result.violations()); + if (!violations.isEmpty()) { + violations.sort(String::compareTo); + throw new GradleException( + "verifyJpaCandidateEvidence: " + + violations.size() + + " violation(s):\n " + + String.join("\n ", violations)); + } + for (JpaEvidenceManifest manifest : result.manifests()) { + List missing = manifest.missingEvidence(); + getLogger() + .lifecycle( + "{}: {}/{}, {} tests, missing={}", + manifest.cardId(), + manifest.attainedReadiness(), + manifest.evidenceGrade(), + manifest.testResult().executedTestCount(), + missing.isEmpty() ? "none" : String.join(",", missing)); + } + getLogger() + .lifecycle( + "verifyJpaCandidateEvidence: OK — {} manifests are content-addressed, linked, zero-skip candidate evidence; no R2 claim was made.", + result.manifests().size()); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaPrimaryFoundationEvidenceTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaPrimaryFoundationEvidenceTask.java new file mode 100644 index 00000000..85009348 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaPrimaryFoundationEvidenceTask.java @@ -0,0 +1,69 @@ +package dev.caskeleton.buildtools.jpa; + +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification emits no reusable output") +public abstract class VerifyJpaPrimaryFoundationEvidenceTask extends DefaultTask { + private static final List REQUIRED_CARDS = + List.of( + "jpa-observability-lifecycle", + "jpa-security-baseline", + "jpa-flyway-migration", + "jpa-transaction-runtime", + "jpa-aggregate-store", + "jpa-query-model", + "jpa-primary-foundation"); + + @InputFile + public abstract RegularFileProperty getRegistryFile(); + + @InputDirectory + public abstract DirectoryProperty getEvidenceOutputDirectory(); + + @TaskAction + public void verifyEvidence() { + JpaEvidenceVerificationResult result = + JpaEvidenceVerifier.verifyDirectory( + getEvidenceOutputDirectory().get().getAsFile(), + JpaEvidenceTaskSupport.loadRegistry(getRegistryFile().get().getAsFile())); + List violations = new ArrayList<>(result.violations()); + JpaEvidenceManifest primary = result.manifest("jpa-primary-foundation"); + if (primary == null || !primary.profile().equals("r2")) { + violations.add( + "jpa-primary-foundation: run with -PjpaEvidenceProfile=r2 in the dedicated CI lane"); + } + for (String cardId : REQUIRED_CARDS) { + JpaEvidenceManifest manifest = result.manifest(cardId); + if (manifest == null) { + violations.add(cardId + ": manifest is missing"); + } else if (!manifest.attainedReadiness().equals("R2")) { + violations.add( + cardId + + ": attained " + + manifest.attainedReadiness() + + "; blockers=" + + String.join(",", manifest.readinessBlockers())); + } + } + if (!violations.isEmpty()) { + violations.sort(String::compareTo); + throw new GradleException( + "verifyJpaPrimaryFoundationEvidence: " + + violations.size() + + " violation(s):\n " + + String.join("\n ", violations)); + } + getLogger() + .lifecycle( + "verifyJpaPrimaryFoundationEvidence: OK — six immutable R2 base manifests and the primary DAG are verified."); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaReadinessRegistryTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaReadinessRegistryTask.java new file mode 100644 index 00000000..fd8c71dd --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaReadinessRegistryTask.java @@ -0,0 +1,64 @@ +package dev.caskeleton.buildtools.jpa; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.SetProperty; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification inspects the live Gradle task graph") +public abstract class VerifyJpaReadinessRegistryTask extends DefaultTask { + @InputFile + public abstract RegularFileProperty getRegistryFile(); + + @Input + public abstract SetProperty getAvailableTaskPaths(); + + @TaskAction + public void verifyRegistry() { + File registryFile = getRegistryFile().get().getAsFile(); + JpaReadinessRegistry registry; + try { + registry = + JpaReadinessRegistryParser.parse( + Files.readString(registryFile.toPath(), StandardCharsets.UTF_8)); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + registryFile, exception); + } catch (RuntimeException invalidRegistry) { + throw new GradleException( + "verifyJpaReadinessRegistry: registry structure is invalid: " + + invalidRegistry.getMessage(), + invalidRegistry); + } + + List violations = + new ArrayList<>( + JpaReadinessRegistryValidator.validate( + registry, path -> getAvailableTaskPaths().get().contains(path))); + if (!violations.isEmpty()) { + violations.sort(String::compareTo); + throw new GradleException( + "verifyJpaReadinessRegistry: " + + violations.size() + + " violation(s):\n " + + String.join("\n ", violations)); + } + getLogger() + .lifecycle( + "verifyJpaReadinessRegistry: OK — {} registry-owned cards, {} owned migration streams, acyclic prerequisites, unique tasks/locations/history tables, and selected task existence verified.", + registry.cards().size(), + registry.cards().stream() + .filter(card -> card.schemaStream() == JpaSchemaStream.OWNED) + .count()); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaReleaseGateTasksTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaReleaseGateTasksTask.java new file mode 100644 index 00000000..ac46b402 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaReleaseGateTasksTask.java @@ -0,0 +1,86 @@ +package dev.caskeleton.buildtools.jpa; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.SetProperty; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification inspects the live Gradle task graph") +public abstract class VerifyJpaReleaseGateTasksTask extends DefaultTask { + @InputFile + public abstract RegularFileProperty getRegistryFile(); + + @Input + public abstract SetProperty getAvailableProjectPaths(); + + @Input + public abstract SetProperty getAvailableTaskPaths(); + + @Input + public abstract SetProperty getTestTaskPaths(); + + @TaskAction + public void verifyGateTasks() { + JpaReleaseRegistry registry; + try { + registry = + JpaReleaseRegistryParser.parse( + Files.readString( + getRegistryFile().get().getAsFile().toPath(), StandardCharsets.UTF_8)); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read JPA release registry", exception); + } + + List violations = new ArrayList<>(); + for (JpaReleaseGate gate : registry.gates()) { + String path = gate.task(); + if (!path.startsWith(":")) { + violations.add( + gate.name() + ": gate task must be an absolute Gradle path, was '" + path + "'"); + continue; + } + if (!getAvailableTaskPaths().get().contains(path)) { + String projectPath = JpaGradleTaskPath.projectPath(path); + if (!getAvailableProjectPaths().get().contains(projectPath)) { + violations.add( + gate.name() + ": no project at '" + projectPath + "' for gate task '" + path + "'"); + } else { + violations.add( + gate.name() + + ": no task '" + + JpaGradleTaskPath.taskName(path) + + "' in '" + + projectPath + + "'"); + } + continue; + } + if (!getTestTaskPaths().get().contains(path)) { + violations.add( + gate.name() + ": '" + path + "' is not a Test task, so it produces no JUnit evidence"); + } + } + + if (!violations.isEmpty()) { + throw new GradleException( + "verifyJpaReleaseGateTasks: " + + violations.size() + + " violation(s):\n " + + String.join("\n ", violations)); + } + getLogger() + .lifecycle( + "verifyJpaReleaseGateTasks: OK — {} gate task(s) resolve to real Test tasks.", + registry.gates().size()); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaSqlConstructionSafetyTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaSqlConstructionSafetyTask.java new file mode 100644 index 00000000..2ff44af5 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/jpa/VerifyJpaSqlConstructionSafetyTask.java @@ -0,0 +1,31 @@ +package dev.caskeleton.buildtools.jpa; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Repository source verification emits no reusable output") +public abstract class VerifyJpaSqlConstructionSafetyTask extends DefaultTask { + @InputDirectory + public abstract DirectoryProperty getMainSource(); + + @TaskAction + public void verifySqlConstruction() { + JpaSqlConstructionSafetyResult result = + JpaSqlConstructionSafetyVerifier.verify(getMainSource().get().getAsFile()); + if (!result.violations().isEmpty()) { + throw new GradleException( + "verifyJpaSqlConstructionSafety: " + + result.violations().size() + + " violation(s):\n " + + String.join("\n ", result.violations())); + } + getLogger() + .lifecycle( + "verifyJpaSqlConstructionSafety: OK — every set_config value in {} binds a parameter.", + getMainSource().get().getAsFile()); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/junit/JUnitEvidenceReader.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/junit/JUnitEvidenceReader.java new file mode 100644 index 00000000..4144c45a --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/junit/JUnitEvidenceReader.java @@ -0,0 +1,114 @@ +package dev.caskeleton.buildtools.junit; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +/** Reads Gradle JUnit XML with one fail-closed parser configuration. */ +public final class JUnitEvidenceReader { + private JUnitEvidenceReader() {} + + public static JUnitEvidenceResult read(String evidenceName, File resultDirectory) { + List resultFiles = resultFiles(resultDirectory); + 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 classes = new LinkedHashSet<>(); + Set selectors = new TreeSet<>(); + + for (File resultFile : resultFiles) { + Element suite = parseSuite(evidenceName, resultFile); + tests += attribute(suite, "tests", evidenceName, resultFile); + skipped += attribute(suite, "skipped", evidenceName, resultFile); + failures += attribute(suite, "failures", evidenceName, resultFile); + errors += attribute(suite, "errors", evidenceName, resultFile); + + NodeList testCases = suite.getElementsByTagName("testcase"); + for (int index = 0; index < testCases.getLength(); index++) { + Element testCase = (Element) testCases.item(index); + String className = testCase.getAttribute("classname"); + boolean wasSkipped = testCase.getElementsByTagName("skipped").getLength() > 0; + if (!className.isBlank() && !wasSkipped) { + classes.add(className); + } + String methodName = + testCase.getAttribute("name").replaceFirst("\\([^)]*\\)$", ""); + selectors.add(className + "#" + methodName); + } + } + + return new JUnitEvidenceResult( + tests, skipped, failures, errors, classes, selectors, resultFiles); + } + + private static List resultFiles(File resultDirectory) { + if (resultDirectory == null || !resultDirectory.isDirectory()) { + return List.of(); + } + try (var paths = Files.walk(resultDirectory.toPath())) { + return paths + .filter(Files::isRegularFile) + .map(java.nio.file.Path::toFile) + .filter(file -> file.getName().startsWith("TEST-") && file.getName().endsWith(".xml")) + .sorted(Comparator.comparing(File::getPath)) + .toList(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to inspect " + resultDirectory, exception); + } + } + + private static Element parseSuite(String evidenceName, File resultFile) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + Document document = factory.newDocumentBuilder().parse(resultFile); + Element root = document.getDocumentElement(); + if (!root.getTagName().equals("testsuite")) { + throw new IllegalStateException( + evidenceName + ": " + resultFile.getName() + " root must be testsuite"); + } + return root; + } catch (IllegalStateException exception) { + throw exception; + } catch (Exception unreadable) { + throw new IllegalStateException( + evidenceName + ": " + resultFile + " is not readable JUnit XML", unreadable); + } + } + + private static int attribute( + Element suite, String name, String evidenceName, File resultFile) { + String raw = suite.hasAttribute(name) ? suite.getAttribute(name) : null; + if (raw == null || !raw.matches("\\d+")) { + throw new IllegalStateException( + evidenceName + ": " + resultFile.getName() + " has invalid " + name + "='" + raw + "'"); + } + return Integer.parseInt(raw); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/junit/JUnitEvidenceResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/junit/JUnitEvidenceResult.java new file mode 100644 index 00000000..168bcdb5 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/junit/JUnitEvidenceResult.java @@ -0,0 +1,24 @@ +package dev.caskeleton.buildtools.junit; + +import java.io.File; +import java.util.List; +import java.util.Set; + +public record JUnitEvidenceResult( + int tests, + int skipped, + int failures, + int errors, + Set executedClasses, + Set executedSelectors, + List resultFiles) { + public JUnitEvidenceResult { + executedClasses = Set.copyOf(executedClasses); + executedSelectors = Set.copyOf(executedSelectors); + resultFiles = List.copyOf(resultFiles); + } + + public boolean isClean() { + return tests > 0 && skipped == 0 && failures == 0 && errors == 0; + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingBuildEvidenceManifest.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingBuildEvidenceManifest.java new file mode 100644 index 00000000..edd13b39 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingBuildEvidenceManifest.java @@ -0,0 +1,32 @@ +package dev.caskeleton.buildtools.messaging; + +import java.util.List; +import java.util.Objects; + +public record MessagingBuildEvidenceManifest( + int schemaVersion, + String sourceDigest, + String artifactDigest, + String producerTask, + List scenarioIds, + MessagingEvidenceCounts counts, + String command, + String generatedAt, + MessagingEvidenceHashes hashes, + List failures, + List skips, + List unsupportedClaims) { + public MessagingBuildEvidenceManifest { + Objects.requireNonNull(sourceDigest, "sourceDigest"); + Objects.requireNonNull(artifactDigest, "artifactDigest"); + Objects.requireNonNull(producerTask, "producerTask"); + scenarioIds = List.copyOf(scenarioIds); + Objects.requireNonNull(counts, "counts"); + Objects.requireNonNull(command, "command"); + Objects.requireNonNull(generatedAt, "generatedAt"); + Objects.requireNonNull(hashes, "hashes"); + failures = List.copyOf(failures); + skips = List.copyOf(skips); + unsupportedClaims = List.copyOf(unsupportedClaims); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingCertificationPlugin.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingCertificationPlugin.java new file mode 100644 index 00000000..255f40f5 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingCertificationPlugin.java @@ -0,0 +1,29 @@ +package dev.caskeleton.buildtools.messaging; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class MessagingCertificationPlugin implements Plugin { + @Override + public void apply(Project project) { + project.getTasks().register( + "verifyMessagingCertificationEvidence", + VerifyMessagingCertificationEvidenceTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails when the committed broker certification manifest claims a scenario the certification lane did not produce."); + task.dependsOn("messagingCertificationTest"); + task.getProducedManifest() + .set(project.getLayout().getBuildDirectory().file( + "messaging-certification/broker-certification-evidence.jsonl")); + task.getCommittedManifest() + .fileValue(project.getRootProject().file( + "messaging/messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl")); + task.getReportFile() + .set(project.getLayout().getBuildDirectory().file( + "reports/messaging-certification-evidence.txt")); + task.getOutputs().upToDateWhen(ignored -> false); + }); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceCounts.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceCounts.java new file mode 100644 index 00000000..5b2cd339 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceCounts.java @@ -0,0 +1,15 @@ +package dev.caskeleton.buildtools.messaging; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public record MessagingEvidenceCounts(int executed, int passed, int failed, int skipped) { + @JsonIgnore + public int totalOutcomes() { + return passed + failed + skipped; + } + + @JsonIgnore + public boolean isPassing() { + return failed == 0 && skipped == 0; + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceHasher.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceHasher.java new file mode 100644 index 00000000..6e1e69ae --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceHasher.java @@ -0,0 +1,66 @@ +package dev.caskeleton.buildtools.messaging; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; + +public final class MessagingEvidenceHasher { + private MessagingEvidenceHasher() {} + + public static String sha256Bytes(byte[] bytes) { + MessageDigest digest = digest(); + return "sha256:" + HexFormat.of().formatHex(digest.digest(bytes)); + } + + public static String sha256FileSet( + String domain, File rootDirectory, List files) { + MessageDigest digest = digest(); + digest.update(domain.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + List sorted = + files.stream() + .sorted(Comparator.comparing(file -> relativePath(rootDirectory, file))) + .toList(); + for (File input : sorted) { + if (!input.isFile()) { + throw new IllegalStateException( + "Messaging evidence input is missing: " + relativePath(rootDirectory, input)); + } + byte[] path = relativePath(rootDirectory, input).getBytes(StandardCharsets.UTF_8); + byte[] content; + try { + content = Files.readAllBytes(input.toPath()); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + input, exception); + } + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(path.length).array()); + digest.update(path); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(content.length).array()); + digest.update(content); + } + return "sha256:" + HexFormat.of().formatHex(digest.digest()); + } + + private static String relativePath(File rootDirectory, File file) { + return rootDirectory.toPath().toAbsolutePath().normalize() + .relativize(file.toPath().toAbsolutePath().normalize()) + .toString() + .replace(File.separatorChar, '/'); + } + + private static MessageDigest digest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceHashes.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceHashes.java new file mode 100644 index 00000000..10585c08 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceHashes.java @@ -0,0 +1,13 @@ +package dev.caskeleton.buildtools.messaging; + +import java.util.Objects; + +public record MessagingEvidenceHashes( + String profile, String catalog, String schema, String settings) { + public MessagingEvidenceHashes { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(catalog, "catalog"); + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(settings, "settings"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceManifest.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceManifest.java new file mode 100644 index 00000000..42aa4d66 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceManifest.java @@ -0,0 +1,21 @@ +package dev.caskeleton.buildtools.messaging; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +public record MessagingEvidenceManifest( + String producerTask, + MessagingEvidenceCounts counts, + List failures, + List skips, + Instant generatedAt) { + + public MessagingEvidenceManifest { + Objects.requireNonNull(producerTask, "producerTask"); + Objects.requireNonNull(counts, "counts"); + failures = List.copyOf(failures); + skips = List.copyOf(skips); + Objects.requireNonNull(generatedAt, "generatedAt"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceResultEntry.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceResultEntry.java new file mode 100644 index 00000000..3744d963 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceResultEntry.java @@ -0,0 +1,10 @@ +package dev.caskeleton.buildtools.messaging; + +import java.util.Objects; + +public record MessagingEvidenceResultEntry(String scenarioId, String reason) { + public MessagingEvidenceResultEntry { + Objects.requireNonNull(scenarioId, "scenarioId"); + Objects.requireNonNull(reason, "reason"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceVerifier.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceVerifier.java new file mode 100644 index 00000000..5766ff64 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceVerifier.java @@ -0,0 +1,53 @@ +package dev.caskeleton.buildtools.messaging; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; + +/** Semantic rules for Messaging evidence that JSON Schema cannot express. */ +public final class MessagingEvidenceVerifier { + private MessagingEvidenceVerifier() {} + + public static List scenarioIds(Collection selectors) { + List ids = + selectors.stream() + .map(MessagingEvidenceVerifier::scenarioId) + .sorted() + .toList(); + if (new HashSet<>(ids).size() != ids.size()) { + throw new IllegalArgumentException("Messaging qualification scenario IDs are not unique."); + } + return ids; + } + + public static List validate( + MessagingEvidenceManifest manifest, String expectedProducer) { + List violations = new ArrayList<>(); + if (!manifest.producerTask().equals(expectedProducer)) { + violations.add( + "producerTask is '" + + manifest.producerTask() + + "', not '" + + expectedProducer + + "'"); + } + if (manifest.counts().executed() != manifest.counts().totalOutcomes()) { + violations.add("counts do not add up: " + manifest.counts()); + } + if (!manifest.counts().isPassing() + || !manifest.failures().isEmpty() + || !manifest.skips().isEmpty()) { + violations.add("failed or skipped qualification cannot produce PASS evidence"); + } + return List.copyOf(violations); + } + + private static String scenarioId(String selector) { + String withoutPackage = selector.replaceFirst("^.*\\.", ""); + return withoutPackage + .replace('#', '.') + .replaceAll("[^A-Za-z0-9._:-]", "-") + .replaceAll("-+", "-"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingQualificationPlugin.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingQualificationPlugin.java new file mode 100644 index 00000000..5b889800 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingQualificationPlugin.java @@ -0,0 +1,192 @@ +package dev.caskeleton.buildtools.messaging; + +import java.io.File; +import java.util.List; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.tasks.JavaExec; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.TaskProvider; + +public final class MessagingQualificationPlugin implements Plugin { + private static final String SCHEMA_VALIDATOR_MAIN = + "dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator"; + + @Override + public void apply(Project project) { + Project root = project.getRootProject(); + var resultRoot = root.getLayout().getBuildDirectory().dir("test-results/messaging-evidence"); + var evidenceFile = + root.getLayout().getBuildDirectory().file("messaging-evidence/contracts-schema/manifest.json"); + File profileFile = root.file("config/messaging/profile-compatibility.yaml"); + File commonSchema = + root.file("config/messaging/evidence/build-evidence-manifest-v1.schema.json"); + + TaskProvider prepare = + project + .getTasks() + .register( + "prepareMessagingContractEvidence", + PrepareMessagingContractEvidenceTask.class, + task -> { + task.setGroup("verification"); + task.getEvidenceFile().set(evidenceFile); + task.getProfileFile().fileValue(profileFile); + task.getSourceDigest() + .convention(project.getProviders().gradleProperty("messagingSourceDigest").orElse("")); + task.getArtifactDigest() + .convention(project.getProviders().gradleProperty("messagingArtifactDigest").orElse("")); + task.getProfileHash() + .convention(project.getProviders().gradleProperty("messagingProfileHash").orElse("")); + task.getOutputs().upToDateWhen(ignored -> false); + }); + + TaskProvider json = + registerEvidenceTask( + project, + "verifyMessagingJsonSchemaV1", + "Qualifies the deterministic local Draft 2020-12 envelope candidate.", + List.of("json-schema"), + List.of( + ":adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest", + "verifyMessagingJsonSchemaV1"), + List.of( + ":adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest", + ":adapter:outbound:messaging:verifyDependencyPolicy"), + resultRoot, + evidenceFile, + profileFile, + commonSchema); + + TaskProvider validateJson = + registerSchemaValidation( + project, + "validateMessagingJsonSchemaV1EvidenceManifestSchema", + "Validates the exact generated JSON qualification manifest bytes against the common Draft 2020-12 schema.", + json, + commonSchema, + evidenceFile); + json.configure(task -> task.finalizedBy(validateJson)); + + TaskProvider contracts = + registerEvidenceTask( + project, + "verifyMessagingContracts", + "Qualifies the closed Task 3-6 contract, catalog, binding and schema candidate.", + List.of("application", "shared", "compiled", "json-schema"), + List.of( + ":application-core:messagingApplicationContractQualificationTest", + ":shared-contract:messagingSharedSchemaQualificationTest", + ":adapter:outbound:messaging:messagingCompiledContractsQualificationTest", + ":adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest", + "verifyMessagingContracts"), + List.of( + validateJson, + ":application-core:messagingApplicationContractQualificationTest", + ":shared-contract:messagingSharedSchemaQualificationTest", + ":adapter:outbound:messaging:messagingCompiledContractsQualificationTest", + ":adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest", + ":adapter:outbound:messaging:verifyDependencyPolicy"), + resultRoot, + evidenceFile, + profileFile, + commonSchema); + + TaskProvider validateContracts = + registerSchemaValidation( + project, + "validateMessagingContractsEvidenceManifestSchema", + "Validates the exact generated combined qualification manifest bytes against the common Draft 2020-12 schema.", + contracts, + commonSchema, + evidenceFile); + contracts.configure(task -> task.finalizedBy(validateContracts)); + } + + private static TaskProvider registerEvidenceTask( + Project project, + String name, + String description, + List resultDirectories, + List commandTasks, + List dependencies, + org.gradle.api.provider.Provider resultRoot, + org.gradle.api.provider.Provider evidenceFile, + File profileFile, + File commonSchema) { + Project root = project.getRootProject(); + return project + .getTasks() + .register( + name, + VerifyMessagingEvidenceTask.class, + task -> { + task.setGroup("verification"); + task.setDescription(description); + dependencies.forEach(task::dependsOn); + task.getProducerTaskName().set(name); + task.getResultDirectories().set(resultDirectories); + task.getCommandTasks().set(commandTasks); + task.getSourceDigest() + .convention(project.getProviders().gradleProperty("messagingSourceDigest").orElse("")); + task.getArtifactDigest() + .convention(project.getProviders().gradleProperty("messagingArtifactDigest").orElse("")); + task.getProfileHash() + .convention(project.getProviders().gradleProperty("messagingProfileHash").orElse("")); + task.getProfileFile().fileValue(profileFile); + task.getCommonSchemaFile().fileValue(commonSchema); + task.getProjectRootDirectory().set(root.getLayout().getProjectDirectory()); + task.getResultRootDirectory().set(resultRoot); + task.getEvidenceFile().set(evidenceFile); + task.getCatalogFiles().from(root.file("config/messaging/readiness-cards.yaml")); + task.getSchemaFiles() + .from( + root.file( + "shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json"), + root.fileTree( + "adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12")); + task.getSettingsFiles() + .from( + root.file("adapter/outbound/messaging/build.gradle"), + root.file("adapter/outbound/messaging/gradle.lockfile")); + task.getOutputs().upToDateWhen(ignored -> false); + }); + } + + private static TaskProvider registerSchemaValidation( + Project project, + String name, + String description, + TaskProvider producer, + File commonSchema, + org.gradle.api.provider.Provider evidenceFile) { + return project + .getTasks() + .register( + name, + JavaExec.class, + task -> { + task.setGroup("verification"); + task.setDescription(description); + task.dependsOn(producer); + task.getMainClass().set(SCHEMA_VALIDATOR_MAIN); + task.args(commonSchema.getAbsolutePath(), evidenceFile.get().getAsFile().getAbsolutePath()); + task.getInputs().file(commonSchema); + task.getInputs().file(evidenceFile); + task.getOutputs().upToDateWhen(ignored -> false); + task.setClasspath( + project.files( + project.provider( + () -> { + Project messaging = + project.getRootProject().project(":adapter:outbound:messaging"); + JavaPluginExtension java = + messaging.getExtensions().getByType(JavaPluginExtension.class); + return java.getSourceSets() + .getByName(SourceSet.TEST_SOURCE_SET_NAME) + .getRuntimeClasspath(); + }))); + }); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingQualificationReader.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingQualificationReader.java new file mode 100644 index 00000000..73a2de68 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingQualificationReader.java @@ -0,0 +1,37 @@ +package dev.caskeleton.buildtools.messaging; + +import dev.caskeleton.buildtools.junit.JUnitEvidenceReader; +import dev.caskeleton.buildtools.junit.JUnitEvidenceResult; +import java.io.File; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +public final class MessagingQualificationReader { + private MessagingQualificationReader() {} + + public static MessagingQualificationResult read( + File resultRoot, List resultDirectories) { + int executed = 0; + int failed = 0; + int skipped = 0; + Set selectors = new TreeSet<>(); + for (String directory : resultDirectories) { + File resultDirectory = new File(resultRoot, directory); + JUnitEvidenceResult result = + JUnitEvidenceReader.read("messaging-evidence/" + directory, resultDirectory); + executed += result.tests(); + failed += result.failures() + result.errors(); + skipped += result.skipped(); + selectors.addAll(result.executedSelectors()); + } + if (executed <= 0) { + throw new IllegalStateException( + "Messaging qualification XML contains no discovered test cases."); + } + List scenarioIds = MessagingEvidenceVerifier.scenarioIds(selectors); + return new MessagingQualificationResult( + scenarioIds, + new MessagingEvidenceCounts(executed, executed - failed - skipped, failed, skipped)); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingQualificationResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingQualificationResult.java new file mode 100644 index 00000000..7c853762 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/MessagingQualificationResult.java @@ -0,0 +1,10 @@ +package dev.caskeleton.buildtools.messaging; + +import java.util.List; + +public record MessagingQualificationResult( + List scenarioIds, MessagingEvidenceCounts counts) { + public MessagingQualificationResult { + scenarioIds = List.copyOf(scenarioIds); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/PrepareMessagingContractEvidenceTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/PrepareMessagingContractEvidenceTask.java new file mode 100644 index 00000000..47c946d4 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/PrepareMessagingContractEvidenceTask.java @@ -0,0 +1,63 @@ +package dev.caskeleton.buildtools.messaging; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Prepares non-cacheable qualification evidence state") +public abstract class PrepareMessagingContractEvidenceTask extends DefaultTask { + @Internal + public abstract RegularFileProperty getEvidenceFile(); + + @InputFile + public abstract RegularFileProperty getProfileFile(); + + @Input + public abstract Property getSourceDigest(); + + @Input + public abstract Property getArtifactDigest(); + + @Input + public abstract Property getProfileHash(); + + @TaskAction + public void prepare() { + File output = getEvidenceFile().get().getAsFile(); + if (output.exists() && !output.delete()) { + throw new GradleException("Could not delete stale Messaging evidence " + output); + } + requireDigest("messagingSourceDigest", getSourceDigest().get()); + requireDigest("messagingArtifactDigest", getArtifactDigest().get()); + String suppliedProfile = requireDigest("messagingProfileHash", getProfileHash().get()); + byte[] profileBytes; + try { + profileBytes = Files.readAllBytes(getProfileFile().get().getAsFile().toPath()); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read Messaging profile", exception); + } + String exactProfile = MessagingEvidenceHasher.sha256Bytes(profileBytes); + if (!suppliedProfile.equals(exactProfile)) { + throw new GradleException( + "messagingProfileHash does not match exact config/messaging/profile-compatibility.yaml bytes."); + } + } + + static String requireDigest(String propertyName, String value) { + if (value == null || !value.matches("sha256:[a-f0-9]{64}")) { + throw new GradleException( + "-P" + propertyName + "=sha256:<64-lowercase-hex> is required for Messaging evidence."); + } + return value; + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/VerifyMessagingCertificationEvidenceTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/VerifyMessagingCertificationEvidenceTask.java new file mode 100644 index 00000000..4c2897fb --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/VerifyMessagingCertificationEvidenceTask.java @@ -0,0 +1,70 @@ +package dev.caskeleton.buildtools.messaging; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.LinkedHashSet; +import java.util.Set; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; + +public abstract class VerifyMessagingCertificationEvidenceTask extends DefaultTask { + @InputFile + public abstract RegularFileProperty getProducedManifest(); + + @InputFile + public abstract RegularFileProperty getCommittedManifest(); + + @OutputFile + public abstract RegularFileProperty getReportFile(); + + @TaskAction + public void verifyEvidence() { + try { + Set ran = normalizedClaims(getProducedManifest().get().getAsFile().toPath()); + Set shipped = normalizedClaims(getCommittedManifest().get().getAsFile().toPath()); + if (!ran.equals(shipped)) { + Set unproven = new LinkedHashSet<>(shipped); + unproven.removeAll(ran); + Set unrecorded = new LinkedHashSet<>(ran); + unrecorded.removeAll(shipped); + throw new GradleException( + "the committed certification manifest does not match this run.\n" + + " claimed but not produced: " + display(unproven) + "\n" + + " produced but not claimed: " + display(unrecorded) + "\n" + + "Copy " + getProducedManifest().get().getAsFile() + " over " + + getCommittedManifest().get().getAsFile() + + " — the manifest is a record of a run, not a statement about one."); + } + var report = getReportFile().get().getAsFile().toPath(); + Files.createDirectories(report.getParent()); + Files.writeString( + report, + "scenarios=" + ran.size() + " manifest=" + getCommittedManifest().get().getAsFile() + "\n", + StandardCharsets.UTF_8); + } catch (IOException failure) { + throw new GradleException("failed to verify messaging certification evidence", failure); + } + } + + private static Set normalizedClaims(java.nio.file.Path file) throws IOException { + Set result = new LinkedHashSet<>(); + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + if (line.trim().isEmpty()) { + continue; + } + result.add( + line.replaceAll(",\\\"gitCommit\\\":\\\"[^\\\"]*\\\"", "") + .replaceAll(",\\\"observedAt\\\":\\\"[^\\\"]*\\\"", "")); + } + return result; + } + + private static String display(Set values) { + return values.isEmpty() ? "none" : values.toString(); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/VerifyMessagingEvidenceTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/VerifyMessagingEvidenceTask.java new file mode 100644 index 00000000..55521980 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/messaging/VerifyMessagingEvidenceTask.java @@ -0,0 +1,190 @@ +package dev.caskeleton.buildtools.messaging; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@DisableCachingByDefault(because = "Evidence contains a generation timestamp and live test results") +public abstract class VerifyMessagingEvidenceTask extends DefaultTask { + private static final ObjectMapper JSON = JsonMapper.builder().build(); + private static final List UNSUPPORTED_CLAIMS = + List.of( + "consumer-compatibility-full-suite", + "durable-outbox-r2", + "kafka-acknowledged-r2", + "regex-engine-timeout", + "remote-schema-resolution"); + + @Input + public abstract Property getProducerTaskName(); + + @Input + public abstract ListProperty getResultDirectories(); + + @Input + public abstract ListProperty getCommandTasks(); + + @Input + public abstract Property getSourceDigest(); + + @Input + public abstract Property getArtifactDigest(); + + @Input + public abstract Property getProfileHash(); + + @InputFile + public abstract RegularFileProperty getProfileFile(); + + @InputFile + public abstract RegularFileProperty getCommonSchemaFile(); + + @InputFiles + public abstract ConfigurableFileCollection getCatalogFiles(); + + @InputFiles + public abstract ConfigurableFileCollection getSchemaFiles(); + + @InputFiles + public abstract ConfigurableFileCollection getSettingsFiles(); + + @Internal + public abstract DirectoryProperty getProjectRootDirectory(); + + @Internal + public abstract DirectoryProperty getResultRootDirectory(); + + @OutputFile + public abstract RegularFileProperty getEvidenceFile(); + + @TaskAction + public void verifyAndWrite() { + String producer = getProducerTaskName().get(); + MessagingQualificationResult result; + try { + result = + MessagingQualificationReader.read( + getResultRootDirectory().get().getAsFile(), getResultDirectories().get()); + } catch (IllegalArgumentException | IllegalStateException invalidEvidence) { + throw new GradleException(invalidEvidence.getMessage(), invalidEvidence); + } + + String sourceDigest = + PrepareMessagingContractEvidenceTask.requireDigest( + "messagingSourceDigest", getSourceDigest().get()); + String artifactDigest = + PrepareMessagingContractEvidenceTask.requireDigest( + "messagingArtifactDigest", getArtifactDigest().get()); + String suppliedProfile = + PrepareMessagingContractEvidenceTask.requireDigest( + "messagingProfileHash", getProfileHash().get()); + + File profileFile = getProfileFile().get().getAsFile(); + byte[] profileBytes; + try { + profileBytes = Files.readAllBytes(profileFile.toPath()); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + profileFile, exception); + } + String exactProfile = MessagingEvidenceHasher.sha256Bytes(profileBytes); + if (!suppliedProfile.equals(exactProfile)) { + throw new GradleException( + "messagingProfileHash does not match exact config/messaging/profile-compatibility.yaml bytes."); + } + + File projectRoot = getProjectRootDirectory().get().getAsFile(); + MessagingEvidenceHashes hashes = + new MessagingEvidenceHashes( + exactProfile, + MessagingEvidenceHasher.sha256FileSet( + "ca-skeleton.messaging.evidence.catalog.v1", + projectRoot, + getCatalogFiles().getFiles().stream().toList()), + MessagingEvidenceHasher.sha256FileSet( + "ca-skeleton.messaging.evidence.schema-set.v1", + projectRoot, + getSchemaFiles().getFiles().stream().toList()), + MessagingEvidenceHasher.sha256FileSet( + "ca-skeleton.messaging.evidence.settings.v1", + projectRoot, + getSettingsFiles().getFiles().stream().toList())); + + String command = + "./gradlew " + + String.join(" ", getCommandTasks().get()) + + " -PmessagingSourceDigest= -PmessagingArtifactDigest= " + + "-PmessagingProfileHash= --console=plain"; + String generatedAt = Instant.now().toString(); + MessagingBuildEvidenceManifest manifest = + new MessagingBuildEvidenceManifest( + 1, + sourceDigest, + artifactDigest, + producer, + result.scenarioIds(), + result.counts(), + command, + generatedAt, + hashes, + List.of(), + List.of(), + UNSUPPORTED_CLAIMS); + + MessagingEvidenceManifest semanticManifest = + new MessagingEvidenceManifest( + producer, + result.counts(), + List.of(), + List.of(), + Instant.parse(generatedAt)); + List semanticViolations = + MessagingEvidenceVerifier.validate(semanticManifest, producer); + if (!semanticViolations.isEmpty()) { + throw new GradleException( + "Messaging evidence fails the rules the manifest schema cannot express:\n " + + String.join("\n ", semanticViolations)); + } + if (!getCommonSchemaFile().get().getAsFile().isFile()) { + throw new GradleException("Common Messaging evidence schema is missing."); + } + + File output = getEvidenceFile().get().getAsFile(); + File parent = output.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) { + throw new GradleException("Could not create Messaging evidence directory " + parent); + } + try { + String json = + JSON.writerWithDefaultPrettyPrinter().writeValueAsString(manifest) + + System.lineSeparator(); + Files.writeString(output.toPath(), json, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to write " + output, exception); + } + getLogger() + .lifecycle( + "{}: wrote payload-free evidence with {} scenarios.", + producer, + result.counts().executed()); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoJUnitXml.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoJUnitXml.java new file mode 100644 index 00000000..abf187f0 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoJUnitXml.java @@ -0,0 +1,74 @@ +package dev.caskeleton.buildtools.mongo; + +import java.io.File; +import java.util.LinkedHashSet; +import java.util.Set; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +final class MongoJUnitXml { + private MongoJUnitXml() {} + + static Set selectors(File directory) { + Set selectors = new LinkedHashSet<>(); + File[] files = directory.listFiles(file -> file.isFile() && file.getName().endsWith(".xml")); + if (files == null) { + return selectors; + } + java.util.Arrays.sort(files, java.util.Comparator.comparing(File::getName)); + for (File file : files) { + Element suite = parse(file); + NodeList testCases = suite.getElementsByTagName("testcase"); + for (int index = 0; index < testCases.getLength(); index++) { + Element testCase = (Element) testCases.item(index); + selectors.add(testCase.getAttribute("classname") + "#" + testCase.getAttribute("name")); + } + } + return selectors; + } + + static SuiteCounts counts(File file) { + Element suite = parse(file); + return new SuiteCounts(attribute(suite, "tests", file), attribute(suite, "skipped", file)); + } + + private static Element parse(File file) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + Element root = factory.newDocumentBuilder().parse(file).getDocumentElement(); + if (!"testsuite".equals(root.getTagName())) { + throw new IllegalStateException(file + " root must be testsuite"); + } + return root; + } catch (IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + throw new IllegalStateException(file + " is not readable JUnit XML", exception); + } + } + + private static int attribute(Element suite, String name, File file) { + String value = suite.hasAttribute(name) ? suite.getAttribute(name) : null; + if (value == null || !value.matches("\\d+")) { + throw new IllegalStateException(file.getName() + " has invalid " + name + "='" + value + "'"); + } + return Integer.parseInt(value); + } + + record SuiteCounts(int tests, int skipped) { + int executed() { + return tests - skipped; + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessResult.java new file mode 100644 index 00000000..ac691ec9 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessResult.java @@ -0,0 +1,3 @@ +package dev.caskeleton.buildtools.mongo; + +public record MongoLaneDisjointnessResult(int unitCount, int contractCount) {} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessVerifier.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessVerifier.java new file mode 100644 index 00000000..d6c826c2 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessVerifier.java @@ -0,0 +1,29 @@ +package dev.caskeleton.buildtools.mongo; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class MongoLaneDisjointnessVerifier { + private MongoLaneDisjointnessVerifier() {} + + public static MongoLaneDisjointnessResult verify(File unitResults, File contractResults) { + Set unit = MongoJUnitXml.selectors(unitResults); + Set contract = MongoJUnitXml.selectors(contractResults); + Set overlap = new LinkedHashSet<>(unit); + overlap.retainAll(contract); + if (!overlap.isEmpty()) { + List sample = new ArrayList<>(overlap); + if (sample.size() > 5) { + sample = sample.subList(0, 5); + } + throw new IllegalStateException( + overlap.size() + + " tests run in both the unit lane and the stable contract lane, so `check` executes them twice: " + + sample); + } + return new MongoLaneDisjointnessResult(unit.size(), contract.size()); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContract.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContract.java new file mode 100644 index 00000000..c42d822c --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContract.java @@ -0,0 +1,18 @@ +package dev.caskeleton.buildtools.mongo; + +public record MongoReleaseContract(String id, String task, String className, int minimumExecuted) { + public MongoReleaseContract { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("id must not be blank"); + } + if (task == null || task.isBlank()) { + throw new IllegalArgumentException("task must not be blank"); + } + if (className == null || className.isBlank()) { + throw new IllegalArgumentException("className must not be blank"); + } + if (minimumExecuted < 0) { + throw new IllegalArgumentException("minimumExecuted must not be negative"); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneResult.java new file mode 100644 index 00000000..9eb5ed5a --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneResult.java @@ -0,0 +1,9 @@ +package dev.caskeleton.buildtools.mongo; + +import java.util.List; + +public record MongoReleaseContractLaneResult(List checkedContractIds) { + public MongoReleaseContractLaneResult { + checkedContractIds = List.copyOf(checkedContractIds); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneVerifier.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneVerifier.java new file mode 100644 index 00000000..ce77555a --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneVerifier.java @@ -0,0 +1,51 @@ +package dev.caskeleton.buildtools.mongo; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public final class MongoReleaseContractLaneVerifier { + private MongoReleaseContractLaneVerifier() {} + + public static MongoReleaseContractLaneResult verify( + File manifest, Set hermeticLanes, File resultsRoot) { + List checked = new ArrayList<>(); + List wrongLane = new ArrayList<>(); + for (MongoReleaseContract contract : MongoReleaseContractManifestParser.parse(manifest)) { + if (!hermeticLanes.contains(contract.task())) { + continue; + } + File resultFile = + new File( + new File(resultsRoot, contract.task()), "TEST-" + contract.className() + ".xml"); + if (!resultFile.isFile()) { + wrongLane.add( + contract.id() + + " names lane '" + + contract.task() + + "', which did not run " + + contract.className()); + continue; + } + int executed = MongoJUnitXml.counts(resultFile).executed(); + if (executed < contract.minimumExecuted()) { + wrongLane.add( + contract.id() + + " requires " + + contract.minimumExecuted() + + " executed test(s) in '" + + contract.task() + + "' and the lane ran " + + executed); + } + checked.add(contract.id()); + } + if (!wrongLane.isEmpty()) { + throw new IllegalStateException( + "the Mongo release manifest points at lanes that cannot produce its evidence: " + + String.join("; ", wrongLane)); + } + return new MongoReleaseContractLaneResult(checked); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractManifestParser.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractManifestParser.java new file mode 100644 index 00000000..99f14071 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractManifestParser.java @@ -0,0 +1,58 @@ +package dev.caskeleton.buildtools.mongo; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +public final class MongoReleaseContractManifestParser { + private static final ObjectMapper JSON = JsonMapper.builder().build(); + + private MongoReleaseContractManifestParser() {} + + public static List parse(File manifest) { + final String raw; + try { + raw = Files.readString(manifest.toPath()); + } catch (IOException exception) { + throw new UncheckedIOException("cannot read " + manifest, exception); + } + JsonNode root = JSON.readTree(raw); + JsonNode contracts = root == null ? null : root.get("contracts"); + if (contracts == null || !contracts.isArray()) { + throw new IllegalStateException("release manifest field 'contracts' must be a list"); + } + List parsed = new ArrayList<>(); + for (JsonNode contract : contracts) { + parsed.add( + new MongoReleaseContract( + requiredText(contract, "id"), + requiredText(contract, "task"), + requiredText(contract, "className"), + requiredInt(contract, "minimumExecuted"))); + } + return List.copyOf(parsed); + } + + private static String requiredText(JsonNode node, String field) { + JsonNode value = node == null ? null : node.get(field); + String text = value == null ? null : value.stringValue(); + if (text == null || text.isBlank()) { + throw new IllegalStateException("field '" + field + "' must be non-blank text"); + } + return text; + } + + private static int requiredInt(JsonNode node, String field) { + JsonNode value = node == null ? null : node.get(field); + if (value == null || !value.isIntegralNumber()) { + throw new IllegalStateException("field '" + field + "' must be an integer"); + } + return value.asInt(); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoVerificationPlugin.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoVerificationPlugin.java new file mode 100644 index 00000000..6cdceb6a --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/MongoVerificationPlugin.java @@ -0,0 +1,59 @@ +package dev.caskeleton.buildtools.mongo; + +import java.util.List; +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class MongoVerificationPlugin implements Plugin { + @Override + public void apply(Project project) { + project + .getTasks() + .register( + "verifyMongoTestLaneDisjointness", + VerifyMongoTestLaneDisjointnessTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails when the unit lane and the stable contract lane execute the same test."); + task.dependsOn("test", "mongoStableContractTest"); + task.getUnitResults() + .set(project.getLayout().getBuildDirectory().dir("test-results/test")); + task.getContractResults() + .set( + project + .getLayout() + .getBuildDirectory() + .dir("test-results/mongoStableContractTest")); + task.getReportFile() + .set( + project + .getLayout() + .getBuildDirectory() + .file("reports/mongo-test-lane-disjointness.txt")); + }); + + project + .getTasks() + .register( + "verifyMongoReleaseContractLanes", + VerifyMongoReleaseContractLanesTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails when a blocking release contract names a lane that did not run its class."); + task.dependsOn("test", "mongoStableContractTest"); + task.getManifestFile() + .fileValue(project.getRootProject().file("config/mongodb/release-contracts.json")); + task.getResultsRoot() + .set(project.getLayout().getBuildDirectory().dir("test-results")); + task.getHermeticLanes().convention(List.of("test", "mongoStableContractTest")); + task.getReportFile() + .set( + project + .getLayout() + .getBuildDirectory() + .file("reports/mongo-release-contract-lanes.txt")); + }); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/VerifyMongoReleaseContractLanesTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/VerifyMongoReleaseContractLanesTask.java new file mode 100644 index 00000000..d190a628 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/VerifyMongoReleaseContractLanesTask.java @@ -0,0 +1,60 @@ +package dev.caskeleton.buildtools.mongo; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.util.LinkedHashSet; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification reads local JUnit evidence from the current build") +public abstract class VerifyMongoReleaseContractLanesTask extends DefaultTask { + @InputFile + public abstract RegularFileProperty getManifestFile(); + + @InputDirectory + public abstract DirectoryProperty getResultsRoot(); + + @Input + public abstract ListProperty getHermeticLanes(); + + @OutputFile + public abstract RegularFileProperty getReportFile(); + + @TaskAction + public void verifyReleaseContractLanes() { + final MongoReleaseContractLaneResult result; + try { + result = + MongoReleaseContractLaneVerifier.verify( + getManifestFile().get().getAsFile(), + new LinkedHashSet<>(getHermeticLanes().get()), + getResultsRoot().get().getAsFile()); + } catch (IllegalStateException invalidEvidence) { + throw new GradleException(invalidEvidence.getMessage(), invalidEvidence); + } + writeReport( + "hermetic release contracts verified: " + + String.join(", ", result.checkedContractIds()) + + "\n"); + } + + private void writeReport(String report) { + try { + var path = getReportFile().get().getAsFile().toPath(); + Files.createDirectories(path.getParent()); + Files.writeString(path, report); + } catch (IOException exception) { + throw new UncheckedIOException("cannot write Mongo release contract lane report", exception); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/VerifyMongoTestLaneDisjointnessTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/VerifyMongoTestLaneDisjointnessTask.java new file mode 100644 index 00000000..36a8178c --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/mongo/VerifyMongoTestLaneDisjointnessTask.java @@ -0,0 +1,53 @@ +package dev.caskeleton.buildtools.mongo; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification reads local JUnit evidence from the current build") +public abstract class VerifyMongoTestLaneDisjointnessTask extends DefaultTask { + @InputDirectory + public abstract DirectoryProperty getUnitResults(); + + @InputDirectory + public abstract DirectoryProperty getContractResults(); + + @OutputFile + public abstract RegularFileProperty getReportFile(); + + @TaskAction + public void verifyLanes() { + final MongoLaneDisjointnessResult result; + try { + result = + MongoLaneDisjointnessVerifier.verify( + getUnitResults().get().getAsFile(), getContractResults().get().getAsFile()); + } catch (IllegalStateException invalidEvidence) { + throw new GradleException(invalidEvidence.getMessage(), invalidEvidence); + } + writeReport( + "unit=" + + result.unitCount() + + " contract=" + + result.contractCount() + + " overlap=0\n"); + } + + private void writeReport(String report) { + try { + var path = getReportFile().get().getAsFile().toPath(); + Files.createDirectories(path.getParent()); + Files.writeString(path, report); + } catch (IOException exception) { + throw new UncheckedIOException("cannot write Mongo lane disjointness report", exception); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationApiSurfacePlugin.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationApiSurfacePlugin.java new file mode 100644 index 00000000..c90f9eab --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationApiSurfacePlugin.java @@ -0,0 +1,52 @@ +package dev.caskeleton.buildtools.notification; + +import java.io.File; +import java.util.List; +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class NotificationApiSurfacePlugin implements Plugin { + @Override + public void apply(Project project) { + Project root = project.getRootProject(); + List sourceRoots = + List.of( + root.file( + "application-core/src/main/java/dev/caskeleton/application/notification"), + root.file( + "adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification")); + File snapshot = root.file("../docs/notification/api-surface-snapshot.txt"); + boolean updateApproved = project.hasProperty("approveNotificationApiChange"); + boolean ceilingRaiseApproved = project.hasProperty("raiseNotificationApiCeiling"); + + project + .getTasks() + .register( + "verifyNotificationApiSurface", + VerifyNotificationApiSurfaceTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails without mutation when the notification platform public type surface drifts."); + task.getSourceRoots().from(sourceRoots); + task.getSnapshotFile().fileValue(snapshot); + task.getUpdateApprovalRequested().set(updateApproved); + }); + + project + .getTasks() + .register( + "updateNotificationApiSurface", + UpdateNotificationApiSurfaceTask.class, + task -> { + task.setGroup("build setup"); + task.setDescription( + "Explicitly updates the committed notification public type surface after review."); + task.getSourceRoots().from(sourceRoots); + task.getSnapshotFile().fileValue(snapshot); + task.getApproved().set(updateApproved); + task.getCeilingRaiseApproved().set(ceilingRaiseApproved); + task.getOutputs().upToDateWhen(ignored -> false); + }); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationApiSurfaceResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationApiSurfaceResult.java new file mode 100644 index 00000000..f714b212 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationApiSurfaceResult.java @@ -0,0 +1,12 @@ +package dev.caskeleton.buildtools.notification; + +import java.util.Objects; + +public record NotificationApiSurfaceResult(int publicTypeCount, String renderedSurface) { + public NotificationApiSurfaceResult { + if (publicTypeCount < 0) { + throw new IllegalArgumentException("publicTypeCount must not be negative"); + } + Objects.requireNonNull(renderedSurface, "renderedSurface"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationApiSurfaceVerifier.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationApiSurfaceVerifier.java new file mode 100644 index 00000000..aa92b8c7 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationApiSurfaceVerifier.java @@ -0,0 +1,156 @@ +package dev.caskeleton.buildtools.notification; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Repository policy for the notification platform's committed public Java type surface. */ +public final class NotificationApiSurfaceVerifier { + private static final String HEADER = + "# NTF-022 — public type surface of the notification platform.\n" + + "# Every top-level public type under the platform packages. Growth is a reviewed\n" + + "# change: ./gradlew updateNotificationApiSurface -PapproveNotificationApiChange\n"; + private static final Pattern PACKAGE_PATTERN = + Pattern.compile("(?m)^package\\s+([\\w.]+);"); + private static final Pattern DECLARATION_PATTERN = + Pattern.compile( + "(?m)^public\\s+(?:final\\s+|abstract\\s+|sealed\\s+|non-sealed\\s+|static\\s+)*" + + "(class|interface|record|enum|@interface)\\s+(\\w+)"); + + private NotificationApiSurfaceVerifier() {} + + public static String render(Collection sourceRoots) { + TreeSet types = new TreeSet<>(); + for (File root : sourceRoots) { + if (!root.isDirectory()) { + continue; + } + try (var paths = Files.walk(root.toPath())) { + paths + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".java")) + .filter(path -> !path.getFileName().toString().equals("package-info.java")) + .forEach(path -> collectTypes(path.toFile(), types)); + } catch (IOException exception) { + throw new UncheckedIOException("failed to scan " + root, exception); + } + } + return HEADER + (types.isEmpty() ? "" : String.join("\n", types) + "\n"); + } + + public static NotificationApiSurfaceResult verify( + Collection sourceRoots, File snapshotFile) { + String canonical = render(sourceRoots); + List canonicalTypes = typeLines(canonical); + if (canonicalTypes.isEmpty()) { + throw new IllegalStateException( + "verifyNotificationApiSurface: found no public types under " + + sourceRoots + + ". The source roots moved; fix the paths rather than accepting an empty surface."); + } + if (!snapshotFile.isFile()) { + throw new IllegalStateException( + "verifyNotificationApiSurface: missing committed baseline " + snapshotFile); + } + + List committed = + readLines(snapshotFile).stream() + .filter(line -> !line.startsWith("#")) + .filter(line -> !line.trim().isEmpty()) + .toList(); + List added = difference(canonicalTypes, committed); + List removed = difference(committed, canonicalTypes); + if (!added.isEmpty() || !removed.isEmpty()) { + throw new IllegalStateException( + "verifyNotificationApiSurface: the notification public type surface changed.\n" + + (added.isEmpty() + ? "" + : " added (" + added.size() + "):\n " + String.join("\n ", added) + "\n") + + (removed.isEmpty() + ? "" + : " removed (" + + removed.size() + + "):\n " + + String.join("\n ", removed) + + "\n") + + "A type added here is a type other code may now depend on forever. If that is intended:\n" + + " ./gradlew updateNotificationApiSurface -PapproveNotificationApiChange"); + } + return new NotificationApiSurfaceResult(canonicalTypes.size(), canonical); + } + + public static void requireNoUnapprovedGrowth( + int committedCount, String rendered, boolean ceilingRaiseApproved) { + if (ceilingRaiseApproved) { + return; + } + int renderedCount = typeCount(rendered); + if (renderedCount > committedCount) { + throw new IllegalStateException( + "updateNotificationApiSurface: the public surface would grow from " + + committedCount + + " to " + + renderedCount + + " types.\n" + + "Either land the addition together with a removal that pays for it, " + + "or raise the ceiling deliberately:\n" + + " ./gradlew updateNotificationApiSurface " + + "-PapproveNotificationApiChange -PraiseNotificationApiCeiling"); + } + } + + public static int typeCount(String surface) { + return typeLines(surface).size(); + } + + private static void collectTypes(File file, TreeSet types) { + String text = read(file); + Matcher packageMatcher = PACKAGE_PATTERN.matcher(text); + if (!packageMatcher.find()) { + return; + } + String packageName = packageMatcher.group(1); + Matcher declarationMatcher = DECLARATION_PATTERN.matcher(text); + while (declarationMatcher.find()) { + types.add(packageName + "." + declarationMatcher.group(2)); + } + } + + private static List typeLines(String surface) { + return surface.lines() + .filter(line -> !line.startsWith("#")) + .filter(line -> !line.trim().isEmpty()) + .toList(); + } + + private static List difference(List left, List right) { + ArrayList result = new ArrayList<>(left); + result.removeAll(right); + result.sort(String::compareTo); + return List.copyOf(result); + } + + private static String read(File file) { + try { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } + + private static List readLines(File file) { + try { + return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationClaim.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationClaim.java new file mode 100644 index 00000000..e80202e0 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationClaim.java @@ -0,0 +1,13 @@ +package dev.caskeleton.buildtools.notification; + +import java.util.List; +import java.util.Objects; + +public record NotificationClaim( + String name, NotificationClaimStatus status, List evidence) { + public NotificationClaim { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(status, "status"); + evidence = List.copyOf(evidence); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationClaimStatus.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationClaimStatus.java new file mode 100644 index 00000000..c0f475c4 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationClaimStatus.java @@ -0,0 +1,21 @@ +package dev.caskeleton.buildtools.notification; + +public enum NotificationClaimStatus { + SATISFIED("satisfied"), + UNSATISFIED("unsatisfied"); + + private final String externalValue; + + NotificationClaimStatus(String externalValue) { + this.externalValue = externalValue; + } + + public static NotificationClaimStatus parse(String value) { + for (NotificationClaimStatus status : values()) { + if (status.externalValue.equals(value)) { + return status; + } + } + throw new IllegalStateException("unsupported claim status '" + value + "'"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidenceManifest.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidenceManifest.java new file mode 100644 index 00000000..d1fe22ff --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidenceManifest.java @@ -0,0 +1,13 @@ +package dev.caskeleton.buildtools.notification; + +import java.util.List; +import java.util.Objects; + +public record NotificationEvidenceManifest( + String matrixDocument, List claims, List grades) { + public NotificationEvidenceManifest { + Objects.requireNonNull(matrixDocument, "matrixDocument"); + claims = List.copyOf(claims); + grades = List.copyOf(grades); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidencePlugin.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidencePlugin.java new file mode 100644 index 00000000..f9c39849 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidencePlugin.java @@ -0,0 +1,25 @@ +package dev.caskeleton.buildtools.notification; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class NotificationEvidencePlugin implements Plugin { + @Override + public void apply(Project project) { + Project root = project.getRootProject(); + project + .getTasks() + .register( + "verifyNotificationEvidence", + VerifyNotificationEvidenceTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails when a notification support grade claims more than the executable evidence proves."); + task.getManifestFile() + .fileValue(root.file("../docs/notification/evidence-manifest.json")); + task.getRepositoryDirectory().set(root.getLayout().getProjectDirectory().dir("..")); + task.getProjectDirectory().set(root.getLayout().getProjectDirectory()); + }); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidenceResult.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidenceResult.java new file mode 100644 index 00000000..99fdbf24 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidenceResult.java @@ -0,0 +1,12 @@ +package dev.caskeleton.buildtools.notification; + +import java.util.Objects; + +public record NotificationEvidenceResult(int satisfiedClaimCount, String matrixFileName) { + public NotificationEvidenceResult { + if (satisfiedClaimCount < 0) { + throw new IllegalArgumentException("satisfiedClaimCount must not be negative"); + } + Objects.requireNonNull(matrixFileName, "matrixFileName"); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidenceVerifier.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidenceVerifier.java new file mode 100644 index 00000000..d9cec9d1 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationEvidenceVerifier.java @@ -0,0 +1,245 @@ +package dev.caskeleton.buildtools.notification; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** Pure repository-policy verifier for notification support grades and their evidence. */ +public final class NotificationEvidenceVerifier { + private static final ObjectMapper JSON = JsonMapper.builder().build(); + + private NotificationEvidenceVerifier() {} + + public static NotificationEvidenceResult verify( + File manifestFile, File repositoryDirectory, File projectDirectory) { + NotificationEvidenceManifest manifest = parseManifest(manifestFile); + File matrixFile = new File(repositoryDirectory, manifest.matrixDocument()); + if (!matrixFile.isFile()) { + throw new IllegalStateException( + "verifyNotificationEvidence: missing support matrix " + matrixFile); + } + + List problems = new ArrayList<>(); + Set satisfied = new HashSet<>(); + for (NotificationClaim claim : manifest.claims()) { + if (claim.status() == NotificationClaimStatus.SATISFIED) { + if (claim.evidence().isEmpty()) { + problems.add( + "claim '" + claim.name() + "' is marked satisfied with no evidence at all"); + continue; + } + List missing = + claim.evidence().stream() + .filter(path -> !new File(projectDirectory, path).isFile()) + .toList(); + if (missing.isEmpty()) { + satisfied.add(claim.name()); + } else { + problems.add( + "claim '" + + claim.name() + + "' names evidence that does not exist: " + + String.join(", ", missing)); + } + } else if (!claim.evidence().isEmpty()) { + problems.add( + "claim '" + + claim.name() + + "' is not satisfied but names evidence; either the status or the evidence list is wrong"); + } + } + + Set knownGrades = + manifest.grades().stream().map(NotificationGrade::name).collect(java.util.stream.Collectors.toSet()); + boolean insideTable = false; + int gradeColumn = -1; + int gradeAssigningTables = 0; + List lines = readLines(matrixFile); + for (int index = 0; index < lines.size(); index++) { + List cells = tableCells(lines.get(index)); + if (cells == null) { + insideTable = false; + gradeColumn = -1; + continue; + } + if (cells.stream().allMatch(cell -> cell.isEmpty() || cell.matches(":?-{2,}:?"))) { + continue; + } + if (!insideTable) { + insideTable = true; + gradeColumn = cells.indexOf("Grade"); + if (gradeColumn > 0) { + gradeAssigningTables++; + } else { + gradeColumn = -1; + } + continue; + } + if (gradeColumn < 0) { + continue; + } + if (gradeColumn >= cells.size()) { + problems.add( + matrixFile.getName() + + ":" + + (index + 1) + + " has " + + cells.size() + + " cell(s) but its table's grade column is " + + (gradeColumn + 1)); + continue; + } + String gradeName = cells.get(gradeColumn); + if (gradeName.isEmpty()) { + continue; + } + if (!knownGrades.contains(gradeName)) { + problems.add( + matrixFile.getName() + + ":" + + (index + 1) + + " uses grade '" + + gradeName + + "', which the evidence manifest does not define"); + continue; + } + NotificationGrade grade = + manifest.grades().stream() + .filter(candidate -> candidate.name().equals(gradeName)) + .findFirst() + .orElseThrow(); + List unmet = + grade.requiredClaims().stream().filter(claim -> !satisfied.contains(claim)).toList(); + if (!unmet.isEmpty()) { + problems.add( + matrixFile.getName() + + ":" + + (index + 1) + + " claims '" + + gradeName + + "', which requires " + + String.join(", ", unmet) + + " — not proven by any artifact in the manifest"); + } + } + + if (knownGrades.isEmpty()) { + throw new IllegalStateException( + "verifyNotificationEvidence: the manifest defines no grades, so the check would pass whatever the support matrix claims."); + } + if (gradeAssigningTables == 0) { + throw new IllegalStateException( + "verifyNotificationEvidence: " + + matrixFile.getName() + + " has no table that assigns a grade (a 'Grade' column that is not the first column), so no claim in it was checked."); + } + if (!problems.isEmpty()) { + throw new IllegalStateException( + "verifyNotificationEvidence: the support matrix claims more than the evidence proves.\n " + + String.join("\n ", problems) + + "\nEither add the artifact and mark the claim satisfied, or lower the grade. A grade is a promise about production behaviour; the manifest is where it is kept."); + } + + return new NotificationEvidenceResult(satisfied.size(), matrixFile.getName()); + } + + static NotificationEvidenceManifest parseManifest(File manifestFile) { + if (!manifestFile.isFile()) { + throw new IllegalStateException( + "verifyNotificationEvidence: missing manifest " + manifestFile); + } + JsonNode root = JSON.readTree(read(manifestFile)); + String matrixDocument = requiredText(root, "matrixDocument"); + + JsonNode claimsNode = root.get("claims"); + if (claimsNode == null || !claimsNode.isObject()) { + throw new IllegalStateException("verifyNotificationEvidence: claims must be an object"); + } + List claims = new ArrayList<>(); + claimsNode + .properties() + .forEach( + entry -> { + JsonNode value = entry.getValue(); + String status = requiredText(value, "status"); + claims.add( + new NotificationClaim( + entry.getKey(), + NotificationClaimStatus.parse(status), + stringArray(value.get("evidence")))); + }); + + JsonNode gradesNode = root.get("grades"); + if (gradesNode == null || !gradesNode.isObject()) { + throw new IllegalStateException("verifyNotificationEvidence: grades must be an object"); + } + List grades = new ArrayList<>(); + gradesNode + .properties() + .forEach( + entry -> grades.add(new NotificationGrade(entry.getKey(), stringArray(entry.getValue())))); + + return new NotificationEvidenceManifest(matrixDocument, claims, grades); + } + + private static String requiredText(JsonNode node, String field) { + JsonNode value = node == null ? null : node.get(field); + if (value == null || !value.isString() || value.asText().isBlank()) { + throw new IllegalStateException( + "verifyNotificationEvidence: required text field '" + field + "' is missing"); + } + return value.asText(); + } + + private static List stringArray(JsonNode node) { + if (node == null || node.isNull()) { + return List.of(); + } + if (!node.isArray()) { + throw new IllegalStateException("verifyNotificationEvidence: expected JSON array"); + } + List values = new ArrayList<>(); + for (JsonNode value : node) { + if (!value.isString()) { + throw new IllegalStateException("verifyNotificationEvidence: array values must be strings"); + } + values.add(value.asText()); + } + return List.copyOf(values); + } + + private static List tableCells(String line) { + String trimmed = line.trim(); + if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) { + return null; + } + return java.util.Arrays.stream(trimmed.substring(1, trimmed.length() - 1).split("\\|", -1)) + .map(String::trim) + .toList(); + } + + private static String read(File file) { + try { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } + + private static List readLines(File file) { + try { + return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationGrade.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationGrade.java new file mode 100644 index 00000000..2bf43b82 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/NotificationGrade.java @@ -0,0 +1,11 @@ +package dev.caskeleton.buildtools.notification; + +import java.util.List; +import java.util.Objects; + +public record NotificationGrade(String name, List requiredClaims) { + public NotificationGrade { + Objects.requireNonNull(name, "name"); + requiredClaims = List.copyOf(requiredClaims); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/UpdateNotificationApiSurfaceTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/UpdateNotificationApiSurfaceTask.java new file mode 100644 index 00000000..2f9f6ddc --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/UpdateNotificationApiSurfaceTask.java @@ -0,0 +1,70 @@ +package dev.caskeleton.buildtools.notification; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "This task intentionally updates a committed review baseline") +public abstract class UpdateNotificationApiSurfaceTask extends DefaultTask { + @InputFiles + public abstract ConfigurableFileCollection getSourceRoots(); + + @OutputFile + public abstract RegularFileProperty getSnapshotFile(); + + @Input + public abstract Property getApproved(); + + @Input + public abstract Property getCeilingRaiseApproved(); + + @TaskAction + public void updateSurface() { + if (!getApproved().get()) { + throw new GradleException( + "updateNotificationApiSurface requires -PapproveNotificationApiChange"); + } + List roots = getSourceRoots().getFiles().stream().sorted().toList(); + String canonical = NotificationApiSurfaceVerifier.render(roots); + File snapshot = getSnapshotFile().get().getAsFile(); + if (snapshot.isFile()) { + try { + int committedCount = + NotificationApiSurfaceVerifier.typeCount( + Files.readString(snapshot.toPath(), StandardCharsets.UTF_8)); + NotificationApiSurfaceVerifier.requireNoUnapprovedGrowth( + committedCount, canonical, getCeilingRaiseApproved().get()); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + snapshot, exception); + } catch (IllegalStateException unapprovedGrowth) { + throw new GradleException(unapprovedGrowth.getMessage(), unapprovedGrowth); + } + } + File parent = snapshot.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) { + throw new GradleException( + "updateNotificationApiSurface: failed to create " + parent); + } + try { + Files.writeString(snapshot.toPath(), canonical, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to write " + snapshot, exception); + } + getLogger() + .lifecycle( + "updateNotificationApiSurface: wrote reviewed baseline {}", snapshot); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/VerifyNotificationApiSurfaceTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/VerifyNotificationApiSurfaceTask.java new file mode 100644 index 00000000..e44fe5a0 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/VerifyNotificationApiSurfaceTask.java @@ -0,0 +1,48 @@ +package dev.caskeleton.buildtools.notification; + +import java.io.File; +import java.util.List; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Repository surface verification emits no reusable output") +public abstract class VerifyNotificationApiSurfaceTask extends DefaultTask { + @InputFiles + public abstract ConfigurableFileCollection getSourceRoots(); + + @InputFile + public abstract RegularFileProperty getSnapshotFile(); + + @Input + public abstract Property getUpdateApprovalRequested(); + + @TaskAction + public void verifySurface() { + if (getUpdateApprovalRequested().get()) { + throw new GradleException( + "verifyNotificationApiSurface is read-only; use updateNotificationApiSurface " + + "-PapproveNotificationApiChange for an intentional update."); + } + List roots = getSourceRoots().getFiles().stream().sorted().toList(); + NotificationApiSurfaceResult result; + try { + result = + NotificationApiSurfaceVerifier.verify( + roots, getSnapshotFile().get().getAsFile()); + } catch (IllegalStateException invalidSurface) { + throw new GradleException(invalidSurface.getMessage(), invalidSurface); + } + getLogger() + .lifecycle( + "verifyNotificationApiSurface: OK — {} public types, unchanged.", + result.publicTypeCount()); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/VerifyNotificationEvidenceTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/VerifyNotificationEvidenceTask.java new file mode 100644 index 00000000..3fe79cf0 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notification/VerifyNotificationEvidenceTask.java @@ -0,0 +1,41 @@ +package dev.caskeleton.buildtools.notification; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Repository evidence verification emits no reusable output") +public abstract class VerifyNotificationEvidenceTask extends DefaultTask { + @InputFile + public abstract RegularFileProperty getManifestFile(); + + @InputDirectory + public abstract DirectoryProperty getRepositoryDirectory(); + + @InputDirectory + public abstract DirectoryProperty getProjectDirectory(); + + @TaskAction + public void verifyEvidence() { + NotificationEvidenceResult result; + try { + result = + NotificationEvidenceVerifier.verify( + getManifestFile().get().getAsFile(), + getRepositoryDirectory().get().getAsFile(), + getProjectDirectory().get().getAsFile()); + } catch (IllegalStateException invalidEvidence) { + throw new GradleException(invalidEvidence.getMessage(), invalidEvidence); + } + getLogger() + .lifecycle( + "verifyNotificationEvidence: OK — {} claims proven, every grade in {} is backed.", + result.satisfiedClaimCount(), + result.matrixFileName()); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfiguration.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfiguration.java new file mode 100644 index 00000000..345cbb38 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfiguration.java @@ -0,0 +1,11 @@ +package dev.caskeleton.buildtools.notificationconfig; + +import java.util.List; + +public record NotificationConfiguration( + List registeredVariables, List boundVariables) { + public NotificationConfiguration { + registeredVariables = List.copyOf(registeredVariables); + boundVariables = List.copyOf(boundVariables); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfigurationPlugin.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfigurationPlugin.java new file mode 100644 index 00000000..c3c76380 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfigurationPlugin.java @@ -0,0 +1,33 @@ +package dev.caskeleton.buildtools.notificationconfig; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public final class NotificationConfigurationPlugin implements Plugin { + @Override + public void apply(Project project) { + var resourceRoot = + project.getLayout().getProjectDirectory().dir("app-bootstrap/src/main/resources"); + var applicationYaml = resourceRoot.file("application.yml"); + var configurationDirectory = resourceRoot.dir("config"); + var environmentRegistry = + project.getLayout().getProjectDirectory().file("../docs/registries/env-keys.yaml"); + + project + .getTasks() + .register( + "verifyNotificationConfiguration", + VerifyNotificationConfigurationTask.class, + task -> { + task.setGroup("verification"); + task.setDescription( + "Fails when docs/registries/env-keys.yaml registers a notification platform key no binding reads."); + task.getApplicationYaml().set(applicationYaml); + task.getConfigurationDirectory().set(configurationDirectory); + task.getEnvironmentRegistry().set(environmentRegistry); + task.getResourceDisplayPath().convention("app-bootstrap/src/main/resources"); + task.getTrackedFiles() + .from(applicationYaml, environmentRegistry, project.fileTree(configurationDirectory)); + }); + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfigurationVerifier.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfigurationVerifier.java new file mode 100644 index 00000000..c2884ec2 --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfigurationVerifier.java @@ -0,0 +1,105 @@ +package dev.caskeleton.buildtools.notificationconfig; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class NotificationConfigurationVerifier { + private static final Pattern PLACEHOLDER = + Pattern.compile("\\$\\{(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)(:[^}]*)?}"); + private static final Pattern REGISTERED = + Pattern.compile("^\\s*- name:\\s*(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]+)\\s*$"); + + private NotificationConfigurationVerifier() {} + + public static NotificationConfiguration verify( + File applicationYaml, + File configurationDirectory, + File environmentRegistry, + String resourceDisplayPath) { + requireFile(applicationYaml); + requireFile(environmentRegistry); + + List boundSources = new ArrayList<>(); + boundSources.add(applicationYaml); + if (configurationDirectory.isDirectory()) { + File[] configurationFiles = + configurationDirectory.listFiles( + file -> file.isFile() && file.getName().endsWith(".yml")); + if (configurationFiles != null) { + Arrays.sort(configurationFiles, java.util.Comparator.comparing(File::getName)); + boundSources.addAll(Arrays.asList(configurationFiles)); + } + } + + Set boundVariables = new TreeSet<>(); + for (File source : boundSources) { + Matcher matcher = PLACEHOLDER.matcher(read(source)); + while (matcher.find()) { + boundVariables.add(matcher.group(1)); + } + } + if (boundVariables.isEmpty()) { + throw new IllegalStateException( + "verifyNotificationConfiguration: no APP_NOTIFICATION_PLATFORM_* placeholder is bound " + + "anywhere in " + + resourceDisplayPath + + ", so there is nothing to compare the registry against."); + } + + Set registeredVariables = new TreeSet<>(); + for (String line : readLines(environmentRegistry)) { + Matcher matcher = REGISTERED.matcher(line); + if (matcher.matches()) { + registeredVariables.add(matcher.group(1)); + } + } + + List problems = + registeredVariables.stream() + .filter(variable -> !boundVariables.contains(variable)) + .map(variable -> variable + " is registered in env-keys.yaml and bound by nothing") + .toList(); + if (!problems.isEmpty()) { + throw new IllegalStateException( + "verifyNotificationConfiguration: the env-key registry promises settings the binding " + + "does not have.\n " + + String.join("\n ", problems) + + "\nThe binding is the fact; the registry describes it."); + } + + return new NotificationConfiguration( + new ArrayList<>(registeredVariables), new ArrayList<>(boundVariables)); + } + + private static void requireFile(File file) { + if (!file.isFile()) { + throw new IllegalStateException("verifyNotificationConfiguration: missing " + file); + } + } + + private static String read(File file) { + try { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } + + private static List readLines(File file) { + try { + return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read " + file, exception); + } + } +} diff --git a/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/VerifyNotificationConfigurationTask.java b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/VerifyNotificationConfigurationTask.java new file mode 100644 index 00000000..d194d2db --- /dev/null +++ b/src/build-tools/src/main/java/dev/caskeleton/buildtools/notificationconfig/VerifyNotificationConfigurationTask.java @@ -0,0 +1,52 @@ +package dev.caskeleton.buildtools.notificationconfig; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Verification task has no outputs") +public abstract class VerifyNotificationConfigurationTask extends DefaultTask { + @Internal + public abstract RegularFileProperty getApplicationYaml(); + + @Internal + public abstract DirectoryProperty getConfigurationDirectory(); + + @Internal + public abstract RegularFileProperty getEnvironmentRegistry(); + + @Input + public abstract Property getResourceDisplayPath(); + + @InputFiles + public abstract ConfigurableFileCollection getTrackedFiles(); + + @TaskAction + public void verifyConfiguration() { + NotificationConfiguration configuration; + try { + configuration = + NotificationConfigurationVerifier.verify( + getApplicationYaml().get().getAsFile(), + getConfigurationDirectory().get().getAsFile(), + getEnvironmentRegistry().get().getAsFile(), + getResourceDisplayPath().get()); + } catch (IllegalStateException invalidConfiguration) { + throw new GradleException(invalidConfiguration.getMessage(), invalidConfiguration); + } + + getLogger() + .lifecycle( + "verifyNotificationConfiguration: OK — {} registered platform keys, all bound under {}.", + configuration.registeredVariables().size(), + getResourceDisplayPath().get()); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/config/EnvContractVerifierTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/config/EnvContractVerifierTest.java new file mode 100644 index 00000000..e0f8d355 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/config/EnvContractVerifierTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.buildtools.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class EnvContractVerifierTest { + + @Test + void validContractReturnsTypedResult(@TempDir Path temp) throws Exception { + Fixture fixture = fixture(temp); + + EnvContractResult result = + EnvContractVerifier.verify( + fixture.envFile().toFile(), + fixture.applicationYaml().toFile(), + fixture.registry().toFile(), + List.of(), + List.of(new MetadataScope("app.redis.", fixture.redisMetadata().toFile())), + List.of("APP_REDIS_")); + + assertEquals(2, result.envKeyCount()); + assertEquals(1, result.requiredPlaceholderCount()); + assertEquals(2, result.applicationReferenceCount()); + assertEquals(1, result.typedPropertyCount()); + assertEquals(2, result.consumedOrDeprecatedRegistryRowCount()); + assertTrue(result.warnings().isEmpty()); + } + + @Test + void typedPropertyMissingFromRegistryFails(@TempDir Path temp) throws Exception { + Fixture fixture = fixture(temp); + Files.writeString( + fixture.redisMetadata(), + "{\"properties\":[{\"name\":\"app.redis.host\"},{\"name\":\"app.redis.port\"}]}"); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + EnvContractVerifier.verify( + fixture.envFile().toFile(), + fixture.applicationYaml().toFile(), + fixture.registry().toFile(), + List.of(), + List.of(new MetadataScope("app.redis.", fixture.redisMetadata().toFile())), + List.of("APP_REDIS_"))); + + assertTrue(failure.getMessage().contains("typed configuration properties absent")); + assertTrue(failure.getMessage().contains("app.redis.port")); + } + + @Test + void requiredPlaceholderMissingFromExampleFails(@TempDir Path temp) throws Exception { + Fixture fixture = fixture(temp); + Files.writeString(fixture.envFile(), "APP_REDIS_HOST=localhost\n"); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + EnvContractVerifier.verify( + fixture.envFile().toFile(), + fixture.applicationYaml().toFile(), + fixture.registry().toFile(), + List.of(), + List.of(new MetadataScope("app.redis.", fixture.redisMetadata().toFile())), + List.of("APP_REDIS_"))); + + assertTrue(failure.getMessage().contains("required env absent from src/.env.example")); + assertTrue(failure.getMessage().contains("APP_REQUIRED")); + } + + private static Fixture fixture(Path temp) throws Exception { + Path repositoryRoot = temp.resolve("repository/src"); + Path docsRoot = temp.resolve("repository/docs/registries"); + Files.createDirectories(repositoryRoot.resolve("app-bootstrap/src/main/resources")); + Files.createDirectories( + repositoryRoot.resolve("adapter/outbound/cache-redis/build/classes/java/main/META-INF")); + Files.createDirectories(docsRoot); + + Path envFile = repositoryRoot.resolve(".env.example"); + Path applicationYaml = repositoryRoot.resolve("app-bootstrap/src/main/resources/application.yml"); + Path registry = docsRoot.resolve("env-keys.yaml"); + Path metadata = + repositoryRoot.resolve( + "adapter/outbound/cache-redis/build/classes/java/main/META-INF/spring-configuration-metadata.json"); + + Files.writeString(envFile, "APP_REQUIRED=required\nAPP_REDIS_HOST=localhost\n"); + Files.writeString( + applicationYaml, "required: ${APP_REQUIRED}\nredis: ${APP_REDIS_HOST:localhost}\n"); + Files.writeString( + registry, + "- name: APP_REQUIRED\n type: string\n default: none\n" + + "- name: APP_REDIS_HOST\n property: app.redis.host\n type: string\n default: localhost\n"); + Files.writeString(metadata, "{\"properties\":[{\"name\":\"app.redis.host\"}]}"); + return new Fixture(repositoryRoot, envFile, applicationYaml, registry, metadata); + } + + private record Fixture( + Path repositoryRoot, + Path envFile, + Path applicationYaml, + Path registry, + Path redisMetadata) {} +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/config/VerifyEnvKeysTaskTypeTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/config/VerifyEnvKeysTaskTypeTest.java new file mode 100644 index 00000000..b2891801 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/config/VerifyEnvKeysTaskTypeTest.java @@ -0,0 +1,19 @@ +package dev.caskeleton.buildtools.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.gradle.api.file.ConfigurableFileCollection; +import org.junit.jupiter.api.Test; + +class VerifyEnvKeysTaskTypeTest { + @Test + void taskDeclaresOnlyTheProductionFilesItActuallyScans() throws Exception { + assertEquals( + ConfigurableFileCollection.class, + VerifyEnvKeysTask.class.getMethod("getProductionSources").getReturnType()); + assertThrows( + NoSuchMethodException.class, + () -> VerifyEnvKeysTask.class.getMethod("getRepositoryRoot")); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceExecutionServiceTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceExecutionServiceTest.java new file mode 100644 index 00000000..cda9cae9 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceExecutionServiceTest.java @@ -0,0 +1,35 @@ +package dev.caskeleton.buildtools.jpa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class JpaEvidenceExecutionServiceTest { + @Test + void recordsTypedTaskOutcomesByAbsoluteTaskPath() { + JpaEvidenceExecutionService service = + new JpaEvidenceExecutionService() { + @Override + public org.gradle.api.services.BuildServiceParameters.None getParameters() { + return null; + } + }; + + service.record(":verifyArchitecture", JpaEvidenceTaskOutcome.SUCCESS); + service.record(":app-bootstrap:verifyEnvKeys", JpaEvidenceTaskOutcome.SKIPPED); + service.record(":broken", JpaEvidenceTaskOutcome.FAILED); + + assertEquals( + JpaEvidenceTaskOutcome.SUCCESS, service.outcome(":verifyArchitecture").orElseThrow()); + assertEquals( + JpaEvidenceTaskOutcome.SKIPPED, + service.outcome(":app-bootstrap:verifyEnvKeys").orElseThrow()); + assertEquals(JpaEvidenceTaskOutcome.FAILED, service.outcome(":broken").orElseThrow()); + assertTrue(service.completedSuccessfully(":verifyArchitecture")); + assertFalse(service.completedSuccessfully(":app-bootstrap:verifyEnvKeys")); + assertFalse(service.completedSuccessfully(":broken")); + assertFalse(service.completedSuccessfully(":unknown")); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidencePluginFunctionalTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidencePluginFunctionalTest.java new file mode 100644 index 00000000..95d6dd6d --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidencePluginFunctionalTest.java @@ -0,0 +1,128 @@ +package dev.caskeleton.buildtools.jpa; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JpaEvidencePluginFunctionalTest { + @Test + void pluginWiresTypedGeneratorInputsAndExecutionService(@TempDir Path projectDir) throws Exception { + Files.createDirectories(projectDir.resolve("config/jpa")); + Files.writeString( + projectDir.resolve("config/jpa/readiness-cards.yaml"), + """ + { + "schema-version": 1, + "legacy-adoption": { + "state": "frozen", + "location": "db/migration", + "history-table": "flyway_schema_history", + "immutable-applied-versions": [], + "allowed-origin": "fixture" + }, + "cards": {} + } + """, + UTF_8); + Files.writeString(projectDir.resolve("settings.gradle"), "rootProject.name='fixture'\n", UTF_8); + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.jpa-evidence' + } + + ext.sourceRevision = 'abc123def456' + ext.traceableVersion = '1.2.3+abc123def456' + + configurations { postgresqlIntegrationTestRuntimeClasspath } + tasks.register('verifyJpaReadinessRegistry') + + tasks.register('verifyJpaEvidenceModel') { + doLast { + def generator = tasks.named('generateJpaEvidenceManifests').get() + assert generator.evidenceProfile.get() == 'candidate' + assert generator.ciJob.get() == 'local-unpublished' + assert generator.topology.get() == 'single-postgresql-testcontainer' + assert generator.sourceRevision.get() == 'abc123def456' + assert generator.traceableVersion.get() == '1.2.3+abc123def456' + assert generator.pgjdbcVersion.get() == '' + assert generator.hibernateVersion.get() == '' + assert generator.flywayVersion.get() == '' + assert generator.getJUnitResultDirectories().get().isEmpty() + assert generator.getExecutionService().isPresent() + } + } + """, + UTF_8); + + BuildResult result = + GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments("verifyJpaEvidenceModel", "--console=plain") + .build(); + + assertTrue(result.getOutput().contains("BUILD SUCCESSFUL"), result.getOutput()); + } + @Test + void executionServiceSeesCompletedProducerBeforeDependentConsumer(@TempDir Path projectDir) + throws Exception { + Files.createDirectories(projectDir.resolve("config/jpa")); + Files.writeString( + projectDir.resolve("config/jpa/readiness-cards.yaml"), + """ + { + "schema-version": 1, + "legacy-adoption": { + "state": "frozen", + "location": "db/migration", + "history-table": "flyway_schema_history", + "immutable-applied-versions": [], + "allowed-origin": "fixture" + }, + "cards": {} + } + """, + UTF_8); + Files.writeString(projectDir.resolve("settings.gradle"), "rootProject.name='fixture'\n", UTF_8); + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'ca.jpa-evidence' + } + ext.sourceRevision = 'abc123def456' + ext.traceableVersion = '1.2.3+abc123def456' + configurations { postgresqlIntegrationTestRuntimeClasspath } + tasks.register('verifyJpaReadinessRegistry') + tasks.register('producer') + tasks.register('verifyOutcome') { + dependsOn 'producer' + doLast { + def service = tasks.named('generateJpaEvidenceManifests').get().getExecutionService().get() + assert service.completedSuccessfully(':producer') + } + } + """, + UTF_8); + + BuildResult result = + GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments("verifyOutcome", "--console=plain") + .build(); + + assertTrue(result.getOutput().contains("BUILD SUCCESSFUL"), result.getOutput()); + } + +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidencePluginTypeTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidencePluginTypeTest.java new file mode 100644 index 00000000..72d06fa7 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidencePluginTypeTest.java @@ -0,0 +1,41 @@ +package dev.caskeleton.buildtools.jpa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.gradle.api.DefaultTask; +import org.gradle.api.Plugin; +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.junit.jupiter.api.Test; + +class JpaEvidencePluginTypeTest { + + @Test + void jpaEvidenceIsImplementedAsJavaBinaryPluginWithTypedTasks() { + assertTrue(Plugin.class.isAssignableFrom(JpaEvidencePlugin.class)); + assertTrue(DefaultTask.class.isAssignableFrom(GenerateJpaEvidenceManifestsTask.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyJpaCandidateEvidenceTask.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyJpaPrimaryFoundationEvidenceTask.class)); + } + @Test + void generatorUsesTypedInputsInsteadOfExecutionTimeGradleModel() throws Exception { + assertEquals(Property.class, GenerateJpaEvidenceManifestsTask.class.getMethod("getEvidenceProfile").getReturnType()); + assertEquals(Property.class, GenerateJpaEvidenceManifestsTask.class.getMethod("getSourceRevision").getReturnType()); + assertEquals(Property.class, GenerateJpaEvidenceManifestsTask.class.getMethod("getTraceableVersion").getReturnType()); + assertEquals(MapProperty.class, GenerateJpaEvidenceManifestsTask.class.getMethod("getJUnitResultDirectories").getReturnType()); + assertEquals(Property.class, GenerateJpaEvidenceManifestsTask.class.getMethod("getExecutionService").getReturnType()); + + String source = Files.readString( + Path.of("src/main/java/dev/caskeleton/buildtools/jpa/GenerateJpaEvidenceManifestsTask.java")); + assertFalse(source.contains("getProject()"), source); + assertFalse(source.contains("getState()"), source); + assertFalse(source.contains("org.gradle.api.Project"), source); + assertFalse(source.contains("org.gradle.api.Task"), source); + assertFalse(source.contains("org.gradle.api.tasks.testing.Test"), source); + } + +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTestResultLocatorTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTestResultLocatorTest.java new file mode 100644 index 00000000..afe3b3bf --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceTestResultLocatorTest.java @@ -0,0 +1,55 @@ +package dev.caskeleton.buildtools.jpa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JpaEvidenceTestResultLocatorTest { + @Test + void readsJUnitEvidenceFromConfiguredTaskPathWithoutGradleTestObject(@TempDir Path repositoryRoot) + throws Exception { + Path results = repositoryRoot.resolve("adapter/build/test-results/contractTest"); + Files.createDirectories(results); + Files.writeString( + results.resolve("TEST-contract.xml"), + """ + + + + + """); + + JpaEvidenceTestResultLocator locator = + new JpaEvidenceTestResultLocator( + repositoryRoot, + Map.of(":adapter:contractTest", results.toAbsolutePath().toString())); + + JpaGeneratedTestResult result = locator.read(":adapter:contractTest"); + + assertEquals(java.util.List.of(":adapter:contractTest"), result.tasks()); + assertEquals( + java.util.List.of("adapter/build/test-results/contractTest"), result.resultDirectories()); + assertEquals(2, result.executedTestCount()); + assertEquals(1, result.skippedOrAbortedCount()); + assertEquals(0, result.failureCount()); + assertEquals(0, result.errorCount()); + assertTrue(result.executedSelectors().contains("a.ContractTest#runs")); + } + + @Test + void missingTaskMappingFailsClosed(@TempDir Path repositoryRoot) { + JpaEvidenceTestResultLocator locator = + new JpaEvidenceTestResultLocator(repositoryRoot, Map.of()); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> locator.read(":missing:test")); + + assertTrue(failure.getMessage().contains("no configured JUnit result directory")); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceVerifierTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceVerifierTest.java new file mode 100644 index 00000000..1da16ac8 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaEvidenceVerifierTest.java @@ -0,0 +1,152 @@ +package dev.caskeleton.buildtools.jpa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JpaEvidenceVerifierTest { + + @Test + void canonicalJsonSortsObjectKeysRecursivelyAndHashIsStable() { + String canonical = JpaEvidenceVerifier.canonicalJson("{\"z\":1,\"a\":{\"y\":2,\"b\":3}}"); + + assertEquals("{\"a\":{\"b\":3,\"y\":2},\"z\":1}", canonical); + assertEquals(64, JpaEvidenceVerifier.sha256(canonical).length()); + assertEquals(JpaEvidenceVerifier.sha256(canonical), JpaEvidenceVerifier.sha256(canonical)); + } + + @Test + void requiredEvidenceIncludesMigrationLifecycleClaims() { + JpaReadinessCard card = + new JpaReadinessCard( + "card", + JpaCardState.SELECTED, + JpaSchemaStream.OWNED, + List.of(), + List.of(), + ":test", + List.of(), + List.of("real-postgresql", "no-skip"), + Optional.of(new JpaCardEvidence(List.of(new JpaEvidenceScenario("dev.caskeleton.X#x", List.of("real-postgresql"))), List.of())), + List.of(), + Optional.of(new JpaMigrationSpec("db/migration/jpa/test", "flyway_jpa_test_history", 0, 1, List.of("upgrade", "rollback")))); + + assertEquals( + List.of("migration-lifecycle:rollback", "migration-lifecycle:upgrade", "no-skip", "real-postgresql"), + JpaEvidenceVerifier.requiredEvidence(card)); + } + + @Test + void validCandidateManifestHasNoViolations() { + assertTrue(JpaEvidenceVerifier.validateManifest(validManifest()).isEmpty()); + } + + @Test + void r2ManifestRequiresImmutableRuntimeAndRetainedCiEvidence() { + JpaEvidenceManifest base = validManifest(); + JpaEvidenceManifest manifest = + new JpaEvidenceManifest( + base.cardId(), + "R2", + "candidate", + base.evidenceGrade(), + List.of("real-postgresql"), + base.readinessBlockers(), + base.testResult(), + base.postgresql(), + base.dependencies(), + new JpaSourceEvidence(true), + new JpaProducerEvidence("local-unpublished"), + "local-file"); + + List violations = JpaEvidenceVerifier.validateManifest(manifest); + + assertContains(violations, "R2 requires the r2 profile"); + assertContains(violations, "clean worktree"); + assertContains(violations, "missing evidence"); + assertContains(violations, "real CI job identity"); + assertContains(violations, "externally retained artifact"); + } + + @Test + void outputDirectoryRequiresExactlyOneValidManifestPerActiveCard(@TempDir Path temp) throws Exception { + JpaReadinessRegistry registry = + new JpaReadinessRegistry( + 1, + new JpaLegacyAdoption( + "transition-only", "db/migration/postgresql", "flyway_schema_history", List.of(1, 3, 4, 5), "LEGACY_ADOPTED"), + List.of( + simpleCard("active", JpaCardState.SELECTED), + simpleCard("planned", JpaCardState.NOT_IMPLEMENTED))); + Path active = temp.resolve("active"); + Files.createDirectories(active); + JpaEvidenceManifest activeManifest = withCardId(validManifest(), "active"); + Files.writeString(active.resolve("manifest.json"), JpaEvidenceVerifier.prettyJson(activeManifest)); + + JpaEvidenceVerificationResult result = JpaEvidenceVerifier.verifyDirectory(temp.toFile(), registry); + + assertTrue(result.violations().isEmpty(), result.violations().toString()); + assertEquals(List.of("active"), result.manifests().stream().map(JpaEvidenceManifest::cardId).toList()); + } + + private static JpaReadinessCard simpleCard(String id, JpaCardState state) { + return new JpaReadinessCard( + id, + state, + JpaSchemaStream.NONE, + List.of(), + List.of(), + ":test", + List.of(), + List.of("no-skip"), + state == JpaCardState.NOT_IMPLEMENTED + ? Optional.empty() + : Optional.of(new JpaCardEvidence(List.of(new JpaEvidenceScenario("dev.caskeleton.X#x", List.of())), List.of())), + List.of(), + Optional.empty()); + } + + private static JpaEvidenceManifest validManifest() { + return new JpaEvidenceManifest( + "candidate-card", + "R1", + "candidate", + "E1", + List.of(), + List.of(), + new JpaTestResult(2, 0, 0, 0, true), + new JpaPostgresqlEvidence("postgres@sha256:" + "a".repeat(64)), + new JpaDependencyVersions("1", "1", "1"), + new JpaSourceEvidence(false), + new JpaProducerEvidence("local-candidate"), + "build/jpa-evidence"); + } + + private static JpaEvidenceManifest withCardId(JpaEvidenceManifest value, String cardId) { + return new JpaEvidenceManifest( + cardId, + value.attainedReadiness(), + value.profile(), + value.evidenceGrade(), + value.missingEvidence(), + value.readinessBlockers(), + value.testResult(), + value.postgresql(), + value.dependencies(), + value.source(), + value.producer(), + value.artifactLocation()); + } + + private static void assertContains(List violations, String fragment) { + assertTrue( + violations.stream().anyMatch(violation -> violation.contains(fragment)), + fragment + " -> " + violations); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaGradleTaskSnapshotTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaGradleTaskSnapshotTest.java new file mode 100644 index 00000000..44449103 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaGradleTaskSnapshotTest.java @@ -0,0 +1,27 @@ +package dev.caskeleton.buildtools.jpa; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.gradle.api.Project; +import org.gradle.api.tasks.testing.Test; +import org.gradle.testfixtures.ProjectBuilder; + +class JpaGradleTaskSnapshotTest { + @org.junit.jupiter.api.Test + void capturesProjectsTasksAndTestTaskTypesWithoutExecutionTimeProjectLookup() { + Project root = ProjectBuilder.builder().withName("root").build(); + Project child = ProjectBuilder.builder().withName("child").withParent(root).build(); + root.getTasks().register("plain"); + child.getTasks().register("contractTest", Test.class); + + JpaGradleTaskSnapshot snapshot = JpaGradleTaskSnapshot.capture(root); + + assertTrue(snapshot.projectPaths().contains(":")); + assertTrue(snapshot.projectPaths().contains(":child")); + assertTrue(snapshot.taskPaths().contains(":plain")); + assertTrue(snapshot.taskPaths().contains(":child:contractTest")); + assertTrue(snapshot.testTaskPaths().contains(":child:contractTest")); + assertFalse(snapshot.testTaskPaths().contains(":plain")); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaQualificationPluginTypeTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaQualificationPluginTypeTest.java new file mode 100644 index 00000000..2ecf629a --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaQualificationPluginTypeTest.java @@ -0,0 +1,32 @@ +package dev.caskeleton.buildtools.jpa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.api.DefaultTask; +import org.gradle.api.Plugin; +import org.junit.jupiter.api.Test; + +class JpaQualificationPluginTypeTest { + @Test + void qualificationUsesJavaBinaryPluginAndTypedTasks() { + assertTrue(Plugin.class.isAssignableFrom(JpaQualificationPlugin.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyJpaReleaseGateTasksTask.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyJpaReadinessRegistryTask.class)); + } + + @Test + void releaseRegistryIsParsedIntoTypedRecords() throws Exception { + String raw = Files.readString(Path.of("..", "config", "jpa", "release-registry.json")); + + JpaReleaseRegistry registry = JpaReleaseRegistryParser.parse(raw); + + assertEquals(1, registry.schemaVersion()); + assertEquals(4, registry.databases().size()); + assertEquals("hibernate-orm", registry.provider().name()); + assertEquals(6, registry.gates().size()); + assertTrue(registry.gates().stream().allMatch(JpaReleaseGate::blocking)); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistryValidatorTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistryValidatorTest.java new file mode 100644 index 00000000..845f3d92 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaReadinessRegistryValidatorTest.java @@ -0,0 +1,236 @@ +package dev.caskeleton.buildtools.jpa; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import org.junit.jupiter.api.Test; + +class JpaReadinessRegistryValidatorTest { + private static final Path REGISTRY = + Path.of("..", "config", "jpa", "readiness-cards.yaml").normalize(); + + @Test + void currentRegistryIsValidWhenAllSelectedTasksExist() throws Exception { + JpaReadinessRegistry registry = baseline(); + + List violations = JpaReadinessRegistryValidator.validate(registry, ignored -> true); + + assertTrue(violations.isEmpty(), violations.toString()); + } + + @Test + void registryOwnsMembershipSoAnAdditionalStructurallyValidCardIsAccepted() throws Exception { + JpaReadinessRegistry registry = baseline(); + JpaReadinessCard futureCard = + new JpaReadinessCard( + "jpa-future-capability", + JpaCardState.NOT_IMPLEMENTED, + JpaSchemaStream.NONE, + List.of(), + List.of(), + ":adapter:outbound:persistence-jpa:futureCapabilityReadiness", + List.of(), + List.of("no-skip"), + Optional.empty(), + List.of(), + Optional.empty()); + registry = withAddedCard(registry, futureCard); + + List violations = JpaReadinessRegistryValidator.validate(registry, ignored -> true); + + assertTrue(violations.isEmpty(), violations.toString()); + } + + @Test + void duplicateTaskAndMissingSelectedTaskFailClosed() throws Exception { + JpaReadinessRegistry registry = baseline(); + String duplicatedTask = registry.card("jpa-observability-lifecycle").readinessTask(); + JpaReadinessCard security = registry.card("jpa-security-baseline"); + registry = withCard(registry, copyWithReadinessTask(security, duplicatedTask)); + + List violations = + JpaReadinessRegistryValidator.validate(registry, task -> !task.equals(duplicatedTask)); + + assertContains(violations, "duplicate task"); + assertContains(violations, "selected task does not exist"); + } + + @Test + void prerequisiteCycleAndUnknownEvidenceRequirementFailClosed() throws Exception { + JpaReadinessRegistry registry = baseline(); + JpaReadinessCard observability = registry.card("jpa-observability-lifecycle"); + registry = + withCard( + registry, + copyWithPrerequisites(observability, List.of("jpa-security-baseline"))); + + JpaReadinessCard security = registry.card("jpa-security-baseline"); + JpaCardEvidence evidence = + new JpaCardEvidence( + List.of( + new JpaEvidenceScenario( + "dev.caskeleton.ReadinessTest#startsPostgreSql", + List.of("unknown-claim"))), + List.of()); + registry = withCard(registry, copyWithEvidence(security, Optional.of(evidence))); + + List violations = JpaReadinessRegistryValidator.validate(registry, ignored -> true); + + assertContains(violations, "prerequisite cycle"); + assertContains(violations, "evidence covers unknown requirement"); + } + + @Test + void duplicateMigrationLocationFailsClosed() throws Exception { + JpaReadinessRegistry registry = baseline(); + JpaReadinessCard card = registry.card("jpa-idempotency-owner-safe-v2"); + JpaMigrationSpec migration = card.migration().orElseThrow(); + migration = + new JpaMigrationSpec( + "db/migration/jpa/core", + migration.historyTable(), + migration.requiredCoreEpoch(), + migration.featureRevision(), + migration.lifecycleEvidence()); + registry = withCard(registry, copyWithMigration(card, Optional.of(migration))); + + List violations = JpaReadinessRegistryValidator.validate(registry, ignored -> true); + + assertContains(violations, "duplicate migration location"); + } + + @Test + void activeCardWithoutEvidenceFailsClosed() throws Exception { + JpaReadinessRegistry registry = baseline(); + JpaReadinessCard card = registry.card("jpa-observability-lifecycle"); + registry = withCard(registry, copyWithEvidence(card, Optional.empty())); + + List violations = JpaReadinessRegistryValidator.validate(registry, ignored -> true); + + assertContains(violations, "active card requires evidence"); + } + + @Test + void duplicateEvidenceSelectorFailsClosed() throws Exception { + JpaReadinessRegistry registry = baseline(); + JpaReadinessCard card = registry.card("jpa-observability-lifecycle"); + JpaCardEvidence evidence = + new JpaCardEvidence( + List.of( + new JpaEvidenceScenario( + "dev.caskeleton.ReadinessTest#startsPostgreSql", + List.of("real-postgresql")), + new JpaEvidenceScenario( + "dev.caskeleton.ReadinessTest#startsPostgreSql", + List.of("lifecycle"))), + List.of()); + registry = withCard(registry, copyWithEvidence(card, Optional.of(evidence))); + + List violations = JpaReadinessRegistryValidator.validate(registry, ignored -> true); + + assertContains(violations, "duplicate evidence selector"); + } + + @Test + void evidenceTaskClaimMustBeOwnedByCard() throws Exception { + JpaReadinessRegistry registry = baseline(); + JpaReadinessCard card = registry.card("jpa-primary-foundation"); + JpaCardEvidence evidence = + new JpaCardEvidence( + List.of(), List.of(new JpaEvidenceTaskClaim(":test", List.of("architecture")))); + registry = withCard(registry, copyWithEvidence(card, Optional.of(evidence))); + + List violations = JpaReadinessRegistryValidator.validate(registry, ignored -> true); + + assertContains(violations, "evidence task claim is not owned by card"); + } + + @Test + void parserRejectsUnknownCardField() throws Exception { + String raw = Files.readString(REGISTRY); + String corrupted = + raw.replace( + "\"state\": \"selected\"", + "\"state\": \"selected\", \"typo-field\": true"); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> JpaReadinessRegistryParser.parse(corrupted)); + + assertTrue(failure.getMessage().contains("unknown card keys"), failure.getMessage()); + } + + private static JpaReadinessRegistry baseline() throws Exception { + return JpaReadinessRegistryParser.parse(Files.readString(REGISTRY)); + } + + private static JpaReadinessRegistry withAddedCard( + JpaReadinessRegistry registry, JpaReadinessCard card) { + List cards = new ArrayList<>(registry.cards()); + cards.add(card); + return new JpaReadinessRegistry(registry.schemaVersion(), registry.legacyAdoption(), cards); + } + + private static JpaReadinessRegistry withCard( + JpaReadinessRegistry registry, JpaReadinessCard replacement) { + List cards = + registry.cards().stream() + .map(card -> card.id().equals(replacement.id()) ? replacement : card) + .toList(); + return new JpaReadinessRegistry(registry.schemaVersion(), registry.legacyAdoption(), cards); + } + + private static JpaReadinessCard copyWithId(JpaReadinessCard card, String id) { + return copy(card, id, card.prerequisites(), card.readinessTask(), card.evidence(), card.migration()); + } + + private static JpaReadinessCard copyWithReadinessTask(JpaReadinessCard card, String task) { + return copy(card, card.id(), card.prerequisites(), task, card.evidence(), card.migration()); + } + + private static JpaReadinessCard copyWithPrerequisites(JpaReadinessCard card, List prerequisites) { + return copy(card, card.id(), prerequisites, card.readinessTask(), card.evidence(), card.migration()); + } + + private static JpaReadinessCard copyWithEvidence( + JpaReadinessCard card, Optional evidence) { + return copy(card, card.id(), card.prerequisites(), card.readinessTask(), evidence, card.migration()); + } + + private static JpaReadinessCard copyWithMigration( + JpaReadinessCard card, Optional migration) { + return copy(card, card.id(), card.prerequisites(), card.readinessTask(), card.evidence(), migration); + } + + private static JpaReadinessCard copy( + JpaReadinessCard card, + String id, + List prerequisites, + String readinessTask, + Optional evidence, + Optional migration) { + return new JpaReadinessCard( + id, + card.state(), + card.schemaStream(), + prerequisites, + card.externalPrerequisites(), + readinessTask, + card.supportTasks(), + card.requiredEvidence(), + evidence, + card.dispatchModes(), + migration); + } + + private static void assertContains(List violations, String fragment) { + assertTrue( + violations.stream().anyMatch(violation -> violation.contains(fragment)), + fragment + " -> " + violations); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyVerifierTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyVerifierTest.java new file mode 100644 index 00000000..0b4188e6 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/jpa/JpaSqlConstructionSafetyVerifierTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.buildtools.jpa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JpaSqlConstructionSafetyVerifierTest { + @Test + void parameterizedSetConfigIsAccepted(@TempDir Path temp) throws Exception { + write(temp.resolve("Safe.java"), "jdbc.query(\"select set_config('app.tenant_id', ?, true)\");\n"); + + JpaSqlConstructionSafetyResult result = JpaSqlConstructionSafetyVerifier.verify(temp.toFile()); + + assertTrue(result.violations().isEmpty()); + } + + @Test + void nonParameterizedSetConfigReportsFileAndLine(@TempDir Path temp) throws Exception { + Path source = temp.resolve("nested/Unsafe.java"); + write( + source, + "class Unsafe {\n" + + " void one() {}\n" + + " String sql = \"select set_config('app.tenant_id', 'literal', true)\";\n" + + "}\n"); + + JpaSqlConstructionSafetyResult result = JpaSqlConstructionSafetyVerifier.verify(temp.toFile()); + + assertEquals(1, result.violations().size()); + assertTrue(result.violations().getFirst().contains("Unsafe.java:3: set_config value is not parameterized")); + } + + @Test + void commentOnlyMentionsAreIgnored(@TempDir Path temp) throws Exception { + write( + temp.resolve("Comments.java"), + "// set_config('tenant', 'literal', true)\n" + + "/* set_config('tenant', 'literal', true) */\n" + + " * set_config('tenant', 'literal', true)\n"); + + JpaSqlConstructionSafetyResult result = JpaSqlConstructionSafetyVerifier.verify(temp.toFile()); + + assertTrue(result.violations().isEmpty()); + } + + private static void write(Path path, String content) throws Exception { + Files.createDirectories(path.getParent()); + Files.writeString(path, content); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/junit/JUnitEvidenceReaderTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/junit/JUnitEvidenceReaderTest.java new file mode 100644 index 00000000..ed765641 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/junit/JUnitEvidenceReaderTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.buildtools.junit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JUnitEvidenceReaderTest { + + @Test + void readsSuiteCountsAndStableSelectors(@TempDir Path temp) throws Exception { + Files.writeString( + temp.resolve("TEST-one.xml"), + """ + + + + + """); + + JUnitEvidenceResult result = JUnitEvidenceReader.read("lane", temp.toFile()); + + assertEquals(2, result.tests()); + assertEquals(1, result.skipped()); + assertEquals(0, result.failures()); + assertEquals(0, result.errors()); + assertEquals(java.util.Set.of("a.Ran"), result.executedClasses()); + assertTrue(result.executedSelectors().contains("a.Ran#ran")); + assertTrue(result.executedSelectors().contains("a.Skipped#skipped")); + assertEquals(1, result.resultFiles().size()); + } + + @Test + void suiteAttributesAreRequiredAndNumeric(@TempDir Path temp) throws Exception { + Files.writeString( + temp.resolve("TEST-bad.xml"), + "\n"); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, () -> JUnitEvidenceReader.read("lane", temp.toFile())); + + assertTrue(failure.getMessage().contains("invalid tests='x'")); + } + + @Test + void missingResultFilesFailClosed(@TempDir Path temp) { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, () -> JUnitEvidenceReader.read("lane", temp.toFile())); + + assertTrue(failure.getMessage().contains("no JUnit XML result files")); + } + + @Test + void doctypeIsRejected(@TempDir Path temp) throws Exception { + Files.writeString( + temp.resolve("TEST-doctype.xml"), + """ + ]> + + + + """); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, () -> JUnitEvidenceReader.read("lane", temp.toFile())); + + assertTrue(failure.getMessage().contains("not readable JUnit XML")); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingBuildEvidenceManifestJsonTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingBuildEvidenceManifestJsonTest.java new file mode 100644 index 00000000..8ea97de2 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingBuildEvidenceManifestJsonTest.java @@ -0,0 +1,40 @@ +package dev.caskeleton.buildtools.messaging; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +class MessagingBuildEvidenceManifestJsonTest { + @Test + void countsSerializeOnlySchemaFields() { + var manifest = + new MessagingBuildEvidenceManifest( + 1, + "sha256:" + "a".repeat(64), + "sha256:" + "b".repeat(64), + "verifyMessagingContracts", + List.of("Contract.scenario"), + new MessagingEvidenceCounts(1, 1, 0, 0), + "./gradlew verifyMessagingContracts", + Instant.parse("2026-09-17T00:00:00Z").toString(), + new MessagingEvidenceHashes( + "sha256:" + "c".repeat(64), + "sha256:" + "d".repeat(64), + "sha256:" + "e".repeat(64), + "sha256:" + "f".repeat(64)), + List.of(), + List.of(), + List.of("remote-schema-resolution")); + + String json = JsonMapper.builder().build().writeValueAsString(manifest); + + assertTrue(json.contains("\"executed\":1"), json); + assertTrue(json.contains("\"passed\":1"), json); + assertFalse(json.contains("passing"), json); + assertFalse(json.contains("totalOutcomes"), json); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingCertificationPluginFunctionalTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingCertificationPluginFunctionalTest.java new file mode 100644 index 00000000..07143ced --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingCertificationPluginFunctionalTest.java @@ -0,0 +1,45 @@ +package dev.caskeleton.buildtools.messaging; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MessagingCertificationPluginFunctionalTest { + @Test + void pluginRegistersTypedCertificationEvidenceGate(@TempDir Path projectDir) throws Exception { + Files.writeString(projectDir.resolve("settings.gradle"), "rootProject.name='fixture'\n"); + Files.createDirectories(projectDir.resolve("messaging/messaging-testkit/src/main/resources/messaging")); + Files.writeString( + projectDir.resolve("messaging/messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl"), + "{\"scenario\":\"round-trip\",\"gitCommit\":\"committed\",\"observedAt\":\"old\"}\n"); + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { id 'ca.messaging-certification' } + tasks.register('messagingCertificationTest') { + doLast { + def out = layout.buildDirectory.file('messaging-certification/broker-certification-evidence.jsonl').get().asFile + out.parentFile.mkdirs() + out.text = '{"scenario":"round-trip","gitCommit":"runtime","observedAt":"now"}\\n' + } + } + """); + + BuildResult result = + GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments("verifyMessagingCertificationEvidence", "--console=plain") + .build(); + + assertTrue(result.getOutput().contains("BUILD SUCCESSFUL"), result.getOutput()); + assertTrue( + Files.readString(projectDir.resolve("build/reports/messaging-certification-evidence.txt")) + .contains("scenarios=1")); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceVerifierTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceVerifierTest.java new file mode 100644 index 00000000..5494b3ba --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingEvidenceVerifierTest.java @@ -0,0 +1,52 @@ +package dev.caskeleton.buildtools.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class MessagingEvidenceVerifierTest { + + @Test + void validPassManifestHasNoViolations() { + var manifest = + new MessagingEvidenceManifest( + "verifyMessagingContracts", + new MessagingEvidenceCounts(3, 3, 0, 0), + List.of(), + List.of(), + Instant.parse("2026-09-17T00:00:00Z")); + + assertTrue( + MessagingEvidenceVerifier.validate(manifest, "verifyMessagingContracts").isEmpty()); + } + + @Test + void producerCountSkipAndFailureAreValidated() { + var manifest = + new MessagingEvidenceManifest( + "wrong", + new MessagingEvidenceCounts(4, 2, 1, 0), + List.of("boom"), + List.of(), + Instant.parse("2026-09-17T00:00:00Z")); + + List violations = MessagingEvidenceVerifier.validate(manifest, "expected"); + + assertTrue(violations.stream().anyMatch(message -> message.contains("producerTask"))); + assertTrue(violations.stream().anyMatch(message -> message.contains("counts do not add up"))); + assertTrue(violations.stream().anyMatch(message -> message.contains("failed or skipped"))); + } + + @Test + void selectorsBecomeStableUniqueScenarioIds() { + assertEquals( + List.of("OrderContract.publishes", "RetryContract.retries"), + MessagingEvidenceVerifier.scenarioIds( + List.of( + "dev.caskeleton.messaging.OrderContract#publishes", + "dev.caskeleton.messaging.RetryContract#retries"))); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingQualificationPluginTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingQualificationPluginTest.java new file mode 100644 index 00000000..1ae12e05 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/messaging/MessagingQualificationPluginTest.java @@ -0,0 +1,116 @@ +package dev.caskeleton.buildtools.messaging; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.api.DefaultTask; +import org.gradle.api.Plugin; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MessagingQualificationPluginTest { + @TempDir Path projectDir; + + @BeforeEach + void setUp() throws Exception { + Files.createDirectories(projectDir.resolve("adapter/outbound/messaging")); + Files.createDirectories(projectDir.resolve("application-core")); + Files.createDirectories(projectDir.resolve("shared-contract")); + Files.writeString( + projectDir.resolve("settings.gradle"), + """ + rootProject.name='fixture' + include ':application-core', ':shared-contract', ':adapter:outbound:messaging' + """); + Files.writeString( + projectDir.resolve("application-core/build.gradle"), + """ + plugins { id 'java' } + tasks.register('messagingApplicationContractQualificationTest') + """); + Files.writeString( + projectDir.resolve("shared-contract/build.gradle"), + """ + plugins { id 'java' } + tasks.register('messagingSharedSchemaQualificationTest') + """); + Files.writeString( + projectDir.resolve("adapter/outbound/messaging/build.gradle"), + """ + plugins { id 'java' } + tasks.register('messagingCompiledContractsQualificationTest') + tasks.register('messagingJsonSchemaV1QualificationTest') + tasks.register('verifyDependencyPolicy') + """); + } + + @Test + void qualificationPluginIsJavaBinaryPluginWithTypedTasks() { + assertTrue(Plugin.class.isAssignableFrom(MessagingQualificationPlugin.class)); + assertTrue(DefaultTask.class.isAssignableFrom(PrepareMessagingContractEvidenceTask.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyMessagingEvidenceTask.class)); + } + + @Test + void qualificationPluginRegistersOnlyImplementedProducersAndWiresSchemaValidation() + throws Exception { + Files.writeString( + projectDir.resolve("build.gradle"), + """ + plugins { + id 'base' + id 'ca.messaging-qualification' + } + tasks.register('reportMessagingQualification') { + doLast { + def implemented = ['verifyMessagingContracts', 'verifyMessagingJsonSchemaV1'] + def planned = [ + 'verifyMessagingPollingOutboxR2', + 'verifyMessagingKafkaProducerR2', + 'verifyMessagingSecurityR2', + 'verifyMessagingReleaseProfile', + 'verifyMessagingTargetBindingPreflight', + 'verifyMessagingTargetBinding', + 'verifyMessagingDeploymentCutover', + 'verifyMessagingCleanupTargetBinding', + 'verifyMessagingFinalR2Profile'] + logger.lifecycle('implemented=' + implemented.collect { tasks.findByName(it) != null }) + logger.lifecycle('planned=' + planned.collect { tasks.findByName(it) != null }) + + def json = tasks.named('verifyMessagingJsonSchemaV1').get() + def contracts = tasks.named('verifyMessagingContracts').get() + logger.lifecycle('json-finalizers=' + json.finalizedBy.getDependencies(json)*.name.sort()) + logger.lifecycle('contracts-dependencies=' + contracts.taskDependencies.getDependencies(contracts)*.name.sort()) + logger.lifecycle('contracts-finalizers=' + contracts.finalizedBy.getDependencies(contracts)*.name.sort()) + } + } + """); + + BuildResult result = runner("reportMessagingQualification", "--console=plain").build(); + + assertTrue(result.getOutput().contains("implemented=[true, true]"), result.getOutput()); + assertTrue( + result + .getOutput() + .contains("planned=[false, false, false, false, false, false, false, false, false]"), + result.getOutput()); + assertTrue( + result.getOutput().contains("validateMessagingJsonSchemaV1EvidenceManifestSchema"), + result.getOutput()); + assertTrue(result.getOutput().contains("contracts-dependencies="), result.getOutput()); + assertTrue( + result.getOutput().contains("validateMessagingContractsEvidenceManifestSchema"), + result.getOutput()); + } + + private GradleRunner runner(String... args) { + return GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withPluginClasspath() + .withArguments(args); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessVerifierTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessVerifierTest.java new file mode 100644 index 00000000..df3f7f3a --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/mongo/MongoLaneDisjointnessVerifierTest.java @@ -0,0 +1,46 @@ +package dev.caskeleton.buildtools.mongo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MongoLaneDisjointnessVerifierTest { + @Test + void reportsDisjointLaneCounts(@TempDir Path temp) throws Exception { + Path unit = Files.createDirectories(temp.resolve("unit")); + Path contract = Files.createDirectories(temp.resolve("contract")); + writeSuite(unit, "UnitTest", "unitOne", "unitTwo"); + writeSuite(contract, "ContractTest", "contractOne"); + MongoLaneDisjointnessResult result = MongoLaneDisjointnessVerifier.verify(unit.toFile(), contract.toFile()); + assertEquals(2, result.unitCount()); + assertEquals(1, result.contractCount()); + } + + @Test + void overlappingSelectorsFailWithEvidence(@TempDir Path temp) throws Exception { + Path unit = Files.createDirectories(temp.resolve("unit")); + Path contract = Files.createDirectories(temp.resolve("contract")); + writeSuite(unit, "SharedTest", "sameCase"); + writeSuite(contract, "SharedTest", "sameCase"); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> MongoLaneDisjointnessVerifier.verify(unit.toFile(), contract.toFile())); + assertTrue(failure.getMessage().contains("1 tests run in both")); + assertTrue(failure.getMessage().contains("SharedTest#sameCase")); + } + + private static void writeSuite(Path directory, String className, String... methods) throws Exception { + StringBuilder cases = new StringBuilder(); + for (String method : methods) { + cases.append("\n"); + } + Files.writeString(directory.resolve("TEST-" + className + ".xml"), + "\n" + + cases + "\n"); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneVerifierTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneVerifierTest.java new file mode 100644 index 00000000..d463d4cc --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/mongo/MongoReleaseContractLaneVerifierTest.java @@ -0,0 +1,98 @@ +package dev.caskeleton.buildtools.mongo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MongoReleaseContractLaneVerifierTest { + @Test + void verifiesOnlyContractsOwnedByHermeticLanes(@TempDir Path temp) throws Exception { + Path manifest = temp.resolve("release-contracts.json"); + Files.writeString( + manifest, + """ + {"contracts":[ + {"id":"C-1","task":"test","className":"a.Unit","minimumExecuted":1}, + {"id":"C-2","task":"mongoStableContractTest","className":"a.Contract","minimumExecuted":2}, + {"id":"C-3","task":"mongoReplicaSetTest","className":"a.Docker","minimumExecuted":1} + ]} + """); + Path results = Files.createDirectories(temp.resolve("results")); + writeSuite(results, "test", "a.Unit", 1, 0); + writeSuite(results, "mongoStableContractTest", "a.Contract", 3, 1); + + MongoReleaseContractLaneResult result = + MongoReleaseContractLaneVerifier.verify( + manifest.toFile(), Set.of("test", "mongoStableContractTest"), results.toFile()); + + assertEquals(java.util.List.of("C-1", "C-2"), result.checkedContractIds()); + } + + @Test + void missingResultFileFailsWithContractAndLane(@TempDir Path temp) throws Exception { + Path manifest = manifest(temp, "C-MISSING", "mongoStableContractTest", "a.Missing", 1); + Path results = Files.createDirectories(temp.resolve("results")); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + MongoReleaseContractLaneVerifier.verify( + manifest.toFile(), Set.of("mongoStableContractTest"), results.toFile())); + + assertTrue(failure.getMessage().contains("C-MISSING names lane 'mongoStableContractTest'")); + assertTrue(failure.getMessage().contains("a.Missing")); + } + + @Test + void insufficientExecutedCountFails(@TempDir Path temp) throws Exception { + Path manifest = manifest(temp, "C-MIN", "mongoStableContractTest", "a.Contract", 3); + Path results = Files.createDirectories(temp.resolve("results")); + writeSuite(results, "mongoStableContractTest", "a.Contract", 4, 2); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + MongoReleaseContractLaneVerifier.verify( + manifest.toFile(), Set.of("mongoStableContractTest"), results.toFile())); + + assertTrue(failure.getMessage().contains("requires 3 executed test(s)")); + assertTrue(failure.getMessage().contains("lane ran 2")); + } + + private static Path manifest( + Path temp, String id, String task, String className, int minimumExecuted) throws Exception { + Path manifest = temp.resolve("release-contracts.json"); + Files.writeString( + manifest, + "{\"contracts\":[{\"id\":\"" + + id + + "\",\"task\":\"" + + task + + "\",\"className\":\"" + + className + + "\",\"minimumExecuted\":" + + minimumExecuted + + "}]}\n"); + return manifest; + } + + private static void writeSuite( + Path resultsRoot, String task, String className, int tests, int skipped) throws Exception { + Path lane = Files.createDirectories(resultsRoot.resolve(task)); + Files.writeString( + lane.resolve("TEST-" + className + ".xml"), + "\n"); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/notification/NotificationApiSurfaceVerifierTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/notification/NotificationApiSurfaceVerifierTest.java new file mode 100644 index 00000000..b1e0fbe8 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/notification/NotificationApiSurfaceVerifierTest.java @@ -0,0 +1,82 @@ +package dev.caskeleton.buildtools.notification; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NotificationApiSurfaceVerifierTest { + + @Test + void rendersAndVerifiesTopLevelPublicTypesAcrossRoots(@TempDir Path temp) throws Exception { + Path first = source(temp.resolve("one"), "dev.example.one", "public final class Alpha {}"); + Path second = source(temp.resolve("two"), "dev.example.two", "public interface Beta {}"); + Path snapshot = temp.resolve("snapshot.txt"); + + String rendered = NotificationApiSurfaceVerifier.render(List.of(first.toFile(), second.toFile())); + Files.writeString(snapshot, rendered); + NotificationApiSurfaceResult result = + NotificationApiSurfaceVerifier.verify( + List.of(first.toFile(), second.toFile()), snapshot.toFile()); + + assertTrue(rendered.contains("dev.example.one.Alpha")); + assertTrue(rendered.contains("dev.example.two.Beta")); + assertEquals(2, result.publicTypeCount()); + } + + @Test + void driftReportsAddedAndRemovedTypes(@TempDir Path temp) throws Exception { + Path root = source(temp.resolve("src"), "dev.example", "public class Current {}"); + Path snapshot = temp.resolve("snapshot.txt"); + Files.writeString(snapshot, "# old\n# header\ndev.example.Old\n"); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> NotificationApiSurfaceVerifier.verify(List.of(root.toFile()), snapshot.toFile())); + + assertTrue(failure.getMessage().contains("added (1)")); + assertTrue(failure.getMessage().contains("dev.example.Current")); + assertTrue(failure.getMessage().contains("removed (1)")); + assertTrue(failure.getMessage().contains("dev.example.Old")); + } + + @Test + void emptyRenderingFailsClosed(@TempDir Path temp) throws Exception { + Path root = temp.resolve("src"); + Files.createDirectories(root); + Path snapshot = temp.resolve("snapshot.txt"); + Files.writeString(snapshot, "# empty\n"); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> NotificationApiSurfaceVerifier.verify(List.of(root.toFile()), snapshot.toFile())); + + assertTrue(failure.getMessage().contains("found no public types")); + } + + @Test + void growthNeedsSeparateCeilingApproval(@TempDir Path temp) throws Exception { + Path root = source(temp.resolve("src"), "dev.example", "public class First {}\npublic interface Second {}"); + String rendered = NotificationApiSurfaceVerifier.render(List.of(root.toFile())); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> NotificationApiSurfaceVerifier.requireNoUnapprovedGrowth(1, rendered, false)); + + assertTrue(failure.getMessage().contains("grow from 1 to 2 types")); + } + + private static Path source(Path root, String packageName, String declaration) throws Exception { + Files.createDirectories(root); + Files.writeString(root.resolve("Types.java"), "package " + packageName + ";\n" + declaration + "\n"); + return root; + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/notification/NotificationEvidenceVerifierTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/notification/NotificationEvidenceVerifierTest.java new file mode 100644 index 00000000..f4a2f711 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/notification/NotificationEvidenceVerifierTest.java @@ -0,0 +1,163 @@ +package dev.caskeleton.buildtools.notification; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NotificationEvidenceVerifierTest { + + @Test + void validGradeIsBackedBySatisfiedClaimAndExistingArtifact(@TempDir Path temp) throws Exception { + Fixture fixture = fixture(temp); + + NotificationEvidenceResult result = + NotificationEvidenceVerifier.verify( + fixture.manifest().toFile(), + fixture.repositoryRoot().toFile(), + fixture.projectRoot().toFile()); + + assertEquals(1, result.satisfiedClaimCount()); + assertEquals("support-matrix.md", result.matrixFileName()); + } + + @Test + void satisfiedClaimWithoutEvidenceFails(@TempDir Path temp) throws Exception { + Fixture fixture = fixture(temp); + Files.writeString( + fixture.manifest(), + """ + { + "matrixDocument": "docs/notification/support-matrix.md", + "claims": {"delivery": {"status": "satisfied", "evidence": []}}, + "grades": {"Stable": ["delivery"]} + } + """); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + NotificationEvidenceVerifier.verify( + fixture.manifest().toFile(), + fixture.repositoryRoot().toFile(), + fixture.projectRoot().toFile())); + + assertTrue(failure.getMessage().contains("claim 'delivery' is marked satisfied with no evidence")); + } + + @Test + void unknownGradeInAssigningTableFails(@TempDir Path temp) throws Exception { + Fixture fixture = fixture(temp); + Files.writeString( + fixture.matrix(), + """ + | Channel | Grade | + | --- | --- | + | email | Experimental | + """); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + NotificationEvidenceVerifier.verify( + fixture.manifest().toFile(), + fixture.repositoryRoot().toFile(), + fixture.projectRoot().toFile())); + + assertTrue(failure.getMessage().contains("uses grade 'Experimental'")); + assertTrue(failure.getMessage().contains("does not define")); + } + + @Test + void matrixWithoutGradeAssignmentTableFailsClosed(@TempDir Path temp) throws Exception { + Fixture fixture = fixture(temp); + Files.writeString( + fixture.matrix(), + """ + | Grade | Requires | + | --- | --- | + | Stable | delivery | + """); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + NotificationEvidenceVerifier.verify( + fixture.manifest().toFile(), + fixture.repositoryRoot().toFile(), + fixture.projectRoot().toFile())); + + assertTrue(failure.getMessage().contains("has no table that assigns a grade")); + } + + @Test + void unsupportedClaimStatusFailsAtParseBoundary(@TempDir Path temp) throws Exception { + Fixture fixture = fixture(temp); + Files.writeString( + fixture.manifest(), + """ + { + "matrixDocument": "docs/notification/support-matrix.md", + "claims": {"delivery": {"status": "maybe", "evidence": ["evidence.txt"]}}, + "grades": {"Stable": ["delivery"]} + } + """); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + NotificationEvidenceVerifier.verify( + fixture.manifest().toFile(), + fixture.repositoryRoot().toFile(), + fixture.projectRoot().toFile())); + + assertTrue(failure.getMessage().contains("unsupported claim status 'maybe'")); + } + + private static Fixture fixture(Path temp) throws Exception { + Path repositoryRoot = temp.resolve("repository"); + Path projectRoot = repositoryRoot.resolve("src"); + Path docs = repositoryRoot.resolve("docs/notification"); + Files.createDirectories(docs); + Path evidence = + projectRoot.resolve("adapter/outbound/notification/src/test/java/DeliveryContractTest.java"); + Files.createDirectories(evidence.getParent()); + + Path manifest = docs.resolve("evidence-manifest.json"); + Path matrix = docs.resolve("support-matrix.md"); + Files.writeString(evidence, "final class DeliveryContractTest {}\n"); + Files.writeString( + manifest, + """ + { + "matrixDocument": "docs/notification/support-matrix.md", + "claims": { + "delivery": { + "status": "satisfied", + "evidence": ["adapter/outbound/notification/src/test/java/DeliveryContractTest.java"] + } + }, + "grades": {"Stable": ["delivery"]} + } + """); + Files.writeString( + matrix, + """ + | Channel | Grade | + | --- | --- | + | email | Stable | + """); + return new Fixture(repositoryRoot, projectRoot, manifest, matrix); + } + + private record Fixture( + Path repositoryRoot, Path projectRoot, Path manifest, Path matrix) {} +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfigurationVerifierTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfigurationVerifierTest.java new file mode 100644 index 00000000..0c2c2762 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/notificationconfig/NotificationConfigurationVerifierTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.buildtools.notificationconfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NotificationConfigurationVerifierTest { + + @Test + void allRegisteredPlatformKeysMustBeBound(@TempDir Path temp) throws Exception { + Path resources = temp.resolve("resources"); + Path config = resources.resolve("config"); + Files.createDirectories(config); + Path application = resources.resolve("application.yml"); + Path registry = temp.resolve("env-keys.yaml"); + + Files.writeString(application, "spring:\n config:\n import: config/notification.yml\n"); + Files.writeString( + config.resolve("notification.yml"), + "enabled: ${APP_NOTIFICATION_PLATFORM_ENABLED:false}\n" + + "sender: ${APP_NOTIFICATION_PLATFORM_SENDER:no-reply}\n"); + Files.writeString( + registry, + "- name: APP_NOTIFICATION_PLATFORM_ENABLED\n" + + "- name: APP_NOTIFICATION_PLATFORM_SENDER\n"); + + NotificationConfiguration result = + NotificationConfigurationVerifier.verify( + application.toFile(), config.toFile(), registry.toFile(), "app-bootstrap/src/main/resources"); + + assertEquals( + java.util.List.of( + "APP_NOTIFICATION_PLATFORM_ENABLED", "APP_NOTIFICATION_PLATFORM_SENDER"), + result.registeredVariables()); + } + + @Test + void registeredButUnboundKeyFails(@TempDir Path temp) throws Exception { + Path resources = temp.resolve("resources"); + Files.createDirectories(resources.resolve("config")); + Path application = resources.resolve("application.yml"); + Path registry = temp.resolve("env-keys.yaml"); + + Files.writeString(application, "enabled: ${APP_NOTIFICATION_PLATFORM_ENABLED:false}\n"); + Files.writeString( + registry, + "- name: APP_NOTIFICATION_PLATFORM_ENABLED\n" + + "- name: APP_NOTIFICATION_PLATFORM_UNUSED\n"); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + NotificationConfigurationVerifier.verify( + application.toFile(), + resources.resolve("config").toFile(), + registry.toFile(), + "app-bootstrap/src/main/resources")); + assertTrue(failure.getMessage().contains("APP_NOTIFICATION_PLATFORM_UNUSED is registered")); + assertTrue(failure.getMessage().contains("bound by nothing")); + } + + @Test + void noPlatformBindingFailsClosed(@TempDir Path temp) throws Exception { + Path resources = temp.resolve("resources"); + Files.createDirectories(resources.resolve("config")); + Path application = resources.resolve("application.yml"); + Path registry = temp.resolve("env-keys.yaml"); + Files.writeString(application, "spring:\n application:\n name: fixture\n"); + Files.writeString(registry, "- name: APP_NOTIFICATION_PLATFORM_ENABLED\n"); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + NotificationConfigurationVerifier.verify( + application.toFile(), + resources.resolve("config").toFile(), + registry.toFile(), + "app-bootstrap/src/main/resources")); + assertTrue( + failure.getMessage().contains("no APP_NOTIFICATION_PLATFORM_* placeholder is bound")); + } +} diff --git a/src/build-tools/src/test/java/dev/caskeleton/buildtools/plugin/SmallBinaryPluginsTypeTest.java b/src/build-tools/src/test/java/dev/caskeleton/buildtools/plugin/SmallBinaryPluginsTypeTest.java new file mode 100644 index 00000000..a4607074 --- /dev/null +++ b/src/build-tools/src/test/java/dev/caskeleton/buildtools/plugin/SmallBinaryPluginsTypeTest.java @@ -0,0 +1,36 @@ +package dev.caskeleton.buildtools.plugin; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.caskeleton.buildtools.config.ConfigContractPlugin; +import dev.caskeleton.buildtools.config.VerifyEnvKeysTask; +import dev.caskeleton.buildtools.mongo.MongoVerificationPlugin; +import dev.caskeleton.buildtools.mongo.VerifyMongoReleaseContractLanesTask; +import dev.caskeleton.buildtools.mongo.VerifyMongoTestLaneDisjointnessTask; +import dev.caskeleton.buildtools.notification.NotificationApiSurfacePlugin; +import dev.caskeleton.buildtools.notification.NotificationEvidencePlugin; +import dev.caskeleton.buildtools.notification.UpdateNotificationApiSurfaceTask; +import dev.caskeleton.buildtools.notification.VerifyNotificationApiSurfaceTask; +import dev.caskeleton.buildtools.notification.VerifyNotificationEvidenceTask; +import org.gradle.api.DefaultTask; +import org.gradle.api.Plugin; +import org.junit.jupiter.api.Test; + +class SmallBinaryPluginsTypeTest { + @Test + void smallVerificationPluginsUseJavaBinaryPluginAndTypedTaskClasses() { + assertTrue(Plugin.class.isAssignableFrom(ConfigContractPlugin.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyEnvKeysTask.class)); + + assertTrue(Plugin.class.isAssignableFrom(NotificationEvidencePlugin.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyNotificationEvidenceTask.class)); + + assertTrue(Plugin.class.isAssignableFrom(NotificationApiSurfacePlugin.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyNotificationApiSurfaceTask.class)); + assertTrue(DefaultTask.class.isAssignableFrom(UpdateNotificationApiSurfaceTask.class)); + + assertTrue(Plugin.class.isAssignableFrom(MongoVerificationPlugin.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyMongoTestLaneDisjointnessTask.class)); + assertTrue(DefaultTask.class.isAssignableFrom(VerifyMongoReleaseContractLanesTask.class)); + } +} diff --git a/src/build.gradle b/src/build.gradle index 3f18b94c..6a46d370 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -14,7 +14,9 @@ plugins { // Applied to the ROOT, not per leaf: the evidence closures live on rootProject.ext, the // membership check compares each composition against the runtime closure it resolves, and the // architecture rules are about the repository. + id 'ca.release-provenance' id 'ca.evidence' + id 'ca.conditional-transport-qualification' id 'ca.runtime-membership' id 'ca.architecture' id 'ca.archive-hygiene' @@ -24,96 +26,16 @@ plugins { id 'ca.notification-configuration' id 'ca.jpa-qualification' id 'ca.messaging-qualification' + id 'ca.developer-bootstrap' } -// feature-build-release-supply-chain-contract D1/D9 — a released archive carries an exact SemVer -// coordinate plus the source revision that produced it. -// -// Required for a RELEASE, not for a build. This used to throw during configuration whenever a source -// revision could not be found, so `./gradlew test` on a source archive with no `.git` failed before -// it compiled anything — release traceability enforced on `classes`. An ordinary build is now a -// SNAPSHOT, and `verifyReleaseProvenance` (wired into `releaseCheck`) is what refuses to call an -// untraceable build a release. -// -// The MAJOR.MINOR.PATCH base can be supplied with -PreleaseVersion or RELEASE_VERSION. The revision -// can be supplied with -PgitRevision, GIT_SHA, or GITHUB_SHA; local builds read the current Git -// commit. -String releaseVersion = providers.gradleProperty('releaseVersion') - .orElse(providers.environmentVariable('RELEASE_VERSION')) - .getOrElse('0.0.1') -if (!(releaseVersion ==~ /\d+\.\d+\.\d+/)) { - throw new GradleException( - "releaseVersion must be MAJOR.MINOR.PATCH without a leading 'v', pre-release, or build metadata; got '${releaseVersion}'.") -} - -String declaredRevision = providers.gradleProperty('gitRevision') - .orElse(providers.environmentVariable('GIT_SHA')) - .orElse(providers.environmentVariable('GITHUB_SHA')) - .getOrElse('') -boolean revisionIsTraceable = declaredRevision ==~ /(?i)[0-9a-f]{7,40}/ -String sourceRevision = revisionIsTraceable - ? declaredRevision.toLowerCase(Locale.ROOT).take(12) - : 'unknown' -String traceableVersion = revisionIsTraceable - ? "${releaseVersion}+${sourceRevision}" - : "${releaseVersion}-SNAPSHOT" - -ext.releaseVersion = releaseVersion -ext.sourceRevision = sourceRevision -ext.traceableVersion = traceableVersion -ext.releaseProvenanceComplete = revisionIsTraceable - -tasks.register('verifyReleaseProvenance') { - group = 'verification' - description = 'Fails when the build cannot name the source revision it was produced from.' - outputs.upToDateWhen { false } - doLast { - if (!revisionIsTraceable) { - throw new GradleException( - 'verifyReleaseProvenance: no source revision. A release archive has to name the ' + - 'commit that produced it; supply -PgitRevision=, GIT_SHA or ' + - 'GITHUB_SHA, or build from a Git checkout.') - } - logger.lifecycle("verifyReleaseProvenance: OK — ${traceableVersion}") - } -} - - - - +// feature-build-release-supply-chain-contract D1/D9 — release provenance/version semantics live in +// ca.release-provenance so they can be tested in a minimal TestKit fixture instead of configuring +// this entire repository. The root still owns the repository group; the convention owns version. group = 'dev.caskeleton' -version = traceableVersion -Map> conditionalTransportEvidence = [ - 'conditional-transport-graphql': - project(':adapter:inbound:graphql').layout.buildDirectory.dir( - 'test-results/graphqlTransportQualificationTest'), - 'conditional-transport-grpc': - project(':adapter:inbound:grpc').layout.buildDirectory.dir( - 'test-results/grpcTransportQualificationTest'), - 'conditional-transport-websocket': - project(':adapter:inbound:websocket').layout.buildDirectory.dir( - 'test-results/websocketTransportQualificationTest'), - 'conditional-transport-composition': - project(':app-bootstrap').layout.buildDirectory.dir( - 'test-results/conditionalTransportCompositionTest') -] -tasks.register('conditionalTransportQualification') { - group = 'verification' - description = 'Runs the exact no-skip GraphQL, gRPC, and WebSocket P1 qualification evidence.' - dependsOn ':adapter:inbound:graphql:graphqlTransportQualificationTest' - dependsOn ':adapter:inbound:grpc:grpcTransportQualificationTest' - dependsOn ':adapter:inbound:websocket:websocketTransportQualificationTest' - dependsOn ':app-bootstrap:conditionalTransportCompositionTest' - dependsOn tasks.named('verifyRuntimeModuleMembership') - inputs.files(conditionalTransportEvidence.values()) - doLast { - conditionalTransportEvidence.each { String evidenceName, Provider directory -> - rootProject.ext.verifyNoSkipJUnitXml( - evidenceName, directory.get().asFile) - } - } -} +// Conditional transport evidence wiring and verification live in +// ca.conditional-transport-qualification. // One explicit command regenerates every module's Gradle-default lockfile. tasks.register('resolveAndLockAll') { @@ -130,92 +52,8 @@ tasks.register('verifyDependencyLocks') { }.collect { "${it.path}:verifyDependencyLocks" } } -// feature-developer-experience-contract D3 — one ordered first-run entrypoint. Each stage is a -// separate task so the task name and exit code identify the failed phase without log archaeology. -def repositoryDir = rootProject.projectDir.parentFile -def baseComposeFile = new File(repositoryDir, 'docker-compose.yml') -def localComposeFile = new File(repositoryDir, 'docker-compose.local.yml') -def composeCommand = ['docker', 'compose', '-f', baseComposeFile.absolutePath, - '-f', localComposeFile.absolutePath] - -def bootstrapCompile = tasks.register('bootstrapCompile') { - group = 'developer experience' - description = 'Stage 1/4: compiles the default application composition and its required upstream projects.' - dependsOn ':app-bootstrap:compileTestJava' -} - -def bootstrapDockerPreflight = tasks.register('bootstrapDockerPreflight', Exec) { - group = 'developer experience' - description = 'Checks that the Docker CLI can reach a running Docker daemon.' - commandLine 'docker', 'info' - ignoreExitValue = true - standardOutput = new ByteArrayOutputStream() - errorOutput = new ByteArrayOutputStream() - doLast { - if (executionResult.get().exitValue != 0) { - throw new GradleException( - 'bootstrap: Docker가 필요합니다. Docker Desktop/daemon을 시작한 뒤 ' + - '`docker info`가 성공하는지 확인하세요.\n' + errorOutput.toString()) - } - } -} -bootstrapDockerPreflight.configure { dependsOn bootstrapCompile } - -def bootstrapDependencies = tasks.register('bootstrapDependencies', Exec) { - group = 'developer experience' - description = 'Stage 2/4: starts the local PostgreSQL dependency and waits for readiness.' - commandLine composeCommand + ['up', '-d', '--wait', 'db'] -} -bootstrapDependencies.configure { dependsOn bootstrapCompile } -bootstrapDependencies.configure { dependsOn bootstrapDockerPreflight } - -def bootstrapMigrateAndStart = tasks.register('bootstrapMigrateAndStart', Exec) { - group = 'developer experience' - description = 'Stage 3/4: builds/starts the app; startup Flyway must finish before health is ready.' - commandLine composeCommand + ['up', '-d', '--build', '--wait', 'app'] -} -bootstrapMigrateAndStart.configure { dependsOn bootstrapDependencies } - -def bootstrapSmoke = tasks.register('bootstrapSmoke') { - group = 'developer experience' - description = 'Stage 4/4: requires HTTP 200 and status=UP from GET /api/healthcheck.' - doLast { - URI endpoint = URI.create('http://localhost:8080/api/healthcheck') - long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60) - String lastFailure = 'no response' - while (System.nanoTime() < deadline) { - HttpURLConnection connection = null - try { - connection = (HttpURLConnection) endpoint.toURL().openConnection() - connection.connectTimeout = 2_000 - connection.readTimeout = 2_000 - connection.requestMethod = 'GET' - int status = connection.responseCode - String body = status >= 200 && status < 400 ? connection.inputStream.text : - connection.errorStream?.text - if (status == 200 && body != null && body.contains('"status":"UP"')) { - logger.lifecycle('bootstrapSmoke: OK — GET /api/healthcheck returned HTTP 200 and status=UP.') - return - } - lastFailure = "HTTP ${status}: ${body}" - } catch (IOException ex) { - lastFailure = ex.message - } finally { - connection?.disconnect() - } - sleep(1_000) - } - throw new GradleException( - "bootstrapSmoke: /api/healthcheck did not become healthy within 60s; last result: ${lastFailure}") - } -} -bootstrapSmoke.configure { dependsOn bootstrapMigrateAndStart } - -tasks.register('bootstrap') { - group = 'developer experience' - description = 'Runs the complete four-stage local bootstrap contract.' - dependsOn bootstrapSmoke -} +// feature-developer-experience-contract D3 — the ordered local bootstrap lifecycle is owned by +// ca.developer-bootstrap so this root script only declares that the repository uses it. // --------------------------------------------------------------------------------------------- // Lifecycle. @@ -226,7 +64,7 @@ tasks.register('bootstrap') { // qualityCheck SpotBugs + FindSecBugs across every leaf (ca.quality-conventions) // configContractCheck the environment contract (:app-bootstrap) // integrationCheck the declared strict test lanes (ca.strict-test-lane) -// qualificationCheck repository/TestKit behavior + full boot composition + auxiliary style +// qualificationCheck full boot composition + auxiliary-source style checks // ci leaf checks + repository gates + qualification // releaseCheck ci + archive hygiene + public path snapshot + release provenance // @@ -237,7 +75,7 @@ tasks.register('bootstrap') { // --------------------------------------------------------------------------------------------- // Task PATHS, not TaskProviders. The root is evaluated before any leaf, and a leaf's `check`, -// `qualityCheck` and `strictTestLaneCheck` are created by the convention plugins the leaf applies — +// `qualityCheck` and the category-specific test-lane checks are created by convention plugins — // so `project(':x').tasks.named('check')` from here resolves a task that does not exist yet. A path // string is resolved when the task graph is built, which is after every project is evaluated. // @@ -267,19 +105,24 @@ tasks.register('configContractCheck') { tasks.register('integrationCheck') { group = 'verification' - description = "Runs every production CI leaf's declared strict test lane. Several need a container runtime." - dependsOn leafPaths.collect { "${it}:strictTestLaneCheck" } + description = "Runs every production CI leaf's integration-category test lanes. Several need a container runtime." + dependsOn leafPaths.collect { "${it}:integrationTestLaneCheck" } } tasks.named('architectureCheck') { - dependsOn ':app-bootstrap:architectureTest' + dependsOn leafPaths.collect { "${it}:architectureTestLaneCheck" } +} + +tasks.register('systemTestCheck') { + group = 'verification' + description = "Runs every production CI leaf's full application/system test lanes." + dependsOn leafPaths.collect { "${it}:systemTestLaneCheck" } } tasks.register('qualificationCheck') { group = 'verification' description = 'Runs repository/build-behavior qualification that is intentionally outside local leaf checks.' - dependsOn ':app-bootstrap:functionalTest' - dependsOn ':app-bootstrap:bootCompositionTest' + dependsOn tasks.named('systemTestCheck') dependsOn leafPaths.collect { "${it}:auxiliaryStyleCheck" } } diff --git a/src/config/architecture/modules.json b/src/config/architecture/modules.json index ff9d9c64..2231c1da 100644 --- a/src/config/architecture/modules.json +++ b/src/config/architecture/modules.json @@ -104,7 +104,9 @@ "domain-core", "application-core", "shared-contract", - "adapter-outbound-support" + "adapter-outbound-support", + "messaging-core-api", + "messaging-schema-api" ] }, { @@ -463,6 +465,203 @@ "messaging-policy", "messaging-transport-spi" ] + }, + { + "id": "grpc-admin", + "gradle_path": ":grpc:grpc-admin", + "source_path": "src/grpc/grpc-admin", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-server" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-client", + "gradle_path": ":grpc:grpc-client", + "source_path": "src/grpc/grpc-client", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-codegen", + "gradle_path": ":grpc:grpc-codegen", + "source_path": "src/grpc/grpc-codegen", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-proto-contract" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-core-api", + "gradle_path": ":grpc:grpc-core-api", + "source_path": "src/grpc/grpc-core-api", + "allowed_dependencies": [], + "build": "optional-grpc" + }, + { + "id": "grpc-discovery", + "gradle_path": ":grpc:grpc-discovery", + "source_path": "src/grpc/grpc-discovery", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-client" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-observability", + "gradle_path": ":grpc:grpc-observability", + "source_path": "src/grpc/grpc-observability", + "allowed_dependencies": [ + "grpc-core-api" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-operation-ledger-jpa", + "gradle_path": ":grpc:grpc-operation-ledger-jpa", + "source_path": "src/grpc/grpc-operation-ledger-jpa", + "allowed_dependencies": [ + "grpc-core-api" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-policy", + "gradle_path": ":grpc:grpc-policy", + "source_path": "src/grpc/grpc-policy", + "allowed_dependencies": [ + "grpc-core-api" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-proto-contract", + "gradle_path": ":grpc:grpc-proto-contract", + "source_path": "src/grpc/grpc-proto-contract", + "allowed_dependencies": [ + "grpc-core-api" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-server", + "gradle_path": ":grpc:grpc-server", + "source_path": "src/grpc/grpc-server", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-spring-boot-starter", + "gradle_path": ":grpc:grpc-spring-boot-starter", + "source_path": "src/grpc/grpc-spring-boot-starter", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy", + "grpc-server", + "grpc-client", + "grpc-discovery", + "grpc-admin", + "grpc-observability", + "grpc-proto-contract", + "grpc-codegen", + "grpc-operation-ledger-jpa" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-testkit", + "gradle_path": ":grpc:grpc-testkit", + "source_path": "src/grpc/grpc-testkit", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy", + "grpc-server", + "grpc-client", + "grpc-discovery", + "grpc-admin", + "grpc-observability", + "grpc-proto-contract", + "grpc-codegen", + "grpc-operation-ledger-jpa" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-advanced-bootstrap", + "gradle_path": ":grpc-advanced:grpc-advanced-bootstrap", + "source_path": "src/grpc-advanced/grpc-advanced-bootstrap", + "allowed_dependencies": [ + "grpc-core-api" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-advanced-compat", + "gradle_path": ":grpc-advanced:grpc-advanced-compat", + "source_path": "src/grpc-advanced/grpc-advanced-compat", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy", + "grpc-server", + "grpc-client", + "grpc-advanced-bootstrap" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-advanced-diagnostics", + "gradle_path": ":grpc-advanced:grpc-advanced-diagnostics", + "source_path": "src/grpc-advanced/grpc-advanced-diagnostics", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-client", + "grpc-advanced-bootstrap" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-advanced-edition", + "gradle_path": ":grpc-advanced:grpc-advanced-edition", + "source_path": "src/grpc-advanced/grpc-advanced-edition", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-proto-contract", + "grpc-advanced-bootstrap" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-advanced-resilience", + "gradle_path": ":grpc-advanced:grpc-advanced-resilience", + "source_path": "src/grpc-advanced/grpc-advanced-resilience", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy", + "grpc-client", + "grpc-discovery", + "grpc-advanced-bootstrap" + ], + "build": "optional-grpc" + }, + { + "id": "grpc-advanced-streaming", + "gradle_path": ":grpc-advanced:grpc-advanced-streaming", + "source_path": "src/grpc-advanced/grpc-advanced-streaming", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy", + "grpc-advanced-bootstrap" + ], + "build": "optional-grpc" } ], "composition_roots": [ diff --git a/src/config/jpa/readiness-cards.yaml b/src/config/jpa/readiness-cards.yaml index 0adc13d1..3b971a5c 100644 --- a/src/config/jpa/readiness-cards.yaml +++ b/src/config/jpa/readiness-cards.yaml @@ -35,7 +35,6 @@ "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest", "support-tasks": [ ":adapter:outbound:persistence-jpa:verifyJpaSqlConstructionSafety", - ":adapter:outbound:persistence-jpa:verifyJpaSecurityFixtures", ":adapter:inbound:web:jpaPersistenceRedactionContractTest" ], "required-evidence": [ diff --git a/src/gradle/libs.versions.toml b/src/gradle/libs.versions.toml index 920b802f..ff13c1e7 100644 --- a/src/gradle/libs.versions.toml +++ b/src/gradle/libs.versions.toml @@ -32,6 +32,7 @@ [versions] approvaltests = "31.0.0" +assertj = "3.27.7" archunit = "1.3.0" avro = "1.12.0" # AWS SDK v2 BOM coordinate. Imported at MODULE scope by adapter:outbound:objectstorage, @@ -61,6 +62,7 @@ jqwik = "1.9.1" jackson3 = "3.0.2" jsonSchemaValidator = "3.0.2" junitJupiter = "5.11.3" +junitPlatform = "1.11.3" logstashLogbackEncoder = "8.0" okhttp = "4.12.0" protobuf = "4.33.2" @@ -95,6 +97,7 @@ uuidCreator = "6.1.1" [libraries] approvaltests = { module = "com.approvaltests:approvaltests", version.ref = "approvaltests" } +assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } archunit-junit5 = { module = "com.tngtech.archunit:archunit-junit5", version.ref = "archunit" } avro = { module = "org.apache.avro:avro", version.ref = "avro" } blockhound = { module = "io.projectreactor.tools:blockhound", version.ref = "blockhound" } @@ -111,6 +114,7 @@ jnats = { module = "io.nats:jnats", version.ref = "jnats" } jqwik = { module = "net.jqwik:jqwik", version.ref = "jqwik" } json-schema-validator = { module = "com.networknt:json-schema-validator", version.ref = "jsonSchemaValidator" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" } logstash-logback-encoder = { module = "net.logstash.logback:logstash-logback-encoder", version.ref = "logstashLogbackEncoder" } mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" } diff --git a/src/grpc/grpc-testkit/build.gradle b/src/grpc/grpc-testkit/build.gradle index 44c0f3c8..fd9eb56a 100644 --- a/src/grpc/grpc-testkit/build.gradle +++ b/src/grpc/grpc-testkit/build.gradle @@ -25,6 +25,7 @@ strictTestLanes { 'classifier that refuses to infer NOT_SENT from an unobserved state.' } lane('grpcPerformanceTest') { + performance() tag = 'grpc-performance' description = 'Performance lane: unary latency percentiles, channel stream saturation, ' + 'executor saturation and drain budget against a recorded baseline.' diff --git a/src/messaging/CLAUDE.md b/src/messaging/CLAUDE.md index e99d76b1..dacd813e 100644 --- a/src/messaging/CLAUDE.md +++ b/src/messaging/CLAUDE.md @@ -54,21 +54,28 @@ experimental transport, 일부 codec/bridge/testkit처럼 starter graph에 들 새 leaf를 application에 채택할 때는 starter 또는 composition dependency와 architecture edge policy, qualification/support 상태를 함께 변경한다. -## application이 이 family에 도달하는 경로 (MSG-015, 미해결) +## application이 이 family에 도달하는 경로 (MSG-015, canonical bridge 구현 / legacy cutover 잔존) -리뷰(`docs/reviews/2026-08-14-messaging-module-code-review.md` §6.1·§6.3)가 정한 흐름은 -`application-owned port → platform anti-corruption bridge → platform`이고, §6.2는 그 bridge의 자리를 -`adapter/outbound/messaging/platformbridge/`로 지정한다. §6.3 첫 규칙: **application은 신규 -broker/runtime 타입을 직접 보지 않고 자기 port만 소유한다.** +리뷰(`docs/reviews/2026-08-14-messaging-module-code-review.md` §6.1·§6.3)가 정한 +`application-owned port → platform anti-corruption bridge → platform` 경로는 이제 존재한다. -**현재 그 bridge가 없다.** `adapter/outbound/messaging`은 platform에 의존하지 않고(registry의 -`allowed_dependencies` 참조), `app-bootstrap`이 starter를 직접 문다. 그래서 서로를 모르는 messaging -스택 두 개가 한 아티팩트에 있고, 그것이 `kafkaSeamProducer`/`messagingKafkaProducer` bean 이름 충돌로 -드러났다. +- application-owned port: `IntegrationEventPublishPort` +- bridge: `adapter/outbound/messaging/platformbridge/PlatformIntegrationEventPublishAdapter` +- platform boundary: `EncodedMessagePublisher` +- central implementation: `DefaultMessagePublisher` -이것이 리뷰의 **MSG-015 (P0)** 이며 닫히지 않았다. 지금 안전한 이유는 설계가 아니라 기본값이다 — -`app.messaging.enabled=false`. 이 스위치를 켜는 배포는 port를 거치지 않는 경로로 platform을 쓰게 된다. -bridge 구현은 런타임 배선 변경이라 리뷰가 말한 대로 별도 change set이다. +bridge는 exact canonical envelope bytes를 다시 serialize하지 않고, destination/access/admission/runtime +lease/transport/outcome normalization/observation은 platform 중앙 publish path를 그대로 탄다. bridge +package는 `messaging-core-api`와 `messaging-schema-api`만 보며 concrete runtime/transport/Kafka 타입을 +직접 보지 않는다. + +다만 **MSG-015의 전체 cutover가 끝난 것은 아니다.** legacy `OutboxEvent`와 realtime publisher는 +schema/order/tenant 등 canonical metadata가 부족해 아직 `MessageBroker → KafkaSender` R0 경로를 +사용한다. 그 metadata를 추측해 platform envelope를 만드는 것은 금지한다. 따라서 legacy outbox +storage/relay migration을 별도 change set으로 완료한 뒤에만 R0 seam을 제거한다. + +canonical bridge의 producer identity는 `app.messaging.producer-id`로 명시한다. 값이 없으면 bridge bean을 +만들지 않으며 `spring.application.name`/host/pod 이름에서 identity를 발명하지 않는다. ## Stable 승격에 필요한 증거 diff --git a/src/messaging/messaging-kafka/build.gradle b/src/messaging/messaging-kafka/build.gradle index 355925d6..a14c287a 100644 --- a/src/messaging/messaging-kafka/build.gradle +++ b/src/messaging/messaging-kafka/build.gradle @@ -1,5 +1,6 @@ plugins { id 'ca.jmh-benchmarks' + id 'ca.messaging-certification' } dependencies { @@ -43,30 +44,29 @@ dependencies { // the lane rewrites it. strictTestLanes { lane('messagingCertificationTest') { + integration() tag = 'messaging-certification' description = 'Runs the broker fault scenarios against a real Kafka and writes the ' + 'certification evidence the compatibility matrix reads.' - customize = { test -> - // Pinned images. A certification claim names the build it was made against, so a - // floating tag would make a red run unattributable and a green one unrepeatable. - // `-PmessagingKafkaImage=` overrides for a one-off run against another version. - test.systemProperty 'messaging.kafka.image', - (project.findProperty('messagingKafkaImage') ?: 'apache/kafka:4.1.0').toString() - test.systemProperty 'messaging.toxiproxy.image', - (project.findProperty('messagingToxiproxyImage') - ?: 'ghcr.io/shopify/toxiproxy:2.12.0').toString() - test.systemProperty 'messaging.certification.manifest', - project.layout.buildDirectory - .file('messaging-certification/broker-certification-evidence.jsonl') - .get().asFile.absolutePath - // The commit is part of the evidence: "certified" is a claim about one source tree. - // Read from the environment rather than a `-P` flag so the CI job's command line stays - // the literal grammar the gate matrix lint accepts. - test.systemProperty 'messaging.certification.commit', - (project.findProperty('certificationCommit') - ?: providers.environmentVariable('GITHUB_SHA').getOrElse('local')) - .toString() - } + // Pinned images. A certification claim names the build it was made against, so a + // floating tag would make a red run unattributable and a green one unrepeatable. + // `-PmessagingKafkaImage=` overrides for a one-off run against another version. + systemProperty 'messaging.kafka.image', + (project.findProperty('messagingKafkaImage') ?: 'apache/kafka:4.1.0').toString() + systemProperty 'messaging.toxiproxy.image', + (project.findProperty('messagingToxiproxyImage') + ?: 'ghcr.io/shopify/toxiproxy:2.12.0').toString() + systemProperty 'messaging.certification.manifest', + project.layout.buildDirectory + .file('messaging-certification/broker-certification-evidence.jsonl') + .get().asFile.absolutePath + // The commit is part of the evidence: "certified" is a claim about one source tree. + // Read from the environment rather than a `-P` flag so the CI job's command line stays + // the literal grammar the gate matrix lint accepts. + systemProperty 'messaging.certification.commit', + (project.findProperty('certificationCommit') + ?: providers.environmentVariable('GITHUB_SHA').getOrElse('local')) + .toString() } } @@ -80,52 +80,3 @@ tasks.named('test', Test) { excludeTags 'messaging-certification' } } - -tasks.register('verifyMessagingCertificationEvidence') { - group = 'verification' - description = 'Fails when the committed broker certification manifest claims a scenario the ' + - 'certification lane did not produce.' - dependsOn 'messagingCertificationTest' - - def produced = layout.buildDirectory - .file('messaging-certification/broker-certification-evidence.jsonl') - def committed = rootProject.file( - 'messaging/messaging-testkit/src/main/resources/messaging/' + - 'broker-certification-evidence.jsonl') - inputs.file(produced) - inputs.file(committed) - outputs.file(layout.buildDirectory.file('reports/messaging-certification-evidence.txt')) - // A gate whose result can be served from an earlier run is evidence about that run. - outputs.upToDateWhen { false } - - doLast { - // The commit and the observation instant differ on every run by design, so they are not - // part of the comparison — what has to match is which adapter proved which scenario against - // which image, and which test produced it. - Closure> claims = { File file -> - file.readLines('UTF-8') - .findAll { !it.trim().isEmpty() } - .collect { line -> - line.replaceAll(/,"gitCommit":"[^"]*"/, '') - .replaceAll(/,"observedAt":"[^"]*"/, '') - } - .toSet() - } - Set ran = claims(produced.get().asFile) - Set shipped = claims(committed) - - if (ran != shipped) { - def unproven = shipped - ran - def unrecorded = ran - shipped - throw new GradleException( - "the committed certification manifest does not match this run.\n" + - " claimed but not produced: ${unproven.isEmpty() ? 'none' : unproven}\n" + - " produced but not claimed: ${unrecorded.isEmpty() ? 'none' : unrecorded}\n" + - "Copy ${produced.get().asFile} over ${committed} — the manifest is a " + - "record of a run, not a statement about one.") - } - def report = outputs.files.singleFile - report.parentFile.mkdirs() - report.text = "scenarios=${ran.size()} manifest=${committed}\n" - } -} diff --git a/src/messaging/messaging-runtime-core/src/main/java/dev/caskeleton/messaging/runtime/DefaultMessagePublisher.java b/src/messaging/messaging-runtime-core/src/main/java/dev/caskeleton/messaging/runtime/DefaultMessagePublisher.java index 72985785..49425653 100644 --- a/src/messaging/messaging-runtime-core/src/main/java/dev/caskeleton/messaging/runtime/DefaultMessagePublisher.java +++ b/src/messaging/messaging-runtime-core/src/main/java/dev/caskeleton/messaging/runtime/DefaultMessagePublisher.java @@ -13,6 +13,7 @@ import dev.caskeleton.messaging.api.publish.RoutingOutcome; import dev.caskeleton.messaging.policy.DestinationProfile; import dev.caskeleton.messaging.policy.MessagingAdmissionController; import dev.caskeleton.messaging.schema.EncodedMessage; +import dev.caskeleton.messaging.schema.EncodedMessagePublisher; import dev.caskeleton.messaging.schema.MessageCodec; import dev.caskeleton.messaging.schema.MessageCodecRegistry; import dev.caskeleton.messaging.security.DestinationAccessPolicy; @@ -52,7 +53,7 @@ import java.util.function.LongSupplier; * cancellation. A permit or lease that leaks on the failure path is a limiter that shrinks by one * per failure until it stops accepting anything. */ -public final class DefaultMessagePublisher implements MessagePublisher { +public final class DefaultMessagePublisher implements MessagePublisher, EncodedMessagePublisher { private final DestinationProfileRegistry destinations; @@ -168,25 +169,53 @@ public final class DefaultMessagePublisher implements MessagePublisher { profile = destinations.require(destination.name()); requireSupportedOptions(profile, options); if (!access.mayPublish(destination.name())) { - // Before encoding: an unauthorized publish must not serialise the payload, because the - // encoded bytes are what a claim-check or a log would then be holding. return rejected( "PUBLISH_FORBIDDEN", - "this application may not publish to '" + destination.name().value() + '\'', + "this application may not publish to '" + destination.name().value() + "'", startedAt); } encoded = encode(message); } catch (RuntimeException beforeTheWire) { - // Nothing left this process, so the outcome is definite. Reporting it as ambiguous would send - // the caller into reconciliation for a message no broker ever saw. return rejected("PUBLISH_PREPARATION_FAILED", sanitized(beforeTheWire), startedAt); } + return sendEncoded(destination, profile, encoded, options, startedAt); + } + @Override + public CompletionStage publishEncoded( + MessageDestination destination, + MessageEnvelope message, + PublishOptions options) { + Objects.requireNonNull(destination, "destination must not be null"); + Objects.requireNonNull(message, "message must not be null"); + Objects.requireNonNull(options, "options must not be null"); + + long startedAt = nanoTime.getAsLong(); + DestinationProfile profile; + try { + profile = destinations.require(destination.name()); + requireSupportedOptions(profile, options); + if (!access.mayPublish(destination.name())) { + return rejected( + "PUBLISH_FORBIDDEN", + "this application may not publish to '" + destination.name().value() + "'", + startedAt); + } + requireEncodedEnvelope(message); + } catch (RuntimeException beforeTheWire) { + return rejected("PUBLISH_PREPARATION_FAILED", sanitized(beforeTheWire), startedAt); + } + return sendEncoded(destination, profile, message, options, startedAt); + } + + private CompletionStage sendEncoded( + MessageDestination destination, + DestinationProfile profile, + MessageEnvelope encoded, + PublishOptions options, + long startedAt) { Duration remaining = remainingBudget(options.timeout(), startedAt); if (remaining.isZero() || remaining.isNegative()) { - // The budget was spent resolving and encoding, so nothing has been transmitted and the - // outcome is still definite. Sending anyway would start a message the caller has already - // stopped waiting for. return rejected( "PUBLISH_DEADLINE_EXCEEDED", "the " @@ -204,13 +233,9 @@ public final class DefaultMessagePublisher implements MessagePublisher { transport.publish(new TransportPublishRequest(profile, encoded, options)), remaining) .handle( (result, failure) -> { - // One release per acquisition, whatever happened. A permit that leaks on the - // failure path is a limiter that shrinks by one per failure until it accepts - // nothing. held.close(); admission.complete(destination.name().value()); if (failure != null) { - // The request was on the wire when this failed, so the broker may hold it. PublishResult ambiguous = isDeadline(failure) ? ambiguousResult( @@ -236,6 +261,13 @@ public final class DefaultMessagePublisher implements MessagePublisher { } } + private static void requireEncodedEnvelope(MessageEnvelope message) { + if (!message.contentType().equals(message.payload().contentType())) { + throw new IllegalArgumentException( + "pre-encoded envelope content type must match its encoded payload content type"); + } + } + /** * Refuses options the destination cannot honour, before anything is encoded or sent. * diff --git a/src/messaging/messaging-runtime-core/src/test/java/dev/caskeleton/messaging/runtime/DefaultMessagePublisherTest.java b/src/messaging/messaging-runtime-core/src/test/java/dev/caskeleton/messaging/runtime/DefaultMessagePublisherTest.java index 1caad120..5efc0c9e 100644 --- a/src/messaging/messaging-runtime-core/src/test/java/dev/caskeleton/messaging/runtime/DefaultMessagePublisherTest.java +++ b/src/messaging/messaging-runtime-core/src/test/java/dev/caskeleton/messaging/runtime/DefaultMessagePublisherTest.java @@ -26,6 +26,7 @@ import dev.caskeleton.messaging.policy.MessagingAdmissionController; import dev.caskeleton.messaging.policy.PayloadLimitGuard; import dev.caskeleton.messaging.policy.PayloadPolicy; import dev.caskeleton.messaging.schema.EncodedMessage; +import dev.caskeleton.messaging.schema.EncodedMessagePublisher; import dev.caskeleton.messaging.schema.MessageCodec; import dev.caskeleton.messaging.schema.MessageCodecRegistry; import dev.caskeleton.messaging.security.DestinationAccessPolicy; @@ -85,6 +86,39 @@ class DefaultMessagePublisherTest { .isZero(); } + @Test + @DisplayName("a pre-encoded publish skips codecs but still runs admission, runtime and transport") + void aPreEncodedPublishPreservesWireBytesWithoutBypassingThePipeline() { + SingleCodecRegistry codecs = new SingleCodecRegistry(); + DefaultMessagePublisher publisher = + new DefaultMessagePublisher( + registry(), + allowOrders(), + codecs, + admission, + new FixedRuntimeRegistry(transport), + transport); + byte[] wireBytes = "{\"already\":\"encoded\"}".getBytes(StandardCharsets.UTF_8); + MessageEnvelope encoded = + envelope().withPayload(new EncodedMessage(wireBytes, ContentType.JSON, Optional.empty())); + + PublishResult result = + ((EncodedMessagePublisher) publisher) + .publishEncoded( + new MessageDestination<>( + ORDERS, new MessageType("order.created"), EncodedMessage.class), + encoded, + PublishOptions.defaults()) + .toCompletableFuture() + .join(); + + assertThat(result.completion()).isEqualTo(PublishCompletion.CONFIRMED); + assertThat(codecs.encodes()).isZero(); + assertThat(transport.lastRequest().envelope().payload().bytes()).containsExactly(wireBytes); + assertThat(transport.lastRequest().envelope().payload().contentType()).isEqualTo(ContentType.JSON); + assertThat(admission.inFlight()).isZero(); + } + @Test @DisplayName("an unregistered destination is rejected before anything is encoded") void anUnregisteredDestinationIsRejected() { @@ -431,6 +465,8 @@ class DefaultMessagePublisherTest { private boolean stalled; + private TransportPublishRequest lastRequest; + private void failWith(RuntimeException failure) { this.failure = failure; } @@ -442,6 +478,7 @@ class DefaultMessagePublisherTest { @Override public CompletionStage publish(TransportPublishRequest request) { + this.lastRequest = request; if (failure != null) { return CompletableFuture.failedFuture(failure); } @@ -488,6 +525,10 @@ class DefaultMessagePublisherTest { private int published() { return published.get(); } + + private TransportPublishRequest lastRequest() { + return lastRequest; + } } /** A registry with no installed generation, which is what a rotation gap looks like. */ diff --git a/src/messaging/messaging-schema-api/src/main/java/dev/caskeleton/messaging/schema/EncodedMessagePublisher.java b/src/messaging/messaging-schema-api/src/main/java/dev/caskeleton/messaging/schema/EncodedMessagePublisher.java new file mode 100644 index 00000000..94d4d225 --- /dev/null +++ b/src/messaging/messaging-schema-api/src/main/java/dev/caskeleton/messaging/schema/EncodedMessagePublisher.java @@ -0,0 +1,24 @@ +package dev.caskeleton.messaging.schema; + +import dev.caskeleton.messaging.api.MessageEnvelope; +import dev.caskeleton.messaging.api.destination.MessageDestination; +import dev.caskeleton.messaging.api.publish.PublishOptions; +import dev.caskeleton.messaging.api.publish.PublishResult; +import java.util.concurrent.CompletionStage; + +/** + * Publishes an envelope whose payload has already been encoded by an authoritative application + * contract boundary. + * + *

This is intentionally narrower than the transport SPI. Callers may skip only the codec step; + * destination resolution, access policy, admission, runtime leasing, transport normalization and + * observation remain owned by the platform's central publisher. + */ +@FunctionalInterface +public interface EncodedMessagePublisher { + + CompletionStage publishEncoded( + MessageDestination destination, + MessageEnvelope message, + PublishOptions options); +} diff --git a/src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingCoreAutoConfiguration.java b/src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingCoreAutoConfiguration.java index 0caded5c..89c155bd 100644 --- a/src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingCoreAutoConfiguration.java +++ b/src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingCoreAutoConfiguration.java @@ -436,7 +436,7 @@ public class MessagingCoreAutoConfiguration { */ @Bean @ConditionalOnMissingBean(MessagePublisher.class) - public MessagePublisher messagingPublisher( + public dev.caskeleton.messaging.runtime.DefaultMessagePublisher messagingPublisher( dev.caskeleton.messaging.runtime.DestinationProfileRegistry destinations, dev.caskeleton.messaging.security.DestinationAccessPolicy access, dev.caskeleton.messaging.schema.MessageCodecRegistry codecs, diff --git a/src/messaging/messaging-spring-boot-starter/src/test/java/dev/caskeleton/messaging/autoconfigure/MessagingStarterOffContractTest.java b/src/messaging/messaging-spring-boot-starter/src/test/java/dev/caskeleton/messaging/autoconfigure/MessagingStarterOffContractTest.java index 2d154756..2d4f7c4f 100644 --- a/src/messaging/messaging-spring-boot-starter/src/test/java/dev/caskeleton/messaging/autoconfigure/MessagingStarterOffContractTest.java +++ b/src/messaging/messaging-spring-boot-starter/src/test/java/dev/caskeleton/messaging/autoconfigure/MessagingStarterOffContractTest.java @@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.messaging.api.MessageEnvelope; import dev.caskeleton.messaging.api.destination.MessageDestination; import dev.caskeleton.messaging.api.publish.MessagePublisher; +import dev.caskeleton.messaging.schema.EncodedMessagePublisher; import dev.caskeleton.messaging.api.publish.PublishOptions; import dev.caskeleton.messaging.api.publish.PublishResult; import java.util.Set; @@ -187,6 +188,7 @@ class MessagingStarterOffContractTest { + "depend on that leaf.") .hasNotFailed(); assertThat(context).hasSingleBean(MessagePublisher.class); + assertThat(context).hasSingleBean(EncodedMessagePublisher.class); }); } diff --git a/src/optional-platforms/settings.gradle b/src/optional-platforms/settings.gradle index f6401e34..cf08c106 100644 --- a/src/optional-platforms/settings.gradle +++ b/src/optional-platforms/settings.gradle @@ -2,6 +2,10 @@ pluginManagement { includeBuild('../build-logic') } +plugins { + id 'ca.optional-architecture-registry' +} + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { @@ -15,45 +19,3 @@ dependencyResolutionManagement { } rootProject.name = 'optional-platforms' - -include ':grpc' -project(':grpc').projectDir = file('../grpc') -include ':grpc-advanced' -project(':grpc-advanced').projectDir = file('../grpc-advanced') - -include ':grpc:grpc-admin' -project(':grpc:grpc-admin').projectDir = file('../grpc/grpc-admin') -include ':grpc:grpc-client' -project(':grpc:grpc-client').projectDir = file('../grpc/grpc-client') -include ':grpc:grpc-codegen' -project(':grpc:grpc-codegen').projectDir = file('../grpc/grpc-codegen') -include ':grpc:grpc-core-api' -project(':grpc:grpc-core-api').projectDir = file('../grpc/grpc-core-api') -include ':grpc:grpc-discovery' -project(':grpc:grpc-discovery').projectDir = file('../grpc/grpc-discovery') -include ':grpc:grpc-observability' -project(':grpc:grpc-observability').projectDir = file('../grpc/grpc-observability') -include ':grpc:grpc-operation-ledger-jpa' -project(':grpc:grpc-operation-ledger-jpa').projectDir = file('../grpc/grpc-operation-ledger-jpa') -include ':grpc:grpc-policy' -project(':grpc:grpc-policy').projectDir = file('../grpc/grpc-policy') -include ':grpc:grpc-proto-contract' -project(':grpc:grpc-proto-contract').projectDir = file('../grpc/grpc-proto-contract') -include ':grpc:grpc-server' -project(':grpc:grpc-server').projectDir = file('../grpc/grpc-server') -include ':grpc:grpc-spring-boot-starter' -project(':grpc:grpc-spring-boot-starter').projectDir = file('../grpc/grpc-spring-boot-starter') -include ':grpc:grpc-testkit' -project(':grpc:grpc-testkit').projectDir = file('../grpc/grpc-testkit') -include ':grpc-advanced:grpc-advanced-bootstrap' -project(':grpc-advanced:grpc-advanced-bootstrap').projectDir = file('../grpc-advanced/grpc-advanced-bootstrap') -include ':grpc-advanced:grpc-advanced-compat' -project(':grpc-advanced:grpc-advanced-compat').projectDir = file('../grpc-advanced/grpc-advanced-compat') -include ':grpc-advanced:grpc-advanced-diagnostics' -project(':grpc-advanced:grpc-advanced-diagnostics').projectDir = file('../grpc-advanced/grpc-advanced-diagnostics') -include ':grpc-advanced:grpc-advanced-edition' -project(':grpc-advanced:grpc-advanced-edition').projectDir = file('../grpc-advanced/grpc-advanced-edition') -include ':grpc-advanced:grpc-advanced-resilience' -project(':grpc-advanced:grpc-advanced-resilience').projectDir = file('../grpc-advanced/grpc-advanced-resilience') -include ':grpc-advanced:grpc-advanced-streaming' -project(':grpc-advanced:grpc-advanced-streaming').projectDir = file('../grpc-advanced/grpc-advanced-streaming') diff --git a/src/sample-portfolio/build.gradle b/src/sample-portfolio/build.gradle index f5d73367..2a579774 100644 --- a/src/sample-portfolio/build.gradle +++ b/src/sample-portfolio/build.gradle @@ -2,6 +2,7 @@ plugins { id 'ca.spring-library' id 'ca.spring-config' id 'org.springframework.boot' + id 'ca.auxiliary-source-set' } // Fixture/sample module. Production modules must not depend on this module. @@ -20,7 +21,7 @@ tasks.named('test') { .withPathSensitivity(PathSensitivity.RELATIVE) } -strictTestLanes { +auxiliarySourceSets { sourceSet('posterImageMigrationTest') { compilesAgainst 'main', 'test' } } @@ -104,39 +105,39 @@ strictTestLanes { description = 'OpenAPI drift gate: runtime springdoc /v3/api-docs vs the committed snapshot. ' + '-PapproveOpenApiChange regenerates the committed baseline.' requires('dev.caskeleton.sample.portfolio.adapter.inbound.web.contract.OpenApiDriftContractTest') - customize = { test -> - test.systemProperty 'openapi.snapshot.write', - project.hasProperty('approveOpenApiChange') ? 'true' : 'false' - // Pin UTC like the main test task for host-locale independence. - test.jvmArgs '-Duser.timezone=UTC' - } + systemProperty 'openapi.snapshot.write', + project.hasProperty('approveOpenApiChange') ? 'true' : 'false' + // Pin UTC like the main test task for host-locale independence. + jvmArgs '-Duser.timezone=UTC' } } -def posterImageMigrationQualification = registerStrictQualificationTest( - name: 'posterImageMigrationTest', - sourceSet: sourceSets.posterImageMigrationTest, - requiredClasses: [ +def posterImageMigrationQualification = extensions.getByName('strictQualification').register( + 'posterImageMigrationTest', + sourceSets.posterImageMigrationTest, + [ 'dev.caskeleton.sample.portfolio.qualification.PosterImageIdempotencyRotationQualificationTest', 'dev.caskeleton.sample.portfolio.qualification.PosterImageRetirementQualificationTest', 'dev.caskeleton.sample.portfolio.qualification.PosterImageV8MigrationQualificationTest' ], - description: 'Runs the non-skipping PostgreSQL Poster image migration/rotation lane.') + 'Runs the non-skipping PostgreSQL Poster image migration/rotation lane.' +) posterImageMigrationQualification.configure { shouldRunAfter tasks.named('test') } -def messagingSampleContractQualification = registerStrictQualificationTest( - name: 'messagingSampleContractQualificationTest', - sourceSet: sourceSets.test, - requiredClasses: [ +def messagingSampleContractQualification = extensions.getByName('strictQualification').register( + 'messagingSampleContractQualificationTest', + sourceSets.test, + [ 'dev.caskeleton.sample.portfolio.application.event.WorkLogReservedContractContributionTest' ], - junitXmlOutput: rootProject.layout.buildDirectory.dir( + rootProject.layout.buildDirectory.dir( 'test-results/messaging-evidence/sample'), - binaryResultsOutput: rootProject.layout.buildDirectory.dir( + rootProject.layout.buildDirectory.dir( 'test-results/messaging-evidence-binary/sample'), - description: 'Runs exact Messaging sample contract qualification tests.') + 'Runs exact Messaging sample contract qualification tests.' +) messagingSampleContractQualification.configure { dependsOn ':prepareMessagingContractEvidence' } diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java index 58c24ea3..6a875638 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java @@ -1,8 +1,8 @@ package dev.caskeleton.sample.portfolio.application.event; import dev.caskeleton.application.observability.CorrelationIdPort; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.sample.portfolio.domain.poster.PosterArchived; import dev.caskeleton.sample.portfolio.domain.poster.PosterCreated; import dev.caskeleton.sample.portfolio.domain.poster.PosterDeleted; @@ -24,13 +24,13 @@ import org.springframework.stereotype.Component; @Component public class PosterEventPublisher { - private final OutboxAppendPort outbox; + private final LegacyOutboxAppendPort outbox; private final OutboxEventIdFactory eventIdFactory; private final CorrelationIdPort correlationIdPort; private final Clock clock; public PosterEventPublisher( - OutboxAppendPort outbox, + LegacyOutboxAppendPort outbox, OutboxEventIdFactory eventIdFactory, CorrelationIdPort correlationIdPort, Clock clock) { diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java index d3b3a566..5425d21b 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java @@ -1,8 +1,8 @@ package dev.caskeleton.sample.portfolio.application.event; import dev.caskeleton.application.observability.CorrelationIdPort; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; import java.time.Clock; import org.springframework.stereotype.Component; @@ -11,13 +11,13 @@ import org.springframework.stereotype.Component; @Component public final class PosterImagePublicationEventPublisher { - private final OutboxAppendPort outbox; + private final LegacyOutboxAppendPort outbox; private final OutboxEventIdFactory ids; private final CorrelationIdPort correlations; private final Clock clock; public PosterImagePublicationEventPublisher( - OutboxAppendPort outbox, + LegacyOutboxAppendPort outbox, OutboxEventIdFactory ids, CorrelationIdPort correlations, Clock clock) { diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java index 50b6f14d..b5a6d422 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java @@ -4,8 +4,8 @@ import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; import dev.caskeleton.application.observability.CorrelationIdPort; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.transaction.TransactionMode; import dev.caskeleton.application.transaction.TransactionPort; @@ -41,7 +41,7 @@ public class CreateWorkLogUseCase implements CommandUseCase appended = new ArrayList<>(); boolean appendCalledInTx = false; @@ -169,7 +169,7 @@ class CreateWorkLogOutboxTest { .handle(createCmd()); assertThat(tx.appendHappenedInsideTx) - .as("OutboxAppendPort.append must be called inside tx.inWrite (D2)") + .as("LegacyOutboxAppendPort.append must be called inside tx.inWrite (D2)") .isTrue(); } diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java index 56945ae6..df00a12c 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java @@ -5,7 +5,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.github.f4b6a3.uuid.UuidCreator; import dev.caskeleton.application.observability.CorrelationIdPort; -import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.transaction.TransactionPort; import dev.caskeleton.sample.portfolio.application.command.BatchCreateWorkLogsCommand; import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; @@ -103,7 +103,7 @@ class WorkLogUseCasesTest { }; /** No-op outbox port — existing tests focus on use-case behaviour, not outbox wiring. */ - static final OutboxAppendPort NO_OP_OUTBOX = e -> {}; + static final LegacyOutboxAppendPort NO_OP_OUTBOX = e -> {}; static final CorrelationIdPort NO_CORRELATION = Optional::empty; diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java index 9a515fda..bd5b7e6c 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java @@ -14,8 +14,8 @@ import dev.caskeleton.adapter.inbound.web.authz.MethodSecurityConfig; import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry; import dev.caskeleton.application.observability.CorrelationIdPort; +import dev.caskeleton.application.outbox.LegacyOutboxAppendPort; import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.transaction.TransactionPort; import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; import dev.caskeleton.sample.portfolio.application.command.DeleteWorkLogCommand; @@ -201,7 +201,7 @@ class WorkLogAuthorizationContractTest { } @Bean - OutboxAppendPort outboxAppendPort() { + LegacyOutboxAppendPort outboxAppendPort() { // No-op for authorization tests — the contract under test is auth, not outbox. return (NewOutboxEvent e) -> {}; } @@ -221,7 +221,7 @@ class WorkLogAuthorizationContractTest { WorkLogRepository repository, WorkLogIdFactory idFactory, OutboxEventIdFactory eventIdFactory, - OutboxAppendPort outboxAppendPort, + LegacyOutboxAppendPort outboxAppendPort, CorrelationIdPort correlationIdPort, Clock clock, TransactionPort tx) { diff --git a/src/settings.gradle b/src/settings.gradle index a62f952d..b862fe2f 100644 --- a/src/settings.gradle +++ b/src/settings.gradle @@ -3,7 +3,7 @@ pluginManagement { // plugins are testable with TestKit and do not force every project to recompile when one of them // changes. includeBuild('build-logic') - includeBuild('build-qualification') + includeBuild('build-tools') } plugins { diff --git a/src/shared-contract/build.gradle b/src/shared-contract/build.gradle index f409813a..0384cc17 100644 --- a/src/shared-contract/build.gradle +++ b/src/shared-contract/build.gradle @@ -1,5 +1,6 @@ plugins { id 'ca.java-library' + id 'ca.auxiliary-source-set' } // Skeleton-wide operational contracts only. No business/domain concepts. @@ -7,34 +8,35 @@ plugins { dependencies { } -strictTestLanes { +auxiliarySourceSets { sourceSet('edgeRateLimitContractTest') { compilesAgainst 'main' inherits 'implementation', 'compileOnly', 'runtimeOnly' } +} +strictTestLanes { lane('edgeRateLimitContractTest') { // Its own source set is the selection; nothing else in this leaf can join the lane. sourceSet = 'edgeRateLimitContractTest' description = 'Runs the provider-neutral edge rate-limit shared contract.' - customize = { test -> - test.group = 'redis verification' - test.jvmArgs '-Duser.timezone=UTC' - } + group = 'redis verification' + jvmArgs '-Duser.timezone=UTC' } } -def messagingSharedSchemaQualification = registerStrictQualificationTest( - name: 'messagingSharedSchemaQualificationTest', - sourceSet: sourceSets.test, - requiredClasses: [ +def messagingSharedSchemaQualification = extensions.getByName('strictQualification').register( + 'messagingSharedSchemaQualificationTest', + sourceSets.test, + [ 'dev.caskeleton.shared.contract.messaging.MessagingEnvelopeSchemaResourceTest' ], - junitXmlOutput: rootProject.layout.buildDirectory.dir( + rootProject.layout.buildDirectory.dir( 'test-results/messaging-evidence/shared'), - binaryResultsOutput: rootProject.layout.buildDirectory.dir( + rootProject.layout.buildDirectory.dir( 'test-results/messaging-evidence-binary/shared'), - description: 'Runs exact Messaging shared schema qualification tests.') + 'Runs exact Messaging shared schema qualification tests.' +) messagingSharedSchemaQualification.configure { dependsOn ':prepareMessagingContractEvidence' }