feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가

This commit is contained in:
DongHyeonka
2026-08-15 13:01:58 +09:00
parent ac874e49e6
commit 2f5d2fc219
909 changed files with 62510 additions and 6354 deletions
+51 -1
View File
@@ -10,7 +10,14 @@
// leaves of its own. The registry is extensible, so this is a deliberate trade-off, not a constraint.
// Whichever pattern the repository standardises on, the module identities, their allowed internal
// dependency edges and the Stable→Advanced isolation rule stay machine-checked through
// `GraphQlBuildModel` and `GraphQlModuleBoundaryTest`.
// `moduleboundary/GraphQlStableModule`, `moduleboundary/GraphQlAdvancedModule` (declaration) and
// the test-source `moduleboundary/GraphQlBuildModel` + `GraphQlModuleBoundaryTest` (the scan).
//
// The package is `moduleboundary`, not `build`: `src/.gitignore` carries an unanchored `build/`
// rule for Gradle output, which once swallowed this entire model — production code imported types
// that no fresh checkout contained. `verifyNoIgnoredSourcePackages` now blocks that class of
// mistake repository-wide, and the required-class check below blocks the other half of it, where
// the boundary test quietly disappears and the lane still reports green.
//
// Lanes (design §24, Stable plan Task 1 / Task 48):
// graphqlStableTest Stable platform unit + boundary tests (default lane)
@@ -23,6 +30,22 @@
ext.registerGraphQlPlatformTestLanes = { ->
String platformPackage = 'dev.caskeleton.adapter.inbound.graphql'
// Classes whose absence must fail the Stable lane instead of shrinking it. `failOnNoMatchingTests`
// only reacts to an empty lane, so deleting one boundary class out of four hundred tests is
// invisible to it — and losing exactly this class is how the platform shipped without an
// enforced module boundary in the first place.
List<String> requiredStableClasses = [
"${platformPackage}.moduleboundary.GraphQlModuleBoundaryTest".toString(),
]
// Resolved at configuration time: reaching for `rootProject` inside a task action is
// configuration-cache hostile and Gradle 10 removes it.
if (!rootProject.ext.has('readJUnitEvidence')) {
throw new GradleException(
'graphql-platform-conventions.gradle requires gradle/junit-evidence.gradle.')
}
Closure<Map<String, Object>> readJUnitEvidence = rootProject.ext.readJUnitEvidence
tasks.named('test') {
useJUnitPlatform {
excludeTags 'quarantine', 'graphql-performance'
@@ -48,6 +71,33 @@ ext.registerGraphQlPlatformTestLanes = { ->
failOnNoMatchingTests = true
}
failOnNoDiscoveredTests = true
reports.junitXml.required = true
reports.junitXml.outputLocation = layout.buildDirectory.dir('test-results/graphqlStableTest')
doFirst {
// Stale XML from a previous run would let a deleted class report as executed.
File staleResults = reports.junitXml.outputLocation.get().asFile
if (staleResults.exists() && !staleResults.deleteDir()) {
throw new GradleException(
"graphqlStableTest could not delete stale JUnit XML: ${staleResults}")
}
}
doLast {
Map<String, Object> evidence = readJUnitEvidence(
'graphqlStableTest', reports.junitXml.outputLocation.get().asFile)
Set<String> executed = evidence.executedClasses as Set<String>
List<String> missing = requiredStableClasses.findAll { String required ->
!executed.any { String actual ->
actual == required || actual.startsWith(required + '$')
}
}
if (!missing.isEmpty()) {
throw new GradleException(
'graphqlStableTest executed no test case for required boundary class(es): ' +
"${missing}. The lane is green only because the class is gone; restore it " +
'rather than removing it from requiredStableClasses.')
}
}
}
tasks.register('graphqlContractTest', Test) {
+117
View File
@@ -0,0 +1,117 @@
// NTF-022 — the notification platform's public type surface, pinned.
//
// Nearly every top-level type in the platform is public, which means the boundary between "the API
// other code may build on" and "an implementation detail that happens to be reachable" is not
// written down anywhere. Enforcing internal-by-default across several hundred types is a design
// change; pinning the surface is not, and it converts surface growth from something that happens
// silently into something a reviewer sees. A new public type is then a line in a diff.
Closure<String> renderNotificationApiSurface = { List<File> sourceRoots ->
List<String> types = []
sourceRoots.each { File root ->
if (!root.isDirectory()) {
return
}
root.eachFileRecurse { File file ->
if (!file.isFile() || !file.name.endsWith('.java') || file.name == 'package-info.java') {
return
}
String text = file.getText('UTF-8')
def packageMatcher = (text =~ /(?m)^package\s+([\w.]+);/)
if (!packageMatcher.find()) {
return
}
String packageName = packageMatcher.group(1)
// Only top-level public declarations count. A nested public type is reachable only
// through its owner, so it is part of that owner's surface, not a separate one.
def declarationMatcher =
(text =~ /(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+|static\s+)*(class|interface|record|enum|@interface)\s+(\w+)/)
while (declarationMatcher.find()) {
types << "${packageName}.${declarationMatcher.group(2)}".toString()
}
}
}
String header =
"# NTF-022 — public type surface of the notification platform.\n" +
"# Every top-level public type under the platform packages. Growth is a reviewed\n" +
"# change: ./gradlew updateNotificationApiSurface -PapproveNotificationApiChange\n"
header + (types.isEmpty() ? '' : types.toSorted().unique().join('\n') + '\n')
}
List<File> notificationApiSourceRoots = [
rootProject.file('application-core/src/main/java/dev/caskeleton/application/notification'),
rootProject.file(
'adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification'),
]
File notificationApiSnapshotFile =
rootProject.file('../docs/notification/api-surface-snapshot.txt')
boolean notificationApiUpdateApproved = project.hasProperty('approveNotificationApiChange')
tasks.register('verifyNotificationApiSurface') {
group = 'verification'
description = 'Fails without mutation when the notification platform public type surface drifts.'
inputs.files(notificationApiSourceRoots.findAll { it.isDirectory() })
inputs.property('updateApprovalRequested', notificationApiUpdateApproved)
doLast {
if (notificationApiUpdateApproved) {
throw new GradleException(
'verifyNotificationApiSurface is read-only; use updateNotificationApiSurface ' +
'-PapproveNotificationApiChange for an intentional update.')
}
String canonical = renderNotificationApiSurface(notificationApiSourceRoots)
List<String> canonicalTypes = canonical.readLines().findAll { !it.startsWith('#') }
if (canonicalTypes.isEmpty()) {
// An empty rendering means the source roots moved and the check would pass vacuously —
// the exact failure this file exists to prevent, so it is an error rather than a pass.
throw new GradleException(
'verifyNotificationApiSurface: found no public types under ' +
notificationApiSourceRoots.join(', ') +
'. The source roots moved; fix the paths rather than accepting an empty surface.')
}
if (!notificationApiSnapshotFile.isFile()) {
throw new GradleException(
"verifyNotificationApiSurface: missing committed baseline ${notificationApiSnapshotFile}")
}
List<String> committed =
notificationApiSnapshotFile.readLines('UTF-8').findAll { !it.startsWith('#') }
List<String> added = (canonicalTypes - committed).toSorted()
List<String> removed = (committed - canonicalTypes).toSorted()
if (!added.isEmpty() || !removed.isEmpty()) {
throw new GradleException(
'verifyNotificationApiSurface: the notification public type surface changed.\n' +
(added.isEmpty() ? '' : " added (${added.size()}):\n " + added.join('\n ') + '\n') +
(removed.isEmpty() ? '' : " removed (${removed.size()}):\n " + removed.join('\n ') + '\n') +
'A type added here is a type other code may now depend on forever. If that is intended:\n' +
' ./gradlew updateNotificationApiSurface -PapproveNotificationApiChange')
}
logger.lifecycle(
"verifyNotificationApiSurface: OK — ${canonicalTypes.size()} public types, unchanged.")
}
}
tasks.register('updateNotificationApiSurface') {
group = 'build setup'
description = 'Explicitly updates the committed notification public type surface after review.'
inputs.files(notificationApiSourceRoots.findAll { it.isDirectory() })
inputs.property('approved', notificationApiUpdateApproved)
outputs.file(notificationApiSnapshotFile)
outputs.upToDateWhen { false }
doLast {
if (!notificationApiUpdateApproved) {
throw new GradleException(
'updateNotificationApiSurface requires -PapproveNotificationApiChange')
}
String canonical = renderNotificationApiSurface(notificationApiSourceRoots)
if (!notificationApiSnapshotFile.parentFile.isDirectory()
&& !notificationApiSnapshotFile.parentFile.mkdirs()) {
throw new GradleException(
"updateNotificationApiSurface: failed to create ${notificationApiSnapshotFile.parentFile}")
}
notificationApiSnapshotFile.setText(canonical, 'UTF-8')
logger.lifecycle(
"updateNotificationApiSurface: wrote reviewed baseline ${notificationApiSnapshotFile}")
}
}
@@ -0,0 +1,93 @@
// NTF-025 — the configuration reference, the YAML tree and the env-key registry say one thing.
//
// The reference named three properties the binding never had (max-retry-concurrency,
// scheduler-poll-interval, callback-worker-concurrency) and omitted three it did, while
// application.yml carried no platform tree at all. A template user could only discover the settings
// by guessing environment variable names out of Boot's relaxed binding — which works, and gives no
// way to find out which profile, secret, callback and activation settings have to line up.
//
// Three artifacts, one fact. This task fails when they disagree in any direction.
tasks.register('verifyNotificationConfiguration') {
group = 'verification'
description = 'Fails when the notification configuration reference, application.yml and the env-key registry disagree.'
File applicationYaml =
rootProject.file('app-bootstrap/src/main/resources/application.yml')
File referenceDocument =
rootProject.file('../docs/notification/configuration-reference.md')
File environmentRegistry = rootProject.file('../docs/registries/env-keys.yaml')
inputs.files(applicationYaml, referenceDocument, environmentRegistry)
doLast {
[applicationYaml, referenceDocument, environmentRegistry].each { File required ->
if (!required.isFile()) {
throw new GradleException("verifyNotificationConfiguration: missing ${required}")
}
}
// Environment variables the platform tree in application.yml actually references. The tree
// is delimited by its own comment marker rather than by indentation counting, so a reformat
// does not silently empty this set.
String yaml = applicationYaml.getText('UTF-8')
int treeStart = yaml.indexOf(' notification:\n platform:')
if (treeStart < 0) {
throw new GradleException(
'verifyNotificationConfiguration: application.yml has no ' +
'ca-skeleton.notification.platform tree. Without it this check would ' +
'compare the reference against nothing and pass.')
}
int treeEnd = yaml.indexOf('\n persistence:', treeStart)
String tree = treeEnd < 0 ? yaml.substring(treeStart) : yaml.substring(treeStart, treeEnd)
Set<String> yamlVariables = new TreeSet<>()
def placeholder = (tree =~ /\$\{(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)(:[^}]*)?\}/)
while (placeholder.find()) {
yamlVariables << placeholder.group(1)
}
if (yamlVariables.isEmpty()) {
throw new GradleException(
'verifyNotificationConfiguration: the platform tree references no ' +
'APP_NOTIFICATION_PLATFORM_* variable, so nothing would be compared.')
}
// Variables the reference document promises.
Set<String> documentedVariables = new TreeSet<>()
def documented = (referenceDocument.getText('UTF-8') =~ /`(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)`/)
while (documented.find()) {
documentedVariables << documented.group(1)
}
// Variables the registry knows.
Set<String> registeredVariables = new TreeSet<>()
def registered = (environmentRegistry.getText('UTF-8') =~ /(?m)^\s*- name:\s*(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]+)\s*$/)
while (registered.find()) {
registeredVariables << registered.group(1)
}
List<String> problems = []
(yamlVariables - documentedVariables).each {
problems << "${it} is bound in application.yml and absent from the configuration reference"
}
(documentedVariables - yamlVariables).each {
problems << "${it} is documented in the configuration reference and bound nowhere — " +
'this is the shape of the three properties the reference promised and the binding never had'
}
(yamlVariables - registeredVariables).each {
problems << "${it} is bound in application.yml and unregistered in env-keys.yaml"
}
(registeredVariables - yamlVariables).each {
problems << "${it} is registered in env-keys.yaml and read by nothing"
}
if (!problems.isEmpty()) {
throw new GradleException(
'verifyNotificationConfiguration: the configuration surface disagrees with ' +
"itself.\n " + problems.join('\n ') +
'\nThe binding is the fact; the reference and the registry describe it.')
}
logger.lifecycle(
"verifyNotificationConfiguration: OK — ${yamlVariables.size()} platform settings, " +
'bound, documented and registered.')
}
}
+102
View File
@@ -0,0 +1,102 @@
// NTF-024 — a support grade may not outrun the evidence that backs it.
//
// The grade column in docs/notification/support-matrix.md is the strongest claim the platform makes
// about itself, and nothing connected it to anything executable. This task connects them: the
// manifest declares which claims each grade requires and which artifact proves each claim, and the
// task refuses a grade whose claims are not all satisfied by files that actually exist.
//
// It fails on the manifest as well as on the document. An "evidence" entry naming a test that has
// been renamed or deleted is exactly how a gate goes quiet without anyone noticing.
import groovy.json.JsonSlurper
File notificationEvidenceManifest =
rootProject.file('../docs/notification/evidence-manifest.json')
tasks.register('verifyNotificationEvidence') {
group = 'verification'
description = 'Fails when a notification support grade claims more than the executable evidence proves.'
inputs.file(notificationEvidenceManifest)
doLast {
if (!notificationEvidenceManifest.isFile()) {
throw new GradleException(
"verifyNotificationEvidence: missing manifest ${notificationEvidenceManifest}")
}
def manifest = new JsonSlurper().parse(notificationEvidenceManifest)
File repositoryDirectory = rootProject.projectDir.parentFile
File matrixFile = new File(repositoryDirectory, manifest.matrixDocument as String)
if (!matrixFile.isFile()) {
throw new GradleException(
"verifyNotificationEvidence: missing support matrix ${matrixFile}")
}
List<String> problems = []
// 1. Every claim that says it is satisfied must name artifacts that exist.
Set<String> satisfied = [] as Set
manifest.claims.each { String claim, Object declaration ->
List<String> evidence = (declaration.evidence ?: []) as List<String>
if (declaration.status == 'satisfied') {
if (evidence.isEmpty()) {
problems << "claim '${claim}' is marked satisfied with no evidence at all"
return
}
List<String> missing = evidence.findAll { String path ->
!new File(rootProject.projectDir, path).isFile()
}
if (missing.isEmpty()) {
satisfied << claim
} else {
problems << "claim '${claim}' names evidence that does not exist: ${missing.join(', ')}"
}
} else if (!evidence.isEmpty()) {
problems << "claim '${claim}' is not satisfied but names evidence; " +
'either the status or the evidence list is wrong'
}
}
// 2. Every grade the matrix uses must be one the manifest defines, and every claim that
// grade requires must be satisfied.
def gradeCellPattern = ~/^\|[^|]*\|[^|]*\|\s*([^|]+?)\s*\|/
Set<String> knownGrades = manifest.grades.keySet() as Set
matrixFile.readLines('UTF-8').eachWithIndex { String line, int index ->
def matcher = gradeCellPattern.matcher(line)
if (!matcher.find()) {
return
}
String grade = matcher.group(1).trim()
if (grade == 'Grade' || grade.startsWith('-') || grade.isEmpty()) {
return
}
if (!knownGrades.contains(grade)) {
problems << "${matrixFile.name}:${index + 1} uses grade '${grade}', " +
"which the evidence manifest does not define"
return
}
List<String> required = (manifest.grades[grade] ?: []) as List<String>
List<String> unmet = required.findAll { !satisfied.contains(it) }
if (!unmet.isEmpty()) {
problems << "${matrixFile.name}:${index + 1} claims '${grade}', which requires " +
"${unmet.join(', ')} — not proven by any artifact in the manifest"
}
}
if (knownGrades.isEmpty()) {
// A manifest with no grades would let every document line pass unexamined.
throw new GradleException(
'verifyNotificationEvidence: the manifest defines no grades, so the check ' +
'would pass whatever the support matrix claims.')
}
if (!problems.isEmpty()) {
throw new GradleException(
'verifyNotificationEvidence: the support matrix claims more than the evidence ' +
"proves.\n " + problems.join('\n ') +
'\nEither add the artifact and mark the claim satisfied, or lower the grade. ' +
'A grade is a promise about production behaviour; the manifest is where it is kept.')
}
logger.lifecycle(
"verifyNotificationEvidence: OK — ${satisfied.size()} claims proven, " +
"every grade in ${matrixFile.name} is backed.")
}
}