diff --git a/CHANGELOG.md b/CHANGELOG.md index c02ea86..123fc0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.2.1 - Unreleased +- Improved Kotlin JVM and Android semantic role mapping for Gradle projects, including Android plugin aliases, local type handling, comment/string parsing, and role fallback edges, thanks @mrmans0n. + ## 0.2.0 - 2026-05-17 - Added the `acpx` provider for routing review, fix, and revalidate through ACP-compatible coding agents, thanks @mvanhorn. diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 3fa7473..ef1b06b 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3194,6 +3194,80 @@ describe("mapFeatures", () => { ); }); + it("does not add path-only roles to strong Kotlin server roles", async () => { + const root = await fixtureRoot("clawpatch-kotlin-strong-role-path-fallback-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/network/OrderController.kt", + [ + "package com.example.network", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-web-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/network/OrderController.kt", + ), + ), + ).toBe(true); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-external-client" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/network/OrderController.kt", + ), + ), + ).toBe(false); + }); + + it("maps Kotlin Spring configuration imports as configuration roles", async () => { + const root = await fixtureRoot("clawpatch-kotlin-spring-config-import-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/config/PropsConfig.kt", + [ + "package com.example.config", + "", + "import org.springframework.boot.context.properties.EnableConfigurationProperties", + "", + "@EnableConfigurationProperties(AppProps::class)", + "class PropsConfig", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const configuration = result.features.find( + (feature) => + feature.source === "kotlin-server-role-configuration" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/config/PropsConfig.kt", + ), + ); + + expect(configuration?.ownedFiles[0]?.reason).toContain( + "configuration import org.springframework.boot.context.properties.EnableConfigurationProperties", + ); + }); + it("keeps Kotlin feature IDs stable when confidence changes", async () => { const root = await fixtureRoot("clawpatch-kotlin-role-id-stability-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); @@ -3529,6 +3603,134 @@ describe("mapFeatures", () => { expect(framework?.ownedFiles[0]?.reason).not.toContain("org.scheduler.String"); }); + it("does not let settings-only root sources suppress module Kotlin wildcard evidence", async () => { + const root = await fixtureRoot("clawpatch-kotlin-settings-root-src-"); + await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":app")\n'); + await writeFixture( + root, + "src/main/kotlin/org/scheduler/Unused.kt", + "package org.scheduler\nclass Unused\n", + ); + await writeFixture( + root, + "app/build.gradle.kts", + 'plugins { id("org.jetbrains.kotlin.jvm") }\n', + ); + await writeFixture( + root, + "app/src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.*", + "", + "class JobFactory : JobFactoryBase()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "app/src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(framework?.ownedFiles[0]?.reason).toContain("org.scheduler.JobFactoryBase"); + }); + + it("does not let nested Gradle roots suppress outer Kotlin wildcard evidence", async () => { + const root = await fixtureRoot("clawpatch-kotlin-nested-root-local-type-"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.*", + "", + "class JobFactory : JobFactoryBase()", + "", + ].join("\n"), + ); + await writeFixture(root, "nested/settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "nested/build.gradle.kts", + 'plugins { id("org.jetbrains.kotlin.jvm") }\n', + ); + await writeFixture( + root, + "nested/src/main/kotlin/org/scheduler/JobFactoryBase.kt", + "package org.scheduler\nclass JobFactoryBase\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(framework?.ownedFiles[0]?.reason).toContain( + "inherits external type org.scheduler.JobFactoryBase", + ); + }); + + it("maps nested Gradle roots under settings builds independently", async () => { + const root = await fixtureRoot("clawpatch-kotlin-settings-nested-root-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.*", + "", + "class JobFactory : JobFactoryBase()", + "", + ].join("\n"), + ); + await writeFixture(root, "nested/settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "nested/build.gradle.kts", + 'plugins { id("org.jetbrains.kotlin.jvm") }\n', + ); + await writeFixture( + root, + "nested/src/main/kotlin/org/scheduler/JobFactoryBase.kt", + "package org.scheduler\nclass JobFactoryBase\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const nestedModule = result.features.find( + (feature) => feature.title === "Gradle module nested", + ); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(nestedModule?.source).toBe("gradle-module"); + expect(framework?.ownedFiles[0]?.reason).toContain( + "inherits external type org.scheduler.JobFactoryBase", + ); + }); + it("does not treat Kotlin stdlib return types as framework components", async () => { const root = await fixtureRoot("clawpatch-kotlin-stdlib-type-map-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); @@ -3769,6 +3971,3271 @@ describe("mapFeatures", () => { ).toBe(false); }); + it("ignores Kotlin role markers inside nested block comments", async () => { + const root = await fixtureRoot("clawpatch-kotlin-nested-comment-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/Foo.kt", + [ + "package com.example", + "", + "/* outer", + " /* inner */", + " import okhttp3.OkHttpClient", + " import org.springframework.web.bind.annotation.RestController", + " @RestController", + "*/", + "class Foo", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-external-client" || + feature.source === "kotlin-server-role-web-entrypoint", + ), + ).toBe(false); + }); + + it("keeps Kotlin code after comment markers inside strings", async () => { + const root = await fixtureRoot("clawpatch-kotlin-string-comment-marker-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/api/Foo.kt", + [ + "package com.example.api", + "", + 'const val marker = "/*"', + "", + "@org.springframework.web.bind.annotation.RestController", + "class Foo", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-web-entrypoint" && + feature.ownedFiles.some((file) => file.path === "src/main/kotlin/com/example/api/Foo.kt"), + ), + ).toBe(true); + }); + + it("ignores Kotlin role markers inside raw strings", async () => { + const root = await fixtureRoot("clawpatch-kotlin-raw-string-marker-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/Foo.kt", + [ + "package com.example", + "", + 'val template = """', + "import okhttp3.OkHttpClient", + "@org.springframework.web.bind.annotation.RestController", + '"""', + "class Foo", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-external-client" || + feature.source === "kotlin-server-role-web-entrypoint", + ), + ).toBe(false); + }); + + it("keeps Kotlin role IDs stable when confidence buckets merge", async () => { + const root = await fixtureRoot("clawpatch-kotlin-role-bucket-stability-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/network/RemoteClient.kt", + [ + "package com.example.network", + "", + "import okhttp3.OkHttpClient", + "", + "class RemoteClient(private val client: OkHttpClient)", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/network/FallbackClient.kt", + "package com.example.network\nclass FallbackClient\n", + ); + + const project = await detectProject(root); + const first = await mapFeatures(root, project, []); + const before = first.features.find( + (feature) => + feature.source === "kotlin-server-role-external-client" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/network/RemoteClient.kt", + ), + ); + + await writeFixture( + root, + "src/main/kotlin/com/example/network/FallbackClient.kt", + [ + "package com.example.network", + "", + "import retrofit2.Retrofit", + "", + "class FallbackClient(private val retrofit: Retrofit)", + "", + ].join("\n"), + ); + + const second = await mapFeatures(root, project, first.features); + const after = second.features.find( + (feature) => + feature.source === "kotlin-server-role-external-client" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/network/RemoteClient.kt", + ), + ); + + expect(before?.featureId).toBeDefined(); + expect(after?.featureId).toBe(before?.featureId); + expect(after?.ownedFiles.map((file) => file.path).toSorted()).toEqual([ + "src/main/kotlin/com/example/network/FallbackClient.kt", + "src/main/kotlin/com/example/network/RemoteClient.kt", + ]); + }); + + it("does not treat Java sources from the same Gradle module as external Kotlin framework types", async () => { + const root = await fixtureRoot("clawpatch-kotlin-java-local-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/java/com/example/core/BaseService.java", + "package com.example.core;\npublic class BaseService {}\n", + ); + await writeFixture( + root, + "src/main/kotlin/com/example/app/LocalService.kt", + [ + "package com.example.app", + "", + "import com.example.core.BaseService", + "", + "class LocalService : BaseService()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/app/LocalService.kt", + ), + ), + ).toBe(false); + }); + + it("does not treat sibling Gradle module Kotlin types as external framework types", async () => { + const root = await fixtureRoot("clawpatch-kotlin-sibling-module-type-"); + await writeFixture( + root, + "settings.gradle.kts", + 'pluginManagement {}\ninclude(":core", ":app")\n', + ); + await writeFixture( + root, + "core/build.gradle.kts", + 'plugins { id("org.jetbrains.kotlin.jvm") }\n', + ); + await writeFixture( + root, + "app/build.gradle.kts", + 'plugins { id("org.jetbrains.kotlin.jvm") }\n', + ); + await writeFixture( + root, + "core/src/main/kotlin/com/example/core/BaseService.kt", + ["package com.example.core", "", "open class BaseService", ""].join("\n"), + ); + await writeFixture( + root, + "app/src/main/kotlin/com/example/app/AppService.kt", + [ + "package com.example.app", + "", + "import com.example.core.BaseService", + "", + "class AppService : BaseService()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "app/src/main/kotlin/com/example/app/AppService.kt", + ), + ), + ).toBe(false); + }); + + it("does not treat same-package nested Kotlin types as external framework types", async () => { + const root = await fixtureRoot("clawpatch-kotlin-local-nested-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/Job.kt", + ["package com.example.jobs", "", "class Job {", " class Factory", "}", ""].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactoryProvider.kt", + [ + "package com.example.jobs", + "", + "class JobFactoryProvider {", + " fun build(): Job.Factory = Job.Factory()", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactoryProvider.kt", + ), + ), + ).toBe(false); + }); + + it("detects Android Kotlin roles from Gradle plugins without a manifest", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-plugin-role-"); + await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":ui")\n'); + await writeFixture( + root, + "build.gradle.kts", + 'plugins { id("com.android.library") version "1.0" apply false }\n', + ); + await writeFixture(root, "ui/build.gradle.kts", 'plugins { id("com.android.library") }\n'); + await writeFixture( + root, + "ui/src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "ui/src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("detects Android Kotlin roles from convention plugin android blocks", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-convention-block-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("com.company.android.library") version "1.0"', + "}", + "", + "android {", + ' namespace = "com.example"', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("detects Android Kotlin roles from multiline Gradle plugin declarations", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-multiline-plugin-role-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + ["plugins {", " id(", ' "com.android.library"', " )", "}", ""].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("does not treat child android extension blocks as root Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-child-extension-block-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + 'plugins { id("org.jetbrains.kotlin.jvm") }', + "subprojects {", + " android {", + ' namespace = "com.example.child"', + " }", + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("keeps applied Android plugin declarations before unrelated alias apply false entries", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-alias-apply-false-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("com.android.application") version "8.0"', + " alias(libs.plugins.kotlin.compose) apply false", + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("keeps Groovy Android plugin declarations before unrelated apply false entries", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-groovy-apply-false-"); + await writeFixture(root, "settings.gradle", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle", + [ + "plugins {", + " id 'com.android.application' version '8.0'", + " id 'org.jetbrains.kotlin.jvm' version '1.9' apply false", + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("keeps Kotlin DSL Android plugin declarations before unrelated shorthand apply false entries", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-shorthand-apply-false-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("com.android.application") version "8.0"', + ' kotlin("jvm") apply false', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("keeps Kotlin DSL Android plugin declarations before unrelated backtick apply false entries", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-backtick-apply-false-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("com.android.application") version "8.0"', + " `java-library` apply false", + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("keeps Kotlin DSL Android plugin declarations before bare accessor apply false entries", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-accessor-apply-false-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("com.android.application") version "8.0"', + " application apply false", + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("keeps Android plugin declarations before same-line unrelated apply false entries", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-same-line-apply-false-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + 'plugins { id("com.android.application"); id("org.jetbrains.kotlin.jvm") apply false }\n', + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("keeps final Android plugin declarations before later unrelated apply false text", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-trailing-apply-false-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("com.android.application") version "8.0"', + "}", + "", + 'tasks.register("note") {', + ' doLast { println("call .apply(false) elsewhere") }', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("detects Android Kotlin roles from version-catalog plugin aliases without a manifest", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-plugin-alias-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + "plugins { alias(libs.plugins.android.library) }\n", + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("detects Android Kotlin roles from bare plugin aliases without a catalog", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-bare-plugin-alias-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", "plugins { alias(libs.plugins.android) }\n"); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("detects Android Kotlin roles from later wildcard imports", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-wildcard-supertype-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("com.android.application") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import com.external.*", + "import androidx.lifecycle.*", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("detects Android Kotlin roles from resolved version-catalog plugin aliases", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-plugin-catalog-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "gradle/libs.versions.toml", + ["[plugins]", 'agp = { id = "com.android.library", version = "8.0.0" }', ""].join("\n"), + ); + await writeFixture(root, "build.gradle.kts", "plugins { alias(libs.plugins.agp) }\n"); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("does not read parent version-catalog aliases from nested Gradle roots", async () => { + const root = await fixtureRoot("clawpatch-kotlin-nested-catalog-shadow-"); + await writeFixture( + root, + "gradle/libs.versions.toml", + ["[plugins]", 'agp = { id = "com.android.library", version = "8.0.0" }', ""].join("\n"), + ); + await writeFixture(root, "server/settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "server/gradle/libs.versions.toml", + ["[plugins]", 'agp = { id = "org.jetbrains.kotlin.jvm", version = "1.9" }', ""].join("\n"), + ); + await writeFixture(root, "server/build.gradle.kts", "plugins { alias(libs.plugins.agp) }\n"); + await writeFixture( + root, + "server/src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const module = result.features.find((feature) => feature.title === "Gradle module server"); + const web = result.features.find( + (feature) => + feature.source === "kotlin-server-role-web-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "server/src/main/kotlin/com/example/api/OrderController.kt", + ), + ); + + expect(module?.tags).not.toContain("android"); + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + }); + + it("does not read subproject-local version catalogs from Gradle root subprojects", async () => { + const root = await fixtureRoot("clawpatch-kotlin-root-catalog-subproject-"); + await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":server")\n'); + await writeFixture( + root, + "gradle/libs.versions.toml", + ["[plugins]", 'agp = { id = "org.jetbrains.kotlin.jvm", version = "1.9" }', ""].join("\n"), + ); + await writeFixture( + root, + "server/gradle/libs.versions.toml", + ["[plugins]", 'agp = { id = "com.android.library", version = "8.0.0" }', ""].join("\n"), + ); + await writeFixture(root, "server/build.gradle.kts", "plugins { alias(libs.plugins.agp) }\n"); + await writeFixture( + root, + "server/src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const module = result.features.find((feature) => feature.title === "Gradle module server"); + const web = result.features.find( + (feature) => + feature.source === "kotlin-server-role-web-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "server/src/main/kotlin/com/example/api/OrderController.kt", + ), + ); + + expect(module?.tags).not.toContain("android"); + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + }); + + it("detects Android Kotlin roles from quoted version-catalog plugin aliases", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-plugin-quoted-catalog-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "gradle/libs.versions.toml", + ["[plugins]", '"agp.lib" = { id = "com.android.library", version = "8.0.0" }', ""].join("\n"), + ); + await writeFixture(root, "build.gradle.kts", "plugins { alias(libs.plugins.agp.lib) }\n"); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("does not treat version-catalog Android plugin aliases inside Gradle strings as Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-plugin-alias-string-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "gradle/libs.versions.toml", + ["[plugins]", 'agp = { id = "com.android.library", version = "8.0.0" }', ""].join("\n"), + ); + await writeFixture( + root, + "build.gradle.kts", + [ + 'plugins { id("org.jetbrains.kotlin.jvm") }', + 'tasks.register("note") {', + ' doLast { println("alias(libs.plugins.agp)") }', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("detects Android Kotlin roles from dotted-key version-catalog plugin aliases", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-plugin-dotted-catalog-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "gradle/libs.versions.toml", + ["[plugins]", 'agp.id = "com.android.library"', 'agp.version = "8.0.0"', ""].join("\n"), + ); + await writeFixture(root, "build.gradle.kts", "plugins { alias(libs.plugins.agp) }\n"); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("detects Android Kotlin roles from top-level dotted version-catalog plugin aliases", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-top-dotted-catalog-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "gradle/libs.versions.toml", + ['plugins.agp = { id = "com.android.library", version = "8.0.0" }', ""].join("\n"), + ); + await writeFixture(root, "build.gradle.kts", "plugins { alias(libs.plugins.agp) }\n"); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("detects Android Kotlin roles from plugin-specific version-catalog tables", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-plugin-table-catalog-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "gradle/libs.versions.toml", + ["[plugins.agp]", 'id = "com.android.library"', 'version = "8.0.0"', ""].join("\n"), + ); + await writeFixture(root, "build.gradle.kts", "plugins { alias(libs.plugins.agp) }\n"); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("detects Android Kotlin roles from quoted plugin-specific version-catalog tables", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-quoted-plugin-table-catalog-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "gradle/libs.versions.toml", + ['[plugins."agp"]', 'id = "com.android.library"', 'version = "8.0.0"', ""].join("\n"), + ); + await writeFixture(root, "build.gradle.kts", "plugins { alias(libs.plugins.agp) }\n"); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("detects Android Kotlin roles from nested version-catalog plugin tables", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-nested-plugin-catalog-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "gradle/libs.versions.toml", + ["[plugins.android]", 'gradle = { id = "com.android.library", version = "8.0.0" }', ""].join( + "\n", + ), + ); + await writeFixture( + root, + "build.gradle.kts", + "plugins { alias(libs.plugins.android.gradle) }\n", + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("detects Android Kotlin roles from applied Gradle plugin syntax without a manifest", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-apply-plugin-role-"); + await writeFixture(root, "settings.gradle", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle", "apply plugin: 'com.android.library'\n"); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("detects Android Kotlin roles from Groovy apply plugin syntax with spaced colons", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-apply-spaced-colon-"); + await writeFixture(root, "settings.gradle", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle", "apply plugin : 'com.android.library'\n"); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("detects Android Kotlin roles from Groovy apply plugin map syntax", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-apply-map-role-"); + await writeFixture(root, "settings.gradle", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle", 'apply(plugin: "com.android.library")\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + }); + + it("detects root Android apply plugin after Gradle URL strings", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-apply-url-string-"); + await writeFixture(root, "settings.gradle", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle", + [ + "subprojects {", + " repositories {", + " maven { url 'https://example.com/repo' }", + " }", + "}", + "apply plugin: 'com.android.library'", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("detects root Android apply plugin after Gradle child-scope string braces", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-apply-string-brace-"); + await writeFixture(root, "settings.gradle", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle", + [ + "plugins { id 'org.jetbrains.kotlin.jvm' }", + "subprojects {", + " tasks.register('note') { doLast { println('{') } }", + "}", + "apply plugin: 'com.android.library'", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("detects root Android roles from allprojects apply plugin blocks", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-allprojects-apply-"); + await writeFixture(root, "settings.gradle", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle", + [ + "plugins { id 'org.jetbrains.kotlin.jvm' }", + "allprojects {", + " apply plugin: 'com.android.library'", + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("detects root Android roles from allprojects android blocks", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-allprojects-extension-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + 'plugins { id("org.jetbrains.kotlin.jvm") }', + "allprojects {", + " android {", + ' namespace = "com.example"', + " }", + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + }); + + it("does not treat subproject Android apply blocks as root Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-subprojects-apply-"); + await writeFixture(root, "settings.gradle", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle", + [ + "plugins { id 'org.jetbrains.kotlin.jvm' }", + "subprojects {", + " apply plugin: 'com.android.library'", + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("does not treat project Android apply blocks as root Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-project-apply-"); + await writeFixture(root, "settings.gradle", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle", + [ + "plugins { id 'org.jetbrains.kotlin.jvm' }", + "project(':app') {", + " apply plugin: 'com.android.library'", + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("does not treat apply-false Android plugin declarations as Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-apply-false-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("com.android.application") version "1.0" apply false', + ' id("org.jetbrains.kotlin.jvm")', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("does not treat apply-false Android plugin declarations with GString versions as Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-gstring-apply-false-"); + await writeFixture(root, "settings.gradle", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle", + [ + "plugins {", + ' id "com.android.application" version "${agpVersion}" apply false', + ' id "org.jetbrains.kotlin.jvm"', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("does not treat apply-false version-catalog Android plugin aliases as Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-alias-apply-false-module-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + " alias(libs.plugins.android.library) apply false", + ' id("org.jetbrains.kotlin.jvm")', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("does not treat Kotlin DSL apply(false) Android plugin declarations as Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-apply-method-false-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("com.android.application").version("8.0").apply(false)', + ' id("org.jetbrains.kotlin.jvm")', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("does not treat multiline Kotlin DSL apply(false) Android plugin declarations as Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-multiline-apply-false-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("com.android.application")', + ' .version("8.0")', + " .apply(false)", + ' id("org.jetbrains.kotlin.jvm")', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("does not treat split Kotlin DSL apply(false) Android plugin declarations as Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-split-apply-false-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("com.android.application")', + ' .version("8.0")', + " .apply(", + " false", + " )", + ' id("org.jetbrains.kotlin.jvm")', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("does not treat commented Android plugin declarations as Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-commented-plugin-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' // id("com.android.application")', + ' id("org.jetbrains.kotlin.jvm")', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("does not treat nested-commented Kotlin DSL Android plugin declarations as Android modules", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-nested-comment-plugin-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "build.gradle.kts", + [ + "plugins {", + ' id("org.jetbrains.kotlin.jvm")', + "}", + "/* outer", + " /* inner */", + ' id("com.android.application")', + "*/", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find((feature) => + feature.title.startsWith("Kotlin server role web entrypoint "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("does not map Compose runtime-only imports as Android UI entrypoints", async () => { + const root = await fixtureRoot("clawpatch-kotlin-compose-runtime-only-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("com.android.application") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.compose.runtime.mutableStateOf", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel() {", + ' val name = mutableStateOf("app")', + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-ui-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(false); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-view-model" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainViewModel.kt", + ), + ), + ).toBe(true); + }); + + it("keeps Android UI path fallback for injected base activities", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-ui-di-path-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("com.android.application") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/MainActivity.kt", + [ + "package com.example.ui", + "", + "import dagger.hilt.android.AndroidEntryPoint", + "", + "@AndroidEntryPoint", + "class MainActivity : BaseActivity()", + "", + "open class BaseActivity", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-ui-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainActivity.kt", + ), + ), + ).toBe(true); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-dependency-injection" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/MainActivity.kt", + ), + ), + ).toBe(true); + }); + + it("does not add Android path roles after strong framework evidence", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-strong-role-path-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("com.android.application") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/ui/ApiClient.kt", + [ + "package com.example.ui", + "", + "import okhttp3.OkHttpClient", + "", + "class ApiClient(private val client: OkHttpClient)", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/network/MainViewModel.kt", + [ + "package com.example.network", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-ui-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/ApiClient.kt", + ), + ), + ).toBe(false); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-external-client" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/network/MainViewModel.kt", + ), + ), + ).toBe(false); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-external-client" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/ui/ApiClient.kt", + ), + ), + ).toBe(true); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-view-model" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/network/MainViewModel.kt", + ), + ), + ).toBe(true); + }); + + it("does not map Android app utility imports as UI entrypoints", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-app-utility-import-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("com.android.application") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/notifications/NotificationHelper.kt", + [ + "package com.example.notifications", + "", + "import android.app.NotificationChannel", + "import android.app.PendingIntent", + "", + "class NotificationHelper {", + ' fun channel(): NotificationChannel = NotificationChannel("id", "name", 3)', + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-ui-entrypoint" && + feature.ownedFiles.some( + (file) => + file.path === "src/main/kotlin/com/example/notifications/NotificationHelper.kt", + ), + ), + ).toBe(false); + }); + + it("does not map local Android supertype name collisions as UI entrypoints", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-local-activity-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("com.android.application") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/domain/LocalActivity.kt", + [ + "package com.example.domain", + "", + "open class Activity", + "", + "class CleanupJob : Activity()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-ui-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/domain/LocalActivity.kt", + ), + ), + ).toBe(false); + }); + + it("maps Kotlin Apache HTTP imports as external clients", async () => { + const root = await fixtureRoot("clawpatch-kotlin-apache-http-client-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/client/LegacyClient.kt", + [ + "package com.example.client", + "", + "import org.apache.http.client.HttpClient", + "", + "class LegacyClient(private val client: HttpClient)", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const client = result.features.find( + (feature) => + feature.source === "kotlin-server-role-external-client" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/client/LegacyClient.kt", + ), + ); + + expect(client?.ownedFiles[0]?.reason).toContain( + "external client import org.apache.http.client.HttpClient", + ); + }); + + it("keeps injected Android data consumers in data role path fallback", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-injected-data-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("com.android.application") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/data/UserRepository.kt", + [ + "package com.example.data", + "", + "import javax.inject.Inject", + "", + "class UserRepository @Inject constructor()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const data = result.features.find( + (feature) => + feature.source === "kotlin-android-role-data-boundary" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/data/UserRepository.kt", + ), + ); + + expect(data?.ownedFiles[0]?.reason).toContain("path segment data boundary"); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-android-role-dependency-injection" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/data/UserRepository.kt", + ), + ), + ).toBe(false); + }); + + it("does not map Retrofit client annotations as server web entrypoints", async () => { + const root = await fixtureRoot("clawpatch-kotlin-retrofit-annotation-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/client/ApiClient.kt", + [ + "package com.example.client", + "", + "import retrofit2.http.GET", + "", + "interface ApiClient {", + ' @GET("/orders")', + " fun orders(): String", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-external-client" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/client/ApiClient.kt", + ), + ), + ).toBe(true); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-web-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/client/ApiClient.kt", + ), + ), + ).toBe(false); + }); + + it("does not map qualified custom web-like annotations as server web entrypoints", async () => { + const root = await fixtureRoot("clawpatch-kotlin-custom-qualified-web-annotation-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/api/LocalController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.*", + "", + "@com.acme.RestController", + "class LocalController", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-web-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/LocalController.kt", + ), + ), + ).toBe(false); + }); + + it("maps fully qualified Kotlin JAX-RS annotations as server web entrypoints", async () => { + const root = await fixtureRoot("clawpatch-kotlin-qualified-jaxrs-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderResource.kt", + [ + "package com.example.api", + "", + '@jakarta.ws.rs.Path("/orders")', + "class OrderResource", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find( + (feature) => + feature.source === "kotlin-server-role-web-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/OrderResource.kt", + ), + ); + + expect(web?.ownedFiles[0]?.reason).toContain("server web annotation @Path"); + }); + + it("maps later fully qualified Kotlin JAX-RS annotations as server web entrypoints", async () => { + const root = await fixtureRoot("clawpatch-kotlin-qualified-jaxrs-after-custom-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderResource.kt", + [ + "package com.example.api", + "", + "@com.acme.Path", + '@jakarta.ws.rs.Path("/orders")', + "class OrderResource", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const web = result.features.find( + (feature) => + feature.source === "kotlin-server-role-web-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/OrderResource.kt", + ), + ); + + expect(web?.ownedFiles[0]?.reason).toContain("server web annotation @Path"); + }); + + it("maps fully qualified Kotlin return types as framework roles", async () => { + const root = await fixtureRoot("clawpatch-kotlin-qualified-return-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderResource.kt", + [ + "package com.example.api", + "", + "class OrderResource {", + " fun response(): io.ktor.server.response.ApplicationResponse = TODO()", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const component = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/OrderResource.kt", + ), + ); + + expect(component?.ownedFiles[0]?.reason).toContain( + "returns external type io.ktor.server.response.ApplicationResponse", + ); + }); + + it("maps Kotlin supertypes after the first line of a declaration", async () => { + const root = await fixtureRoot("clawpatch-kotlin-multiline-supertypes-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/Worker.kt", + [ + "package com.example.jobs", + "", + "import io.ktor.server.application.Application", + "", + "open class LocalWorker", + "", + "class Worker :", + " LocalWorker,", + " Application {", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const component = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/Worker.kt", + ), + ); + + expect(component?.ownedFiles[0]?.reason).toContain( + "inherits external type io.ktor.server.application.Application", + ); + }); + + it("maps bodyless Kotlin supertypes before top-level functions", async () => { + const root = await fixtureRoot("clawpatch-kotlin-bodyless-supertype-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.JobFactoryBase", + "", + "class JobFactory : JobFactoryBase()", + "", + "fun helper() = Unit", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const component = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(component?.ownedFiles[0]?.reason).toContain( + "inherits external type org.scheduler.JobFactoryBase", + ); + }); + + it("maps bodyless Kotlin supertypes before expect and actual declarations", async () => { + const root = await fixtureRoot("clawpatch-kotlin-bodyless-supertype-expect-actual-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.JobFactoryBase", + "", + "class JobFactory : JobFactoryBase()", + "actual class NativeJob", + "expect fun scheduleJob()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const component = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(component?.ownedFiles[0]?.reason).toContain( + "inherits external type org.scheduler.JobFactoryBase", + ); + }); + + it("maps bodyless Kotlin supertypes before modified top-level functions", async () => { + const root = await fixtureRoot("clawpatch-kotlin-bodyless-supertype-suspend-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.JobFactoryBase", + "", + "class JobFactory : JobFactoryBase()", + "", + "suspend fun runJob() {}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const component = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(component?.ownedFiles[0]?.reason).toContain( + "inherits external type org.scheduler.JobFactoryBase", + ); + }); + + it("maps bodyless Kotlin supertypes before top-level type aliases", async () => { + const root = await fixtureRoot("clawpatch-kotlin-bodyless-supertype-typealias-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.JobFactoryBase", + "", + "class JobFactory : JobFactoryBase()", + "", + "typealias JobId = String", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const component = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(component?.ownedFiles[0]?.reason).toContain( + "inherits external type org.scheduler.JobFactoryBase", + ); + }); + + it("maps Kotlin return types after function-typed parameters", async () => { + const root = await fixtureRoot("clawpatch-kotlin-function-param-return-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/api/Router.kt", + [ + "package com.example.api", + "", + "import org.http4k.routing.Route", + "", + "class Router {", + " fun route(block: () -> Unit): Route = TODO()", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const component = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/Router.kt", + ), + ); + + expect(component?.ownedFiles[0]?.reason).toContain( + "returns external type org.http4k.routing.Route", + ); + }); + + it("does not resolve Kotlin built-in return types through wildcard imports", async () => { + const root = await fixtureRoot("clawpatch-kotlin-builtin-wildcard-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/api/OrderController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.*", + "", + "@RestController", + "class OrderController {", + ' @GetMapping("/orders")', + ' fun body(): ByteArray = "ok".encodeToByteArray()', + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/OrderController.kt", + ), + ), + ).toBe(false); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-web-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/OrderController.kt", + ), + ), + ).toBe(true); + }); + + it("does not resolve Kotlin default return types through wildcard imports", async () => { + const root = await fixtureRoot("clawpatch-kotlin-default-wildcard-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.*", + "", + "class JobFactory {", + " fun failure(): Throwable = TODO()", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ), + ).toBe(false); + }); + + it("does not resolve Kotlin range return types through wildcard imports", async () => { + const root = await fixtureRoot("clawpatch-kotlin-range-wildcard-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/api/RangeController.kt", + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.*", + "", + "@RestController", + "class RangeController {", + " fun ids(): ClosedRange = 1..3", + ' fun version(): KotlinVersion = KotlinVersion(1, 9, 0, "stable")', + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/RangeController.kt", + ), + ), + ).toBe(false); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-web-entrypoint" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/RangeController.kt", + ), + ), + ).toBe(true); + }); + + it("does not resolve dotted Kotlin built-in return types as framework roles", async () => { + const root = await fixtureRoot("clawpatch-kotlin-dotted-builtin-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/api/Entries.kt", + [ + "package com.example.api", + "", + "class Entries {", + " fun first(): Map.Entry = TODO()", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/Entries.kt", + ), + ), + ).toBe(false); + }); + + it("does not resolve local lowercase dotted Kotlin return types as framework roles", async () => { + const root = await fixtureRoot("clawpatch-kotlin-local-lowercase-dotted-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/Routes.kt", + [ + "package com.example", + "", + "object routes { class Handler }", + "class Factory { fun handler(): routes.Handler = TODO() }", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some((file) => file.path === "src/main/kotlin/com/example/Routes.kt"), + ), + ).toBe(false); + }); + + it("does not resolve imported local lowercase dotted Kotlin return types as framework roles", async () => { + const root = await fixtureRoot("clawpatch-kotlin-imported-local-lowercase-dotted-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/routes/Routes.kt", + ["package com.example.routes", "", "object routes { class Handler }", ""].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/factory/Factory.kt", + [ + "package com.example.factory", + "", + "import com.example.routes.routes", + "", + "class Factory { fun handler(): routes.Handler = TODO() }", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/factory/Factory.kt", + ), + ), + ).toBe(false); + }); + + it("does not resolve JVM default return types through wildcard imports", async () => { + const root = await fixtureRoot("clawpatch-kotlin-jvm-default-wildcard-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.*", + "", + "class JobFactory {", + " fun worker(): Runnable = Runnable { }", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ), + ).toBe(false); + }); + + it("does not resolve explicitly imported Kotlin stdlib return types as framework roles", async () => { + const root = await fixtureRoot("clawpatch-kotlin-stdlib-direct-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/time/Timer.kt", + [ + "package com.example.time", + "", + "import kotlin.time.Duration", + "", + "class Timer {", + " fun elapsed(): Duration = Duration.ZERO", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/time/Timer.kt", + ), + ), + ).toBe(false); + }); + + it("resolves explicit Kotlin imports that shadow default built-in names", async () => { + const root = await fixtureRoot("clawpatch-kotlin-explicit-builtin-shadow-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/api/Controller.kt", + [ + "package com.example.api", + "", + "import com.external.Result", + "", + "class Controller {", + " fun result(): Result = TODO()", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/api/Controller.kt", + ), + ); + + expect(framework?.ownedFiles[0]?.reason).toContain("returns external type com.external.Result"); + }); + + it("does not resolve local Kotlin declarations through wildcard imports", async () => { + const root = await fixtureRoot("clawpatch-kotlin-local-wildcard-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.*", + "", + "data class Job(val id: String)", + "", + "class JobFactory {", + ' fun buildJob(): Job = Job("1")', + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ), + ).toBe(false); + }); + + it("does not resolve package-local Kotlin declarations through wildcard imports", async () => { + const root = await fixtureRoot("clawpatch-kotlin-package-local-wildcard-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/Job.kt", + "package com.example.jobs\nclass Job(val id: String)\n", + ); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.*", + "", + "class JobFactory {", + ' fun buildJob(): Job = Job("1")', + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ), + ).toBe(false); + }); + + it("prefers local Kotlin wildcard declarations over earlier external wildcards", async () => { + const root = await fixtureRoot("clawpatch-kotlin-local-wildcard-precedence-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/Job.kt", + "package com.example.jobs\nclass Job(val id: String)\n", + ); + await writeFixture( + root, + "src/main/kotlin/com/example/factory/JobFactory.kt", + [ + "package com.example.factory", + "", + "import org.scheduler.*", + "import com.example.jobs.*", + "", + "class JobFactory {", + ' fun buildJob(): Job = Job("1")', + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/factory/JobFactory.kt", + ), + ), + ).toBe(false); + }); + + it("skips non-matching local Kotlin wildcard imports before external wildcards", async () => { + const root = await fixtureRoot("clawpatch-kotlin-local-wildcard-skip-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/local/Other.kt", + "package com.example.local\nclass Other\n", + ); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import com.example.local.*", + "import org.scheduler.*", + "", + "class JobFactory : JobFactoryBase()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(framework?.ownedFiles[0]?.reason).toContain( + "inherits external type org.scheduler.JobFactoryBase", + ); + }); + + it("skips non-external Kotlin wildcard imports before external wildcards", async () => { + const root = await fixtureRoot("clawpatch-kotlin-non-external-wildcard-skip-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import java.util.*", + "import org.scheduler.*", + "", + "class JobFactory : JobFactoryBase()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(framework?.ownedFiles[0]?.reason).toContain( + "inherits external type org.scheduler.JobFactoryBase", + ); + }); + + it("does not resolve same-package Java declarations through wildcard imports", async () => { + const root = await fixtureRoot("clawpatch-kotlin-java-wildcard-type-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/java/com/example/jobs/Job.java", + "package com.example.jobs;\npublic class Job {}\n", + ); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.*", + "", + "class JobFactory {", + " fun buildJob(): Job = Job()", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ), + ).toBe(false); + }); + + it("preserves path roles for Kotlin interfaces", async () => { + const root = await fixtureRoot("clawpatch-kotlin-interface-path-roles-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/network/RemoteApi.kt", + "package com.example.network\ninterface RemoteApi { fun call(): String }\n", + ); + await writeFixture( + root, + "src/main/kotlin/com/example/repository/UserRepository.kt", + [ + "package com.example.repository", + "", + "import kotlinx.coroutines.flow.Flow", + "", + "interface UserRepository { fun users(): Flow }", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-external-client" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/network/RemoteApi.kt", + ), + ), + ).toBe(true); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-persistence-boundary" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/repository/UserRepository.kt", + ), + ), + ).toBe(true); + expect( + result.features.some( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/repository/UserRepository.kt", + ), + ), + ).toBe(true); + }); + + it("maps Kotlin supertypes after annotated primary constructors", async () => { + const root = await fixtureRoot("clawpatch-kotlin-annotated-constructor-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import javax.inject.Inject", + "import org.scheduler.JobFactoryBase", + "", + "class JobFactory @Inject constructor(private val dep: String) : JobFactoryBase()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(framework?.ownedFiles[0]?.reason).toContain("external type org.scheduler."); + }); + + it("maps Kotlin supertypes after visibility-before-annotation constructors", async () => { + const root = await fixtureRoot("clawpatch-kotlin-constructor-modifier-order-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import javax.inject.Inject", + "import org.scheduler.JobFactoryBase", + "", + "class JobFactory public @Inject constructor(private val dep: String) : JobFactoryBase()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(framework?.ownedFiles[0]?.reason).toContain("external type org.scheduler."); + }); + + it("maps Kotlin supertypes after function-typed constructor parameters", async () => { + const root = await fixtureRoot("clawpatch-kotlin-function-param-constructor-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.JobFactoryBase", + "", + "class JobFactory(cb: () -> Unit) : JobFactoryBase()", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(framework?.ownedFiles[0]?.reason).toContain("external type org.scheduler."); + }); + + it("maps Kotlin supertypes with constructor call commas", async () => { + const root = await fixtureRoot("clawpatch-kotlin-supertype-call-comma-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.JobFactoryBase", + "", + 'class JobFactory : JobFactoryBase("a", "b")', + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(framework?.ownedFiles[0]?.reason).toContain("external type org.scheduler."); + }); + + it("maps Kotlin supertypes with named constructor arguments", async () => { + const root = await fixtureRoot("clawpatch-kotlin-supertype-named-arg-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.JobFactoryBase", + "", + 'class JobFactory : JobFactoryBase(name = "jobs")', + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const framework = result.features.find( + (feature) => + feature.source === "kotlin-server-role-framework-component" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/jobs/JobFactory.kt", + ), + ); + + expect(framework?.ownedFiles[0]?.reason).toContain("external type org.scheduler."); + }); + it("normalizes root Gradle source groups", async () => { const root = await fixtureRoot("clawpatch-root-gradle-map-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 5f7a2eb..1625fff 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -7,6 +7,179 @@ import { FeatureSeed, SeedTestRef } from "./types.js"; const maxOwnedFiles = 12; const maxTests = 8; +const emptyProjectPackages = new Set(); +const kotlinBuiltinTypes = new Set([ + "AbstractMethodError", + "AbstractCollection", + "AbstractIterator", + "AbstractList", + "AbstractMap", + "AbstractMutableCollection", + "AbstractMutableList", + "AbstractMutableMap", + "AbstractMutableSet", + "AbstractSet", + "Annotation", + "Appendable", + "ArithmeticException", + "Any", + "Array", + "ArrayDeque", + "ArrayIndexOutOfBoundsException", + "ArrayList", + "AssertionError", + "AutoCloseable", + "Boolean", + "BooleanArray", + "BooleanIterator", + "Byte", + "ByteArray", + "ByteIterator", + "Char", + "CharArray", + "CharCategory", + "CharDirection", + "CharIterator", + "CharProgression", + "CharRange", + "CharSequence", + "Class", + "ClassCastException", + "ClassLoader", + "ClassNotFoundException", + "Cloneable", + "ClosedFloatingPointRange", + "ClosedRange", + "Collection", + "Comparable", + "Comparator", + "ConcurrentModificationException", + "DeepRecursiveFunction", + "DeepRecursiveScope", + "Double", + "DoubleArray", + "DoubleIterator", + "Enum", + "Error", + "Exception", + "Float", + "FloatArray", + "FloatIterator", + "Grouping", + "HashMap", + "HashSet", + "IndexedValue", + "IllegalArgumentException", + "IllegalMonitorStateException", + "IllegalStateException", + "IllegalThreadStateException", + "IndexOutOfBoundsException", + "InheritableThreadLocal", + "Int", + "Integer", + "IntArray", + "IntIterator", + "IntProgression", + "IntRange", + "InterruptedException", + "InternalError", + "Iterable", + "Iterator", + "KotlinVersion", + "Lazy", + "LazyThreadSafetyMode", + "LinkedHashMap", + "LinkedHashSet", + "List", + "ListIterator", + "Long", + "LongArray", + "LongIterator", + "LongProgression", + "LongRange", + "Map", + "Math", + "MatchGroup", + "MatchGroupCollection", + "MatchNamedGroupCollection", + "MatchResult", + "MutableEntry", + "MutableCollection", + "MutableIterable", + "MutableIterator", + "MutableList", + "MutableListIterator", + "MutableMap", + "MutableSet", + "NegativeArraySizeException", + "NoClassDefFoundError", + "NoSuchElementException", + "NoSuchFieldError", + "NoSuchFieldException", + "NoSuchMethodError", + "NoSuchMethodException", + "NoWhenBranchMatchedException", + "NullPointerException", + "Nothing", + "NotImplementedError", + "Number", + "Object", + "OutOfMemoryError", + "OpenEndRange", + "Package", + "Pair", + "Process", + "ProcessBuilder", + "RandomAccess", + "Readable", + "ReflectiveOperationException", + "Regex", + "RegexOption", + "Result", + "Runnable", + "Runtime", + "RuntimeException", + "SecurityException", + "Sequence", + "Set", + "Short", + "ShortArray", + "ShortIterator", + "String", + "StringBuffer", + "StringBuilder", + "SubclassOptInRequired", + "System", + "Thread", + "ThreadGroup", + "ThreadLocal", + "Throwable", + "Triple", + "TypeNotPresentException", + "UByte", + "UByteArray", + "UByteIterator", + "UInt", + "UIntArray", + "UIntIterator", + "UIntProgression", + "UIntRange", + "ULong", + "ULongArray", + "ULongIterator", + "ULongProgression", + "ULongRange", + "Unit", + "UninitializedPropertyAccessException", + "UnknownError", + "UnsatisfiedLinkError", + "UnsupportedClassVersionError", + "UnsupportedOperationException", + "UShort", + "UShortArray", + "UShortIterator", + "Void", +]); const jvmRoleDefinitions = { "web-entrypoint": { title: "web entrypoint", @@ -173,27 +346,18 @@ type KotlinDeclaration = { type KotlinFileInfo = { packageName: string | null; annotations: Set; - annotationImports: Map; + qualifiedAnnotations: Set; + unqualifiedAnnotations: Set; imports: Map; declarations: KotlinDeclaration[]; functionReturnTypes: Set; }; -const kotlinServerWebAnnotationNames = new Set([ - "Controller", - "RestController", - "RequestMapping", - "GetMapping", - "PostMapping", - "PutMapping", - "DeleteMapping", - "PatchMapping", - "Path", - "GET", - "POST", - "PUT", - "DELETE", - "PATCH", -]); +type ParsedKotlinFile = { filePath: string; info: KotlinFileInfo }; +type KotlinProjectIndex = { + files: ParsedKotlinFile[]; + packages: Set; + packageTypes: Map>; +}; export async function gradleSeeds(root: string): Promise { const roots = await discoverGradleRoots(root); @@ -206,6 +370,8 @@ export async function gradleSeeds(root: string): Promise { async function gradleProjectSeeds(root: string, gradleRoot: string): Promise { const moduleRoots = await gradleModuleRoots(root, gradleRoot); + const projectSourceFiles = await gradleMainSourceFiles(root, moduleRoots); + const kotlinProjectIndex = await gradleKotlinProjectIndex(root, projectSourceFiles); const seeds: FeatureSeed[] = []; for (const moduleRoot of moduleRoots) { const buildFile = await gradleBuildFile(root, moduleRoot); @@ -219,7 +385,7 @@ async function gradleProjectSeeds(root: string, gradleRoot: string): Promise isGradleTestFile(moduleRoot, file)); - const tags = await gradleTags(root, buildFile, sourceFiles); + const tags = await gradleTags(root, gradleRoot, buildFile, sourceFiles); seeds.push({ title: `Gradle module ${moduleRoot}`, @@ -264,7 +430,15 @@ async function gradleProjectSeeds(root: string, gradleRoot: string): Promise 0) { @@ -293,6 +467,37 @@ async function gradleProjectSeeds(root: string, gradleRoot: string): Promise { + const files = await gradleKotlinFiles(root, projectSourceFiles, []); + if (files.length === 0) { + return null; + } + return { + files, + packages: await gradleProjectPackages(root, projectSourceFiles, files), + packageTypes: await kotlinPackageDeclarations(root, projectSourceFiles, files), + }; +} + +async function gradleMainSourceFiles(root: string, moduleRoots: string[]): Promise { + const files = new Set(); + for (const moduleRoot of moduleRoots) { + if ((await gradleBuildFile(root, moduleRoot)) === null) { + continue; + } + const sourceRoot = moduleRoot === "." ? "src" : `${moduleRoot}/src`; + for (const file of (await walk(root, [sourceRoot])) + .filter(isGradleSourceFile) + .filter((path) => !isGradleTestFile(moduleRoot, path))) { + files.add(file); + } + } + return [...files].toSorted(); +} + async function kotlinRoleSeeds( root: string, buildFile: string, @@ -300,53 +505,58 @@ async function kotlinRoleSeeds( sourceFiles: string[], testFiles: string[], tags: string[], + projectIndex: KotlinProjectIndex | null, ): Promise { + if (projectIndex === null) { + return []; + } const matches = new Map< KotlinRoleKey, Map> >(); - const kotlinFiles: Array<{ filePath: string; info: KotlinFileInfo }> = []; - for (const filePath of sourceFiles.filter((file) => file.endsWith(".kt"))) { - const source = await readFile(join(root, filePath), "utf8"); - kotlinFiles.push({ filePath, info: parseKotlinFile(source) }); - } + const sourceFileSet = new Set(sourceFiles); + const kotlinFiles = projectIndex.files.filter(({ filePath }) => sourceFileSet.has(filePath)); if (kotlinFiles.length === 0) { return []; } - const javaFiles: Array<{ filePath: string; info: JavaFileInfo }> = []; - for (const filePath of sourceFiles.filter((file) => file.endsWith(".java"))) { - const source = await readFile(join(root, filePath), "utf8"); - javaFiles.push({ filePath, info: parseJavaFile(source) }); - } - const projectPackages = new Set( - [...kotlinFiles, ...javaFiles].flatMap(({ info }) => - info.packageName === null ? [] : [info.packageName], - ), - ); - const projectTypes = new Set([ - ...kotlinFiles.flatMap(({ info }) => info.declarations.map((declaration) => declaration.name)), - ...javaFiles.flatMap(({ info }) => info.declarations.map((declaration) => declaration.name)), - ]); - const projectPackageTypes = new Set( - [...kotlinFiles, ...javaFiles].flatMap(({ info }) => - info.packageName === null - ? [] - : info.declarations.map((declaration) => `${info.packageName}.${declaration.name}`), - ), - ); for (const { filePath, info } of kotlinFiles) { const frameworkEvidence = kotlinFrameworkRoleEvidence( info, tags, - projectPackages, - projectTypes, - projectPackageTypes, + projectIndex.packages, + projectIndex.packageTypes, ); - const evidence = kotlinEvidenceWithPathFallback( - frameworkEvidence, - kotlinPathRoleEvidence(filePath, tags), + const hasStrongServerRole = + !tags.includes("android") && + frameworkEvidence.some( + (item) => + item.confidence === "high" && + item.role !== "server-framework-component" && + item.role !== "server-extension-boundary", + ); + const hasStrongAndroidNonDiRole = + tags.includes("android") && + frameworkEvidence.some( + (item) => + item.confidence === "high" && + item.role.startsWith("android-") && + item.role !== "android-dependency-injection", + ); + const pathEvidence = kotlinPathRoleEvidence(filePath, tags).filter( + (item) => + !hasStrongServerRole && + !hasStrongAndroidNonDiRole && + !frameworkEvidence.some((evidenceItem) => evidenceItem.role === item.role) && + !( + tags.includes("android") && + item.role === "android-ui-entrypoint" && + frameworkEvidence.some((evidenceItem) => + ["android-data-boundary", "android-view-model"].includes(evidenceItem.role), + ) + ), ); + const evidence = [...frameworkEvidence, ...pathEvidence]; for (const item of evidence) { const byFile = matches.get(item.role) ?? new Map(); const reasons = byFile.get(filePath) ?? []; @@ -395,6 +605,21 @@ async function kotlinRoleSeeds( return seeds; } +async function gradleKotlinFiles( + root: string, + sourceFiles: string[], + parsedFiles: ParsedKotlinFile[], +): Promise { + const byPath = new Map(parsedFiles.map((file) => [file.filePath, file])); + for (const filePath of sourceFiles.filter((file) => file.endsWith(".kt"))) { + if (!byPath.has(filePath)) { + const source = await readFile(join(root, filePath), "utf8"); + byPath.set(filePath, { filePath, info: parseKotlinFile(source) }); + } + } + return [...byPath.values()]; +} + function kotlinRoleGroups( sourceRoot: string, byFile: Map>, @@ -404,16 +629,24 @@ function kotlinRoleGroups( label: string; symbol: string; }> { - return partitionFileGroups(sourceRoot, [...byFile.keys()], maxOwnedFiles).map((group) => ({ - confidence: group.files.some((path) => - (byFile.get(path) ?? []).some((item) => item.confidence === "high"), - ) - ? "high" - : "medium", - group, - label: group.label, - symbol: group.label, - })); + return partitionFileGroups(sourceRoot, [...byFile.keys()], maxOwnedFiles).map((group) => { + const confidence = kotlinGroupConfidence(group.files, byFile); + return { + confidence, + group, + label: group.label, + symbol: group.label, + }; + }); +} + +function kotlinGroupConfidence( + files: string[], + byFile: Map>, +): FeatureSeed["confidence"] { + return files.some((path) => (byFile.get(path) ?? []).some((item) => item.confidence === "high")) + ? "high" + : "medium"; } function kotlinRoleSource(role: KotlinRoleKey): string { @@ -423,24 +656,49 @@ function kotlinRoleSource(role: KotlinRoleKey): string { return `kotlin-server-role-${role.slice("server-".length)}`; } -function kotlinEvidenceWithPathFallback( - frameworkEvidence: KotlinRoleEvidence[], - pathEvidence: KotlinRoleEvidence[], -): KotlinRoleEvidence[] { - if (frameworkEvidence.every((item) => item.role === "server-extension-boundary")) { - return dedupeKotlinEvidence([...frameworkEvidence, ...pathEvidence]); - } - if (frameworkEvidence.every((item) => item.role === "android-dependency-injection")) { - return dedupeKotlinEvidence([ - ...frameworkEvidence, - ...pathEvidence.filter((item) => - ["android-ui-entrypoint", "android-data-boundary", "android-external-client"].includes( - item.role, - ), - ), - ]); +async function gradleProjectPackages( + root: string, + sourceFiles: string[], + kotlinFiles: ParsedKotlinFile[], +): Promise> { + const packages = new Set( + kotlinFiles.flatMap(({ info }) => (info.packageName === null ? [] : [info.packageName])), + ); + for (const filePath of sourceFiles.filter((file) => file.endsWith(".java"))) { + const source = await readFile(join(root, filePath), "utf8"); + const packageName = parseJavaFile(source).packageName; + if (packageName !== null) { + packages.add(packageName); + } } - return frameworkEvidence; + return packages; +} + +async function kotlinPackageDeclarations( + root: string, + sourceFiles: string[], + kotlinFiles: ParsedKotlinFile[], +): Promise>> { + const declarations = new Map>(); + for (const { info } of kotlinFiles) { + const packageName = info.packageName ?? ""; + const packageTypes = declarations.get(packageName) ?? new Set(); + for (const declaration of info.declarations) { + packageTypes.add(declaration.name); + } + declarations.set(packageName, packageTypes); + } + for (const filePath of sourceFiles.filter((file) => file.endsWith(".java"))) { + const source = await readFile(join(root, filePath), "utf8"); + const info = parseJavaFile(source); + const packageName = info.packageName ?? ""; + const packageTypes = declarations.get(packageName) ?? new Set(); + for (const declaration of info.declarations) { + packageTypes.add(declaration.name); + } + declarations.set(packageName, packageTypes); + } + return declarations; } async function jvmRoleSeeds( @@ -516,8 +774,7 @@ function kotlinFrameworkRoleEvidence( info: KotlinFileInfo, tags: string[], projectPackages: Set, - projectTypes: Set, - projectPackageTypes: Set, + kotlinPackageTypes: Map>, ): KotlinRoleEvidence[] { const evidence: KotlinRoleEvidence[] = []; const isAndroid = tags.includes("android"); @@ -552,8 +809,6 @@ function kotlinFrameworkRoleEvidence( "InstallIn", "Provides", "Binds", - "Inject", - "Singleton", "Component", "DependencyGraph", "BindingContainer", @@ -566,7 +821,7 @@ function kotlinFrameworkRoleEvidence( confidence: "high", }); } - if (!isAndroid && isKotlinServerWebAnnotation(annotation, info)) { + if (!isAndroid && isKotlinServerWebAnnotation(info, annotation)) { evidence.push({ role: "server-web-entrypoint", reason: `server web annotation @${annotation}`, @@ -599,11 +854,11 @@ function kotlinFrameworkRoleEvidence( } } - for (const full of info.imports.values()) { - if (isAndroid && isAndroidEntrypointImport(full)) { + for (const [importedName, full] of info.imports.entries()) { + if (isAndroid && isAndroidUiEntrypointImport(full)) { evidence.push({ role: "android-ui-entrypoint", - reason: `Android entrypoint import ${full}`, + reason: `Android UI import ${full}`, confidence: "high", }); } @@ -634,8 +889,6 @@ function kotlinFrameworkRoleEvidence( if ( isAndroid && (full.startsWith("dagger.") || - full.startsWith("javax.inject.") || - full.startsWith("jakarta.inject.") || full.startsWith("org.koin.") || full.startsWith("me.tatarka.inject.") || full.startsWith("dev.zacsweers.metro.")) @@ -649,7 +902,13 @@ function kotlinFrameworkRoleEvidence( confidence: "high", }); } - if (!isAndroid && isKotlinServerWebImport(full)) { + if ( + !isAndroid && + (isKotlinServerWebAnnotationImportUsed(info, importedName, full) || + full.startsWith("io.ktor.server.") || + full.startsWith("org.http4k.") || + full.startsWith("io.javalin.")) + ) { evidence.push({ role: "server-web-entrypoint", reason: `server web import ${full}`, @@ -686,37 +945,21 @@ function kotlinFrameworkRoleEvidence( for (const declaration of info.declarations) { for (const type of declaration.supertypes) { - if ( - isAndroid && - kotlinImportedTypeMatches(info, type, [ - "android.app.Activity", - "android.app.Service", - "android.content.BroadcastReceiver", - "androidx.activity.ComponentActivity", - "androidx.appcompat.app.AppCompatActivity", - "androidx.fragment.app.Fragment", - ]) - ) { + if (isAndroid && isAndroidUiEntrypointSupertype(info, type, kotlinPackageTypes)) { evidence.push({ role: "android-ui-entrypoint", reason: `inherits Android UI type ${type}`, confidence: "high", }); } - if ( - isAndroid && - kotlinImportedTypeMatches(info, type, [ - "androidx.lifecycle.ViewModel", - "androidx.lifecycle.AndroidViewModel", - ]) - ) { + if (isAndroid && isAndroidViewModelSupertype(info, type, kotlinPackageTypes)) { evidence.push({ role: "android-view-model", reason: `inherits Android ViewModel type ${type}`, confidence: "high", }); } - if (isAndroid && kotlinImportedTypeMatches(info, type, ["androidx.room.RoomDatabase"])) { + if (isAndroid && isAndroidRoomSupertype(info, type, kotlinPackageTypes)) { evidence.push({ role: "android-data-boundary", reason: `inherits Room type ${type}`, @@ -726,52 +969,17 @@ function kotlinFrameworkRoleEvidence( } } if (!isAndroid) { - evidence.push( - ...kotlinDeclarationRoleEvidence(info, projectPackages, projectTypes, projectPackageTypes), - ); - evidence.push( - ...kotlinFunctionReturnRoleEvidence(info, projectPackages, projectTypes, projectPackageTypes), - ); + evidence.push(...kotlinDeclarationRoleEvidence(info, projectPackages, kotlinPackageTypes)); + evidence.push(...kotlinFunctionReturnRoleEvidence(info, projectPackages, kotlinPackageTypes)); } return dedupeKotlinEvidence(evidence); } -function isKotlinServerWebAnnotation(annotation: string, info: KotlinFileInfo): boolean { - if (!kotlinServerWebAnnotationNames.has(annotation)) { - return false; - } - const qualified = info.annotationImports.get(annotation); - if (qualified !== undefined) { - return isKotlinServerWebImport(qualified); - } - const imported = info.imports.get(annotation); - if (imported !== undefined) { - return isKotlinServerWebImport(imported); - } - for (const full of info.imports.values()) { - if (full.endsWith(".*") && isKotlinServerWebImport(full)) { - return true; - } - } - return false; -} - -function isKotlinServerWebImport(full: string): boolean { - return ( - full.startsWith("org.springframework.web.bind.annotation.") || - full.startsWith("io.ktor.server.") || - full.startsWith("org.http4k.") || - full.startsWith("io.javalin.") || - /^(?:jakarta|javax)\.ws\.rs\./u.test(full) - ); -} - function kotlinDeclarationRoleEvidence( info: KotlinFileInfo, projectPackages: Set, - projectTypes: Set, - projectPackageTypes: Set, + kotlinPackageTypes: Map>, ): KotlinRoleEvidence[] { const evidence: KotlinRoleEvidence[] = []; for (const declaration of info.declarations) { @@ -783,13 +991,7 @@ function kotlinDeclarationRoleEvidence( }); } for (const type of declaration.supertypes) { - const full = kotlinImportForType( - info, - type, - projectTypes, - projectPackages, - projectPackageTypes, - ); + const full = kotlinImportForType(info, type, kotlinPackageTypes); if (full !== undefined && isExternalProjectImport(full, projectPackages)) { evidence.push({ role: "server-framework-component", @@ -802,37 +1004,14 @@ function kotlinDeclarationRoleEvidence( return evidence; } -function kotlinImportedTypeMatches(info: KotlinFileInfo, type: string, allowed: string[]): boolean { - if (allowed.includes(type)) { - return true; - } - const direct = info.imports.get(type); - if (direct !== undefined && allowed.includes(direct)) { - return true; - } - for (const full of info.imports.values()) { - if (full.endsWith(".*") && allowed.includes(`${full.slice(0, -1)}${type}`)) { - return true; - } - } - return false; -} - function kotlinFunctionReturnRoleEvidence( info: KotlinFileInfo, projectPackages: Set, - projectTypes: Set, - projectPackageTypes: Set, + kotlinPackageTypes: Map>, ): KotlinRoleEvidence[] { const evidence: KotlinRoleEvidence[] = []; for (const type of info.functionReturnTypes) { - const full = kotlinImportForType( - info, - type, - projectTypes, - projectPackages, - projectPackageTypes, - ); + const full = kotlinImportForType(info, type, kotlinPackageTypes); if (full !== undefined && isExternalProjectImport(full, projectPackages)) { evidence.push({ role: "server-framework-component", @@ -847,80 +1026,131 @@ function kotlinFunctionReturnRoleEvidence( function kotlinImportForType( info: KotlinFileInfo, type: string, - projectTypes: Set, - projectPackages: Set, - projectPackageTypes: Set, + kotlinPackageTypes: Map>, ): string | undefined { - if (type.includes(".")) { - const rootType = type.split(".")[0]; - if (rootType !== undefined && projectTypes.has(rootType)) { - return undefined; - } - return type.startsWith("kotlin.") ? undefined : type; + const [rootType, ...nestedParts] = type.split("."); + const isNestedType = nestedParts.length > 0; + if (rootType === undefined || rootType.length === 0) { + return undefined; } - if (info.declarations.some((declaration) => declaration.name === type)) { + if (isKotlinStdlibImport(type)) { return undefined; } - if (info.packageName !== null && projectPackageTypes.has(`${info.packageName}.${type}`)) { + const packageName = info.packageName ?? ""; + if ( + info.declarations.some((declaration) => declaration.name === rootType) || + kotlinPackageTypes.get(packageName)?.has(rootType) === true + ) { return undefined; } + if (isNestedType) { + const directRoot = info.imports.get(rootType); + if (directRoot !== undefined) { + const full = `${directRoot}.${nestedParts.join(".")}`; + return isKotlinStdlibImport(full) ? undefined : full; + } + } + if (isNestedType && /^[a-z]/u.test(rootType)) { + return type; + } const direct = info.imports.get(type); if (direct !== undefined) { - return direct.startsWith("kotlin.") ? undefined : direct; + return isKotlinStdlibImport(direct) ? undefined : direct; + } + if (isKotlinBuiltinType(rootType)) { + return undefined; } - if (isKotlinImplicitType(type)) { + if (!isNestedType && isKotlinBuiltinType(type)) { return undefined; } + for (const full of info.imports.values()) { + if (full.endsWith(".*") && kotlinPackageTypes.get(full.slice(0, -2))?.has(rootType) === true) { + return undefined; + } + } + if (isNestedType) { + for (const full of info.imports.values()) { + if (full.endsWith(".*")) { + if (kotlinPackageTypes.has(full.slice(0, -2))) { + continue; + } + const wildcardType = `${full.slice(0, -1)}${type}`; + if (isKotlinExternalCandidateImport(wildcardType)) { + return wildcardType; + } + } + } + return type; + } for (const full of info.imports.values()) { if (full.endsWith(".*")) { - if (full.startsWith("kotlin.")) { + if (kotlinPackageTypes.has(full.slice(0, -2))) { continue; } - const candidate = `${full.slice(0, -1)}${type}`; - if (isExternalProjectImport(candidate, projectPackages)) { - return candidate; + const wildcardType = `${full.slice(0, -1)}${type}`; + if (isKotlinExternalCandidateImport(wildcardType)) { + return wildcardType; } } } - if (projectTypes.has(type)) { - return undefined; - } return undefined; } -function isKotlinImplicitType(type: string): boolean { - return [ - "Any", - "Array", - "Boolean", - "Byte", - "Char", - "CharSequence", - "Collection", - "Double", - "Exception", - "Float", - "Int", - "Iterable", - "List", - "Long", - "Map", - "MutableCollection", - "MutableList", - "MutableMap", - "MutableSet", - "Nothing", - "Number", - "Pair", - "Result", - "Sequence", - "Set", - "Short", - "String", - "Throwable", - "Triple", - "Unit", - ].includes(type); +function kotlinTypeMatchesImport( + info: KotlinFileInfo, + type: string, + kotlinPackageTypes: Map>, + matches: (full: string) => boolean, +): boolean { + const full = kotlinImportForType(info, type, kotlinPackageTypes); + if (full !== undefined && matches(full)) { + return true; + } + + const [rootType, ...nestedParts] = type.split("."); + const isNestedType = nestedParts.length > 0; + if (rootType === undefined || rootType.length === 0) { + return false; + } + if ((isNestedType && info.imports.has(rootType)) || (!isNestedType && info.imports.has(type))) { + return false; + } + + const packageName = info.packageName ?? ""; + if ( + info.declarations.some((declaration) => declaration.name === rootType) || + kotlinPackageTypes.get(packageName)?.has(rootType) === true + ) { + return false; + } + + for (const candidate of kotlinWildcardImportCandidates(info, type, kotlinPackageTypes)) { + if (matches(candidate)) { + return true; + } + } + + return isNestedType && matches(type); +} + +function kotlinWildcardImportCandidates( + info: KotlinFileInfo, + type: string, + kotlinPackageTypes: Map>, +): string[] { + const [rootType] = type.split("."); + if (rootType === undefined || rootType.length === 0) { + return []; + } + for (const full of info.imports.values()) { + if (full.endsWith(".*") && kotlinPackageTypes.get(full.slice(0, -2))?.has(rootType) === true) { + return []; + } + } + return [...info.imports.values()] + .filter((full) => full.endsWith(".*") && !kotlinPackageTypes.has(full.slice(0, -2))) + .map((full) => `${full.slice(0, -1)}${type}`) + .filter(isKotlinExternalCandidateImport); } function kotlinPathRoleEvidence(filePath: string, tags: string[]): KotlinRoleEvidence[] { @@ -965,6 +1195,94 @@ function kotlinPathRoleEvidence(filePath: string, tags: string[]): KotlinRoleEvi return evidence; } +function isKotlinBuiltinType(type: string): boolean { + return kotlinBuiltinTypes.has(type); +} + +function isKotlinStdlibImport(full: string): boolean { + return full.startsWith("kotlin."); +} + +function isKotlinExternalCandidateImport(full: string): boolean { + return isExternalProjectImport(full, emptyProjectPackages); +} + +function isKotlinServerWebAnnotation(info: KotlinFileInfo, annotation: string): boolean { + if ( + ![ + "Controller", + "RestController", + "RequestMapping", + "GetMapping", + "PostMapping", + "PutMapping", + "DeleteMapping", + "PatchMapping", + "Path", + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + ].includes(annotation) + ) { + return false; + } + for (const full of info.qualifiedAnnotations) { + if (full.split(".").at(-1) === annotation && isKotlinServerWebImport(full)) { + return true; + } + } + if ( + !info.unqualifiedAnnotations.has(annotation) && + [...info.qualifiedAnnotations].some((full) => full.split(".").at(-1) === annotation) + ) { + return false; + } + const imported = info.imports.get(annotation); + if (imported !== undefined) { + return isKotlinServerWebImport(imported); + } + for (const full of info.imports.values()) { + if (full.endsWith(".*") && isKotlinServerWebImport(full)) { + return true; + } + } + return false; +} + +function isKotlinServerWebAnnotationImportUsed( + info: KotlinFileInfo, + importedName: string, + full: string, +): boolean { + if (!isKotlinServerWebAnnotationImport(full)) { + return false; + } + if (full.endsWith(".*")) { + return [...info.unqualifiedAnnotations].some((annotation) => + isKotlinServerWebAnnotation(info, annotation), + ); + } + return info.unqualifiedAnnotations.has(importedName); +} + +function isKotlinServerWebImport(full: string): boolean { + return ( + isKotlinServerWebAnnotationImport(full) || + full.startsWith("io.ktor.server.") || + full.startsWith("org.http4k.") || + full.startsWith("io.javalin.") + ); +} + +function isKotlinServerWebAnnotationImport(full: string): boolean { + return ( + full.startsWith("org.springframework.web.bind.annotation.") || + /^(?:jakarta|javax)\.ws\.rs\./u.test(full) + ); +} + function parseJavaFile(source: string): JavaFileInfo { const stripped = stripJavaComments(source); const packageName = /^\s*package\s+([A-Za-z0-9_.]+)\s*;/mu.exec(stripped)?.[1] ?? null; @@ -1022,34 +1340,37 @@ function parseKotlinFile(source: string): KotlinFileInfo { } const annotations = new Set(); - const annotationImports = new Map(); + const qualifiedAnnotations = new Set(); + const unqualifiedAnnotations = new Set(); for (const match of stripped.matchAll( /@(?:[A-Za-z_][A-Za-z0-9_]*:)?([A-Za-z_][A-Za-z0-9_.]*)/gu, )) { const raw = match[1]; if (raw !== undefined) { - const simple = raw.split(".").at(-1) ?? raw; - annotations.add(simple); + annotations.add(raw.split(".").at(-1) ?? raw); if (raw.includes(".")) { - annotationImports.set(simple, raw); + qualifiedAnnotations.add(raw); + } else { + unqualifiedAnnotations.add(raw); } } } const functionReturnTypes = new Set(); for (const match of stripped.matchAll( - /\bfun\s*(?:<[^>{}\n]*>\s*)?(?:[A-Za-z_][A-Za-z0-9_.]*\s*\.\s*)?[A-Za-z_][A-Za-z0-9_]*\s*\([^(){}]*\)\s*:\s*([^=\n{]+)/gu, + /\bfun\s*(?:<[^>{}\n]*>\s*)?(?:[A-Za-z_][A-Za-z0-9_.]*\s*\.\s*)?[A-Za-z_][A-Za-z0-9_]*\s*\((?:[^(){}]|\([^(){}]*\))*\)\s*:\s*([^=\n{]+)/gu, )) { const type = match[1]; if (type !== undefined) { - functionReturnTypes.add(baseKotlinTypeName(stripGenericParameters(type))); + functionReturnTypes.add(kotlinTypeReferenceName(type)); } } return { packageName, annotations, - annotationImports, + qualifiedAnnotations, + unqualifiedAnnotations, imports, declarations: parseKotlinDeclarations(stripped), functionReturnTypes, @@ -1078,11 +1399,8 @@ function parseJavaDeclarations(source: string): JavaDeclaration[] { function parseKotlinDeclarations(source: string): KotlinDeclaration[] { const declarations: KotlinDeclaration[] = []; - const primaryConstructor = String.raw`\((?:[^(){}]|\([^(){}]*\))*\)`; - const declarationPattern = new RegExp( - String.raw`\b(?:(?:data|sealed|open|abstract|final|inner|value|annotation)\s+)*(?:(enum)\s+)?(?:(fun)\s+)?(class|interface|object)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*<[^{};]*>)?(?:\s+(?:(?:@[A-Za-z_][A-Za-z0-9_.]*(?:\([^{}]*?\))?|public|private|protected|internal)\s+)*constructor\s*${primaryConstructor}|\s*${primaryConstructor})?(?:\s*:\s*([^{\n]+))?`, - "gsu", - ); + const declarationPattern = + /\b(?:(?:expect|actual|data|sealed|open|abstract|final|inner|value|annotation)\s+)*(?:(enum)\s+)?(?:(fun)\s+)?(class|interface|object)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*<[^{};]*>)?(?:(?:\s+(?:(?:@[A-Za-z_][A-Za-z0-9_.]*(?:\([^(){}]*\))?|public|private|protected|internal)\s+)*constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^{}]+?)(?=\s*(?:\{|\n\s*(?:@[A-Za-z_][A-Za-z0-9_.]*(?:\([^(){}]*\))?\s*)*(?:(?:expect|actual|public|private|protected|internal|const|lateinit|suspend|inline|tailrec|operator|infix|external)\s+)*(?:(?:(?:expect|actual|data|sealed|open|abstract|final|inner|value|annotation)\s+)*(?:enum\s+)?(?:fun\s+)?(?:class|interface|object)|fun|val|var|typealias)\s+|$)))?/gsu; for (const match of source.matchAll(declarationPattern)) { const rawKind = match[3]; const name = match[4]; @@ -1199,9 +1517,8 @@ function isExternalProjectImport(full: string, projectPackages: Set): bo return false; } if ( - full.startsWith("javax.") && - !full.startsWith("javax.servlet.") && - !full.startsWith("javax.ws.rs.") + /^(?:javax|jakarta)\./u.test(full) && + !/^(?:javax|jakarta)\.(?:servlet|ws\.rs)\./u.test(full) ) { return false; } @@ -1232,6 +1549,7 @@ function isKotlinExternalClientImport(full: string): boolean { isNetworkClientImport(full) || full.startsWith("retrofit2.") || full.startsWith("okhttp3.") || + full.startsWith("org.apache.http.") || full.startsWith("io.ktor.client.") || full.startsWith("io.grpc.") || full.startsWith("software.amazon.awssdk.") || @@ -1240,17 +1558,55 @@ function isKotlinExternalClientImport(full: string): boolean { ); } -function isAndroidEntrypointImport(full: string): boolean { +function isAndroidUiEntrypointImport(full: string): boolean { return [ "android.app.Activity", + "android.app.ListActivity", + "android.app.Service", "android.content.BroadcastReceiver", "androidx.activity.ComponentActivity", "androidx.appcompat.app.AppCompatActivity", + "androidx.fragment.app.DialogFragment", "androidx.fragment.app.Fragment", "androidx.lifecycle.LifecycleService", ].includes(full); } +function isAndroidUiEntrypointSupertype( + info: KotlinFileInfo, + type: string, + kotlinPackageTypes: Map>, +): boolean { + return kotlinTypeMatchesImport(info, type, kotlinPackageTypes, isAndroidUiEntrypointImport); +} + +function isAndroidViewModelSupertype( + info: KotlinFileInfo, + type: string, + kotlinPackageTypes: Map>, +): boolean { + return kotlinTypeMatchesImport( + info, + type, + kotlinPackageTypes, + (full) => + full === "androidx.lifecycle.ViewModel" || full === "androidx.lifecycle.AndroidViewModel", + ); +} + +function isAndroidRoomSupertype( + info: KotlinFileInfo, + type: string, + kotlinPackageTypes: Map>, +): boolean { + return kotlinTypeMatchesImport( + info, + type, + kotlinPackageTypes, + (full) => full === "androidx.room.RoomDatabase", + ); +} + function isSpringDataPersistenceImport(full: string): boolean { return ( full.startsWith("org.springframework.data.repository.") || @@ -1295,9 +1651,7 @@ function kotlinTypeNames(raw: string): string[] { current += char; } parts.push(current); - return parts - .map((type) => baseKotlinTypeName(stripGenericParameters(type))) - .filter((type) => type.length > 0); + return parts.map((type) => kotlinTypeReferenceName(type)).filter((type) => type.length > 0); } function baseJavaTypeName(raw: string): string { @@ -1316,11 +1670,24 @@ function baseKotlinTypeName(raw: string): string { raw .replace(/\([^()]*\)/gu, "") .replace(/\?.*$/su, "") - .replace(/[^A-Za-z0-9_.]/gu, "") + .split(".") + .at(-1) + ?.replace(/[^A-Za-z0-9_]/gu, "") .trim() ?? "" ); } +function kotlinTypeReferenceName(raw: string): string { + const type = stripGenericParameters(raw) + .replace(/\([^()]*\)/gu, "") + .replace(/\?.*$/su, "") + .trim(); + if (type.includes(".")) { + return type.replace(/[^A-Za-z0-9_.]/gu, ""); + } + return baseKotlinTypeName(type); +} + function splitJavaTypeList(raw: string): string[] { const parts: string[] = []; let depth = 0; @@ -1368,7 +1735,86 @@ function stripJavaComments(source: string): string { } function stripKotlinComments(source: string): string { - return stripJavaComments(source); + let stripped = ""; + let index = 0; + let depth = 0; + let stringMode: "char" | "double" | "raw" | null = null; + while (index < source.length) { + const char = source[index] ?? ""; + const pair = source.slice(index, index + 2); + const triple = source.slice(index, index + 3); + if (stringMode === null && pair === "/*") { + depth += 1; + stripped += " "; + index += 2; + continue; + } + if (depth > 0) { + if (pair === "*/") { + depth = Math.max(0, depth - 1); + stripped += " "; + index += 2; + } else { + stripped += char === "\n" ? "\n" : " "; + index += 1; + } + continue; + } + + if (stringMode === "raw") { + if (triple === '"""') { + stringMode = null; + stripped += " "; + index += 3; + } else { + stripped += char === "\n" ? "\n" : " "; + index += 1; + } + continue; + } + if (stringMode !== null) { + stripped += char === "\n" ? "\n" : " "; + if (char === "\\") { + stripped += source[index + 1] === "\n" ? "\n" : " "; + index += 2; + continue; + } + if ((stringMode === "double" && char === '"') || (stringMode === "char" && char === "'")) { + stringMode = null; + } + index += 1; + continue; + } + + if (triple === '"""') { + stringMode = "raw"; + stripped += " "; + index += 3; + continue; + } + if (char === '"') { + stringMode = "double"; + stripped += " "; + index += 1; + continue; + } + if (char === "'") { + stringMode = "char"; + stripped += " "; + index += 1; + continue; + } + if (pair === "//") { + while (index < source.length && source[index] !== "\n") { + stripped += " "; + index += 1; + } + continue; + } + stripped += char; + index += 1; + } + return stripped; } function dedupeEvidence(evidence: JvmRoleEvidence[]): JvmRoleEvidence[] { @@ -1422,13 +1868,12 @@ async function discoverGradleRootsInto( if (!info.isDirectory() || info.isSymbolicLink()) { return; } - const hasSettings = - (await pathExists(join(full, "settings.gradle"))) || - (await pathExists(join(full, "settings.gradle.kts"))); + const hasSettings = await hasGradleSettings(root, dir); if (hasSettings || (await gradleBuildFile(root, dir)) !== null) { roots.push(dir); } if (hasSettings) { + await discoverNestedGradleRootsInto(root, dir, remainingDepth - 1, roots); return; } for (const entry of await readdir(full)) { @@ -1443,6 +1888,34 @@ async function discoverGradleRootsInto( } } +async function discoverNestedGradleRootsInto( + root: string, + dir: string, + remainingDepth: number, + roots: string[], +): Promise { + if (remainingDepth < 0 || (dir !== "." && (shouldSkip(dir) || isSampleProjectPath(dir)))) { + return; + } + const full = dir === "." ? root : join(root, dir); + for (const entry of await readdir(full)) { + const child = dir === "." ? entry : `${dir}/${entry}`; + if (shouldSkip(child) || isSampleProjectPath(child)) { + continue; + } + const childFull = join(full, entry); + const childInfo = await lstat(childFull); + if (!childInfo.isDirectory() || childInfo.isSymbolicLink()) { + continue; + } + if (await hasGradleSettings(root, child)) { + await discoverGradleRootsInto(root, child, remainingDepth, roots); + } else { + await discoverNestedGradleRootsInto(root, child, remainingDepth - 1, roots); + } + } +} + async function gradleModuleRoots(root: string, gradleRoot: string): Promise { const modules = new Set([gradleRoot]); await collectGradleModules(root, gradleRoot, 3, modules); @@ -1469,6 +1942,9 @@ async function collectGradleModules( if (!childInfo.isDirectory() || childInfo.isSymbolicLink()) { continue; } + if (await hasGradleSettings(root, child)) { + continue; + } if ((await gradleBuildFile(root, child)) !== null) { modules.add(child); } @@ -1476,6 +1952,14 @@ async function collectGradleModules( } } +async function hasGradleSettings(root: string, moduleRoot: string): Promise { + const full = moduleRoot === "." ? root : join(root, moduleRoot); + return ( + (await pathExists(join(full, "settings.gradle"))) || + (await pathExists(join(full, "settings.gradle.kts"))) + ); +} + async function gradleBuildFile(root: string, moduleRoot: string): Promise { for (const file of ["build.gradle.kts", "build.gradle"]) { const path = moduleRoot === "." ? file : `${moduleRoot}/${file}`; @@ -1518,6 +2002,7 @@ function associatedGradleTests(files: string[], testFiles: string[]): SeedTestRe async function gradleTags( root: string, + gradleRoot: string, buildFile: string, sourceFiles: string[], ): Promise { @@ -1528,39 +2013,393 @@ async function gradleTags( ) { tags.push("kotlin"); } - const buildSource = stripGradleComments( - await readFile(join(root, buildFile), "utf8").catch(() => ""), - ); + const [buildSource, androidAliases] = await Promise.all([ + readFile(join(root, buildFile), "utf8").catch(() => ""), + androidVersionCatalogPluginAliases(root, gradleRoot, buildFile), + ]); if ( - appliesAndroidGradlePlugin(buildSource) || - /\bandroid\s*\{/u.test(buildSource) || - sourceFiles.some((file) => file.endsWith("AndroidManifest.xml")) + sourceFiles.some((file) => file.endsWith("AndroidManifest.xml")) || + hasAndroidExtensionBlock(buildSource, buildFile.endsWith(".kts")) || + hasAppliedAndroidPlugin(buildSource, androidAliases, buildFile.endsWith(".kts")) ) { tags.push("android"); } return tags; } -function appliesAndroidGradlePlugin(source: string): boolean { - const androidPluginId = String.raw`com\.android\.(?:application|library|test|dynamic-feature)`; - const androidPluginPattern = - String.raw`\bid\s*(?:\(\s*)?["']${androidPluginId}["']\s*\)?` + - "|" + - String.raw`\balias\s*\(\s*libs\.plugins\.[A-Za-z0-9_.]*android[A-Za-z0-9_.]*\s*\)` + - "|" + - String.raw`\bapply\s+plugin\s*:\s*["']${androidPluginId}["']` + - "|" + - String.raw`\bapply\s*\(\s*plugin\s*=\s*["']${androidPluginId}["']\s*\)`; - const disabledAndroidPlugin = new RegExp( - String.raw`(?:${androidPluginPattern})(?:\s*\.version\s*\([^)]*\)|\s+version\s+["'][^"']+["'])?\s*(?:\.apply\s*\(\s*false\s*\)|\bapply\s+false\b)`, - "giu", +async function androidVersionCatalogPluginAliases( + root: string, + gradleRoot: string, + buildFile: string, +): Promise> { + const aliases = new Set(); + for (const path of versionCatalogPaths(buildFile, gradleRoot)) { + const source = await readFile(join(root, path), "utf8").catch(() => null); + if (source === null) { + continue; + } + for (const alias of parseAndroidPluginAliases(source)) { + aliases.add(alias); + } + } + return aliases; +} + +function versionCatalogPaths(_buildFile: string, gradleRoot: string): string[] { + return [ + gradleRoot === "." ? "gradle/libs.versions.toml" : `${gradleRoot}/gradle/libs.versions.toml`, + ]; +} + +function parseAndroidPluginAliases(source: string): Set { + const aliases = new Set(); + let inPlugins = false; + let pluginTableAlias: string | null = null; + for (const rawLine of source.split(/\r?\n/u)) { + const line = rawLine.replace(/#.*/u, "").trim(); + if (line.length === 0) { + continue; + } + const section = /^\[([^\]]+)\]$/u.exec(line)?.[1]; + if (section !== undefined) { + const sectionKey = tomlDottedKey(section); + inPlugins = sectionKey === "plugins" || sectionKey.startsWith("plugins."); + pluginTableAlias = sectionKey.startsWith("plugins.") + ? sectionKey.slice("plugins.".length) + : null; + continue; + } + const topLevelPluginAlias = androidTopLevelPluginAliasForLine(line); + if (topLevelPluginAlias !== undefined) { + aliases.add(normalizeVersionCatalogAlias(topLevelPluginAlias)); + continue; + } + if (!inPlugins || !/com\.android\.(?:application|library|dynamic-feature|test)/u.test(line)) { + continue; + } + const alias = androidPluginAliasForLine(line, pluginTableAlias); + if (alias !== undefined) { + aliases.add(normalizeVersionCatalogAlias(alias)); + } + } + return aliases; +} + +function androidTopLevelPluginAliasForLine(line: string): string | undefined { + if (!/com\.android\.(?:application|library|dynamic-feature|test)/u.test(line)) { + return undefined; + } + return tomlPluginAliasKey( + /^plugins\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_.-]+?))(?:\.id)?\s*=/u.exec(line), ); - const activeSource = source.replace(disabledAndroidPlugin, ""); - return new RegExp(androidPluginPattern, "iu").test(activeSource); } -function stripGradleComments(source: string): string { - return stripJavaComments(source); +function androidPluginAliasForLine( + line: string, + pluginTableAlias: string | null, +): string | undefined { + const rawKey = tomlPluginAliasKey( + /^(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_.-]+?))(?:\.id)?\s*=/u.exec(line), + ); + if (pluginTableAlias === null || rawKey === undefined || rawKey === "id") { + return pluginTableAlias ?? rawKey; + } + return `${pluginTableAlias}.${rawKey}`; +} + +function tomlPluginAliasKey(match: RegExpExecArray | null): string | undefined { + return match?.[1] ?? match?.[2] ?? match?.[3]; +} + +function tomlDottedKey(key: string): string { + const segments: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + for (let index = 0; index < key.length; index += 1) { + const char = key[index] ?? ""; + if (quote !== null) { + if (char === "\\" && quote === '"') { + current += char; + index += 1; + current += key[index] ?? ""; + continue; + } + if (char === quote) { + quote = null; + } else { + current += char; + } + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (char === ".") { + segments.push(current.trim()); + current = ""; + continue; + } + current += char; + } + segments.push(current.trim()); + return segments.filter((segment) => segment.length > 0).join("."); +} + +function hasAppliedAndroidPlugin( + buildSource: string, + androidAliases: Set, + isKotlinDsl: boolean, +): boolean { + const source = stripGradleBuildComments(buildSource, isKotlinDsl); + for (const pluginBlock of rootGradlePluginBlocks(source)) { + for (const match of pluginBlock.matchAll(androidPluginDeclarationPattern())) { + const start = match.index ?? 0; + if (!hasGradleApplyFalse(pluginBlock, start)) { + return true; + } + } + for (const match of pluginBlock.matchAll( + /\balias\s*\(\s*libs\.plugins\.([A-Za-z0-9_.]+)\s*\)/gu, + )) { + const alias = match[1]; + if ( + alias !== undefined && + androidAliases.has(normalizeVersionCatalogAlias(alias)) && + !hasGradleApplyFalse(pluginBlock, match.index ?? 0) + ) { + return true; + } + } + } + return hasDirectAndroidApplyPlugin(source); +} + +function rootGradlePluginBlocks(source: string): string[] { + const blocks: string[] = []; + let quote: "'" | '"' | null = null; + for (let index = 0; index < source.length; index += 1) { + const char = source[index] ?? ""; + if (quote !== null) { + if (char === "\\") { + index += 1; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (char !== "{") { + continue; + } + const prefix = source.slice(Math.max(0, index - 100), index).trimEnd(); + if (!/\bplugins\s*$/u.test(prefix) || isInsideGradleChildProjectBlock(source, index)) { + continue; + } + const end = gradleBlockEnd(source, index); + blocks.push(source.slice(index + 1, end)); + index = end; + } + return blocks; +} + +function gradleBlockEnd(source: string, openBrace: number): number { + let quote: "'" | '"' | null = null; + let depth = 1; + for (let index = openBrace + 1; index < source.length; index += 1) { + const char = source[index] ?? ""; + if (quote !== null) { + if (char === "\\") { + index += 1; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + return source.length; +} + +function stripGradleBuildComments(source: string, supportsNestedBlockComments: boolean): string { + let stripped = ""; + let index = 0; + let quote: "'" | '"' | null = null; + let blockDepth = 0; + while (index < source.length) { + const char = source[index] ?? ""; + const pair = source.slice(index, index + 2); + if (blockDepth > 0) { + if (supportsNestedBlockComments && pair === "/*") { + blockDepth += 1; + stripped += " "; + index += 2; + } else if (pair === "*/") { + blockDepth = Math.max(0, blockDepth - 1); + stripped += " "; + index += 2; + } else { + stripped += char === "\n" ? "\n" : " "; + index += 1; + } + continue; + } + if (quote !== null) { + stripped += char; + if (char === "\\") { + stripped += source[index + 1] ?? ""; + index += 2; + continue; + } + if (char === quote) { + quote = null; + } + index += 1; + continue; + } + if (char === "'" || char === '"') { + quote = char; + stripped += char; + index += 1; + continue; + } + if (pair === "//") { + while (index < source.length && source[index] !== "\n") { + stripped += " "; + index += 1; + } + continue; + } + if (pair === "/*") { + blockDepth = 1; + stripped += " "; + index += 2; + continue; + } + stripped += char; + index += 1; + } + return stripped; +} + +function hasDirectAndroidApplyPlugin(source: string): boolean { + const pattern = + /\b(?:apply\s+plugin\s*:\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']|apply\s*\(\s*plugin\s*(?:=|:)\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\))/gu; + for (const match of source.matchAll(pattern)) { + if (!isInsideGradleChildProjectBlock(source, match.index ?? 0)) { + return true; + } + } + return false; +} + +function hasAndroidExtensionBlock(buildSource: string, isKotlinDsl: boolean): boolean { + const source = stripGradleBuildComments(buildSource, isKotlinDsl); + for (const match of source.matchAll(/\bandroid\s*\{/gu)) { + if (!isInsideGradleChildProjectBlock(source, match.index ?? 0)) { + return true; + } + } + return false; +} + +function isInsideGradleChildProjectBlock(source: string, offset: number): boolean { + const scopes: boolean[] = []; + let quote: "'" | '"' | null = null; + for (let index = 0; index < offset; index += 1) { + const char = source[index] ?? ""; + if (quote !== null) { + if (char === "\\") { + index += 1; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (char === "{") { + const prefix = source.slice(Math.max(0, index - 100), index).trimEnd(); + const childProjectScope = + /\bsubprojects\s*$/u.test(prefix) || + /\bsubprojects\.configureEach\s*$/u.test(prefix) || + /\bconfigure\s*\(\s*subprojects\s*\)\s*$/u.test(prefix) || + /\bproject\s*\([^)]*\)\s*$/u.test(prefix); + scopes.push((scopes.at(-1) ?? false) || childProjectScope); + } else if (char === "}") { + scopes.pop(); + } + } + return scopes.includes(true); +} + +function hasGradleApplyFalse(source: string, start: number): boolean { + const segmentEnd = gradlePluginInvocationEnd(source, start); + const segment = source.slice(start, segmentEnd); + return /\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(segment); +} + +function androidPluginDeclarationPattern(): RegExp { + return /\b(?:id\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?|alias\s*\(\s*libs\.plugins\.[A-Za-z0-9_.]*android[A-Za-z0-9_.]*\s*\))/giu; +} + +function gradlePluginInvocationEnd(source: string, start: number): number { + let quote: "'" | '"' | null = null; + for (let index = start + 1; index < source.length; index += 1) { + const char = source[index] ?? ""; + if (quote !== null) { + if (char === "\\") { + index += 1; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (char === ";" || char === "}") { + return index; + } + if ( + char === "\n" && + /^\s*(?:id\s*(?:\(|["'])|alias\s*\(|kotlin\s*\(|`[^`]+`|[A-Za-z_][A-Za-z0-9_]*\s+(?:apply|version)\b)/u.test( + source.slice(index + 1), + ) + ) { + return index; + } + } + return source.length; +} + +function normalizeVersionCatalogAlias(alias: string): string { + return alias.replace(/[-_]/gu, ".").toLowerCase(); } function isGradleSourceFile(path: string): boolean {