plugins { id 'ca.spring-library' id 'ca.spring-config' } // Redis SDK leaf — see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md. // // The design models the SDK as separate Gradle modules. This repository's fail-closed module // registry outranks that layout, so the module boundaries are packages under // dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them. dependencies { // Registered edges the semantic port adapters need. The SDK's *main* source imports nothing from // them today — the semantic cache/session/idempotency/rate-limit adapters that did were removed // and are restored by Phase E of // docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md. // // Two of the three are nonetheless load-bearing right now, which the earlier wording hid: this // leaf declares no `testImplementation project(...)`, so `test` reaches // dev.caskeleton.application.* and dev.caskeleton.shared.* through these `implementation` edges // alone. Dropping either breaks compileTestJava today, not at Phase E. // // ':adapter:outbound:support' is the one with no reference in any source set; it stays for the // stated restoration reason, and that is the only one of the three for which that reason is // doing the work. implementation project(':application-core') implementation project(':shared-contract') implementation project(':adapter:outbound:support') implementation 'org.springframework.boot:spring-boot-autoconfigure' // The role-aware health contributors are HealthIndicators; the readiness probe is the only // place the required/optional Redis taxonomy can actually be enforced. implementation 'org.springframework.boot:spring-boot-health' // Boot's Health type carries Jackson annotations. Without the annotations on the compile // classpath javac emits an 'unknown enum constant' warning, and this build is -Werror. Runtime // does not need it from here — the app already has Jackson — so compileOnly is the honest scope. compileOnly 'com.fasterxml.jackson.core:jackson-annotations' implementation 'io.lettuce:lettuce-core' // Reactor is in the public signature of sdk.api.reactive, and it was reaching this module only // transitively through lettuce-core. A driver upgrade that stopped exposing it would have // broken compilation of the SDK's own published API, so it is declared directly. implementation 'io.projectreactor:reactor-core' implementation 'org.slf4j:slf4j-api' // Deliberately absent: // org.springframework.data:spring-data-redis — the SDK owns its own typed API and command // policy on purpose; routing through Spring Data would reintroduce the untyped, unguarded // command surface the catalog exists to prevent. Zero imports. // io.micrometer:micrometer-core — observation leaves this leaf as RedisObservation through a // Consumer sink; binding it to a meter registry belongs to the composition root, not here. // 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).") } } } }