refactor(jetbrains): move buildSrc to build-tasks composite build

Extract all custom Gradle task classes (FixGeneratedApiTask,
PrepareLocalCliTask, CheckCliTask) into a build-tasks/ composite build
exposed via includeBuild. This keeps backend/build.gradle.kts purely
declarative and co-locates build logic in its own compilable module.
This commit is contained in:
kirillk
2026-04-12 18:10:33 -04:00
parent 0a4aa192aa
commit f7f6a270f5
9 changed files with 258 additions and 227 deletions
+15 -220
View File
@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.openapi.generator)
id("ai.kilocode.jetbrains.build-tasks")
}
kotlin {
@@ -18,6 +19,8 @@ sourceSets {
}
}
// ── OpenAPI client generation ───────────────────────────────────────
openApiGenerate {
generatorName.set("kotlin")
library.set("jvm-okhttp4")
@@ -34,18 +37,15 @@ openApiGenerate {
"sourceFolder" to "src/main/kotlin",
"enumPropertyNaming" to "UPPERCASE",
))
// Remap schema "File" so the generated class is not named java.io.File
modelNameMappings.set(mapOf(
"File" to "DiffFileInfo",
))
// Map empty anyOf references to kotlin.Any; bare numbers to Double
typeMappings.set(mapOf(
"AnyOfLessThanGreaterThan" to "kotlin.Any",
"anyOf<>" to "kotlin.Any",
"number" to "kotlin.Double",
"decimal" to "kotlin.Double",
))
// Normalise OpenAPI 3.1 → 3.0-compatible patterns
openapiNormalizer.set(mapOf(
"SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING" to "true",
"SIMPLIFY_ONEOF_ANYOF" to "true",
@@ -56,202 +56,17 @@ openApiGenerate {
generateModelDocumentation.set(false)
}
// Fix openapi-generator codegen bugs in generated Kotlin sources.
//
// 1) Boolean const enum fix:
// `const: true`/`const: false` fields produce broken single-value enums.
// Fix: replace with kotlin.Boolean, remove the enum class.
//
// 2) Double-parentheses on HashMap-extending data classes:
// `data class Foo(...) : HashMap<String, Any>()()` — extra `()`.
// Fix: remove the trailing `()`.
//
// 7) Empty anyOf wrapper classes:
// anyOf unions of heterogeneous types (e.g. string enum | object) generate
// empty `class Foo () {}` that can't deserialize primitives.
// Fix: replace references with JsonElement, delete the empty class files.
//
// 3) Private Double constructor:
// `kotlin.Double("5000")` — Double has no public String constructor.
// Fix: convert to `5000.0` double literal.
//
// 4) Missing @Contextual on bare kotlin.Any fields:
// kotlinx.serialization can't serialize `Any` without @Contextual.
// Fix: add @Contextual annotation where missing.
//
// 5) Nullable body access in ApiClient.kt:
// `response.body` is nullable in OkHttp but generated code dereferences
// it without safe calls. Fix: replace `body.` with `body?.`.
val fixGeneratedApi by tasks.registering {
val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
dependsOn("openApiGenerate")
val dir = generatedApi
doLast {
// ── Fix 7: empty anyOf wrapper classes → JsonElement ────────
// These are anyOf unions (e.g. string enum | object) that the
// codegen produces as empty classes. Replace all references with
// kotlinx.serialization.json.JsonElement and delete the files.
val modelDir = dir.get().file("ai/kilocode/jetbrains/api/model").asFile
val emptyWrappers = modelDir.listFiles()
?.filter { it.extension == "kt" }
?.filter { f ->
val text = f.readText()
// Match: non-data `class Foo ()` with no `val` properties
text.contains(Regex("""\nclass \w+ \(\n\n\)""")) && !text.contains("val ")
}
?.map { it.nameWithoutExtension }
?: emptyList()
if (emptyWrappers.isNotEmpty()) {
// Delete the empty wrapper class files
for (name in emptyWrappers) {
val f = File(modelDir, "$name.kt")
if (f.exists()) f.delete()
}
// Replace references in all generated files
dir.get().asFile.walkTopDown().filter { it.extension == "kt" }.forEach { file ->
var text = file.readText()
var changed = false
for (name in emptyWrappers) {
if (!text.contains(name)) continue
// Remove import lines FIRST (before replacing class names)
text = text.replace(Regex("""import [^\n]*\.$name\n"""), "")
// Replace type references in code
text = text.replace(Regex("""\b$name\b"""), "kotlinx.serialization.json.JsonElement")
changed = true
}
if (changed) file.writeText(text)
}
}
dir.get().asFile.walkTopDown().filter { it.extension == "kt" }.forEach { file ->
var text = file.readText()
var changed = false
// ── Fix 1: boolean const enums ──────────────────────────
val enumDecl = Regex(
"""enum class (\w+)\(val value: kotlin\.Boolean\)"""
)
val names = enumDecl.findAll(text).map { it.groupValues[1] }.toList()
for (name in names) {
text = text.replace(Regex("""(val \w+:\s*)\w+\.$name""")) { m ->
"${m.groupValues[1]}kotlin.Boolean"
}
text = text.replace(Regex(
"""\n\s*@Serializable\s*\n\s*enum class $name\(val value: kotlin\.Boolean\)\s*\{[^}]*\}"""
), "")
text = text.replace(Regex(
"""\n\s*/\*\*\s*\n(\s*\*[^\n]*\n)*\s*\*/\s*(?=\n\s*\n)"""
), "")
changed = true
}
// ── Fix 2: double-parentheses `HashMap<...>()()` ────────
if (text.contains("()()")) {
text = text.replace("()()", "()")
changed = true
}
// ── Fix 3: `kotlin.Double("...")` → double literal ──────
val doubleCtorPattern = Regex("""kotlin\.Double\("(\d+(?:\.\d+)?)"\)""")
if (doubleCtorPattern.containsMatchIn(text)) {
text = doubleCtorPattern.replace(text) { m ->
val num = m.groupValues[1]
if (num.contains(".")) num else "$num.0"
}
changed = true
}
// ── Fix 4: add @Contextual to bare `kotlin.Any` usages ──
// kotlinx.serialization cannot handle kotlin.Any without @Contextual.
// Only patch @Serializable data class files (which import Contextual).
// Skip enum files, API client files, and infrastructure.
if (text.contains("kotlin.Any") &&
text.contains("import kotlinx.serialization.Contextual") &&
text.contains("@Serializable") &&
text.contains("data class")
) {
// Add @Contextual before kotlin.Any in val/field type positions
// Covers: `val foo: kotlin.Any`, `Map<String, kotlin.Any>`, etc.
text = text.replace(
Regex("""(?<!@Contextual )kotlin\.Any"""),
"@Contextual kotlin.Any"
)
changed = true
}
// ── Fix 5: nullable body in ApiClient ───────────────────
// OkHttp's response.body is nullable but the generated code
// dereferences it without null checks in two places:
// a) responseBody() — add null guard so body smart-casts
// b) request() error branches — `it.body.string()` → `it.body?.string()`
if (file.name == "ApiClient.kt") {
val guard = "val body = response.body"
if (text.contains(guard) && !text.contains("if (body == null) return null")) {
text = text.replace(
guard,
"$guard\n if (body == null) return null"
)
// After the null guard, remove safe calls that cause
// InputStream? issues (body is smart-cast non-null).
text = text.replace("body?.", "body.")
changed = true
}
// Fix `it.body.string()` in error branches of request()
if (text.contains("it.body.string()")) {
text = text.replace("it.body.string()", "it.body?.string()")
changed = true
}
}
// ── Fix 6: register AnySerializer in Serializer.kt ──────
// kotlinx.serialization needs a contextual serializer for Any
// that delegates to JsonElement for dynamic JSON values.
if (file.name == "Serializer.kt") {
if (!text.contains("AnySerializer")) {
// Add import + serializer object at end of file
text = text.replace(
"import kotlinx.serialization.modules.SerializersModuleBuilder",
"import kotlinx.serialization.modules.SerializersModuleBuilder\n" +
"import kotlinx.serialization.KSerializer\n" +
"import kotlinx.serialization.descriptors.SerialDescriptor\n" +
"import kotlinx.serialization.encoding.Decoder\n" +
"import kotlinx.serialization.encoding.Encoder\n" +
"import kotlinx.serialization.json.JsonDecoder\n" +
"import kotlinx.serialization.json.JsonEncoder\n" +
"import kotlinx.serialization.json.JsonElement"
)
// Register it in the SerializersModule
text = text.replace(
"contextual(StringBuilder::class, StringBuilderAdapter)",
"contextual(StringBuilder::class, StringBuilderAdapter)\n" +
" contextual(Any::class, AnySerializer)"
)
// Append the serializer object before the closing of Serializer
text = text.trimEnd() + "\n\n" +
"internal object AnySerializer : KSerializer<Any> {\n" +
" private val delegate = JsonElement.serializer()\n" +
" override val descriptor: SerialDescriptor = delegate.descriptor\n" +
" override fun serialize(encoder: Encoder, value: Any) {\n" +
" val json = (encoder as JsonEncoder).json\n" +
" encoder.encodeSerializableValue(delegate, json.encodeToJsonElement(delegate, value as? JsonElement ?: return))\n" +
" }\n" +
" override fun deserialize(decoder: Decoder): Any {\n" +
" return (decoder as JsonDecoder).decodeJsonElement()\n" +
" }\n" +
"}\n"
changed = true
}
}
if (changed) file.writeText(text)
}
}
generated.set(generatedApi)
}
tasks.named("compileKotlin") {
dependsOn(fixGeneratedApi)
}
// ── CLI binary packaging ────────────────────────────────────────────
val cliDir = layout.buildDirectory.dir("generated/cli/cli")
val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false)
@@ -286,43 +101,23 @@ val localCli by tasks.registering(PrepareLocalCliTask::class) {
exe.set(platform.map { if (it.startsWith("windows")) "kilo.exe" else "kilo" })
}
val checkCli by tasks.registering {
val prod = production
val checkCli by tasks.registering(CheckCliTask::class) {
description = "Verify CLI binaries exist before building"
val dir = cliDir.map { it.asFile }
val prod = production.get()
val platforms = requiredPlatforms.toList()
if (!prod) {
dir.set(cliDir)
this.production.set(prod)
platforms.set(requiredPlatforms)
if (!prod.get()) {
dependsOn(localCli)
}
doLast {
val resolved = dir.get()
if (!resolved.exists() || resolved.listFiles()?.isEmpty() != false) {
throw GradleException(
"CLI binaries not found at ${resolved.absolutePath}.\n" +
"Run 'bun run build' from packages/kilo-jetbrains/ to build CLI and plugin together."
)
}
if (prod) {
val missing = platforms.filter { platform ->
val dir = File(resolved, platform)
val exe = if (platform.startsWith("windows")) "kilo.exe" else "kilo"
!File(dir, exe).exists()
}
if (missing.isNotEmpty()) {
throw GradleException(
"Production build requires all platform CLI binaries.\n" +
"Missing: ${missing.joinToString(", ")}\n" +
"Run 'bun run build:production' to build all platforms."
)
}
}
}
}
tasks.processResources {
dependsOn(checkCli)
}
// ── Dependencies ────────────────────────────────────────────────────
dependencies {
intellijPlatform {
intellijIdea(libs.versions.intellij.platform)
@@ -0,0 +1,16 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
gradlePlugin {
plugins {
create("build-tasks") {
id = "ai.kilocode.jetbrains.build-tasks"
implementationClass = "BuildTasksPlugin"
}
}
}
@@ -0,0 +1 @@
rootProject.name = "build-tasks"
@@ -0,0 +1,13 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
/**
* Empty marker plugin that exposes task classes from this build-logic
* module to the main build. Apply it in any subproject that needs
* [FixGeneratedApiTask], [PrepareLocalCliTask], or [CheckCliTask].
*/
class BuildTasksPlugin : Plugin<Project> {
override fun apply(target: Project) {
// Task classes are available on the classpath once this plugin is applied.
}
}
@@ -0,0 +1,53 @@
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import java.io.File
/**
* Verify that CLI binaries exist before packaging the plugin.
* In production mode, all platform binaries must be present.
* In dev mode, only the current platform binary is required.
*/
abstract class CheckCliTask : DefaultTask() {
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val dir: DirectoryProperty
@get:Input
abstract val production: Property<Boolean>
@get:Input
abstract val platforms: ListProperty<String>
@TaskAction
fun run() {
val resolved = dir.get().asFile
if (!resolved.exists() || resolved.listFiles()?.isEmpty() != false) {
throw GradleException(
"CLI binaries not found at ${resolved.absolutePath}.\n" +
"Run 'bun run build' from packages/kilo-jetbrains/ to build CLI and plugin together."
)
}
if (production.get()) {
val missing = platforms.get().filter { platform ->
val d = File(resolved, platform)
val exe = if (platform.startsWith("windows")) "kilo.exe" else "kilo"
!File(d, exe).exists()
}
if (missing.isNotEmpty()) {
throw GradleException(
"Production build requires all platform CLI binaries.\n" +
"Missing: ${missing.joinToString(", ")}\n" +
"Run 'bun run build:production' to build all platforms."
)
}
}
}
}
@@ -0,0 +1,159 @@
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.TaskAction
import java.io.File
/**
* Post-process openapi-generator output to fix codegen bugs that produce
* uncompilable or runtime-broken Kotlin when using kotlinx.serialization.
*
* Fixes applied:
* 1. Boolean const enums — `const: true`/`false` produce broken single-value
* enums. Replaced with plain `kotlin.Boolean`.
* 2. Double parentheses — `HashMap<…>()()` trailing extra `()`.
* 3. Private Double constructor — `kotlin.Double("5000")` → `5000.0`.
* 4. Missing @Contextual on `kotlin.Any` — kotlinx.serialization can't
* serialize `Any` without it.
* 5. Nullable body in ApiClient — OkHttp's `response.body` is nullable.
* 6. AnySerializer — registers a contextual `KSerializer<Any>` backed by
* `JsonElement` for dynamic JSON values.
* 7. Empty anyOf wrappers — `anyOf` unions that generate empty classes.
* Replaced with `kotlinx.serialization.json.JsonElement`.
*/
abstract class FixGeneratedApiTask : DefaultTask() {
@get:InputDirectory
abstract val generated: DirectoryProperty
@TaskAction
fun run() {
val root = generated.get().asFile
fixEmptyWrappers(root)
root.walkTopDown().filter { it.extension == "kt" }.forEach { fix(it) }
}
// ── Fix 7: empty anyOf wrapper classes → JsonElement ─────────────
private fun fixEmptyWrappers(root: File) {
val models = File(root, "ai/kilocode/jetbrains/api/model")
if (!models.isDirectory) return
val empty = Regex("""\nclass \w+ \(\n\n\)""")
val wrappers = models.listFiles()
?.filter { it.extension == "kt" }
?.filter { f -> val t = f.readText(); empty.containsMatchIn(t) && !t.contains("val ") }
?.map { it.nameWithoutExtension }
?: return
for (name in wrappers) File(models, "$name.kt").delete()
root.walkTopDown().filter { it.extension == "kt" }.forEach { file ->
var text = file.readText()
var changed = false
for (name in wrappers) {
if (!text.contains(name)) continue
text = text.replace(Regex("""import [^\n]*\.$name\n"""), "")
text = text.replace(Regex("""\b$name\b"""), "kotlinx.serialization.json.JsonElement")
changed = true
}
if (changed) file.writeText(text)
}
}
private fun fix(file: File) {
var text = file.readText()
var changed = false
// ── Fix 1: boolean const enums ──────────────────────────────
val decl = Regex("""enum class (\w+)\(val value: kotlin\.Boolean\)""")
for (name in decl.findAll(text).map { it.groupValues[1] }.toList()) {
text = text.replace(Regex("""(val \w+:\s*)\w+\.$name""")) { m ->
"${m.groupValues[1]}kotlin.Boolean"
}
text = text.replace(Regex(
"""\n\s*@Serializable\s*\n\s*enum class $name\(val value: kotlin\.Boolean\)\s*\{[^}]*\}"""
), "")
text = text.replace(Regex(
"""\n\s*/\*\*\s*\n(\s*\*[^\n]*\n)*\s*\*/\s*(?=\n\s*\n)"""
), "")
changed = true
}
// ── Fix 2: double parentheses `HashMap<…>()()` ─────────────
if (text.contains("()()")) {
text = text.replace("()()", "()")
changed = true
}
// ── Fix 3: `kotlin.Double("…")` → double literal ───────────
val ctor = Regex("""kotlin\.Double\("(\d+(?:\.\d+)?)"\)""")
if (ctor.containsMatchIn(text)) {
text = ctor.replace(text) { m ->
val n = m.groupValues[1]
if (n.contains(".")) n else "$n.0"
}
changed = true
}
// ── Fix 4: @Contextual on bare kotlin.Any ───────────────────
if (text.contains("kotlin.Any") &&
text.contains("import kotlinx.serialization.Contextual") &&
text.contains("@Serializable") &&
text.contains("data class")
) {
text = text.replace(
Regex("""(?<!@Contextual )kotlin\.Any"""),
"@Contextual kotlin.Any"
)
changed = true
}
// ── Fix 5: nullable body in ApiClient ───────────────────────
if (file.name == "ApiClient.kt") {
val guard = "val body = response.body"
if (text.contains(guard) && !text.contains("if (body == null) return null")) {
text = text.replace(guard, "$guard\n if (body == null) return null")
text = text.replace("body?.", "body.")
changed = true
}
if (text.contains("it.body.string()")) {
text = text.replace("it.body.string()", "it.body?.string()")
changed = true
}
}
// ── Fix 6: AnySerializer in Serializer.kt ───────────────────
if (file.name == "Serializer.kt" && !text.contains("AnySerializer")) {
text = text.replace(
"import kotlinx.serialization.modules.SerializersModuleBuilder",
"import kotlinx.serialization.modules.SerializersModuleBuilder\n" +
"import kotlinx.serialization.KSerializer\n" +
"import kotlinx.serialization.descriptors.SerialDescriptor\n" +
"import kotlinx.serialization.encoding.Decoder\n" +
"import kotlinx.serialization.encoding.Encoder\n" +
"import kotlinx.serialization.json.JsonDecoder\n" +
"import kotlinx.serialization.json.JsonEncoder\n" +
"import kotlinx.serialization.json.JsonElement"
)
text = text.replace(
"contextual(StringBuilder::class, StringBuilderAdapter)",
"contextual(StringBuilder::class, StringBuilderAdapter)\n" +
" contextual(Any::class, AnySerializer)"
)
text = text.trimEnd() + "\n\n" +
"internal object AnySerializer : KSerializer<Any> {\n" +
" private val delegate = JsonElement.serializer()\n" +
" override val descriptor: SerialDescriptor = delegate.descriptor\n" +
" override fun serialize(encoder: Encoder, value: Any) {\n" +
" val json = (encoder as JsonEncoder).json\n" +
" encoder.encodeSerializableValue(delegate, json.encodeToJsonElement(delegate, value as? JsonElement ?: return))\n" +
" }\n" +
" override fun deserialize(decoder: Decoder): Any {\n" +
" return (decoder as JsonDecoder).decodeJsonElement()\n" +
" }\n" +
"}\n"
changed = true
}
if (changed) file.writeText(text)
}
}
@@ -1,7 +0,0 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
@@ -5,6 +5,7 @@ include("frontend")
include("backend")
pluginManagement {
includeBuild("build-tasks")
repositories {
mavenCentral()
gradlePluginPortal()