diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index c273e54995..1ff4b5d30b 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -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()()` — 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`, etc. - text = text.replace( - Regex("""(? {\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) diff --git a/packages/kilo-jetbrains/build-tasks/build.gradle.kts b/packages/kilo-jetbrains/build-tasks/build.gradle.kts new file mode 100644 index 0000000000..184ed01dc4 --- /dev/null +++ b/packages/kilo-jetbrains/build-tasks/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} + +gradlePlugin { + plugins { + create("build-tasks") { + id = "ai.kilocode.jetbrains.build-tasks" + implementationClass = "BuildTasksPlugin" + } + } +} diff --git a/packages/kilo-jetbrains/build-tasks/settings.gradle.kts b/packages/kilo-jetbrains/build-tasks/settings.gradle.kts new file mode 100644 index 0000000000..35b4ba0bf3 --- /dev/null +++ b/packages/kilo-jetbrains/build-tasks/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "build-tasks" diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt new file mode 100644 index 0000000000..dc7f640c13 --- /dev/null +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt @@ -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 { + override fun apply(target: Project) { + // Task classes are available on the classpath once this plugin is applied. + } +} diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt new file mode 100644 index 0000000000..c6f58ed97e --- /dev/null +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt @@ -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 + + @get:Input + abstract val platforms: ListProperty + + @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." + ) + } + } + } +} diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt new file mode 100644 index 0000000000..41d593440c --- /dev/null +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt @@ -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` 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("""(? {\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) + } +} diff --git a/packages/kilo-jetbrains/buildSrc/src/main/kotlin/PrepareLocalCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt similarity index 100% rename from packages/kilo-jetbrains/buildSrc/src/main/kotlin/PrepareLocalCliTask.kt rename to packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt diff --git a/packages/kilo-jetbrains/buildSrc/build.gradle.kts b/packages/kilo-jetbrains/buildSrc/build.gradle.kts deleted file mode 100644 index 876c922b22..0000000000 --- a/packages/kilo-jetbrains/buildSrc/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -plugins { - `kotlin-dsl` -} - -repositories { - mavenCentral() -} diff --git a/packages/kilo-jetbrains/settings.gradle.kts b/packages/kilo-jetbrains/settings.gradle.kts index 4fa336c9b9..c9b17f77c2 100644 --- a/packages/kilo-jetbrains/settings.gradle.kts +++ b/packages/kilo-jetbrains/settings.gradle.kts @@ -5,6 +5,7 @@ include("frontend") include("backend") pluginManagement { + includeBuild("build-tasks") repositories { mavenCentral() gradlePluginPortal()