feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user