feat(ldd): реестр утверждений, evidence gate и runtime-проверки

This commit is contained in:
2026-10-27 21:46:07 +03:00
parent 0ac75bcd5c
commit 4130a1af2f
8 changed files with 1400 additions and 1 deletions

View 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()
}