Lección 8 de 8. Hasta ahora cada programa era un archivo suelto. Aquí armas un proyecto de verdad con Gradle, la herramienta que usan Android Studio y casi todos los proyectos de Kotlin: configuración en build.gradle.kts, código y pruebas separados, una biblioteca de Maven Central, pruebas con kotlin.test, y un programa instalable que lee un CSV y escribe JSON.
Del archivo suelto al proyecto
Hasta ahora cada programa del curso cabía en un archivo: kotlinc lo compilaba y java lo ejecutaba. Un programa real tiene varios archivos, usa bibliotecas de otros (como kotlinx.serialization o las corrutinas de la lección 7) y lleva pruebas que se ejecutan solas. Para eso está Gradle, la herramienta de build que usan Android Studio, IntelliJ IDEA y casi todos los proyectos de Kotlin: descarga las dependencias, compila, ejecuta las pruebas y empaqueta el resultado. Sus scripts se escriben, además, en Kotlin.
En esta lección armamos un proyecto pequeño pero completo: una herramienta que lee tus gastos de un CSV, los resume por categoría, guarda el resumen en JSON y tiene sus pruebas. Todo lo que ves aquí se compiló y ejecutó con Gradle 9.7.0 y Kotlin 2.4.21.
Instalar Gradle
Gradle se descarga de gradle.org/install (un zip que se descomprime y se añade al PATH), o con SDKMAN en Linux y macOS: sdk install gradle 9.7.0. Necesita un JDK, el mismo que usas para Kotlin. Comprueba la instalación:
gradle --version
salida------------------------------------------------------------ Gradle 9.7.0 ------------------------------------------------------------ Build time: 2026-08-06 14:07:35 UTC Revision: 3defbfc59d757b873d787b2261de5c7f8a00970a Kotlin: 2.4.0 Groovy: 4.0.32 Ant: Apache Ant(TM) version 1.10.17 compiled on April 6 2026 Launcher JVM: 25.0.5 (Eclipse Adoptium 25.0.5+8-LTS)
Ojo con la línea Kotlin: 2.4.0: es la versión que Gradle usa por dentro para leer sus scripts, no la de tu proyecto. La de tu código la fija el plugin de Kotlin en el build.gradle.kts, como verás enseguida. Usamos Gradle 9.7.0 porque es la versión más nueva que el plugin de Kotlin 2.4.21 soporta del todo, según la tabla de compatibilidad de kotlinlang.org; ya existen versiones posteriores de Gradle, pero conviene esperar a que el plugin las cubra.
./gradlew, que descarga la versión exacta de Gradle que pide el proyecto. Si creas el proyecto desde IntelliJ IDEA o Android Studio, el wrapper ya viene incluido. También existe gradle init, un asistente que genera un proyecto de ejemplo; aquí lo escribimos a mano para entender cada pieza.La estructura del proyecto
Gradle sigue una convención: el código va en src/main/kotlin, las pruebas en src/test/kotlin y la configuración en la raíz. Estos son todos los archivos que escribimos:
archivos del proyectobuild.gradle.kts gastos.csv settings.gradle.kts src/main/kotlin/Gasto.kt src/main/kotlin/Main.kt src/main/kotlin/Resumen.kt src/test/kotlin/MesVacioTest.kt src/test/kotlin/ResumenTest.kt
settings.gradle.kts y build.gradle.kts
settings.gradle.kts solo dice cómo se llama el proyecto (y, en proyectos grandes, de qué módulos se compone):
rootProject.name = "gastos"
build.gradle.kts es el corazón: qué plugins se usan, de dónde salen las dependencias y cómo se ejecuta todo.
plugins {
kotlin("jvm") version "2.4.21"
kotlin("plugin.serialization") version "2.4.21"
application
}
group = "com.ejemplo"
version = "1.0"
repositories {
mavenCentral()
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.12.0")
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(25)
}
application {
mainClass = "gastos.MainKt"
}
tasks.test {
useJUnitPlatform()
testLogging {
events("passed", "failed")
}
}- BloqueQué hace
pluginskotlin("jvm")compila Kotlin para la JVM en la versión 2.4.21;plugin.serializationgenera el código de@Serializable;applicationañadegradle runeinstallDist.repositoriesDónde buscar las bibliotecas: Maven Central, el mismo repositorio que usan Java y Android.dependenciesimplementationpara lo que usa el programa;testImplementationpara lo que solo usan las pruebas.kotlin { jvmToolchain(25) }Compila para Java 25 y avisa si el JDK instalado no es ese.applicationLa clase con elmain:gastos.MainKt, porque el archivo esMain.ktdel paquetegastos.tasks.testEjecutar las pruebas con JUnit 5 (JUnit Platform) y mostrar cada una, pase o falle.
El código
El CSV de ejemplo, en la raíz del proyecto:
fecha,categoria,importe 2026-09-28,Súper,854.20 2026-10-01,Transporte,120.00 2026-10-02,Súper,1236.75 2026-10-05,Ocio,450.00 2026-10-09,Transporte,95.50 2026-10-12,Súper,612.10 2026-10-15,Hogar,1899.00 2026-10-20,Ocio,289.90
Un gasto es una data class con su fecha, su categoría y su importe. La función que convierte una línea del CSV en un Gasto vive en el companion object, y require lanza una IllegalArgumentException con un mensaje claro si la línea no tiene tres columnas:
package gastos
import java.math.BigDecimal
import java.time.LocalDate
data class Gasto(val fecha: LocalDate, val categoria: String, val importe: BigDecimal) {
companion object {
// Una línea del CSV: fecha,categoria,importe
fun desdeLinea(linea: String): Gasto {
val partes = linea.split(",")
require(partes.size == 3) { "Se esperaban 3 columnas: $linea" }
return Gasto(
fecha = LocalDate.parse(partes[0]),
categoria = partes[1].trim(),
importe = BigDecimal(partes[2].trim()),
)
}
}
}
fun leerGastos(lineas: List<String>): List<Gasto> =
lineas
.drop(1) // la cabecera
.filter { it.isNotBlank() }
.map(Gasto::desdeLinea)El resumen agrupa por categoría con groupBy, suma con sumOf y ordena de mayor a menor. Son las operaciones de colecciones de la lección 6, y están separadas del main a propósito: así se pueden probar sin leer archivos ni imprimir nada.
package gastos
import java.math.BigDecimal
data class TotalCategoria(val categoria: String, val movimientos: Int, val total: BigDecimal)
fun porCategoria(gastos: List<Gasto>): List<TotalCategoria> =
gastos
.groupBy { it.categoria }
.map { (categoria, lista) -> TotalCategoria(categoria, lista.size, lista.sumOf { it.importe }) }
.sortedByDescending { it.total }
fun totalDelMes(gastos: List<Gasto>, anio: Int, mes: Int): BigDecimal =
gastos
.filter { it.fecha.year == anio && it.fecha.monthValue == mes }
.sumOf { it.importe }Y el programa: comprueba los argumentos, lee el archivo, imprime el resumen y lo guarda en JSON con kotlinx.serialization. exitProcess termina con un código de salida distinto de cero cuando algo falla, para que un script que lo llame se entere.
package gastos
import java.io.File
import kotlin.system.exitProcess
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class FilaResumen(val categoria: String, val movimientos: Int, val total: String)
private val json = Json { prettyPrint = true }
fun main(args: Array<String>) {
if (args.size != 1) {
System.err.println("Uso: gastos <archivo.csv>")
exitProcess(1)
}
val archivo = File(args[0])
if (!archivo.isFile) {
System.err.println("No encuentro el archivo ${archivo.path}")
exitProcess(2)
}
val gastos = leerGastos(archivo.readLines())
val resumen = porCategoria(gastos)
for (t in resumen) {
println("%-12s %3d %10.2f".format(t.categoria, t.movimientos, t.total))
}
println("%-12s %3d %10.2f".format("Total", gastos.size, gastos.sumOf { it.importe }))
val filas = resumen.map { FilaResumen(it.categoria, it.movimientos, it.total.toPlainString()) }
File("resumen.json").writeText(json.encodeToString(filas))
println("Guardado en resumen.json")
}Los importes son BigDecimal y no Double porque es dinero: Double no puede guardar exactamente 0.10, y los centavos acaban descuadrando. El JSON lleva el total como texto (toPlainString()), porque kotlinx.serialization no sabe convertir un BigDecimal sin un serializador propio, y pasarlo a Double perdería justo lo que queríamos conservar.
BigDecimal: su equals, que es lo que usa == en Kotlin, también compara la escala, es decir, el número de decimales. Por eso las pruebas escriben siempre los importes con dos decimales:import java.math.BigDecimal
fun main() {
println(BigDecimal("65.0") == BigDecimal("65.00"))
println(BigDecimal("65.0").compareTo(BigDecimal("65.00")) == 0)
}salidafalse true
Para comparar solo el valor, compareTo. Pasa lo mismo en Java; lo explicamos en BigDecimal en Java.
Las pruebas, con kotlin.test
kotlin.test es la biblioteca de pruebas de Kotlin: funciones como assertEquals o assertFailsWith y la anotación @Test. Por debajo usa JUnit 5, que es quien encuentra y ejecuta las pruebas. Cada prueba es una función de una clase en src/test/kotlin, en el mismo paquete que el código, así que ve sus funciones sin importar nada:
package gastos
import java.math.BigDecimal
import java.time.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class ResumenTest {
private val ejemplo = listOf(
Gasto(LocalDate.of(2026, 9, 30), "Ocio", BigDecimal("10.00")),
Gasto(LocalDate.of(2026, 10, 1), "Hogar", BigDecimal("50.00")),
Gasto(LocalDate.of(2026, 10, 3), "Ocio", BigDecimal("15.00")),
)
@Test
fun `lee una línea del CSV`() {
val gasto = Gasto.desdeLinea("2026-10-05,Ocio,450.00")
assertEquals(Gasto(LocalDate.of(2026, 10, 5), "Ocio", BigDecimal("450.00")), gasto)
}
@Test
fun `rechaza una línea incompleta`() {
val error = assertFailsWith<IllegalArgumentException> {
Gasto.desdeLinea("2026-10-05,Ocio")
}
assertEquals("Se esperaban 3 columnas: 2026-10-05,Ocio", error.message)
}
@Test
fun `suma por categoría y ordena de mayor a menor`() {
val resumen = porCategoria(ejemplo)
assertEquals(
listOf(
TotalCategoria("Hogar", 1, BigDecimal("50.00")),
TotalCategoria("Ocio", 2, BigDecimal("25.00")),
),
resumen,
)
}
@Test
fun `el total del mes solo cuenta ese mes`() {
assertEquals(BigDecimal("10.00"), totalDelMes(ejemplo, 2026, 9))
assertEquals(BigDecimal("65.00"), totalDelMes(ejemplo, 2026, 10))
}
}Los nombres entre comillas invertidas (`lee una línea del CSV`) son una costumbre muy de Kotlin en las pruebas: el nombre de la función es la frase que describe lo que se comprueba, con espacios y acentos, y así sale en los informes. Fuera de las pruebas no se usan. Las comparaciones son con assertEquals(esperado, real), en ese orden, y las data class facilitan mucho la vida: se comparan listas enteras de resúmenes de una sola vez porque su equals compara los datos.
Para ejecutarlas:
gradle test
gradle test> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED > Task :processResources NO-SOURCE > Task :processTestResources NO-SOURCE > Task :compileKotlin > Task :compileJava NO-SOURCE > Task :classes UP-TO-DATE > Task :jar > Task :compileTestKotlin > Task :compileTestJava NO-SOURCE > Task :testClasses UP-TO-DATE > Task :test MesVacioTest > un mes sin gastos suma cero() PASSED ResumenTest > suma por categoría y ordena de mayor a menor() PASSED ResumenTest > lee una línea del CSV() PASSED ResumenTest > rechaza una línea incompleta() PASSED ResumenTest > el total del mes solo cuenta ese mes() PASSED BUILD SUCCESSFUL 4 actionable tasks: 4 executed
Gradle compiló el programa (compileKotlin), luego las pruebas (compileTestKotlin) y las ejecutó (test). Gracias al bloque testLogging vemos cada prueba; sin él, cuando todo pasa, Gradle no dice nada más que BUILD SUCCESSFUL. El orden en que aparecen puede cambiar de una ejecución a otra: JUnit no garantiza ninguno, y una prueba nunca debe depender de que otra se haya ejecutado antes.
Un fallo a propósito
Las pruebas valen por lo que atrapan. Cambiemos en Resumen.kt el sortedByDescending por sortedBy, un descuido fácil, y volvamos a ejecutarlas:
gradle test> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :processResources NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :compileKotlin
> Task :compileJava NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar
> Task :compileTestKotlin
> Task :compileTestJava NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test FAILED
MesVacioTest > un mes sin gastos suma cero() PASSED
ResumenTest > suma por categoría y ordena de mayor a menor() FAILED
org.opentest4j.AssertionFailedError at ResumenTest.kt:33
ResumenTest > lee una línea del CSV() PASSED
ResumenTest > rechaza una línea incompleta() PASSED
ResumenTest > el total del mes solo cuenta ese mes() PASSED
5 tests completed, 1 failed
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: build/reports/tests/test/index.htmlLa prueba del orden falló y Gradle dice en qué línea (ResumenTest.kt:33, la del assertEquals); el detalle, con el valor esperado y el obtenido lado a lado, está en el informe HTML de build/reports/tests/test/index.html. Las demás pruebas siguen pasando, porque el orden no les afecta. Y el build entero se marca como fallido: en un servidor de integración continua, eso impide publicar la versión rota.
El error que te ahorra: pasar el tipo equivocado a una función
En Main.kt es fácil confundir las líneas del archivo con los gastos ya leídos y escribir porCategoria(archivo.readLines()). En un lenguaje sin tipos, el programa arrancaría y fallaría a la mitad. Aquí Gradle se detiene al compilar, con el mensaje de kotlinc y la línea exacta:
gradle build> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :processResources NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :compileKotlin FAILED
e: src/main/kotlin/Main.kt:25:32 Argument type mismatch: actual type is 'List<String>', but 'List<Gasto>' was expected.
[Incubating] Problems report is available at: build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':compileKotlin' (registered by plugin 'org.jetbrains.kotlin.jvm').
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.btapi.BuildToolsApiCompilationWork
> Kotlin compiler: ARGUMENT_TYPE_MISMATCH
Kotlin compiler error
Argument type mismatch: actual type is 'List<String>', but 'List<Gasto>' was expected.
Location: src/main/kotlin/Main.kt line 25Ejecutar el programa
El plugin application añade la tarea run. Los argumentos del programa van en --args, y -q (silencioso) quita las líneas de las tareas para que solo se vea lo que imprime el programa:
gradle run -q --args="gastos.csv"
salidaSúper 3 2703.05 Hogar 1 1899.00 Ocio 2 739.90 Transporte 2 215.50 Total 8 5557.45 Guardado en resumen.json
cat resumen.json
[
{
"categoria": "Súper",
"movimientos": 3,
"total": "2703.05"
},
{
"categoria": "Hogar",
"movimientos": 1,
"total": "1899.00"
},
{
"categoria": "Ocio",
"movimientos": 2,
"total": "739.90"
},
{
"categoria": "Transporte",
"movimientos": 2,
"total": "215.50"
}
]Sin argumentos, el programa escribe cómo se usa y termina con el código 1. Gradle lo trata como un fallo de la tarea run:
gradle run -qUso: gastos <archivo.csv> FAILURE: Build failed with an exception. * What went wrong: Execution failed for task ':run' (registered by plugin 'org.gradle.application'). > Process 'command '/opt/java/openjdk/bin/java'' finished with non-zero exit value 1
Todo junto: gradle build
gradle build compila, ejecuta las pruebas y empaqueta. Si una prueba falla, no hay paquete. Es lo que se ejecuta antes de publicar una versión o en cada pull request:
gradle build> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED > Task :processResources NO-SOURCE > Task :processTestResources NO-SOURCE > Task :compileKotlin > Task :compileJava NO-SOURCE > Task :classes UP-TO-DATE > Task :jar > Task :startScripts > Task :distTar > Task :distZip > Task :assemble > Task :compileTestKotlin > Task :compileTestJava NO-SOURCE > Task :testClasses UP-TO-DATE > Task :test ResumenTest > suma por categoría y ordena de mayor a menor() PASSED ResumenTest > lee una línea del CSV() PASSED ResumenTest > rechaza una línea incompleta() PASSED ResumenTest > el total del mes solo cuenta ese mes() PASSED MesVacioTest > un mes sin gastos suma cero() PASSED > Task :check > Task :build BUILD SUCCESSFUL 7 actionable tasks: 7 executed
Las tareas marcadas NO-SOURCE no tenían nada que hacer (no hay código Java ni recursos), y UP-TO-DATE significa que Gradle vio que nada había cambiado desde la última vez y se la saltó: es lo que hace rápidos los builds siguientes.
Distribuir el programa: installDist
Nadie quiere ejecutar un programa con gradle run. La tarea installDist prepara una carpeta lista para copiar a otra computadora: los .jar (el tuyo y sus dependencias, incluida la biblioteca estándar de Kotlin) y un script de arranque para Linux y macOS y otro para Windows.
gradle installDist
gradle installDist> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED > Task :compileKotlin UP-TO-DATE > Task :compileJava NO-SOURCE > Task :processResources NO-SOURCE > Task :classes UP-TO-DATE > Task :jar UP-TO-DATE > Task :startScripts UP-TO-DATE > Task :installDist BUILD SUCCESSFUL 4 actionable tasks: 1 executed, 3 up-to-date
find build/install -type fbuild/install/gastos/bin/gastos build/install/gastos/bin/gastos.bat build/install/gastos/lib/annotations-13.0.jar build/install/gastos/lib/gastos-1.0.jar build/install/gastos/lib/kotlin-stdlib-2.4.21.jar build/install/gastos/lib/kotlinx-serialization-core-jvm-1.12.0.jar build/install/gastos/lib/kotlinx-serialization-json-jvm-1.12.0.jar
build/install/gastos/bin/gastos gastos.csv
salidaSúper 3 2703.05 Hogar 1 1899.00 Ocio 2 739.90 Transporte 2 215.50 Total 8 5557.45 Guardado en resumen.json código de salida: 0
build/install/gastos/bin/gastos no-existe.csv
salidaNo encuentro el archivo no-existe.csv código de salida: 2
El programa instalado solo necesita una JVM en la computadora donde se ejecute; no hace falta ni Kotlin ni Gradle. Para entregar un solo archivo existen plugins que hacen un fat jar (todo en un .jar), y distZip, que ya se ejecutó dentro de gradle build, deja esta misma carpeta comprimida en build/distributions/.
El wrapper: que todos usen el mismo Gradle
Si cada quien compila con su versión de Gradle, tarde o temprano algo funciona en una computadora y en otra no. El wrapper lo evita: un script, gradlew, que descarga y usa la versión exacta que fija el proyecto.
gradle wrapper
gradle wrapper> Task :wrapper BUILD SUCCESSFUL 1 actionable task: 1 executed
lsbuild build.gradle.kts gastos.csv gradle gradlew gradlew.bat settings.gradle.kts src
gradle/wrapper/gradle-wrapper.propertiesdistributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists
A partir de aquí, en lugar de gradle test se escribe ./gradlew test (o gradlew test en Windows), y quien clone el proyecto no necesita instalar Gradle. gradlew, gradlew.bat y la carpeta gradle/ se suben al repositorio; la carpeta build/, no.
Si vienes de Java: es la misma herramienta
Gradle y Maven sirven igual para Java que para Kotlin, y un proyecto puede mezclar los dos lenguajes: basta con poner los .java en src/main/java. Si ya hiciste un JAR ejecutable en Java o pruebas con JUnit, aquí todo te resultará familiar: kotlin.test se ejecuta sobre el mismo JUnit 5.
Resumen de la lección
- ParaEscribeRecuerda
- Ejecutar las pruebas
gradle testuseJUnitPlatform()entasks.test - Ejecutar el programa
gradle run --args="..."-qpara ver solo su salida - Compilar, probar y empaquetar
gradle buildsi falla una prueba, no hay paquete - Una carpeta lista para copiar
gradle installDistenbuild/install/ - Fijar la versión de Gradle
gradle wrapperluego, siempre./gradlew - Una biblioteca
implementation("grupo:nombre:versión")testImplementationsi es solo para pruebas - Comparar dinero
BigDecimal==compara también la escala
Retos
Reto 1. Añade a Resumen.kt una función masCaro(gastos: List<Gasto>): Gasto? que devuelva el gasto más grande, o null si la lista está vacía.
Ver la solución
fun masCaro(gastos: List<Gasto>): Gasto? = gastos.maxByOrNull { it.importe }salidaGasto(fecha=2026-10-15, categoria=Hogar, importe=1899.00) null
maxByOrNull ya devuelve null con una lista vacía, y el tipo Gasto? obliga a quien la llame a tener en cuenta ese caso (lección 4). Para probarla aquí, metimos en un solo archivo la data class y una versión corta de leerGastos.
Reto 2. Escribe una prueba que compruebe que un mes sin gastos suma cero. Pista: ¿con qué BigDecimal hay que comparar, si sumOf de una lista vacía devuelve BigDecimal.ZERO?
Ver la solución
package gastos
import java.math.BigDecimal
import java.time.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
class MesVacioTest {
@Test
fun `un mes sin gastos suma cero`() {
val gastos = listOf(Gasto(LocalDate.of(2026, 10, 3), "Ocio", BigDecimal("15.00")))
assertEquals(BigDecimal.ZERO, totalDelMes(gastos, 2026, 11))
}
}Está en el proyecto, como un segundo archivo de pruebas, y es la quinta prueba que pasó en gradle test: MesVacioTest > un mes sin gastos suma cero() PASSED. Comparar con BigDecimal("0.00") fallaría por la escala: BigDecimal.ZERO tiene cero decimales.
Reto 3. Calcula el gasto promedio de cada categoría, redondeado a dos decimales. BigDecimal no divide como un Double: busca qué hace falta.
Ver la solución
import java.math.RoundingMode
fun promedios(gastos: List<Gasto>): Map<String, BigDecimal> =
gastos.groupBy { it.categoria }.mapValues { (_, lista) ->
lista.sumOf { it.importe }.divide(BigDecimal(lista.size), 2, RoundingMode.HALF_UP)
}salida{Súper=901.02, Ocio=450.00}Hay que decir cuántos decimales y cómo redondear: 2703.05 / 3 no tiene fin, y un divide sin esos dos datos lanza ArithmeticException. mapValues conserva las claves del mapa y transforma solo los valores.
Terminaste el curso
En ocho lecciones pasaste de un println a un proyecto con pruebas: variables y funciones, colecciones, null safety, clases y data classes, lambdas y funciones de extensión, corrutinas y Gradle. Para comprobar lo que aprendiste, haz el test de Kotlin; para lo que puede sorprenderte, las rarezas de Kotlin. Y para seguir practicando, en los ejemplos de Kotlin hay tareas resueltas, como un servidor web con Ktor, una base de datos SQLite o corrutinas en paralelo. Si tu meta es Android, el siguiente paso es Android Studio, que crea proyectos con esta misma estructura de Gradle.
El proyecto de esta lección se compiló, probó e instaló con Gradle 9.7.0 y Kotlin 2.4.21 sobre Temurin 25.0.5, en un contenedor sin red (Gradle en modo --offline, con las dependencias descargadas antes); los fragmentos sueltos, con kotlinc. Las salidas están copiadas de esa ejecución; de las de Gradle solo quitamos la duración del build y una sugerencia sobre la configuration cache.
