From 0a4aa192aa2bd65fa4d530e697253fa2f235cf3c Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 12 Apr 2026 17:41:25 -0400 Subject: [PATCH] feat(jetbrains): switch generated HTTP client from Moshi to kotlinx.serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade openapi-generator 7.12.0 → 7.21.0 and switch the generated API client serialization library from Moshi to kotlinx.serialization to unify with the RPC DTOs in shared/ which already use kotlinx. - Extract PrepareLocalCliTask to buildSrc/ to fix Gradle configuration cache error (non-static inner class) - Remove moshi/moshi-kotlin deps, add kotlinx-serialization-json - Map bare number types to kotlin.Double (avoids BigDecimal + @Contextual) - Rewrite fixGeneratedApi task for 7 kotlinx codegen bugs: boolean const enums, double-parens on HashMap classes, private Double constructor, missing @Contextual on Any, nullable OkHttp body, AnySerializer for dynamic JSON, and empty anyOf wrapper classes replaced with JsonElement --- .../kilo-jetbrains/backend/build.gradle.kts | 301 ++++++++++-------- packages/kilo-jetbrains/build.gradle.kts | 4 + .../kilo-jetbrains/buildSrc/build.gradle.kts | 7 + .../src/main/kotlin/PrepareLocalCliTask.kt | 74 +++++ .../kilo-jetbrains/gradle/libs.versions.toml | 6 +- 5 files changed, 256 insertions(+), 136 deletions(-) create mode 100644 packages/kilo-jetbrains/buildSrc/build.gradle.kts create mode 100644 packages/kilo-jetbrains/buildSrc/src/main/kotlin/PrepareLocalCliTask.kt diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index eebfe9d963..c273e54995 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -1,16 +1,3 @@ -import org.gradle.api.DefaultTask -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.TaskAction -import org.gradle.process.ExecOperations -import javax.inject.Inject - plugins { alias(libs.plugins.rpc) alias(libs.plugins.kotlin) @@ -18,68 +5,6 @@ plugins { alias(libs.plugins.openapi.generator) } -abstract class PrepareLocalCliTask : DefaultTask() { - @get:InputFile - abstract val script: RegularFileProperty - - @get:Internal - abstract val root: DirectoryProperty - - @get:OutputDirectory - abstract val out: DirectoryProperty - - @get:Input - abstract val platform: Property - - @get:Input - abstract val exe: Property - - @get:Inject - abstract val exec: ExecOperations - - @TaskAction - fun run() { - val bin = out.file("${platform.get()}/${exe.get()}").get().asFile - if (bin.exists()) return - exec.exec { - workingDir = root.get().asFile - commandLine(findBun(), "script/build.ts", "--prepare-cli") - } - } - - /** - * Resolve the absolute path to `bun`. The Gradle daemon's PATH is often - * stripped down and doesn't include Homebrew or user-local bin dirs. - * Probe common install locations so the build works without manual PATH setup. - */ - private fun findBun(): String { - // 1. Already on PATH? - val which = runCatching { - ProcessBuilder("which", "bun") - .redirectErrorStream(true) - .start() - .inputStream.bufferedReader().readLine()?.trim() - }.getOrNull() - if (which != null && File(which).isFile) return which - - // 2. Common install locations - val home = System.getProperty("user.home") - val candidates = listOf( - "$home/.bun/bin/bun", - "/opt/homebrew/bin/bun", - "/usr/local/bin/bun", - "$home/.nvm/current/bin/bun", - ) - for (path in candidates) { - val f = File(path) - if (f.isFile && f.canExecute()) return f.absolutePath - } - - // 3. Fall back — let the OS resolve it (will fail with a clear message) - return "bun" - } -} - kotlin { jvmToolchain(21) } @@ -102,7 +27,7 @@ openApiGenerate { apiPackage.set("ai.kilocode.jetbrains.api.client") modelPackage.set("ai.kilocode.jetbrains.api.model") configOptions.set(mapOf( - "serializationLibrary" to "moshi", + "serializationLibrary" to "kotlinx_serialization", "omitGradleWrapper" to "true", "omitGradlePluginVersions" to "true", "useCoroutines" to "false", @@ -113,10 +38,12 @@ openApiGenerate { modelNameMappings.set(mapOf( "File" to "DiffFileInfo", )) - // Map empty anyOf references to kotlin.Any + // 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( @@ -129,83 +56,194 @@ openApiGenerate { generateModelDocumentation.set(false) } -// Fix openapi-generator 3.1.1 codegen bugs in generated Kotlin sources. +// Fix openapi-generator codegen bugs in generated Kotlin sources. // // 1) Boolean const enum fix: -// The OpenAPI spec uses `const: true` on boolean fields (e.g. `healthy`). -// openapi-generator turns these into single-value enum classes: -// val healthy: GlobalHealth200Response.Healthy -// enum class Healthy(val value: kotlin.Boolean) { @Json(name = "true") TRUE("true") } -// Moshi's EnumJsonAdapter calls nextString() for the value, but the server sends -// a JSON boolean `true`, not a JSON string `"true"`. -// Fix: replace the enum field type with kotlin.Boolean, remove the enum class. +// `const: true`/`const: false` fields produce broken single-value enums. +// Fix: replace with kotlin.Boolean, remove the enum class. // -// 2) anyOf[string, null] fix: -// Fields like Config.model defined as `anyOf: [{type: string}, {type: null}]` -// get generated as empty wrapper classes (e.g. ConfigModel). Moshi then expects -// a JSON object but the server sends a plain string. -// Fix: replace the field type with kotlin.String?, delete the empty wrapper class file. +// 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 { dependsOn("openApiGenerate") val dir = generatedApi doLast { - // ── Fix 1: boolean const enums ────────────────────────────── + // ── 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) + } + } - val enumDecl = Regex( - """enum class (\w+)\(val value: kotlin\.Boolean\)""" - ) dir.get().asFile.walkTopDown().filter { it.extension == "kt" }.forEach { file -> var text = file.readText() - val names = enumDecl.findAll(text).map { it.groupValues[1] }.toList() - if (names.isEmpty()) return@forEach + 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) { - // Replace field type: `val foo: EnclosingClass.EnumName` → `val foo: kotlin.Boolean` text = text.replace(Regex("""(val \w+:\s*)\w+\.$name""")) { m -> "${m.groupValues[1]}kotlin.Boolean" } - // Remove the @JsonClass annotation + enum class block text = text.replace(Regex( - """\n\s*@JsonClass\(generateAdapter = false\)\s*\n\s*enum class $name\(val value: kotlin\.Boolean\)\s*\{[^}]*\}""" + """\n\s*@Serializable\s*\n\s*enum class $name\(val value: kotlin\.Boolean\)\s*\{[^}]*\}""" ), "") - // Remove the orphaned KDoc block that preceded the enum (lines of ` *` ending with `*/`) text = text.replace(Regex( """\n\s*/\*\*\s*\n(\s*\*[^\n]*\n)*\s*\*/\s*(?=\n\s*\n)""" ), "") - } - file.writeText(text) - } - - // ── Fix 2: anyOf[string, null] empty wrapper classes ──────── - // - // These are classes generated from `anyOf: [{type: string}, {type: null}]` - // that should be kotlin.String? instead. The generated class is an empty - // `class FooBar () {}` and fields referencing it need to become String?. - val emptyWrappers = listOf("ConfigModel", "ConfigSmallModel") - for (wrapper in emptyWrappers) { - // Delete the empty wrapper class file - val wrapperFile = dir.get().file( - "ai/kilocode/jetbrains/api/model/$wrapper.kt" - ).asFile - if (wrapperFile.exists()) { - wrapperFile.delete() + changed = true } - // Replace all field references in other files: - // `val model: ConfigModel? = null` → `val model: kotlin.String? = null` - dir.get().asFile.walkTopDown().filter { it.extension == "kt" }.forEach { file -> - val text = file.readText() - if (!text.contains(wrapper)) return@forEach - var patched = text - // Replace field type references - patched = patched.replace(Regex(""":\s*$wrapper\?"""), ": kotlin.String?") - patched = patched.replace(Regex(""":\s*$wrapper([^?\w])"""), ": kotlin.String?$1") - // Remove the import line - patched = patched.replace(Regex("""import [^\n]*\.$wrapper\n"""), "") - if (patched != text) { - file.writeText(patched) + // ── 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) } } } @@ -296,6 +334,5 @@ dependencies { implementation(project(":shared")) implementation(libs.okhttp) implementation(libs.okhttp.sse) - implementation(libs.moshi) - implementation(libs.moshi.kotlin) + implementation(libs.kotlinx.serialization.json) } diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 82d6dd2ddf..3f7698cd3c 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -58,3 +58,7 @@ tasks.named("runIde") { } } +tasks.named("clean") { + delete(layout.buildDirectory) +} + diff --git a/packages/kilo-jetbrains/buildSrc/build.gradle.kts b/packages/kilo-jetbrains/buildSrc/build.gradle.kts new file mode 100644 index 0000000000..876c922b22 --- /dev/null +++ b/packages/kilo-jetbrains/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/packages/kilo-jetbrains/buildSrc/src/main/kotlin/PrepareLocalCliTask.kt b/packages/kilo-jetbrains/buildSrc/src/main/kotlin/PrepareLocalCliTask.kt new file mode 100644 index 0000000000..121b64fa06 --- /dev/null +++ b/packages/kilo-jetbrains/buildSrc/src/main/kotlin/PrepareLocalCliTask.kt @@ -0,0 +1,74 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import java.io.File +import javax.inject.Inject + +abstract class PrepareLocalCliTask : DefaultTask() { + @get:InputFile + abstract val script: RegularFileProperty + + @get:Internal + abstract val root: DirectoryProperty + + @get:OutputDirectory + abstract val out: DirectoryProperty + + @get:Input + abstract val platform: Property + + @get:Input + abstract val exe: Property + + @get:Inject + abstract val exec: ExecOperations + + @TaskAction + fun run() { + val bin = out.file("${platform.get()}/${exe.get()}").get().asFile + if (bin.exists()) return + exec.exec { + workingDir = root.get().asFile + commandLine(findBun(), "script/build.ts", "--prepare-cli") + } + } + + /** + * Resolve the absolute path to `bun`. The Gradle daemon's PATH is often + * stripped down and doesn't include Homebrew or user-local bin dirs. + * Probe common install locations so the build works without manual PATH setup. + */ + private fun findBun(): String { + // 1. Already on PATH? + val which = runCatching { + ProcessBuilder("which", "bun") + .redirectErrorStream(true) + .start() + .inputStream.bufferedReader().readLine()?.trim() + }.getOrNull() + if (which != null && File(which).isFile) return which + + // 2. Common install locations + val home = System.getProperty("user.home") + val candidates = listOf( + "$home/.bun/bin/bun", + "/opt/homebrew/bin/bun", + "/usr/local/bin/bun", + "$home/.nvm/current/bin/bun", + ) + for (path in candidates) { + val f = File(path) + if (f.isFile && f.canExecute()) return f.absolutePath + } + + // 3. Fall back — let the OS resolve it (will fail with a clear message) + return "bun" + } +} diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index e092ab8aa5..5bf4ff9b91 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -6,14 +6,12 @@ kotlin-jvm-plugin = "2.1.20" kotlin-serialization-plugin = "2.1.20" kotlin-serialization = "1.7.3" okhttp = "4.12.0" -moshi = "1.15.1" -openapi-generator = "7.12.0" +openapi-generator = "7.21.0" [libraries] okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" } -moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } -moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } [plugins] intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" }