refactor(build,ci): CI를 단계별로 재편하고 감사 잔여 114건 정리

빌드·CI 레이어 전수 감사(133건) 중 Track A 에서 처리한 E등급 19건을 뺀
나머지를 마무리한다. 한 커밋인 이유는 서로 얽혀 있기 때문이다 — 워크플로가
`checkstyleMain` 을 부르려면 루트가 먼저 Checkstyle 을 붙여야 하고, 모듈 43개가
convention 을 적용하려면 build-logic 이 먼저 그 플러그인을 등록해야 한다.
영역별로 쪼개면 중간 커밋이 빌드되지 않는다.

## CI 단계 분리 (워크플로 29파일 3,360줄 → 19파일 2,692줄, 잡 69 → 64)

모듈이 아니라 단계로 자른다. 기존 28개는 `web-*` `jpa-*` 처럼 모듈로 갈려
있어서 같은 일이 파일마다 중복됐다.

- stage 1 `ci-quality-gates.yml` + `pr-adapters.yml`(신규, 잡 단위 경로 필터) — pull_request
- stage 2 `integration-main.yml`(신규) — push:main + 03:00. 문서 게이트는 여기에 둔다
- stage 3 `release.yml`(신규, 릴리스 워크플로 7개 중 5개 흡수) — push: tags v*

setup 블록 59회 복붙 → `.github/actions/setup-gradle-java` 1개(잡당 13줄 → 5줄).
잡 8개 삭제, 각각 대체 잡을 확인했다. `verifyCleanArchitectureDependencies` 실행
횟수가 태그당 9 → 6, PR당 8 → 4 로 줄었다.

## 컨테이너 릴리스 신설

이미지를 만드는 것이 아무것도 없었다. Dockerfile 은 있었지만
build-push-action / bootBuildImage / jib 사용처가 0건이고, `*-release.yml` 8개는
테스트 후 아티팩트만 올렸다 — 이름만 릴리스였다.

Boot 레이어드 추출 + thin-JAR 엔트리포인트로 Dockerfile 을 고치고 릴리스
워크플로에 이미지 빌드·푸시·SBOM·스캔을 넣었다. 로컬 빌드로 검증했다:
레지스트리 content 241MB, 기동 3.7초, uid 1000, 헬스체크 200.
코드만 바뀐 릴리스는 7.68MB 만 재푸시한다(이전이라면 156MB).
CI 는 배포하지 않는다 — 매니페스트와 ArgoCD 는 별도 repo 로 간다.

## 게이트 정리

- gate-matrix 의 개수 고정 해제: `EXPECTED_GATE_COUNT=49` 와 하드코딩된 49개 id
  목록을 지우고 불변식으로 대체(필드·enum, 워크플로/잡 실재, id 중복,
  `release_blocking: true` 는 실제로 release-gate 의 needs 여야 함).
  행을 추가하려면 테스트부터 고쳐야 하던 구조를 풀었다. 커버리지 8/28 → 28/28
- 문서 게이트 4개를 `check` 에서 떼어 `verifyDocumentationContracts` 로 묶고
  stage 2 에 배치. 어겨도 런타임은 멀쩡하므로 개발을 막지 않는다
- `verifyOneTypePerFile`(정규식 Java 파싱, 126파일 미탐) → Checkstyle
  `OneTopLevelClass` + `OuterTypeFilename`. main 위반 0건, test 의 fixture 29건은
  정책을 넓히지 않고 suppressions 에 사유와 함께 명시 제외
- leaf 하나의 `check` 가 끌고 오던 저장소 전역 게이트 18개를 재배치.
  `:domain-core:check` 가 13 태스크 11초로 끝난다
- convention 플러그인 2개 신설(`ca.platform-module`, `ca.grpc-platform-module`),
  플랫폼 모듈 43개에 적용. 손수 짠 Test 태스크 17개를 `strictTestLanes` 로 전환
  (태스크 이름 전부 보존 — CI 가 이름으로 부른다)
- `ca.api-surface` 의 정규식 Java 파서를 javac parse-only 로 교체
  (기존 베이스라인 3개와 바이트 동일 확인)
- 죽은 태스크 5개 삭제, `src/gradle` 1,713 → 1,440줄, 모듈 build.gradle
  3,072 → 2,977줄

## 검사가 검사를 못 하고 있던 것들

- 11개 계약 테스트가 gitignore 된 `src/.env` 를 요구했다. `.gitignore` 자신이
  "examples beside it are the tracked contract, never a real one" 이라고 적어둔
  규칙과 어긋난다. 깨끗한 체크아웃에는 그 파일이 없으므로 CI 에서 돌 수 없었다.
  추적되는 `.env.example` 로 돌린다
- **`.env.local.example` 이 5432 를 가리키는데 compose 는 5433 을 게시한다.**
  이 파일을 복사해 시작하는 신규 개발자는 DB 연결에 실패한다. 이걸 잡으라고
  만든 테스트가 추적 안 되는 파일을 읽어서, 이미 설정이 끝난 머신에서만 돌고
  정작 처음 받는 사람에겐 아무 검사도 안 하고 있었다. 포트를 고치고 테스트를
  추적 파일로 돌렸다
- `MongoModuleBoundaryTest` 의 `DO_NOT_INCLUDE_JARS` 때문에 임포트가 0개가 되어
  규칙 10개가 "failed to check any classes" 로 실패 중이었다. 이 레인에서는
  모듈 자기 클래스가 jar 로 올라온다. `importPackages(ROOT)` 가 이미 서드파티를
  거르므로 옵션은 불필요했다
- `ReleaseManifestTaskExistenceTest` 가 build 파일 텍스트에서 `tasks.register(`
  만 찾아, convention 의 `lane('...')` 로 바뀐 태스크를 미등록으로 오판했다
- `ProfileSeparationContractTest` 는 런처가 주입하는 `src/.env` 가 맞는 대상이라
  그대로 두되, 파일이 없으면 명시적으로 skip 한다 — "안 돌았다" 가 "통과했다"
  로 읽히지 않게

## 검증 (전부 깨끗한 체크아웃에서, 커밋 전에 실행)

`verify-gradle-wrapper.sh` PASS · `verify-gate-matrix.sh` OK(drift 0) ·
워크플로 YAML 전수 파싱 OK · actionlint 지적 0 · `gradlew help` ·
`verifyCleanArchitectureDependencies` · `build-logic test` ·
`:app-bootstrap:test` **1001 tests 실패 0 스킵 5** · `:domain-core:check` ·
`verifyDocumentationContracts`.

## 남은 문제

- 첫 `v*` 태그는 이미지 취약점 스캔에서 실패한다(CRITICAL/HIGH 9건:
  ubuntu 베이스 2, tomcat-embed-core 3, amqp-client 3, httpcore5 2).
  억제를 넣지 않았다 — 릴리스 1회차를 초록으로 만들려고 임계값을 내리면
  게이트가 장식이 된다. 의존성·베이스 갱신이 선행돼야 한다
- `fileserver-v*` / `web-v*` / `websocket-v*` 태그는 이제 아무 run 도 만들지
  않는다(배포 단위가 하나라는 결정에 따른 것)
- main push 마다 무거운 레인 3개가 새로 돈다 — 러너 분이 늘어난다
- `ProfileSeparationContractTest` 가 찾아낸 4개 값(cache command-timeout,
  cache positive-soft-ttl, idempotency provider, rate-limit command-timeout)이
  `.env.example` 과 인라인 기본값 사이에서 갈린다. 런타임 설정 판단이라
  건드리지 않았다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 18:27:15 +09:00
co-authored by Claude Opus 5
parent 1535481794
commit 9bc2e75fe5
132 changed files with 4002 additions and 3083 deletions
+14 -3
View File
@@ -13,12 +13,23 @@ without any check noticing.
| `ca.strict-qualification` | qualification lanes that cannot pass without executing every named class, re-checked against the JUnit XML |
| `ca.evidence` | the JUnit XML reader and the no-skip / required-class rules built on it |
| `ca.api-surface` | read-only API surface verification with an explicit, separate update task |
| `ca.testkit-publisher` | a leaf's testkit source set consumers and its optional consumable artifact |
| `ca.dependency-policy` | declared absences, checked against the resolved graph rather than against a comment |
| `ca.runtime-membership` | the resolved runtime project closure against the registry's memberships |
| `ca.platform-module` | what a vendored platform leaf (`messaging:*`, `grpc:*`, `grpc-advanced:*`) is: `java-library` |
| `ca.grpc-platform-module` | `ca.platform-module` plus the module-scope `io.grpc:grpc-bom` import, for the leaves that already had it |
`dev.caskeleton.buildlogic.ModuleRegistry` and `JUnitEvidence` are plain classes rather than plugins,
because settings and projects load plugins through different mechanisms and both need them.
`ca.testkit-publisher` was here and is not any more: every leaf with a `testkit` source set moved to
Gradle's own `java-test-fixtures` (ADR-BUILD-001).
Plain classes rather than plugins, because settings and projects load plugins through different
mechanisms and more than one caller needs each:
| Class | Owns |
| --- | --- |
| `dev.caskeleton.buildlogic.ModuleRegistry` | reading and validating `config/architecture/modules.json`, including which runtime compositions exist |
| `dev.caskeleton.buildlogic.JUnitEvidence` | one JUnit XML reader, with DOCTYPE processing off and no defaulting of absent counts |
| `dev.caskeleton.buildlogic.RequiredTestExecution` | the single decision "a test this build names must actually have run", asked by `ca.strict-test-lane` and by `ca.evidence` |
| `dev.caskeleton.buildlogic.JavaPublicTypes` | public top-level types, parsed by javac rather than matched with a regular expression |
## What the design named and this build does not have
@@ -31,6 +31,15 @@ class ApiSurfaceExtension {
/** 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.
*
* <p>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<String> additionalSourceRoots = []
}
def apiSurface = extensions.create('apiSurface', ApiSurfaceExtension)
@@ -47,36 +56,44 @@ def updateName = { "update${apiSurface.label}ApiSurface" }
def approvalProperty = { "approve${apiSurface.label}ApiSurfaceChange" }
def ceilingProperty = { "raise${apiSurface.label}ApiSurfaceCeiling" }
def renderSurface = { ->
File sourceRoot = project.file(apiSurface.sourceRoot)
def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/
def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/
List<String> types = []
if (sourceRoot.isDirectory()) {
sourceRoot.eachFileRecurse { candidate ->
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
return
}
String text = candidate.getText('UTF-8')
def packageMatcher = packagePattern.matcher(text)
if (!packageMatcher.find()) {
return
}
String packageName = packageMatcher.group(1)
def typeMatcher = typePattern.matcher(text)
while (typeMatcher.find()) {
types << "${packageName}.${typeMatcher.group(2)}".toString()
}
}
// 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<File> 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<String> 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.')
}
types = types.unique().toSorted()
StringBuilder header = new StringBuilder()
header.append("# ").append(apiSurface.description).append('\n')
apiSurface.rationale.each { header.append('# ').append(it).append('\n') }
header.append("# Update only after review with:\n")
header.append("# ./gradlew ${owningProjectPath}:${updateName()} -P${approvalProperty()}\n")
header.append("# types: ${types.size()}\n")
return header.toString() + (types.isEmpty() ? '' : types.join('\n') + '\n')
return header.toString() + types.join('\n') + '\n'
}
def countTypes = { String surface ->
@@ -116,6 +133,8 @@ project.afterEvaluate {
// the approval; a change that raises the total says so out loud.
boolean ceilingRaiseApproved = project.hasProperty(ceilingProperty())
List<File> roots = sourceRootFiles()
tasks.register(verifyName()) {
group = 'verification'
description = "Fails without mutation when the committed ${apiSurface.label} public API " +
@@ -126,7 +145,7 @@ project.afterEvaluate {
"${verifyName()} is read-only; use ${updateName()} to record an approved " +
"change.")
}
String rendered = renderSurface()
String rendered = renderSurface(roots)
if (!apiSurface.baseline.isFile()) {
throw new GradleException(
"${verifyName()}: missing committed baseline ${apiSurface.baseline}")
@@ -159,7 +178,7 @@ project.afterEvaluate {
"${updateName()} requires -P${approvalProperty()}: growing the public " +
"surface is a review decision, not a build step.")
}
String rendered = renderSurface()
String rendered = renderSurface(roots)
if (apiSurface.baseline.isFile() && !ceilingRaiseApproved) {
int committedCount = countTypes(apiSurface.baseline.getText('UTF-8'))
int renderedCount = countTypes(rendered)
@@ -1,4 +1,5 @@
import dev.caskeleton.buildlogic.JUnitEvidence
import dev.caskeleton.buildlogic.RequiredTestExecution
// JUnit evidence: what a lane actually executed, read one way.
//
@@ -50,13 +51,11 @@ Closure<Map<String, Object>> verifyRequiredJUnitClasses = {
String evidenceName, File resultDirectory, List<String> requiredClasses ->
Map<String, Object> evidence = verifyNoSkipJUnitXml(evidenceName, resultDirectory)
Set<String> executedClasses = evidence.executedClasses as Set<String>
// A nested class counts for its outer class: a required class whose cases all live in
// @Nested inner classes is executed, and matching on exact names alone would call it missing.
List<String> missingClasses = requiredClasses.findAll { String requiredClass ->
!executedClasses.any { String executedClass ->
executedClass == requiredClass || executedClass.startsWith(requiredClass + '$')
}
}
// 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<String> missingClasses = RequiredTestExecution.absent(requiredClasses, executedClasses)
if (!missingClasses.isEmpty()) {
throw new GradleException(
"${evidenceName}: no executed test cases for required classes: ${missingClasses}")
@@ -0,0 +1,52 @@
// 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'
}
// Read from the root's `ext.grpcVersion` SSOT, which is where the four leaves read it from. Resolved
// at apply time, which is the same moment their inline blocks resolved it: the root sets the property
// while evaluating its own build file, long before any leaf is evaluated.
Object declaredGrpcVersion = project.rootProject.findProperty('grpcVersion')
if (declaredGrpcVersion == null || declaredGrpcVersion.toString().isBlank()) {
throw new GradleException(
"${project.path} applies ca.grpc-platform-module, which imports io.grpc:grpc-bom, but " +
'the root project declares no `ext.grpcVersion`. Importing an unversioned BOM ' +
'would leave every io.grpc coordinate in this leaf unmanaged.')
}
String grpcVersion = declaredGrpcVersion.toString()
// Fail-closed rather than silently skipped. `dependencyManagement` is Spring's extension, so without
// that plugin there is nothing to import into — and a BOM that was never imported does not announce
// itself: it surfaces later as an io.grpc coordinate with no version, in whichever leaf asks first.
if (!project.pluginManager.hasPlugin('io.spring.dependency-management')) {
throw new GradleException(
"${project.path} applies ca.grpc-platform-module before " +
"'io.spring.dependency-management'. The grpc BOM is imported through that " +
'plugin, so applying it afterwards would leave io.grpc versions unmanaged ' +
'without failing anything here.')
}
dependencyManagement {
imports {
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
}
}
@@ -0,0 +1,23 @@
// 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. That is
// not merely repetition. The root build applies every other plugin a leaf gets, centrally, and states
// why: "leaves in this repository have no plugins {} block — the root is where a leaf acquires its
// plugins, and splitting that would mean two places to look" (src/build.gradle). These forty-three
// files were the exception, so there were two places to look, and the one with forty-three copies is
// the one that drifts — a platform leaf added without the line compiles until the first consumer
// writes `api`, and then fails somewhere else.
//
// Deliberately thin. Everything else these leaves share — the toolchain, Spotless, Checkstyle,
// SpotBugs, Error Prone, dependency locking, the strict lane conventions — the root already applies
// to every leaf, and duplicating any of it here would be the second place to look this plugin exists
// to remove. What belongs here is what is true of the vendored platform and false of the rest.
plugins {
id 'java-library'
}
@@ -1,5 +1,3 @@
import groovy.json.JsonSlurper
// Where the registry's repository-root-relative source paths are resolved from.
//
// Defaults to the parent of the Gradle root, which is this repository's layout: the build lives in
@@ -7,7 +5,7 @@ import groovy.json.JsonSlurper
// fixture, whose projects sit beside its registry — says so rather than having the plugin guess.
ext.moduleRegistryRepositoryRoot = rootProject.projectDir.parentFile
def verifyRuntimeModuleMembership = tasks.register('verifyRuntimeModuleMembership') {
tasks.register('verifyRuntimeModuleMembership') {
group = 'verification'
description = 'Verifies registry runtime membership against both shipped composition roots.'
@@ -31,7 +29,9 @@ def verifyRuntimeModuleMembership = tasks.register('verifyRuntimeModuleMembershi
} catch (IllegalStateException invalid) {
throw new GradleException(invalid.message, invalid)
}
List<String> compositionIds = registry.RUNTIME_COMPOSITIONS.toList()
// From the registry file, not from a constant in the reader: the registry owns which runtime
// compositions exist, so a derived project adds or drops one by editing JSON.
List<String> compositionIds = registry.runtimeCompositions
Map<String, String> moduleIdByGradlePath =
registry.modules.collectEntries { [(it.gradlePath): it.id] }
// Resolved here rather than through `rootProject` inside the action: that is `Task.project` at
@@ -102,5 +102,3 @@ def verifyRuntimeModuleMembership = tasks.register('verifyRuntimeModuleMembershi
'match the registry')
}
}
rootProject.ext.verifyRuntimeModuleMembership = verifyRuntimeModuleMembership
@@ -294,15 +294,13 @@ strictTestLanes.lanes.all { StrictTestLaneSpec lane ->
// reading of the docs. Coverage that silently shrank is a gate that silently weakened,
// and this is the shape that produces it: a test renamed, the lane not updated.
if (!lane.requiredTests.isEmpty()) {
List<String> absent = lane.requiredTests.findAll { String required ->
!executedSelectors.any { String executed ->
// A parameterized test executes as `method(String)[1]`, so an exact-equality
// check would report a test that ran as absent.
executed == required ||
executed.startsWith(required + '(') ||
executed.startsWith(required + '[')
}
}
// 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<String> absent = dev.caskeleton.buildlogic.RequiredTestExecution.absent(
lane.requiredTests, executedSelectors)
if (!absent.isEmpty()) {
throw new GradleException(
"strict test lane '${lane.name}' in ${owningProjectPath} required " +
@@ -0,0 +1,122 @@
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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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<String> render(Collection<File> sourceRoots) {
List<File> 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<JavaFileObject> diagnostics = new DiagnosticCollector<>()
StandardJavaFileManager fileManager =
compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)
Set<String> 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<? extends CompilationUnitTree> units = task.parse()
List<Diagnostic<? extends JavaFileObject>> 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<String>(types)
}
}
@@ -21,9 +21,6 @@ import groovy.json.JsonSlurper
*/
final class ModuleRegistry {
/** The runtime compositions this repository recognises. */
static final Set<String> RUNTIME_COMPOSITIONS = ['app-bootstrap', 'sample-portfolio'] as Set
/** Exactly the fields a module entry carries — extra or missing is a failure, not a default. */
private static final Set<String> MODULE_FIELDS =
['id', 'gradle_path', 'source_path', 'allowed_dependencies', 'runtime_memberships'] as Set
@@ -33,11 +30,24 @@ final class ModuleRegistry {
/** Every registered module, in registry order. */
final List<Module> modules
/**
* The runtime compositions this registry declares, in registry order.
*
* <p>Read from {@code runtime_compositions}, not from a constant. The list used to exist twice —
* once here as {@code RUNTIME_COMPOSITIONS} and once in the JSON — and {@code read} only checked
* that the two copies agreed, so the JSON field looked like configuration while deciding nothing.
* The cost of that was not cosmetic: a derived project that drops or renames a composition root
* fails in <em>settings</em>, before any project exists, with no way to recover short of editing
* this file. The JSON is the registry, so the JSON is where the list lives.
*/
final List<String> runtimeCompositions
/** The file this was read from, for failure messages that name it. */
final File source
private ModuleRegistry(List<Module> modules, File source) {
private ModuleRegistry(List<Module> modules, List<String> runtimeCompositions, File source) {
this.modules = Collections.unmodifiableList(modules)
this.runtimeCompositions = Collections.unmodifiableList(runtimeCompositions)
this.source = source
}
@@ -82,12 +92,24 @@ final class ModuleRegistry {
if (!(parsed.modules instanceof List) || parsed.modules.isEmpty()) {
throw new IllegalStateException("Module registry has no modules: ${registryFile}")
}
if (!(parsed.runtime_compositions instanceof List) ||
parsed.runtime_compositions.collect { it as String }.toSet() != RUNTIME_COMPOSITIONS ||
parsed.runtime_compositions.size() != RUNTIME_COMPOSITIONS.size()) {
if (!(parsed.runtime_compositions instanceof List) || parsed.runtime_compositions.isEmpty()) {
throw new IllegalStateException(
"Module registry runtime_compositions must be exactly ${RUNTIME_COMPOSITIONS}: ${registryFile}")
"Module registry needs a nonempty 'runtime_compositions' list: ${registryFile}")
}
List<String> runtimeCompositions = parsed.runtime_compositions.withIndex().collect {
value, index ->
if (!(value instanceof String) || (value as String).isBlank()) {
throw new IllegalStateException(
"Module registry has a non-string or blank runtime_compositions entry at " +
"index ${index}: ${registryFile}")
}
value as String
}
if (runtimeCompositions.toSet().size() != runtimeCompositions.size()) {
throw new IllegalStateException(
"Module registry contains duplicate runtime_compositions: ${registryFile}")
}
Set<String> declaredCompositions = runtimeCompositions.toSet()
File canonicalRoot = repositoryRoot.canonicalFile
String rootPrefix = canonicalRoot.path + File.separator
Set<String> ids = new LinkedHashSet<>()
@@ -130,7 +152,7 @@ final class ModuleRegistry {
throw new IllegalStateException(
"Module registry entry '${id}' contains duplicate runtime memberships.")
}
Set<String> unknown = runtimeMemberships.toSet() - RUNTIME_COMPOSITIONS
Set<String> unknown = runtimeMemberships.toSet() - declaredCompositions
if (!unknown.isEmpty()) {
throw new IllegalStateException(
"Module registry entry '${id}' references unknown runtime memberships ${unknown.toSorted()}.")
@@ -175,7 +197,7 @@ final class ModuleRegistry {
allowedDependencies, runtimeMemberships)
}
RUNTIME_COMPOSITIONS.each { compositionId ->
runtimeCompositions.each { compositionId ->
Module composition = modules.find { it.id == compositionId }
if (composition == null || !composition.runtimeMemberships.contains(compositionId)) {
throw new IllegalStateException(
@@ -203,7 +225,7 @@ final class ModuleRegistry {
}
}
return new ModuleRegistry(modules, registryFile)
return new ModuleRegistry(modules, runtimeCompositions, registryFile)
}
/** Modules whose runtime_memberships name the given composition. */
@@ -0,0 +1,74 @@
package dev.caskeleton.buildlogic
/**
* "A test this build names must actually have run", decided in one place.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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:
*
* <ul>
* <li>{@code $} — a {@code @Nested} inner class, reported as {@code Outer$Inner};</li>
* <li>{@code (} — a method's parameter list, reported as {@code method(String)};</li>
* <li>{@code [} — one invocation of a parameterized test, reported as {@code method[1]}.</li>
* </ul>
*
* <p>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<String> 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.
*
* <p>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<String> absent(Collection<String> required, Collection<String> executed) {
if (required == null || required.isEmpty()) {
return []
}
Collection<String> observed = executed ?: []
return required.findAll { String requiredSelector ->
!observed.any { String executedSelector -> satisfies(executedSelector, requiredSelector) }
}
}
}
@@ -107,6 +107,75 @@ class ApiSurfaceConventionTest {
"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() {
@@ -145,6 +145,60 @@ class ModuleRegistryTest {
assertTrue(failure.message.contains('fields must be exactly'), failure.message)
}
@Test
@DisplayName("the registry decides which runtime compositions exist, so a derived project may drop one")
void theRegistryOwnsItsCompositionList() {
// The list used to be a constant here as well as a field in the JSON, and read() only checked
// that the two agreed. A derived project that drops the sample fixture then failed in
// *settings* — before any project exists — with no recovery short of editing this class.
String json = """{"runtime_compositions":["app-bootstrap"],
"modules":[${entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]',
'["app-bootstrap"]')}]}"""
def parsed = read(json)
assertEquals(['app-bootstrap'], parsed.runtimeCompositions)
assertEquals(['app-bootstrap'], parsed.membersOf('app-bootstrap').collect { it.id })
}
@Test
@DisplayName("a composition the registry names is still checked, whatever it is called")
void aRenamedCompositionIsStillChecked() {
// Dropping the constant must not drop the rule. A membership naming something the registry
// does not declare is still refused, against the declared list rather than a fixed one.
String json = """{"runtime_compositions":["service-bootstrap"],
"modules":[${entry('service-bootstrap', ':service-bootstrap', 'src/alpha', '[]',
'["service-bootstrap"]')},
${entry('beta', ':beta', 'src/beta', '[]', '["app-bootstrap"]')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('unknown runtime memberships'), failure.message)
}
@Test
@DisplayName("an empty runtime_compositions list is refused")
void anEmptyCompositionListIsRefused() {
String json = """{"runtime_compositions":[],
"modules":[${entry('alpha', ':alpha', 'src/alpha')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains("nonempty 'runtime_compositions'"), failure.message)
}
@Test
@DisplayName("a duplicated runtime composition is refused")
void aDuplicatedCompositionIsRefused() {
String json = """{"runtime_compositions":["app-bootstrap","app-bootstrap"],
"modules":[${entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]',
'["app-bootstrap"]')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('duplicate runtime_compositions'), failure.message)
}
@Test
@DisplayName("a runtime composition that does not include itself is refused")
void compositionMustIncludeItself() {
@@ -0,0 +1,120 @@
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.
*
* <p>The grpc BOM is the part worth testing rather than reading. Its two preconditions — a root that
* declares {@code ext.grpcVersion}, and Spring's dependency-management plugin to import into — are
* both satisfied today by the order in which the root build applies things, and both are invisible
* at the call site. A convention that skipped the import when either was missing would not fail
* here; it would surface much later as an io.grpc coordinate with no version, in whichever leaf
* asked for one first.
*/
class PlatformModuleConventionTest {
Path projectDir
@BeforeEach
void setUp() {
projectDir = Files.createTempDirectory('platform-module')
Files.writeString(projectDir.resolve('settings.gradle'), "rootProject.name = 'fixture'\n")
}
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 'io.spring.dependency-management' version '1.1.7'
id 'ca.grpc-platform-module'
}
repositories { mavenCentral() }
tasks.register('reportManagedVersion') {
String managed = dependencyManagement.managedVersions['io.grpc:grpc-api']
doLast { logger.lifecycle('managed-grpc-api=' + managed) }
}
''')
Files.writeString(projectDir.resolve('gradle.properties'), "grpcVersion=1.68.1\n")
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 root that declares no grpcVersion")
void grpcConventionRefusesAMissingVersion() {
buildFile('''
plugins {
id 'ca.grpc-platform-module'
}
''')
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains('ext.grpcVersion'),
"the refusal should name the property that is missing:\n${result.output}")
}
@Test
@DisplayName("the grpc convention refuses to be applied before dependency-management")
void grpcConventionRefusesAMissingDependencyManagement() {
// Without Spring's plugin there is no `dependencyManagement` block to import the BOM into.
// Skipping the import quietly is the failure mode this refuses.
buildFile('''
plugins {
id 'ca.grpc-platform-module'
}
''')
Files.writeString(projectDir.resolve('gradle.properties'), "grpcVersion=1.68.1\n")
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains('io.spring.dependency-management'),
"the refusal should name the plugin the import needs:\n${result.output}")
}
}
@@ -0,0 +1,87 @@
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.
*
* <p>"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.
*
* <p>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<String> 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))
}
}