feat(ldd): реестр утверждений, evidence gate и runtime-проверки
This commit is contained in:
171
buildSrc/src/main/kotlin/lms/evidence/EvidenceCheckTask.kt
Normal file
171
buildSrc/src/main/kotlin/lms/evidence/EvidenceCheckTask.kt
Normal file
@@ -0,0 +1,171 @@
|
||||
package lms.evidence
|
||||
|
||||
import groovy.json.JsonSlurper
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.ConfigurableFileCollection
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.InputFiles
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import java.time.Instant
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/**
|
||||
* LDD evidence gate: проверяет, что каждое активное P0-утверждение имеет
|
||||
* зелёное JUnit-свидетельство, а P1-пробелы видны в отчёте.
|
||||
*/
|
||||
abstract class EvidenceCheckTask : DefaultTask() {
|
||||
@get:InputFile
|
||||
abstract val claimsFile: RegularFileProperty
|
||||
|
||||
@get:InputFiles
|
||||
abstract val runtimeFiles: ConfigurableFileCollection
|
||||
|
||||
@get:InputFiles
|
||||
abstract val testResults: ConfigurableFileCollection
|
||||
|
||||
@get:OutputFile
|
||||
abstract val reportFile: RegularFileProperty
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@TaskAction
|
||||
fun check() {
|
||||
val claims = parseClaims(claimsFile.get().asFile)
|
||||
if (claims.isEmpty()) throw GradleException("LDD: claims.json не содержит claims")
|
||||
|
||||
data class Result(val key: String, val passed: Boolean)
|
||||
|
||||
val xmlFiles =
|
||||
testResults.files.flatMap { candidate ->
|
||||
if (candidate.isDirectory) {
|
||||
candidate
|
||||
.walkTopDown()
|
||||
.filter { it.isFile && it.name.startsWith("TEST-") && it.extension == "xml" }
|
||||
.toList()
|
||||
} else {
|
||||
listOf(candidate).filter { it.isFile && it.name.startsWith("TEST-") && it.extension == "xml" }
|
||||
}
|
||||
}
|
||||
val results = mutableMapOf<String, Result>()
|
||||
for (xmlFile in xmlFiles) {
|
||||
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xmlFile)
|
||||
val testCases = document.getElementsByTagName("testcase")
|
||||
for (index in 0 until testCases.length) {
|
||||
val node = testCases.item(index) as org.w3c.dom.Element
|
||||
val key = "${node.getAttribute("classname")}#${node.getAttribute("name")}"
|
||||
val broken =
|
||||
node.getElementsByTagName("failure").length > 0 ||
|
||||
node.getElementsByTagName("error").length > 0 ||
|
||||
node.getElementsByTagName("skipped").length > 0
|
||||
results[key] = Result(key, !broken)
|
||||
}
|
||||
}
|
||||
|
||||
val runtimeChecks = parseRuntimeChecks(runtimeFiles.files)
|
||||
val ids = HashSet<String>()
|
||||
val gaps = mutableListOf<String>()
|
||||
val p0Gaps = mutableListOf<String>()
|
||||
val rows = mutableListOf<String>()
|
||||
|
||||
for (claim in claims) {
|
||||
val id = claim["id"]?.toString().orEmpty().ifBlank { "?" }
|
||||
if (!ids.add(id)) throw GradleException("LDD: дублирующийся claim id $id")
|
||||
val criticality = claim["criticality"]?.toString().orEmpty().ifBlank { "P1" }
|
||||
val status = claim["status"]?.toString().orEmpty().ifBlank { "active" }
|
||||
val title = claim["title"]?.toString().orEmpty()
|
||||
val evidence = stringList(claim["evidence"])
|
||||
val runtime = stringList(claim["runtime"])
|
||||
|
||||
val missingTests = evidence.filter { results[it]?.passed != true }
|
||||
val failedTests = evidence.filter { results[it]?.passed == false }
|
||||
val runtimeMissing =
|
||||
runtime.filter { idValue ->
|
||||
val state = runtimeChecks[idValue]
|
||||
state == null || state != "passed"
|
||||
}
|
||||
val proven =
|
||||
status == "active" &&
|
||||
(evidence.isNotEmpty() || runtime.isNotEmpty()) &&
|
||||
missingTests.isEmpty() &&
|
||||
failedTests.isEmpty() &&
|
||||
(runtime.isEmpty() || runtimeMissing.isEmpty())
|
||||
val reasons =
|
||||
buildList {
|
||||
if (status != "active") add("status=$status")
|
||||
if (evidence.isEmpty() && runtime.isEmpty()) add("нет свидетельств")
|
||||
missingTests.forEach { add("не найден/не прошёл: $it") }
|
||||
failedTests.forEach { add("упал: $it") }
|
||||
if (runtime.isNotEmpty() && runtimeChecks.isEmpty()) add("runtime не запускался")
|
||||
runtimeMissing.forEach { add("runtime не прошёл: $it") }
|
||||
}
|
||||
if (!proven && status == "active" && criticality == "P0") {
|
||||
p0Gaps += "$id: ${reasons.joinToString("; ")}"
|
||||
}
|
||||
if (!proven) gaps += "$id ($criticality): ${reasons.joinToString("; ")}"
|
||||
rows +=
|
||||
"| $id | $criticality | ${if (proven) "✅ proven" else "⚠️ gap"} | $title | " +
|
||||
"${evidence.size} tests${if (runtime.isNotEmpty()) ", ${runtime.size} runtime" else ""} |"
|
||||
}
|
||||
|
||||
reportFile.get().asFile.parentFile.mkdirs()
|
||||
val report =
|
||||
buildString {
|
||||
appendLine("# LDD evidence report")
|
||||
appendLine()
|
||||
appendLine("Сформирован: ${Instant.now()}")
|
||||
appendLine("Утверждений: ${claims.size}; proven: ${claims.size - gaps.size}; gaps: ${gaps.size}.")
|
||||
appendLine()
|
||||
appendLine("| Claim | Уровень | Статус | Формулировка | Свидетельства |")
|
||||
appendLine("|---|---|---|---|---|")
|
||||
rows.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
if (gaps.isEmpty()) {
|
||||
appendLine("Все активные claims подтверждены.")
|
||||
} else {
|
||||
appendLine("## Пробелы")
|
||||
appendLine()
|
||||
gaps.forEach { appendLine("- $it") }
|
||||
}
|
||||
appendLine()
|
||||
appendLine("P0 без свидетельств: ${p0Gaps.size}.")
|
||||
}
|
||||
reportFile.get().asFile.writeText(report)
|
||||
|
||||
println(
|
||||
"LDD evidence: claims=${claims.size}, proven=${claims.size - gaps.size}, " +
|
||||
"gaps=${gaps.size}, p0Gaps=${p0Gaps.size}",
|
||||
)
|
||||
println("LDD report: ${reportFile.get().asFile.path}")
|
||||
if (p0Gaps.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"LDD P0 evidence gate failed:\n" + p0Gaps.joinToString("\n") { " - $it" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun parseClaims(file: java.io.File): List<Map<String, Any?>> {
|
||||
if (!file.isFile) throw GradleException("LDD: не найден ${file.path}")
|
||||
val root = JsonSlurper().parse(file) as Map<String, Any?>
|
||||
return (root["claims"] as? List<Map<String, Any?>>).orEmpty()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun parseRuntimeChecks(files: Set<java.io.File>): Map<String, String> {
|
||||
val checks = mutableMapOf<String, String>()
|
||||
for (file in files.filter { it.isFile }) {
|
||||
val root = JsonSlurper().parse(file) as Map<String, Any?>
|
||||
val list = (root["checks"] as? List<Map<String, Any?>>).orEmpty()
|
||||
for (check in list) {
|
||||
val id = check["id"]?.toString().orEmpty()
|
||||
if (id.isNotEmpty()) checks[id] = check["status"]?.toString().orEmpty()
|
||||
}
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
private fun stringList(value: Any?): List<String> =
|
||||
(value as? List<*>).orEmpty().mapNotNull { it?.toString()?.trim()?.takeIf(String::isNotEmpty) }.distinct()
|
||||
}
|
||||
Reference in New Issue
Block a user