refactor(build,src): testkit 소스셋 이관과 빌드 게이트 정상화, 미추적 빌드 파일 추적
한 커밋인 이유: src/build.gradle 안에서 ca.testkit-publisher 플러그인 제거와
게이트 수정이 얽혀 있다. 플러그인 적용부만 빼면 web·websocket·persistence-jpa·
persistence-mongo·app-bootstrap 이 사라진 testkitPublisher() 와 *Testkit
컨피규레이션을 계속 참조해 설정 단계에서 빌드가 죽는다. 파일 단위로 나눌 수 없다.
1) testkit 소스셋 → Gradle 표준 java-test-fixtures 이관
web, websocket, persistence-jpa, persistence-mongo, httpclient, graphql 과
이들의 testkit 컨피규레이션을 소비하던 app-bootstrap.
자체 제작 ca.testkit-publisher.gradle 77줄이 사라진다.
2) 실행되지 않거나 실패할 수 없던 빌드 게이트 정상화 (E등급)
- strict-test-lane 의 실행 카운터가 skip 을 실행으로 세던 것 수정.
전부 skip 인 레인은 이제 실패한다 (회귀 테스트 2건 추가)
- public-path 스냅샷이 gitignore 된 src/.env 를 읽던 것을
config/security.yml 의 바인딩 기본값으로 교체
- verifyEnvKeys 가 build/ 산출물을 소스로 읽어 삭제된 키를 사용 중으로
오판하던 것 수정 (입력 4,637 → 4,630 파일)
- jpa-evidence 가 git 실패를 "워크트리 깨끗함"으로 읽던 것을 fail-closed 로
- notification-evidence 의 Grade 열 탐지를 헤더 기준으로 교체 +
표 부재 시 fail-closed
- spring70CompatibilityTest 가 레인을 복제하며 잃은 fail-closed 복구
(태스크명 유지 — 워크플로 3곳과 gate-matrix 린트 무손상)
- 메시징 R2 스켈레톤 주변의 도달 불가 검증 45줄을 MSG-015 명시적 실패로 교체
3) git 에 없던 빌드 필수 파일 추적
- src/gradle/libs.versions.toml — src/build.gradle 이 9곳에서 참조하는데
추적되지 않아 깨끗한 체크아웃에서 설정이 실패했다
- app-bootstrap config/*.yml 15개 — application.yml 이 전부 import 한다.
하드코딩된 시크릿은 없고 값은 secret://environment/APP_* 참조다
4) 진행 중이던 구현 작업 반영 (redis/idempotency 구성, startup 검증,
아키텍처 테스트 클래스, notification 콜백 레지스트리 등)
검증:
- 깨끗한 체크아웃에서 ./gradlew help 통과
- :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
→ 20개 클래스 174 tests, 실패 0, 스킵 0 (이전에는 0개 실행)
미해결: verifyOneTypePerFile 은 손대지 않았다(Checkstyle 로 교체 권고).
B/C/D 등급 100여 건과 CI 단계 분리는 별도 작업 —
docs/superpowers/plans/2026-09-16-ci-stage-separation.md 참고.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e34519113b
commit
1535481794
@@ -7,6 +7,13 @@ dependencies {
|
||||
implementation 'org.springframework:spring-web' // Slack webhook client (RestClient)
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
// Micrometer core for the metric sink that implements NotificationMetricsPort. The sink used
|
||||
// to live in the composition root because the MeterRegistry does; but a MeterRegistry is an
|
||||
// external system like any other, and an implementation of an application port is an
|
||||
// outbound adapter wherever its dependency happens to be configured (BOOT-017). Its logging
|
||||
// counterpart, LoggingNotificationMetrics, was already here.
|
||||
implementation 'io.micrometer:micrometer-core'
|
||||
|
||||
// Notification Delivery Platform.
|
||||
// - mail: the SMTP provider adapter is built on JavaMailSender/MimeMessageHelper, which is where
|
||||
// multipart/alternative, inline resources and header validation already live. Rebuilding MIME
|
||||
@@ -32,7 +39,7 @@ dependencies {
|
||||
// adapter that renders strings and never serves a request.
|
||||
implementation 'org.thymeleaf:thymeleaf'
|
||||
|
||||
implementation('com.networknt:json-schema-validator:3.0.2') {
|
||||
implementation(libs.json.schema.validator) {
|
||||
exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml'
|
||||
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ 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.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-test:3.8.7=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -88,6 +89,7 @@ org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath
|
||||
org.eclipse.angus:angus-mail:2.0.5=runtimeClasspath,testRuntimeClasspath
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.javassist:javassist:3.29.0-GA=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -100,6 +102,7 @@ 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
|
||||
org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.callback.CallbackLimits;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapterRegistry;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Profile-to-adapter lookup over a map fixed at assembly time.
|
||||
*
|
||||
* <p>This used to be an anonymous class inside the composition root's registries configuration
|
||||
* (BOOT-017). Two things were wrong with that, and only one of them was placement.
|
||||
*
|
||||
* <p>The placement: an implementation of an {@code application-core} port is an outbound adapter,
|
||||
* and building it from a map the composition root assembled does not change what it is. The
|
||||
* composition root still decides the map's contents; what it no longer holds is the lookup, the
|
||||
* refusal and the limits.
|
||||
*
|
||||
* <p>The other, which the move surfaced: {@link #require} runs <em>per callback request</em>, not
|
||||
* at startup. While it lived in bootstrap it was briefly migrated to the startup-failure factory
|
||||
* along with that leaf's genuine startup guards, which would have classified an unknown profile in
|
||||
* an inbound request as a configuration error carrying a JVM exit code. Here the contract is the
|
||||
* one the interface documents and every sibling {@code require(...)} registry in this repository
|
||||
* uses.
|
||||
*
|
||||
* <p>Refusing an unknown profile is a security property rather than a convenience: an endpoint that
|
||||
* accepted any profile name would let a caller choose which signature check runs.
|
||||
*/
|
||||
public final class MapProviderCallbackAdapterRegistry implements ProviderCallbackAdapterRegistry {
|
||||
|
||||
private final Map<ProviderProfileId, ProviderCallbackAdapter> byProfile;
|
||||
private final CallbackLimits limits;
|
||||
|
||||
/**
|
||||
* Creates the registry.
|
||||
*
|
||||
* @param byProfile the profiles this deployment actually configured, copied defensively
|
||||
* @param limits the limits every callback endpoint of this platform enforces
|
||||
*/
|
||||
public MapProviderCallbackAdapterRegistry(
|
||||
Map<ProviderProfileId, ProviderCallbackAdapter> byProfile, CallbackLimits limits) {
|
||||
this.byProfile = Map.copyOf(Objects.requireNonNull(byProfile, "byProfile"));
|
||||
this.limits = Objects.requireNonNull(limits, "limits");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderCallbackAdapter require(ProviderProfileId profileId) {
|
||||
Objects.requireNonNull(profileId, "profileId");
|
||||
ProviderCallbackAdapter adapter = byProfile.get(profileId);
|
||||
if (adapter == null) {
|
||||
throw new IllegalStateException("no callback adapter registered for the profile");
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CallbackLimits limitsFor(ProviderProfileId profileId) {
|
||||
return limits;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventProjector;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventProjectorRegistry;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Provider-to-projector lookup over a map fixed at assembly time.
|
||||
*
|
||||
* <p>Moved out of the composition root alongside {@link MapProviderCallbackAdapterRegistry}
|
||||
* (BOOT-017). It was a lambda there rather than an anonymous class, which is why the ArchUnit rule
|
||||
* that caught its sibling could not see it — a lambda implementing an interface leaves no class for
|
||||
* the rule to inspect. Leaving it behind would have meant the boundary held only where a tool
|
||||
* happened to be able to check it, which is a worse state than not having the rule: the code would
|
||||
* look compliant and be inconsistent.
|
||||
*/
|
||||
public final class MapProviderEventProjectorRegistry implements ProviderEventProjectorRegistry {
|
||||
|
||||
private final Map<ProviderId, ProviderEventProjector> byProvider;
|
||||
|
||||
/**
|
||||
* Creates the registry.
|
||||
*
|
||||
* @param byProvider the projectors this deployment declared, copied defensively
|
||||
*/
|
||||
public MapProviderEventProjectorRegistry(Map<ProviderId, ProviderEventProjector> byProvider) {
|
||||
this.byProvider = Map.copyOf(Objects.requireNonNull(byProvider, "byProvider"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProviderEventProjector> find(ProviderId providerId) {
|
||||
return Optional.ofNullable(byProvider.get(providerId));
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.observation;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.observation.CardinalityGuard;
|
||||
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Micrometer-backed metric sink, living in the composition root because the registry does.
|
||||
*
|
||||
* <p>Every tag map is validated before a meter is created. Validating after registration would be
|
||||
* too late: Micrometer creates the time series on first use, so a rejected tag would already have
|
||||
* cost the series it was supposed to prevent.
|
||||
*
|
||||
* <p>Gauges are held through a strong reference deliberately. Micrometer's gauge holds its source
|
||||
* weakly, so a locally-created holder is collected and the gauge silently reports NaN forever after
|
||||
* — a failure mode that looks like a broken exporter rather than a bug here.
|
||||
*/
|
||||
public final class MicrometerNotificationMetrics implements NotificationMetricsPort {
|
||||
|
||||
private final MeterRegistry registry;
|
||||
private final CardinalityGuard guard;
|
||||
private final Map<String, AtomicReference<Double>> gauges = new ConcurrentHashMap<>();
|
||||
|
||||
public MicrometerNotificationMetrics(MeterRegistry registry, CardinalityGuard guard) {
|
||||
this.registry = Objects.requireNonNull(registry, "registry");
|
||||
this.guard = Objects.requireNonNull(guard, "guard");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void increment(String metricName, Map<String, String> tags) {
|
||||
// bound, not validate: the keys were checked and the values never were, so one metric could
|
||||
// become one series per caller-supplied category.
|
||||
Map<String, String> bounded = guard.bound(tags);
|
||||
registry.counter(metricName, tags(bounded)).increment();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void record(String metricName, Map<String, String> tags, Duration value) {
|
||||
// bound, not validate: the keys were checked and the values never were, so one metric could
|
||||
// become one series per caller-supplied category.
|
||||
Map<String, String> bounded = guard.bound(tags);
|
||||
registry.timer(metricName, tags(bounded)).record(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void gauge(String metricName, Map<String, String> tags, double value) {
|
||||
// bound, not validate: the keys were checked and the values never were, so one metric could
|
||||
// become one series per caller-supplied category.
|
||||
Map<String, String> bounded = guard.bound(tags);
|
||||
Tags meterTags = tags(bounded);
|
||||
gauges
|
||||
.computeIfAbsent(
|
||||
metricName + meterTags,
|
||||
key -> {
|
||||
AtomicReference<Double> holder = new AtomicReference<>(value);
|
||||
registry.gauge(metricName, meterTags, holder, AtomicReference::get);
|
||||
return holder;
|
||||
})
|
||||
.set(value);
|
||||
}
|
||||
|
||||
private static Tags tags(Map<String, String> tags) {
|
||||
return Tags.of(tags.entrySet().stream().map(e -> Tag.of(e.getKey(), e.getValue())).toList());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user