Files
claude-code 1c3d38060f
werkator build succeeded after 01:00
werkator/deploy build succeeded after 00:26
werkator/vulnerability-data build succeeded after 02:28
werkator/mutation build failed after 16:00
MatrixSource at a Mutation-CRAP of at most 15 (#92)
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-16 17:55:14 +02:00

546 lines
29 KiB
Kotlin

import com.github.jk1.license.filter.DependencyFilter
import com.github.jk1.license.filter.LicenseBundleNormalizer
import com.github.jk1.license.render.InventoryHtmlReportRenderer
import com.github.jk1.license.render.ReportRenderer
import java.net.URI
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("jvm") version "2.4.21"
application
// the required checks of doc/agentic-workflow.md; doc/risk-analysis.md sets their levels
id("org.owasp.dependencycheck") version "13.0.0"
id("com.github.jk1.dependency-license-report") version "3.1.4"
id("info.solidsoft.pitest") version "1.19.0"
id("de.javagil.werkcrappit") version "0.26.12"
}
group = "de.hoennig"
// main's version at its commit 74bd671, raised by each PR document merged after it, in the order of the merges;
// a branch adds its own PR document, so that no two pull requests change the same line (doc/agentic-workflow.md);
// the base changes only when a PR of the old rule merges with a version of its own: to that version at that merge
val releases = releasesOf("0.50.0", prDocuments(since = "74bd671", directory = "doc/prs"))
version = releases.last().version
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
// jlink cuts the bundle's runtime out of this JDK, native libraries included: Temurin is
// built against an old glibc and starts on Hostsharing's hosts (Werkator, PR#62)
vendor = JvmVendorSpec.ADOPTIUM
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_25
}
}
repositories {
mavenCentral()
maven { url = uri("https://git.javagil.de/api/packages/mi/maven") } // werkcrappit-pit
}
val jacksonVersion = "2.22.3"
val trixnityVersion = "5.9.2"
dependencies {
// no web framework: the JDK's HttpServer and HttpClient are enough for a localhost dashboard
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion")
implementation("org.xerial:sqlite-jdbc:3.53.4.0")
// Matrix with the user's own account, end-to-end encrypted rooms included (werkregie PR#8)
implementation("de.connect2x.trixnity:trixnity-client:$trixnityVersion")
implementation("de.connect2x.trixnity:trixnity-client-cryptodriver-vodozemac:$trixnityVersion")
implementation("de.connect2x.trixnity:trixnity-client-repository-exposed:$trixnityVersion")
// the client's store: Trixnity's Exposed repositories need R2DBC, which SQLite does not offer
implementation("io.r2dbc:r2dbc-h2:1.1.0.RELEASE")
// Trixnity's Ktor version; the Java engine uses the JDK's HttpClient, as the other sources do
implementation("io.ktor:ktor-client-java:3.5.2")
runtimeOnly("org.slf4j:slf4j-nop:2.0.20")
constraints {
// Exposed, under Trixnity, brings 2.3.20; Kotlin before 2.4.20 has CVE-2026-53914 (OWASP Dependency-Check)
implementation("org.jetbrains.kotlin:kotlin-reflect:2.4.21")
}
testImplementation("io.kotest:kotest-runner-junit5:6.2.5")
testImplementation("io.kotest:kotest-assertions-core:6.2.5")
}
application {
mainClass = "de.hoennig.werkregie.dashboard.MainKt"
applicationName = "werkregie-dashboard"
// sqlite-jdbc and vodozemac load their native libraries
applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED")
}
// ---- the footer links the repository the build comes from ----
/**
* The web page of a git remote: `git@host:owner/repo.git` or `https://user:token@host/owner/repo.git`
* become `https://host/owner/repo`; null for a local path or a host alias without a domain.
* Only these characters reach the page, so a remote can carry neither a token nor HTML into it.
*/
fun repositoryWebUrl(remote: String): String? {
val scp = Regex("""[\w.-]+@([\w-]+(?:\.[\w-]+)+):(?!/)(.+)""").matchEntire(remote.trim())
val uri: URI? = runCatching { URI(remote.trim()) }.getOrNull()
val (host, path) = when {
scp != null -> scp.groupValues[1] to scp.groupValues[2]
uri != null && uri.scheme in setOf("https", "http", "ssh") && uri.host != null -> uri.host to uri.path
else -> return null
}
val url = "https://$host/" + path.trim('/').removeSuffix(".git")
return url.takeIf { Regex("""https://[\w-]+(\.[\w-]+)+(/[\w.~-]+)+""").matches(it) }
}
val repositoryUrl: String = runCatching {
providers.exec {
commandLine("git", "remote", "get-url", "origin")
isIgnoreExitValue = true
}.standardOutput.asText.get()
}.getOrNull()?.let(::repositoryWebUrl) ?: "https://git.javagil.de/mi/werkregie"
val checkRepositoryWebUrl = tasks.register("checkRepositoryWebUrl") {
description = "Checks how the build turns a git remote into the repository link of the footer."
val wrong = mapOf(
"mih09-git@git.javagil.de:mi/werkregie.git" to "https://git.javagil.de/mi/werkregie",
"ssh://git@git.javagil.de:2222/mi/werkregie.git" to "https://git.javagil.de/mi/werkregie",
"https://claude-code:secret@git.javagil.de/mi/werkregie.git" to "https://git.javagil.de/mi/werkregie",
"https://github.com/mhoennig/werkregie/" to "https://github.com/mhoennig/werkregie",
"gitea:mi/werkregie.git" to null,
"/srv/git/werkregie.git" to null,
"file:///srv/git/werkregie.git" to null,
"https://git.javagil.de/mi/a\"b" to null,
).map { (remote, expected) -> Triple(remote, expected, repositoryWebUrl(remote)) }
.filter { (_, expected, actual) -> actual != expected }
.map { (remote, expected, actual) -> "repositoryWebUrl(\"$remote\") = $actual, expected $expected" }
doLast { check(wrong.isEmpty()) { wrong.joinToString("\n") } }
}
tasks.check { dependsOn(checkRepositoryWebUrl) }
// ---- the version and the changelog follow from the PR documents (werkregie PR#79) ----
enum class Raise { MAJOR, MINOR, PATCH }
/** A PR document as its merge added it; [merged] is the date of the merge, null on a branch. */
data class PrDocument(val path: String, val merged: String?, val raise: Raise, val releaseNotes: String?)
data class Release(val version: String, val document: PrDocument?)
/** Without a line `Version:` under its title a document raises the patch version, as one opened before this rule. */
fun prDocument(path: String, merged: String?, text: String): PrDocument {
val value = Regex("""^Version: *(\S*) *$""", RegexOption.MULTILINE).find(text.substringBefore("\n## "))?.groupValues?.get(1)
val raise = value?.let { Raise.entries.find { raise -> raise.name.equals(it, ignoreCase = true) } }
if (value != null && raise == null) throw GradleException("$path: Version: $value, expected major, minor or patch")
val notes = Regex("""^## Release Notes *\n(.*?)(?=^## |\z)""", setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL))
.find(text)?.groupValues?.get(1)?.trim()?.ifEmpty { null }
return PrDocument(path, merged, raise ?: Raise.PATCH, notes)
}
/** The PR documents that the output of `git log -p --format="commit %cs"` or `git diff -p` adds, in its order. */
fun addedPrDocuments(patch: String): List<PrDocument> {
val added = mutableListOf<Triple<String, String?, MutableList<String>>>()
var merged: String? = null
var inHunk = false
patch.lines().forEach { line ->
when {
line.startsWith("commit ") -> merged = line.removePrefix("commit ")
line.startsWith("diff --git ") -> inHunk = false
line.startsWith("+++ b/") && !inHunk -> added += Triple(line.removePrefix("+++ b/"), merged, mutableListOf())
line.startsWith("@@") -> inHunk = true
line.startsWith("+") && inHunk -> added.last().third += line.substring(1)
}
}
return added.filter { (path) -> Regex("""\d{4}-\d{2}-\d{2}-[^/]*\.md""").matches(path.substringAfterLast('/')) }
.map { (path, merged, lines) -> prDocument(path, merged, lines.joinToString("\n")) }
}
/**
* The PR documents that main added after the commit [since], in the order of the merges, from the added lines,
* so that a later change of a document changes no version; on a branch, then the ones it adds against main.
*/
fun prDocuments(since: String, directory: String, git: (List<String>) -> String? = { git(*it.toTypedArray()) }): List<PrDocument> {
val pathspec = ":(top)$directory"
// independent of the user's git configuration, which the parsing of the patch would otherwise depend on
val patch = listOf("-M", "--diff-filter=A", "-p", "--no-color", "--no-ext-diff", "--src-prefix=a/", "--dst-prefix=b/")
// without main, a branch would miss what main added since it branched off, and the version would be too low
git(listOf("rev-parse", "--verify", "-q", "origin/main"))
?: throw GradleException("no origin/main, which the version needs; git fetch origin main")
val onMain = git(listOf("merge-base", "--is-ancestor", "HEAD", "origin/main")) != null
val main = if (onMain) "HEAD" else "origin/main"
val log = git(listOf("-c", "core.quotePath=false", "log", "--first-parent", "--diff-merges=first-parent", "--reverse") +
patch + listOf("--format=commit %cs", "$since..$main", "--", pathspec))
?: throw GradleException("no history from $since to $main, which the version needs; a shallow clone?")
val merged = addedPrDocuments(log)
if (onMain) return merged
val own = git(listOf("-c", "core.quotePath=false", "diff") + patch + listOf("origin/main...HEAD", "--", pathspec))
return merged + addedPrDocuments(own.orEmpty()).filter { document -> merged.none { it.path == document.path } }
}
fun raised(version: String, raise: Raise): String {
val (major, minor, patch) = version.split('.').map(String::toInt)
return when (raise) {
Raise.MAJOR -> "${major + 1}.0.0"
Raise.MINOR -> "$major.${minor + 1}.0"
Raise.PATCH -> "$major.$minor.${patch + 1}"
}
}
/** The [base] version first, then one release per document. */
fun releasesOf(base: String, documents: List<PrDocument>): List<Release> =
listOf(Release(base, null)) + documents.scan(base) { version, document -> raised(version, document.raise) }.drop(1)
.zip(documents, ::Release)
fun changelog(releases: List<Release>): String = buildString {
append("# Changelog\n")
releases.reversed().forEach { (version, document) ->
if (document != null) {
append("\n## $version (${document.merged ?: "not merged"})\n\n${document.releaseNotes ?: "Internal change."}\n")
}
}
}
tasks.register("changelog") {
description = "Writes build/changelog/CHANGELOG.md from the release notes of the PR documents."
val text = changelog(releases)
val target = layout.buildDirectory.file("changelog/CHANGELOG.md")
inputs.property("text", text)
outputs.file(target)
doLast { target.get().asFile.writeText(text) }
}
val checkReleases = tasks.register("checkReleases") {
description = "Checks how the build derives the version and the changelog from the PR documents of a git history."
val repository = temporaryDir
doLast {
repository.deleteRecursively()
repository.mkdirs()
fun run(args: List<String>): String? {
val process = ProcessBuilder(listOf("git", "-c", "commit.gpgsign=false") + args).directory(repository).start()
val output = process.inputStream.bufferedReader().readText()
return output.takeIf { process.waitFor() == 0 }
}
fun git(vararg args: String) = checkNotNull(run(args.toList())) { "git ${args.joinToString(" ")} failed" }
fun commit(path: String, text: String) {
repository.resolve(path).apply { parentFile.mkdirs() }.writeText(text)
git("add", ".")
git("commit", "-qm", path)
}
val wrong = mutableListOf<String>()
fun expect(what: String, actual: Any, expected: Any) {
if (actual != expected) wrong += "$what: $actual, expected $expected"
}
git("init", "-q", "-b", "main")
git("config", "user.name", "check")
git("config", "user.email", "check@example.org")
commit("doc/prs/README.md", "# PR documents\n")
val since = git("rev-parse", "HEAD").trim()
expect("without origin/main", runCatching { prDocuments(since, "doc/prs", ::run) }.exceptionOrNull()?.message.orEmpty(),
"no origin/main, which the version needs; git fetch origin main")
fun versions() = releasesOf("0.48.0", prDocuments(since, "doc/prs", ::run)).map { it.version }
commit("doc/prs/2026-01-01-PR#1-a.md", "# PR#1: A\n\nVersion: minor\n\n## Problem\n\nA.\n\n## Release Notes\n\nA is new.\n\n## Open Questions\n\nNone.\n")
// a later change of a document changes no version, also in a commit that adds another one
repository.resolve("doc/prs/2026-01-01-PR#1-a.md").writeText("# PR#1: A\n\nVersion: major\n")
// a version line below the title does not count
commit("doc/prs/2026-01-02-PR#2-b.md", "# PR#2: B\n\nVersion: patch\n\n## Problem\n\nVersion: major\n")
git("update-ref", "refs/remotes/origin/main", "HEAD")
expect("main", versions(), listOf("0.48.0", "0.49.0", "0.49.1"))
git("checkout", "-qb", "claude-code/c")
commit("doc/prs/2026-01-03-PR#3-c.md", "# PR#3: C\n\nVersion: minor\n")
commit("doc/prs/2026-01-03-PR#4-d.md", "# PR#4: D, opened before the rule\n")
git("checkout", "-q", "main")
commit("doc/prs/2026-01-04-PR#5-e.md", "# PR#5: E\n\nVersion: patch\n")
git("update-ref", "refs/remotes/origin/main", "HEAD")
git("checkout", "-q", "claude-code/c")
expect("branch behind main", versions(), listOf("0.48.0", "0.49.0", "0.49.1", "0.49.2", "0.50.0", "0.50.1"))
val releases = releasesOf("0.48.0", prDocuments(since, "doc/prs", ::run))
expect("changelog", changelog(releases.take(3)).replace(Regex("""\(\d{4}-\d{2}-\d{2}\)"""), "(date)"),
"# Changelog\n\n## 0.49.1 (date)\n\nInternal change.\n\n## 0.49.0 (date)\n\nA is new.\n")
expect("changelog of a branch", changelog(releases).lines()[2], "## 0.50.1 (not merged)")
expect("wrong version line", runCatching { prDocument("x.md", null, "# X\n\nVersion: feature\n") }.exceptionOrNull()?.message.orEmpty(),
"x.md: Version: feature, expected major, minor or patch")
check(wrong.isEmpty()) { wrong.joinToString("\n") }
}
}
tasks.check { dependsOn(checkReleases) }
// the exact versions of what ships, in gradle.lockfile, so that a pull request's check knows what it changes;
// after a change of the dependencies: ./gradlew dependencies --configuration runtimeClasspath --write-locks
configurations.runtimeClasspath { resolutionStrategy.activateDependencyLocking() }
// criticality high in doc/risk-analysis.md: a known vulnerability from CVSS 4 fails the build
val failOnCvss = 4.0
// a pull request's check: only checkNewVulnerabilities decides, against the lockfile of the merge base with this ref
val vulnerabilityBaseline = providers.gradleProperty("vulnerabilityBaseline")
// not part of check, but Werkator's builds "dependencies" and "vulnerability-data":
// the first download of the NVD data exceeds the build's timeout
dependencyCheck {
failBuildOnCVSS = if (vulnerabilityBaseline.isPresent) 11.0f else failOnCvss.toFloat()
// the check only reads the NVD data; dependencyCheckUpdate loads it, in Werkator's nightly build "vulnerability-data"
autoUpdate = false
// what ships, not the test libraries
scanConfigurations = listOf("runtimeClasspath")
suppressionFile = file("config/dependency-check-suppressions.xml").path
formats = listOf("HTML", "JSON")
nvd {
// an empty key, e.g. a variable set to "", counts as none: the NVD refuses a key of length 0
apiKey = providers.environmentVariable("NVD_API_KEY").orElse(providers.gradleProperty("OWASP_API_KEY"))
.orNull?.takeIf { it.isNotBlank() }
// dependencyCheckUpdate skips the download when the data is younger
validForHours = 24
}
analyzers {
assemblyEnabled = false
// Sonatype's OSS Index answers only with credentials
ossIndex { enabled = false }
}
}
// its inputs are neither the resolved dependencies nor the NVD data, so Gradle would take an old report as up to date
tasks.named("dependencyCheckAnalyze") { outputs.upToDateWhen { false } }
/**
* Fails only on a vulnerability from [failOnCvss] in a dependency that the merge base with [vulnerabilityBaseline] lacks,
* i.e. one the pull request adds or changes; the vulnerabilities main has already are named, but fail nothing,
* so that a vulnerability found anew stops no unrelated work. Main itself is checked each night in full.
*/
val checkNewVulnerabilities = tasks.register("checkNewVulnerabilities") {
group = "verification"
description = "Fails on the vulnerabilities of the dependencies this branch adds or changes against -PvulnerabilityBaseline"
dependsOn("dependencyCheckAnalyze")
val report = layout.buildDirectory.file("reports/dependency-check/dependency-check-report.json")
val baseline = vulnerabilityBaseline
val projectDir = layout.projectDirectory.asFile
doLast {
fun git(vararg args: String): String? {
// stderr unread would fill its pipe and hold git; a failure shows in the exit code
val process = ProcessBuilder("git", *args).directory(projectDir)
.redirectError(ProcessBuilder.Redirect.DISCARD).start()
val out = process.inputStream.bufferedReader().readText()
return out.takeIf { process.waitFor() == 0 }
}
val ref = baseline.orNull
?: throw GradleException("checkNewVulnerabilities needs -PvulnerabilityBaseline, e.g. origin/main")
val base = git("merge-base", "HEAD", ref)?.trim() ?: throw GradleException("no merge base with $ref")
val prefix = git("rev-parse", "--show-prefix")?.trim().orEmpty()
// a base without a lockfile knows no dependency: then each vulnerable one counts as new, the safe side;
// only the branches from before the lockfile meet it
val lockfile = git("show", "$base:${prefix}gradle.lockfile")
if (lockfile == null) logger.warn("the merge base $base has no gradle.lockfile: each vulnerability counts as new")
val known = lockfile.orEmpty().lines()
.filter { it.isNotBlank() && !it.startsWith("#") && !it.startsWith("empty=") }
.map { it.substringBefore('=') }
.toSet()
fun score(vulnerability: Map<*, *>): Double =
listOf("cvssv4", "cvssv3", "cvssv2").mapNotNull { key ->
val cvss = vulnerability[key] as? Map<*, *> ?: return@mapNotNull null
val data = cvss["cvssData"] as? Map<*, *> ?: cvss
(data["baseScore"] ?: data["score"]) as? Number
}.maxOfOrNull { it.toDouble() } ?: 0.0
@Suppress("UNCHECKED_CAST")
val json = groovy.json.JsonSlurper().parse(report.get().asFile) as Map<String, Any?>
val dependencies = json["dependencies"] as List<Map<*, *>>
val found = dependencies.flatMap { dependency ->
val coordinates = (dependency["packages"] as? List<Map<*, *>>).orEmpty()
.mapNotNull { (it["id"] as? String)?.takeIf { id -> id.startsWith("pkg:maven/") } }
.map { it.removePrefix("pkg:maven/").replace('/', ':').replace('@', ':') }
val vulnerabilities = (dependency["vulnerabilities"] as? List<Map<*, *>>).orEmpty()
.filter { score(it) >= failOnCvss }
.map { "${it["name"]} (CVSS ${score(it)})" }
coordinates.flatMap { coordinate -> vulnerabilities.map { coordinate to it } }
}.distinct()
val (onMain, brought) = found.partition { (coordinate, _) -> coordinate in known }
onMain.forEach { (coordinate, vulnerability) -> logger.warn("already on main: $coordinate $vulnerability") }
if (brought.isNotEmpty()) {
throw GradleException("this branch brings vulnerable dependencies:\n" +
brought.joinToString("\n") { (coordinate, vulnerability) -> " $coordinate $vulnerability" })
}
logger.lifecycle(
"no vulnerability from CVSS $failOnCvss in a dependency this branch adds or changes; ${onMain.size} on main",
)
}
}
licenseReport {
// the plugin adds its own renderer for checkLicense to these arrays, so they need their base types
filters = arrayOf<DependencyFilter>(LicenseBundleNormalizer())
renderers = arrayOf<ReportRenderer>(InventoryHtmlReportRenderer("index.html", "werkregie-dashboard"))
excludeBoms = true
allowedLicensesFile = file("config/allowed-licenses.json")
}
// the license check takes seconds, so each build runs it; the plugin does not add it to check by itself
tasks.check { dependsOn("checkLicense") }
tasks.processResources {
// the server reports the version, the page's footer shows it, as Werkator's does
val version = project.version.toString()
inputs.property("version", version)
inputs.property("repositoryUrl", repositoryUrl)
filesMatching("werkregie.properties") { expand("version" to version) }
// the page compares its own version with the server's and loads itself again when they differ
filesMatching("static/index.html") { filter { it.replace("@repositoryUrl@", repositoryUrl).replace("@version@", version) } }
}
// ---- the runtime bundle for a host without Java: own JRE + jars + launcher (as Werkator's, ADR 0006) ----
val bundleRuntime = tasks.register<Exec>("bundleRuntime") {
description = "Cuts a minimal JRE for the runtime bundle with jlink."
val output = layout.buildDirectory.dir("bundle-runtime")
val jdk = javaToolchains.launcherFor(java.toolchain).map { it.metadata.installationPath }
outputs.dir(output)
doFirst {
delete(output)
executable = jdk.get().file("bin/jlink").asFile.absolutePath
}
args(
// jdeps: Jackson needs java.desktop (java.beans); jdk.crypto.ec for TLS to Werkator and Gitea;
// Trixnity, Ktor and coroutines need java.logging, java.management, java.instrument and jdk.unsupported
"--add-modules", "java.base,java.desktop,java.net.http,java.sql,jdk.httpserver,jdk.crypto.ec," +
"java.logging,java.management,java.instrument,jdk.unsupported",
"--strip-debug", "--no-header-files", "--no-man-pages", "--compress=zip-6",
"--output", output.get().asFile.absolutePath,
)
}
val runtimeBundle = tasks.register<Tar>("runtimeBundle") {
description = "Packs werkregie-dashboard-runtime-linux-x64.tar.gz: jre/, lib/, bin/."
group = "distribution"
archiveFileName = "werkregie-dashboard-runtime-linux-x64.tar.gz"
destinationDirectory = layout.buildDirectory.dir("distributions")
compression = Compression.GZIP
// reproducible archives normalize file modes since Gradle 9; jlink's executables must keep theirs
useFileSystemPermissions()
into("werkregie-dashboard") {
into("jre") { from(bundleRuntime) }
into("lib") { from(tasks.jar, configurations.runtimeClasspath) }
into("bin") {
from("src/bundle/bin")
filePermissions { unix("rwxr-xr-x") }
}
}
}
tasks.test {
useJUnitPlatform()
}
// ---- the JavaScript of the page: Node's own test runner, without npm packages (ADR 0002) ----
val jsTest = tasks.register<Exec>("jsTest") {
description = "Tests the JavaScript of the page with node --test, in the time zone Europe/Berlin."
group = "verification"
inputs.dir("src/main/resources/static")
inputs.dir("src/test/js")
val marker = layout.buildDirectory.file("js-test/passed")
outputs.file(marker)
// a fixed zone, so that the tests of the days, weeks and months do not depend on the machine
environment("TZ", "Europe/Berlin")
// the files one by one: Node resolves a glob only since version 21
val tests = fileTree("src/test/js") { include("**/*.test.js") }.files.map { it.path }.sorted()
commandLine(listOf("node", "--test") + tests)
doLast { marker.get().asFile.apply { parentFile.mkdirs() }.writeText("") }
}
tasks.check { dependsOn(jsTest) }
// ---- mutation tests: PIT mutates, werkcrappit measures the Mutation-CRAP of each method (doc/agentic-workflow.md) ----
dependencies {
pitest("io.kotest:kotest-extensions-pitest:6.2.5")
}
// each build runs pitestPartial on the classes this branch changes, the nightly build pitestFull on all of them;
// not PIT's incremental history: it keeps no result that a Kotest spec killed, since it does not recognize the spec's name
val partialMutation = gradle.startParameter.taskNames.any { it.endsWith("pitestPartial") }
val mutationBaseline = providers.gradleProperty("mutationBaseline").orElse("origin/main")
fun git(vararg args: String): String? {
val exec = providers.exec {
commandLine("git", *args)
isIgnoreExitValue = true
}
return exec.standardOutput.asText.get().takeIf { exec.result.get().exitValue == 0 }
}
/** Whether two versions of a Kotlin file differ only in their `package` and `import` lines, blank lines and indentation. */
fun movedOnly(before: String, after: String): Boolean {
fun code(source: String) = source.lines().map { it.trim() }
.filterNot { it.isEmpty() || it.startsWith("package ") || it.startsWith("import ") }
return code(before) == code(after)
}
val checkMovedOnly = tasks.register("checkMovedOnly") {
description = "Checks which changes of a moved Kotlin file the mutation tests of a branch ignore."
val wrong = listOf(
Triple("package a.b\n\nimport a.b.X\n\nclass C { fun f() = 1 }\n", "package c.d\n\nimport c.d.X\nimport c.d.Y\n\nclass C { fun f() = 1 }\n", true),
Triple("package a.b\n\nclass C\n", "package c.d\nclass C\n", true),
Triple("package a.b\n\nclass C { fun f() = 1 }\n", "package c.d\n\nclass C { fun f() = 2 }\n", false),
Triple("package a.b\n\nval x = \"a.b\"\n", "package c.d\n\nval x = \"c.d\"\n", false),
).filter { (before, after, expected) -> movedOnly(before, after) != expected }
.map { (before, after, expected) -> "movedOnly(${before.lines().last { it.isNotBlank() }}, …) is not $expected" }
doLast { check(wrong.isEmpty()) { wrong.joinToString("\n") } }
}
tasks.check { dependsOn(checkMovedOnly) }
/**
* The classes of the Kotlin files that this branch changes against the merge base with [mutationBaseline],
* uncommitted and new files included, files that only moved left out:
* per file its top-level classes and its file class `<Name>Kt`, each with its nested ones.
*/
val changedClasses: Set<String> by lazy {
val base = git("merge-base", "HEAD", mutationBaseline.get())?.trim()
?: throw GradleException("no merge base with ${mutationBaseline.get()}")
// a rename line reads "R<score>\t<old>\t<new>"; a file that only moved keeps its code (werkregie PR#90)
val changed = git("diff", "--name-status", "-M", "--diff-filter=d", "--relative", base, "--", "src/main/kotlin").orEmpty().lines()
.map { it.split('\t') }
.filterNot { it.size == 3 && it[0].startsWith("R") && movedOnly(git("show", "$base:./${it[1]}").orEmpty(), file(it[2]).readText()) }
.mapNotNull { it.lastOrNull() } +
git("ls-files", "--others", "--exclude-standard", "--", "src/main/kotlin").orEmpty().lines()
changed.filter { it.endsWith(".kt") }
.flatMap { path ->
val source = file(path).readText()
val pkg = Regex("""^package ([\w.]+)""", RegexOption.MULTILINE).find(source)?.groupValues?.get(1)
val names = Regex("""^(?:[a-z]+ )*(?:class|object|interface) (\w+)""", RegexOption.MULTILINE)
.findAll(source).map { it.groupValues[1] }.toList() + (file(path).nameWithoutExtension + "Kt")
names.flatMap { listOf(it, "$it\$*") }.map { if (pkg == null) it else "$pkg.$it" }
}
.toSet()
}
pitest {
pitestVersion = "1.30.0"
targetClasses = if (partialMutation) provider { changedClasses } else provider { setOf("de.hoennig.werkregie.*") }
targetTests = setOf("de.hoennig.werkregie.*")
// each Kotest run in a fresh minion takes longer than PIT's default of 4 s beyond a test's normal time (Werkator, PR#93)
timeoutConstInMillis = 20000
threads = providers.gradleProperty("pitestThreads").map { it.toInt() }.orElse(2)
jvmArgs = listOf("--enable-native-access=ALL-UNNAMED")
outputFormats = setOf("HTML", "XML")
timestampedReports = false
}
tasks.register("pitestPartial") {
group = "verification"
description = "Mutates the classes this branch changes against -PmutationBaseline, by default origin/main."
dependsOn("pitest")
}
tasks.register("pitestFull") {
group = "verification"
description = "Mutates all classes."
dependsOn("pitest")
}
listOf("pitest", "mutationCrapCheck").forEach { name ->
tasks.named(name) { onlyIf("the branch changes a Kotlin class") { !partialMutation || changedClasses.isNotEmpty() } }
}
werkcrappit {
// a branch fails on each method above the target for Kotlin, 15, in the classes it changes;
// all of main is held by a ratchet from the highest value, lowered with each refactoring until it reaches 15
maxMutationCrap = if (partialMutation) 15 else 272
}