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 { val added = mutableListOf>>() 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? = { git(*it.toTypedArray()) }): List { 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): List = listOf(Release(base, null)) + documents.scan(base) { version, document -> raised(version, document.raise) }.drop(1) .zip(documents, ::Release) fun changelog(releases: List): 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? { 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() 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 val dependencies = json["dependencies"] as List> val found = dependencies.flatMap { dependency -> val coordinates = (dependency["packages"] as? List>).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>).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(LicenseBundleNormalizer()) renderers = arrayOf(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("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("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("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 `Kt`, each with its nested ones. */ val changedClasses: Set by lazy { val base = git("merge-base", "HEAD", mutationBaseline.get())?.trim() ?: throw GradleException("no merge base with ${mutationBaseline.get()}") // a rename line reads "R\t\t"; 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 }