From c963ea814c55f0b57b21c974b21dbb1852a6ce2b Mon Sep 17 00:00:00 2001 From: simtop Date: Sun, 9 Aug 2026 15:23:20 +0200 Subject: [PATCH 1/3] chore: measure the Kotlin daemon heap too, and fix the guard that hid the first attempt The Kotlin daemon is a second JVM that every module's Kotlin compilation runs through, and it was previously unset. Sweeping it needed its own mode, since reading the Gradle daemon's GC log while varying the Kotlin heap measures nothing. Measured twice with the candidate order reversed, because one pass cannot separate a heap effect from the build warming up as the sweep proceeds: heap 1st order reversed mean 1g 44.1s 40.7s 42.4s 1536m 37.9s 37.2s 37.6s 2g 32.6s 33.8s 33.2s <- chosen 3g 30.1s 38.0s 34.1s That control is load-bearing. 3g looked like the clear winner in the first order and mid-pack in the second - a position effect, not a heap one. 1g is slowest even from the last slot where warming helps most, so it is really too small. 2g is the only candidate fast in both orders. Only wall time is trusted here, and the file says so: the daemon exits the moment compilation ends and its GC log was not reliably captured, so its live-set and GC figures moved ~40% between identical runs. The guard was wrong twice, which is why the first sweep looked broken and the second looked fine while reporting the same false alarm. `pgrep -f P -l` matches nothing on macOS - options must precede the pattern. Corrected, it still found nothing, because polling after the build is the wrong moment for a daemon that has already exited. It now checks the artifact instead: if the daemon did not write the GC log we asked it for, the row is labelled NO-GC-LOG rather than reported as a confident zero. The script also now flags a monotonic wall-time trend across candidates and tells you to re-run reversed, which is how the position effect above was found. --- gradle.properties | 19 +++- scripts/measure-jvm-memory.sh | 186 ++++++++++++++++++++++++---------- 2 files changed, 147 insertions(+), 58 deletions(-) diff --git a/gradle.properties b/gradle.properties index f59bd1b..5cd5f19 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,8 +35,23 @@ org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1536m -XX:+HeapDumpOnOutOfMemoryE # The Kotlin compile daemon is a separate JVM that every module compiles through, and it was never # configured at all - so it ran on whatever the Kotlin Gradle plugin defaults to, which varies by -# plugin version. Setting it explicitly is worth more than the specific number. -kotlin.daemon.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=768m +# plugin version. Measured with MODE=kotlin, run TWICE with the candidate order reversed, because +# a single pass cannot tell a heap effect from the build progressively warming up: +# +# heap 1st order 2nd order (reversed) mean +# 1g 44.1s 40.7s 42.4s +# 1536m 37.9s 37.2s 37.6s +# 2g 32.6s 33.8s 33.2s <- chosen +# 3g 30.1s 38.0s 34.1s +# +# The control is what makes this readable. 1g is slowest even from the LAST slot, where warming +# helps most, so it is genuinely too small. 3g looks like the winner in one order and mid-pack in +# the other - that swing is position, not heap. 2g is the only candidate that is fast in both. +# +# Honest limitation: only wall time is trustworthy here. The Kotlin daemon exits as soon as +# compilation ends, and its GC log was not reliably captured, so live-set and GC-overhead figures +# for this daemon varied ~40% between identical runs and were not used to pick the value. +kotlin.daemon.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/scripts/measure-jvm-memory.sh b/scripts/measure-jvm-memory.sh index c06d99f..86d8e83 100644 --- a/scripts/measure-jvm-memory.sh +++ b/scripts/measure-jvm-memory.sh @@ -6,12 +6,18 @@ # Why this exists: the right heap size is not a property of the project, it is a property of the # machine and of what else is running on it. A 16 GB laptop with an emulator and a browser open # wants a different number from a 64 GB desktop, and the usual "4g and you're fine" advice assumes -# the build is the only thing running. So measure instead of guessing. +# the build is the only thing running. Measured here, 4g was *slower* than 3g for exactly that +# reason. So measure instead of guessing. # -# ./scripts/measure-jvm-memory.sh # sweep the default candidates -# ./scripts/measure-jvm-memory.sh 2g 3g 4g 6g # sweep your own +# ./scripts/measure-jvm-memory.sh # sweep the Gradle daemon heap +# ./scripts/measure-jvm-memory.sh 2g 3g 4g 6g # ... with your own candidates +# MODE=kotlin ./scripts/measure-jvm-memory.sh 1g 2g 3g # sweep the KOTLIN daemon heap # TASK=":app:assembleDebug" ./scripts/measure-jvm-memory.sh # +# The two daemons are separate JVMs and must be swept separately: Gradle runs the build, and a +# Kotlin compile daemon does the actual Kotlin compilation for every module. Settle the Gradle +# heap first, put it in gradle.properties, then sweep Kotlin with MODE=kotlin. +# # IMPORTANT: run it with your normal working set open - emulator, browser, IDE. The swap column is # the whole point, and it only means something under realistic memory pressure. Measuring on an # idle machine will tell you a larger heap is always fine, which is true right up until it isn't. @@ -20,39 +26,41 @@ # # live set Max heap still in use after a garbage collection. This is the real working set, # and it is the floor: a heap below it will thrash. Target roughly 1.5-2x. -# gc overhead Share of build wall time spent in GC pauses. Above ~5% the heap is too small; -# near 0% with a large heap means you are over-provisioned and could give the -# memory back to the emulator. +# gc overhead Share of build wall time spent in GC pauses. Compare candidates against each +# other rather than against an absolute - the measured build is a full recompile, +# so the percentages are a worst case, not a typical one. # swap delta Bytes the OS swapped out during the build. Anything above ~0 means the total # allocation is too high for this machine no matter how happy the JVM looks. This # is the ceiling, and it is the number the usual advice ignores. -# -# The recommendation is the smallest candidate that keeps GC overhead under the threshold without -# causing swap. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" +MODE="${MODE:-gradle}" TASK="${TASK:-assembleDebug}" METASPACE="${METASPACE:-1536m}" -KOTLIN_HEAP="${KOTLIN_HEAP:-1536m}" -GC_OVERHEAD_BUDGET="${GC_OVERHEAD_BUDGET:-5.0}" +GRADLE_HEAP="${GRADLE_HEAP:-3g}" # held fixed while sweeping Kotlin +KOTLIN_HEAP="${KOTLIN_HEAP:-1536m}" # held fixed while sweeping Gradle +KOTLIN_METASPACE="${KOTLIN_METASPACE:-768m}" WORK_DIR="$(mktemp -d)" trap 'rm -rf "$WORK_DIR"' EXIT +case "$MODE" in + gradle|kotlin) ;; + *) echo "MODE must be 'gradle' or 'kotlin', got '$MODE'" >&2; exit 1 ;; +esac + CANDIDATES=("$@") if [ ${#CANDIDATES[@]} -eq 0 ]; then - CANDIDATES=(2g 3g 4g 6g) + if [ "$MODE" = "kotlin" ]; then CANDIDATES=(1g 1536m 2g 3g); else CANDIDATES=(2g 3g 4g 6g); fi fi command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 1; } -# macOS reports swap through sysctl; Linux through /proc/vmstat. Returns bytes swapped out. swap_out_bytes() { if [[ "$(uname)" == "Darwin" ]]; then - # vm.swapusage: "total = 2048.00M used = 1234.50M free = 813.50M (encrypted)" sysctl -n vm.swapusage 2>/dev/null \ | awk '{for(i=1;i<=NF;i++) if($i=="used"){gsub(/M/,"",$(i+2)); printf "%.0f", $(i+2)*1048576}}' else @@ -62,37 +70,85 @@ swap_out_bytes() { human_mb() { python3 -c "import sys; print(f'{int(sys.argv[1])/1048576:,.0f} MB')" "$1"; } -echo "Measuring on $(uname -s), $(sysctl -n hw.ncpu 2>/dev/null || nproc) cores, \ -$(python3 -c "import sys;print(f'{int(sys.argv[1])/1073741824:.0f} GB RAM')" "$(sysctl -n hw.memsize 2>/dev/null || echo 0)")" -echo "Task: $TASK Candidates: ${CANDIDATES[*]}" +# `./gradlew --stop` stops Gradle daemons only. Kotlin compile daemons are separate JVMs that +# outlive it, so sweeping Kotlin heaps without this would keep measuring the first one started. +stop_all_daemons() { + ./gradlew --stop >/dev/null 2>&1 || true + pkill -f "org.jetbrains.kotlin.daemon.KotlinCompileDaemon" >/dev/null 2>&1 || true + sleep 1 +} + +# Guards against the whole run being meaningless. `kotlin.daemon.jvmargs` is read as a Gradle +# property, so it is passed with -P; if a future plugin version stops honouring that, the daemon +# would quietly keep its old heap and every row below would be identical. Read the requested -Xmx +# back off the running process instead of trusting that it applied. +# Guards against the whole run being meaningless. Two earlier versions of this check were wrong in +# instructive ways, so it now verifies the ARTIFACT rather than the process: +# +# 1. `pgrep -f PATTERN -l` matches nothing on macOS - options must precede the pattern - so it +# reported "no daemon" while one was running with exactly the requested heap. +# 2. Even corrected, polling after the build is the wrong moment: the Kotlin daemon exits once +# compilation finishes, so a correct pgrep still finds nothing. +# +# The thing we actually depend on is the daemon having accepted `-Xlog:gc:file=`. If that file +# exists and has content, the args were honoured and the numbers are real. If it does not, the row +# is meaningless and must be labelled so rather than quietly reported as zero. +kotlin_measurement_is_valid() { + local log="$1" + [ -s "$log" ] +} + +echo "Mode: $MODE Task: $TASK Candidates: ${CANDIDATES[*]}" +if [ "$MODE" = "kotlin" ]; then + echo "Gradle daemon held fixed at -Xmx$GRADLE_HEAP" +else + echo "Kotlin daemon held fixed at -Xmx$KOTLIN_HEAP" +fi echo "Keep your normal apps open - the swap column depends on it." echo RESULTS="$WORK_DIR/results.tsv" : > "$RESULTS" -for heap in "${CANDIDATES[@]}"; do - gc_log="$WORK_DIR/gc-$heap.log" - jvmargs="-Xmx$heap -XX:MaxMetaspaceSize=$METASPACE -Dfile.encoding=UTF-8 -Xlog:gc:file=$gc_log" +for candidate in "${CANDIDATES[@]}"; do + gradle_gc_log="$WORK_DIR/gradle-gc-$candidate.log" + kotlin_gc_log="$WORK_DIR/kotlin-gc-$candidate.log" - # A new daemon per candidate, or we would measure the previous heap setting. - ./gradlew --stop >/dev/null 2>&1 || true + if [ "$MODE" = "gradle" ]; then + gradle_heap="$candidate"; kotlin_heap="$KOTLIN_HEAP"; gc_log="$gradle_gc_log" + else + gradle_heap="$GRADLE_HEAP"; kotlin_heap="$candidate"; gc_log="$kotlin_gc_log" + fi + + gradle_args="-Xmx$gradle_heap -XX:MaxMetaspaceSize=$METASPACE -Dfile.encoding=UTF-8" + gradle_args="$gradle_args -Xlog:gc:file=$gradle_gc_log" + kotlin_args="-Xmx$kotlin_heap -XX:MaxMetaspaceSize=$KOTLIN_METASPACE" + kotlin_args="$kotlin_args -Xlog:gc:file=$kotlin_gc_log" - # Warm-up: fills the build cache and the daemon's JIT so the measured run is steady-state. - ./gradlew "$TASK" -Dorg.gradle.jvmargs="$jvmargs" \ - -Dkotlin.daemon.jvmargs="-Xmx$KOTLIN_HEAP" --console=plain >/dev/null 2>&1 || true + stop_all_daemons - # Note where the warm-up left off instead of deleting the log. The measured run reuses the same - # daemon (same jvmargs, by design - we want it JIT-warm), and that daemon holds the log file - # open: deleting it here would leave the daemon writing to an unlinked inode and every GC number - # below would come back as zero. Parsing from this offset isolates the measured window instead. - gc_offset=$(wc -c < "$gc_log" 2>/dev/null | tr -d ' ' || echo 0) + # Warm-up: fills the build cache and lets both daemons JIT, so the measured run is steady state. + ./gradlew "$TASK" -Dorg.gradle.jvmargs="$gradle_args" -Pkotlin.daemon.jvmargs="$kotlin_args" \ + --console=plain >/dev/null 2>&1 || true + + # Read from where the warm-up stopped rather than deleting the log: the daemon holds the file + # open, so deleting it leaves it writing to an unlinked inode and every GC figure reads as zero. + # In kotlin mode the log legitimately may not exist yet, so a missing file is offset 0, not an + # error worth printing. + gc_offset=0 + [ -f "$gc_log" ] && gc_offset=$(wc -c < "$gc_log" | tr -d ' ') swap_before="$(swap_out_bytes)" start_ns=$(python3 -c "import time;print(time.time_ns())") - ./gradlew "$TASK" --rerun-tasks -Dorg.gradle.jvmargs="$jvmargs" \ - -Dkotlin.daemon.jvmargs="-Xmx$KOTLIN_HEAP" --console=plain >"$WORK_DIR/build-$heap.log" 2>&1 \ - && status="ok" || status="FAILED" + ./gradlew "$TASK" --rerun-tasks -Dorg.gradle.jvmargs="$gradle_args" \ + -Pkotlin.daemon.jvmargs="$kotlin_args" --console=plain \ + >"$WORK_DIR/build-$candidate.log" 2>&1 && status="ok" || status="FAILED" + + # In kotlin mode the GC log is the proof the daemon honoured our args. No log, no measurement - + # say so instead of reporting a confident zero. + if [ "$status" = "ok" ] && [ "$MODE" = "kotlin" ] && ! kotlin_measurement_is_valid "$gc_log"; then + status="NO-GC-LOG" + fi end_ns=$(python3 -c "import time;print(time.time_ns())") swap_after="$(swap_out_bytes)" @@ -101,14 +157,14 @@ for heap in "${CANDIDATES[@]}"; do [ "$swap_delta" -lt 0 ] && swap_delta=0 # JDK 9+ unified GC logging: - # [12.345s][info][gc] GC(7) Pause Young (Normal) (G1 Evacuation Pause) 812M->233M(3072M) 9.8ms - # The value after "->" is heap still live once the collection finished; the max of those across - # the build is the live set. The trailing duration summed is total pause time. + # [12.345s][info][gc] GC(7) Pause Young (G1 Evacuation Pause) 812M->233M(3072M) 9.8ms + # The value after "->" is heap still live once the collection finished; the max across the build + # is the live set. The trailing durations summed are total pause time. read -r live_set_mb gc_ms gc_count <<<"$(python3 - "$gc_log" "$gc_offset" <<'PY' import re, sys try: with open(sys.argv[1], errors="replace") as handle: - handle.seek(int(sys.argv[2])) # skip everything the warm-up build logged + handle.seek(int(sys.argv[2])) text = handle.read() except OSError: print("0 0 0"); raise SystemExit @@ -121,14 +177,16 @@ PY gc_pct=$(python3 -c \ "import sys; print(f'{(int(sys.argv[1])/max(int(sys.argv[2]),1))*100:.1f}')" "$gc_ms" "$wall_ms") printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$heap" "$wall_ms" "$live_set_mb" "$gc_ms" "$gc_pct" "$swap_delta" "$status" >> "$RESULTS" + "$candidate" "$wall_ms" "$live_set_mb" "$gc_ms" "$gc_pct" "$swap_delta" "$status" >> "$RESULTS" - printf ' %-4s wall %6sms live set %5s MB gc %5sms (%s%%) swapped %s %s\n' \ - "$heap" "$wall_ms" "$live_set_mb" "$gc_ms" "$gc_pct" "$(human_mb "$swap_delta")" "$status" + printf ' %-6s wall %6sms live set %5s MB gc %5sms (%s%%) swapped %s %s\n' \ + "$candidate" "$wall_ms" "$live_set_mb" "$gc_ms" "$gc_pct" "$(human_mb "$swap_delta")" "$status" done +stop_all_daemons echo -python3 - "$RESULTS" "$GC_OVERHEAD_BUDGET" "$METASPACE" "$KOTLIN_HEAP" <<'PY' + +python3 - "$RESULTS" "$MODE" "$METASPACE" "$KOTLIN_METASPACE" <<'PY' import sys rows = [] @@ -137,37 +195,53 @@ for line in open(sys.argv[1]): rows.append(dict(heap=heap, wall=int(wall), live=int(live), gc_pct=float(gc_pct), swap=int(swap), status=status)) -budget, metaspace, kotlin_heap = float(sys.argv[2]), sys.argv[3], sys.argv[4] +mode, metaspace, kotlin_metaspace = sys.argv[2], sys.argv[3], sys.argv[4] ok = [r for r in rows if r["status"] == "ok"] if not ok: print("Every candidate failed - fix the build before tuning memory."); raise SystemExit(1) no_swap = [r for r in ok if r["swap"] == 0] -if not no_swap: +all_swapped = not no_swap +if all_swapped: print("WARNING: every candidate caused swapping. This machine is short on RAM for this") print("workload - close something, or accept that the build competes with the emulator.") no_swap = ok -healthy = [r for r in no_swap if r["gc_pct"] <= budget] or no_swap -pick = min(healthy, key=lambda r: (r["wall"], r["heap"])) +pick = min(no_swap, key=lambda r: (r["wall"], r["heap"])) live_max = max(r["live"] for r in ok) +if live_max == 0: + print("Live set read as 0 MB for every candidate - the GC log was not captured, so these") + print("numbers mean nothing. Check that the daemon accepted -Xlog:gc.") + raise SystemExit(1) + print(f"Live set peaked at {live_max} MB, so anything at or below that will thrash.") print(f"Rule of thumb puts the floor around {int(live_max*1.5)}-{live_max*2} MB.\n") print("Recommended, for this machine under this workload:\n") -print(f" org.gradle.jvmargs=-Xmx{pick['heap']} -XX:MaxMetaspaceSize={metaspace} " - f"-XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8") -print(f" kotlin.daemon.jvmargs=-Xmx{kotlin_heap} -XX:MaxMetaspaceSize=768m\n") -if all(r["swap"] > 0 for r in ok): - print("Chosen as the fastest candidate overall - none of them avoided swapping, so the") - print("swap criterion could not discriminate and wall time decided it.") +if mode == "gradle": + print(f" org.gradle.jvmargs=-Xmx{pick['heap']} -XX:MaxMetaspaceSize={metaspace} " + f"-XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8") + print("\nNow settle the Kotlin daemon separately:") + print(f" MODE=kotlin GRADLE_HEAP={pick['heap']} ./scripts/measure-jvm-memory.sh") +else: + print(f" kotlin.daemon.jvmargs=-Xmx{pick['heap']} -XX:MaxMetaspaceSize={kotlin_metaspace}") + +print() +if all_swapped: + print("Chosen as the fastest candidate overall - none avoided swapping, so that criterion") + print("could not discriminate and wall time decided it.") else: - print(f"Chosen as the fastest candidate that neither swapped nor spent more than " - f"{budget}% in GC.") + print("Chosen as the fastest candidate that did not swap.") print() print("Note on GC %: the measured build uses --rerun-tasks, i.e. a full recompile, which is the") -print("worst case rather than the typical one. Percentages well above the budget are expected") +print("worst case rather than the typical one. Percentages far above a few percent are expected") print("here; what matters is how they compare BETWEEN candidates, and where swap starts.") -print("Re-run KOTLIN_HEAP= to sweep the Kotlin daemon the same way once the Gradle") -print("daemon is settled - it is a separate JVM and every module compiles through it.") +print() +walls = [r["wall"] for r in ok] +if len(walls) > 2 and (walls == sorted(walls) or walls == sorted(walls, reverse=True)): + print("CAUTION: wall time moved monotonically across the candidates in the order they were") + print("run. That is what progressive warming (OS file cache, JIT) looks like as well as a real") + print("heap effect, and the two are indistinguishable from one pass. Re-run with the candidate") + print("order REVERSED: if the same heap wins again it is real, if the winner tracks position") + print("in the list it was warming and these numbers should be discarded.") PY From 26bd19bb2c37d17dbe40aa58a1c557d2d1efa64c Mon Sep 17 00:00:00 2001 From: simtop Date: Sun, 9 Aug 2026 15:43:20 +0200 Subject: [PATCH 2/3] fix: give the Konsist test worker enough heap to actually run A full `:konsist:test` died with "OutOfMemoryError thrown from the UncaughtExceptionHandler in thread Test worker". Gradle forks test workers with a 512 MB heap and org.gradle.jvmargs does not reach them - that sizes the daemon, not the worker. These rules parse every .kt file in the repo into an in-memory Konsist model, and since the module joined billionbeers.jvm.library it also carries jacoco instrumentation, which pushed it over. It hid unusually well, in both directions at once. A single-class run (`--tests "*OneRuleTest*"`) fits in the default heap and passes, which is how the recent rule work was verified. An unchanged repo leaves the task UP-TO-DATE, so `make konsist` returned green without executing anything. Only a full, non-cached run reaches the ceiling - and that is the run CI does. maxHeapSize = 2g. Verified by a --rerun-tasks run: 15 tests across 13 classes execute and pass, where before the worker died before reporting any. --- konsist/build.gradle.kts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/konsist/build.gradle.kts b/konsist/build.gradle.kts index d2d2e89..4fafc79 100644 --- a/konsist/build.gradle.kts +++ b/konsist/build.gradle.kts @@ -29,6 +29,17 @@ val filesUnderRules = // useJUnitPlatform() is not repeated here - billionbeers.jvm.library sets it for this tier. tasks.withType().configureEach { + // Gradle forks test workers with a 512 MB heap by default, and org.gradle.jvmargs does not + // reach them - it sizes the daemon, not the worker. These rules parse every .kt file in the repo + // into an in-memory Konsist model, and since this module joined billionbeers.jvm.library it also + // carries jacoco instrumentation, so 512 MB is not enough: a full run died with + // "OutOfMemoryError thrown from the UncaughtExceptionHandler in thread Test worker". + // + // It hid well. A single-class run (`--tests "*OneRuleTest*"`) fits in the default heap and + // passes, and an unchanged repo leaves the task UP-TO-DATE, so the gate looked green from both + // directions. Only a full, non-cached run reaches the ceiling - which is the run CI does. + maxHeapSize = "2g" + inputs .files(filesUnderRules) .withPropertyName("filesUnderArchitectureRules") From e84cdc677430db0315a1d98923a19f9c43cf6d80 Mon Sep 17 00:00:00 2001 From: simtop Date: Sun, 9 Aug 2026 16:06:45 +0200 Subject: [PATCH 3/3] fix: make the Konsist gate unable to pass without running its rules The architecture gate has now failed silently three separate ways. It never ran at all, because `make test` targets testDebugUnitTest and a pure-JVM module has no such task. It went UP-TO-DATE while the code it guards changed, because the rules read the repo off the filesystem where Gradle cannot see it. And most recently a full run died of OutOfMemory in the test worker while single-class runs kept passing, so it looked green from every angle anyone checked. Each of those got its own fix. This one addresses the shape rather than another symptom: after the run, every `*Test.kt` rule file on disk must have produced a JUnit result class. A rule that exists but never executes now fails the build instead of reading as coverage - which is precisely what AGENTS.md warns about twice and what nothing until now could detect. It is deliberately self-maintaining. The expected set is derived from the files present, not a hardcoded count, so adding a rule raises the bar automatically and the check cannot go stale. It also fails if it finds no rule files at all, since that would make both the check and the gate pass vacuously. A `--tests` filter skips it, because running one rule class is then the intent. Verified with a mutation probe: a rule file with no @Test methods fails the build with "These Konsist rules exist but did not run: GhostRuleTest. Ran 13 of 14", and removing it returns the build to green. A single-class --tests run stays green throughout. --- konsist/build.gradle.kts | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/konsist/build.gradle.kts b/konsist/build.gradle.kts index 4fafc79..49b9e11 100644 --- a/konsist/build.gradle.kts +++ b/konsist/build.gradle.kts @@ -45,3 +45,58 @@ tasks.withType().configureEach { .withPropertyName("filesUnderArchitectureRules") .withPathSensitivity(PathSensitivity.RELATIVE) } + +// Every rule file must actually have run. +// +// This gate has now failed silently three separate ways: it went UP-TO-DATE while the code it +// guards changed (fixed by declaring inputs above), it passed a single-class `--tests` run while a +// full run died of OutOfMemory (fixed by maxHeapSize above), and before either of those it simply +// never ran at all because `make test` targets testDebugUnitTest, which a pure-JVM module does not +// have (AGENTS.md ยง5). Each fix addressed one symptom. This addresses the shape. +// +// The check is self-maintaining on purpose: it counts `*Test.kt` rule files on disk and compares +// against the JUnit XML classes the run produced, so adding a rule raises the bar automatically and +// a hardcoded expected number can never go stale. A rule that is added but never executed - the +// exact thing AGENTS.md warns about twice - now fails the build instead of reading as coverage. +// +// Skipped when a `--tests` filter is supplied, because running one rule class is then correct. +val ruleSourceDir = layout.projectDirectory.dir("src/test/kotlin/com/simtop/konsist") +val hasTestFilter = + gradle.startParameter.taskRequests.any { request -> request.args.any { it == "--tests" } } + +tasks.named("test") { + val resultsDir = reports.junitXml.outputLocation + val ruleDir = ruleSourceDir + val filtered = hasTestFilter + + doLast { + if (filtered) return@doLast + + val expected = + ruleDir.asFile + .listFiles { file -> file.name.endsWith("Test.kt") } + ?.map { it.name.removeSuffix(".kt") } + ?.toSet() + .orEmpty() + val executed = + resultsDir + .get() + .asFile + .listFiles { file -> file.name.startsWith("TEST-") && file.extension == "xml" } + ?.map { it.name.removePrefix("TEST-").removeSuffix(".xml").substringAfterLast('.') } + ?.toSet() + .orEmpty() + + check(expected.isNotEmpty()) { + "Found no *Test.kt rule files in $ruleDir - the layout changed and this check, plus the " + + "whole architecture gate, would pass vacuously." + } + + val missing = expected - executed + check(missing.isEmpty()) { + "These Konsist rules exist but did not run: ${missing.sorted().joinToString(", ")}.\n" + + "A rule that never executes looks like coverage and enforces nothing. Ran " + + "${executed.size} of ${expected.size}." + } + } +}