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:
co-authored by
Claude Opus 5
parent
1535481794
commit
9bc2e75fe5
@@ -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. */
|
||||
|
||||
+74
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user