From 381a27bb9d06f33673a5bf310e8165d0663d8338 Mon Sep 17 00:00:00 2001 From: Nacho Lopez Date: Sat, 16 May 2026 23:43:01 +0200 Subject: [PATCH 01/62] feat(mapper): support kotlin semantic role mapping --- README.md | 6 +- docs/feature-mapping.md | 12 +- src/mapper.test.ts | 415 +++++++++++++++++++++++ src/mappers/gradle.ts | 708 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 1131 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7f7989b..cc0f3fb 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,10 @@ validation commands and records a patch attempt under `.clawpatch/`. - Go package slices from `go list ./...`, including command packages - Go package tests and same-repo imports as review context - Java/Kotlin Gradle source groups and root Gradle build/test commands -- JVM semantic roles from Java code evidence such as annotations, imports, - interfaces, inheritance, and method signatures +- JVM semantic roles from Java and Kotlin code evidence such as annotations, + imports, interfaces, inheritance, supertypes, and method signatures +- Kotlin Android semantic roles for UI entrypoints, ViewModels, data + boundaries, external clients, and dependency injection, including Metro - Ruby project metadata, executables, source groups, RSpec/Minitest suites - Rust `src/main.rs`, `src/bin/*.rs`, `src/lib.rs`, `crates/*`, and `tests/*.rs` diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index 706c01b..3d1b408 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -36,7 +36,8 @@ Supported deterministic mappers today: - Go `internal/*` packages - Python project metadata, console scripts, root app files, bounded source groups, pytest suites, and Flask/FastAPI routes -- JVM semantic role groups from Java annotations, imports, inheritance, interfaces, and method signatures +- Java and Kotlin JVM semantic role groups, plus Kotlin Android semantic role + groups including Hilt, Dagger, Koin, and Metro - Ruby project metadata, executables, source groups, RSpec/Minitest suites, Rails configs, routes, views, assets, and database files - Rust Cargo commands, libraries, workspace crates, and integration tests @@ -76,9 +77,12 @@ Native app mappers use the same bounded grouping model. SwiftPM packages can be discovered below the repo root, Apple projects are grouped by Swift source area, and Gradle modules are grouped from `src/main`, `src/test`, and `src/androidTest`. Root Gradle projects get default `gradle`/`./gradlew` build and test commands. -Java files in Gradle modules also get role-oriented review slices when code -evidence identifies web entrypoints, services, persistence boundaries, external -clients, configuration, framework components, or extension boundaries. +Java and Kotlin files in Gradle modules also get role-oriented review slices +when code evidence identifies web entrypoints, services, persistence boundaries, +external clients, configuration, framework components, extension boundaries, +Android UI entrypoints, ViewModels, data boundaries, or dependency injection. +Kotlin dependency-injection evidence includes Hilt, Dagger, Koin, and Metro +annotations and imports. Python mapping covers `pyproject.toml`, `setup.cfg`, `setup.py`, and `requirements.txt` metadata; `[project.scripts]`, `[tool.poetry.scripts]`, diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 7872b55..9ac34bd 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -861,6 +861,421 @@ describe("mapFeatures", () => { ]); }); + it("maps Kotlin Android semantic roles from framework evidence", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-role-map-"); + await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":app")\n'); + await writeFixture( + root, + "build.gradle.kts", + 'plugins { id("com.android.application") version "1.0" apply false }\n', + ); + await writeFixture(root, "app/build.gradle.kts", 'plugins { id("com.android.application") }\n'); + await writeFixture(root, "app/src/main/AndroidManifest.xml", "\n"); + await writeFixture( + root, + "app/src/main/kotlin/com/example/ui/MainActivity.kt", + [ + "package com.example.ui", + "", + "import androidx.activity.ComponentActivity", + "import androidx.compose.runtime.Composable", + "import androidx.hilt.navigation.compose.hiltViewModel", + "", + "class MainActivity : ComponentActivity()", + "", + "@Composable", + "fun HomeScreen() { hiltViewModel() }", + "", + ].join("\n"), + ); + await writeFixture( + root, + "app/src/main/kotlin/com/example/ui/MainViewModel.kt", + [ + "package com.example.ui", + "", + "import androidx.lifecycle.ViewModel", + "", + "class MainViewModel : ViewModel()", + "", + ].join("\n"), + ); + await writeFixture( + root, + "app/src/main/kotlin/com/example/data/AppDatabase.kt", + [ + "package com.example.data", + "", + "import androidx.room.Database", + "import androidx.room.RoomDatabase", + "", + "@Database(entities = [], version = 1)", + "abstract class AppDatabase : RoomDatabase()", + "", + ].join("\n"), + ); + await writeFixture( + root, + "app/src/main/kotlin/com/example/network/ApiClient.kt", + [ + "package com.example.network", + "", + "import retrofit2.Retrofit", + "", + "class ApiClient(private val retrofit: Retrofit)", + "", + ].join("\n"), + ); + await writeFixture( + root, + "app/src/main/kotlin/com/example/di/AppGraph.kt", + [ + "package com.example.di", + "", + "import dev.zacsweers.metro.BindingContainer", + "import dev.zacsweers.metro.DependencyGraph", + "import dev.zacsweers.metro.Provides", + "", + "@DependencyGraph", + "interface AppGraph", + "", + "@BindingContainer", + "object AppBindings {", + ' @Provides fun provideName(): String = "app"', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "app/src/main/kotlin/com/example/domain/UseCase.kt", + "package com.example.domain\nclass UseCase\n", + ); + await writeFixture( + root, + "app/src/test/kotlin/com/example/ui/MainActivityTest.kt", + "package com.example.ui\nclass MainActivityTest\n", + ); + await writeFixture( + root, + "app/build/generated/source/kapt/debug/com/example/Ignored.kt", + "package com.example\nclass Ignored\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const ui = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role UI entrypoint "), + ); + const viewModel = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role view model "), + ); + const data = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role data boundary "), + ); + const client = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role external client "), + ); + const di = result.features.find((feature) => + feature.title.startsWith("Kotlin Android role dependency injection "), + ); + + expect(project.detected.languages).toContain("kotlin"); + expect(project.detected.packageManagers).toContain("gradle"); + expect(titles).toContain("Gradle module app"); + expect(ui?.source).toBe("kotlin-android-role-ui-entrypoint"); + expect(ui?.kind).toBe("ui-flow"); + expect(ui?.confidence).toBe("high"); + expect(ui?.ownedFiles.map((file) => file.path)).toContain( + "app/src/main/kotlin/com/example/ui/MainActivity.kt", + ); + expect(ui?.tests).toEqual([ + { path: "app/src/test/kotlin/com/example/ui/MainActivityTest.kt", command: null }, + ]); + expect(viewModel?.source).toBe("kotlin-android-role-view-model"); + expect(viewModel?.ownedFiles.map((file) => file.path)).not.toContain( + "app/src/main/kotlin/com/example/ui/MainActivity.kt", + ); + expect(data?.trustBoundaries).toEqual(expect.arrayContaining(["database", "serialization"])); + expect(client?.trustBoundaries).toEqual( + expect.arrayContaining(["network", "external-api", "serialization"]), + ); + expect(di?.source).toBe("kotlin-android-role-dependency-injection"); + expect(di?.ownedFiles.map((file) => file.path)).toContain( + "app/src/main/kotlin/com/example/di/AppGraph.kt", + ); + expect( + result.features.flatMap((feature) => feature.ownedFiles.map((file) => file.path)), + ).not.toContain("app/build/generated/source/kapt/debug/com/example/Ignored.kt"); + }); + + it("maps server-side Kotlin roles and path fallback evidence", async () => { + const root = await fixtureRoot("clawpatch-kotlin-server-role-map-"); + 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.GetMapping", + "import org.springframework.web.bind.annotation.RestController", + "", + "@RestController", + "class OrderController {", + ' @GetMapping("/orders")', + ' fun list(): String = "ok"', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/app/BillingService.kt", + [ + "package com.example.app", + "", + "import jakarta.inject.Singleton", + "", + "@Singleton", + "class BillingService", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/db/OrderRepository.kt", + [ + "package com.example.db", + "", + "import org.springframework.data.repository.CrudRepository", + "", + "interface OrderRepository : CrudRepository", + "class Order", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/kotlin/com/example/client/RemoteClient.kt", + [ + "package com.example.client", + "", + "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", + ); + await writeFixture( + root, + "src/test/kotlin/com/example/api/OrderControllerTest.kt", + "package com.example.api\nclass OrderControllerTest\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 "), + ); + const service = result.features.find((feature) => + feature.title.startsWith("Kotlin server role application service "), + ); + const persistence = result.features.find((feature) => + feature.title.startsWith("Kotlin server role persistence boundary "), + ); + const clientFeatures = result.features.filter((feature) => + feature.title.startsWith("Kotlin server role external client "), + ); + + expect(web?.source).toBe("kotlin-server-role-web-entrypoint"); + expect(web?.tests).toEqual([ + { path: "src/test/kotlin/com/example/api/OrderControllerTest.kt", command: null }, + ]); + expect(service?.source).toBe("kotlin-server-role-application-service"); + expect(persistence?.source).toBe("kotlin-server-role-persistence-boundary"); + expect(clientFeatures.some((feature) => feature.confidence === "high")).toBe(true); + expect(clientFeatures.some((feature) => feature.confidence === "medium")).toBe(true); + }); + + 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"); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\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 fallbackBefore = first.features.find( + (feature) => + feature.source === "kotlin-server-role-external-client" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/network/FallbackClient.kt", + ), + ); + + await writeFixture( + root, + "src/main/kotlin/com/example/network/FallbackClient.kt", + [ + "package com.example.network", + "", + "import okhttp3.OkHttpClient", + "", + "class FallbackClient(private val client: OkHttpClient)", + "", + ].join("\n"), + ); + + const second = await mapFeatures(root, project, first.features); + const fallbackAfter = second.features.find( + (feature) => + feature.source === "kotlin-server-role-external-client" && + feature.ownedFiles.some( + (file) => file.path === "src/main/kotlin/com/example/network/FallbackClient.kt", + ), + ); + + expect(fallbackBefore?.confidence).toBe("medium"); + expect(fallbackAfter?.confidence).toBe("high"); + expect(fallbackAfter?.featureId).toBe(fallbackBefore?.featureId); + }); + + it("does not infer Android roles from non-Android Gradle module paths", async () => { + const root = await fixtureRoot("clawpatch-kotlin-android-path-leak-"); + await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); + await writeFixture( + root, + "apps/android/build.gradle.kts", + 'plugins { id("org.jetbrains.kotlin.jvm") }\n', + ); + await writeFixture( + root, + "apps/android/src/main/kotlin/com/example/di/Injector.kt", + "package com.example.di\nclass Injector\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.some((feature) => feature.source.startsWith("kotlin-android-role-")), + ).toBe(false); + }); + + it("maps Kotlin role evidence from wildcard imports", async () => { + const root = await fixtureRoot("clawpatch-kotlin-wildcard-imports-"); + await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":app")\n'); + await writeFixture(root, "build.gradle.kts", 'plugins { id("org.jetbrains.kotlin.jvm") }\n'); + await writeFixture( + root, + "src/main/kotlin/com/example/client/RemoteClient.kt", + [ + "package com.example.client", + "", + "import retrofit2.*", + "", + "class RemoteClient(private val retrofit: Retrofit)", + "", + ].join("\n"), + ); + await writeFixture(root, "app/build.gradle.kts", 'plugins { id("com.android.application") }\n'); + await writeFixture(root, "app/src/main/AndroidManifest.xml", "\n"); + await writeFixture( + root, + "app/src/main/kotlin/com/example/bootstrap/AppModule.kt", + [ + "package com.example.bootstrap", + "", + "import org.koin.dsl.*", + "", + 'fun appModule() = module { single { "value" } }', + "", + ].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/RemoteClient.kt", + ), + ); + const di = result.features.find( + (feature) => + feature.source === "kotlin-android-role-dependency-injection" && + feature.ownedFiles.some( + (file) => file.path === "app/src/main/kotlin/com/example/bootstrap/AppModule.kt", + ), + ); + + expect(client?.ownedFiles[0]?.reason).toContain("retrofit2.*"); + expect(di?.ownedFiles[0]?.reason).toContain("org.koin.dsl.*"); + }); + + it("maps server-side Kotlin declaration role evidence", async () => { + const root = await fixtureRoot("clawpatch-kotlin-declaration-role-map-"); + 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/ports/PaymentPort.kt", + "package com.example.ports\nfun interface PaymentPort { fun pay() }\n", + ); + await writeFixture( + root, + "src/main/kotlin/com/example/jobs/JobFactory.kt", + [ + "package com.example.jobs", + "", + "import org.scheduler.*", + "", + "class JobFactory : JobFactoryBase() {", + " fun buildJob(): Job = TODO()", + "}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const extension = result.features.find((feature) => + feature.title.startsWith("Kotlin server role extension boundary "), + ); + const framework = result.features.find((feature) => + feature.title.startsWith("Kotlin server role framework component "), + ); + + expect(extension?.source).toBe("kotlin-server-role-extension-boundary"); + expect(extension?.confidence).toBe("medium"); + expect(extension?.ownedFiles.map((file) => file.path)).toContain( + "src/main/kotlin/com/example/ports/PaymentPort.kt", + ); + expect( + extension?.ownedFiles.find( + (file) => file.path === "src/main/kotlin/com/example/ports/PaymentPort.kt", + )?.reason, + ).toContain("interface declaration PaymentPort"); + expect(framework?.source).toBe("kotlin-server-role-framework-component"); + expect(framework?.ownedFiles.map((file) => file.path)).toContain( + "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 ff5e032..7d288e7 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -77,6 +77,100 @@ type JavaFileInfo = { declarations: JavaDeclaration[]; methodReturnTypes: Set; }; +const kotlinRoleDefinitions = { + "android-ui-entrypoint": { + title: "UI entrypoint", + kind: "ui-flow", + tags: ["kotlin", "android", "ui"], + trustBoundaries: ["user-input", "serialization"], + }, + "android-view-model": { + title: "view model", + kind: "service", + tags: ["kotlin", "android", "view-model"], + trustBoundaries: [], + }, + "android-data-boundary": { + title: "data boundary", + kind: "service", + tags: ["kotlin", "android", "data"], + trustBoundaries: ["database", "serialization"], + }, + "android-external-client": { + title: "external client", + kind: "service", + tags: ["kotlin", "android", "network"], + trustBoundaries: ["network", "external-api", "serialization"], + }, + "android-dependency-injection": { + title: "dependency injection", + kind: "config", + tags: ["kotlin", "android", "di"], + trustBoundaries: ["serialization"], + }, + "server-web-entrypoint": { + title: "web entrypoint", + kind: "route", + tags: ["kotlin", "server", "web"], + trustBoundaries: ["network", "user-input", "serialization"], + }, + "server-application-service": { + title: "application service", + kind: "service", + tags: ["kotlin", "server", "service"], + trustBoundaries: [], + }, + "server-persistence-boundary": { + title: "persistence boundary", + kind: "service", + tags: ["kotlin", "server", "persistence"], + trustBoundaries: ["database", "serialization"], + }, + "server-external-client": { + title: "external client", + kind: "service", + tags: ["kotlin", "server", "external-api"], + trustBoundaries: ["network", "external-api", "serialization"], + }, + "server-framework-component": { + title: "framework component", + kind: "library", + tags: ["kotlin", "server", "framework"], + trustBoundaries: [], + }, + "server-extension-boundary": { + title: "extension boundary", + kind: "library", + tags: ["kotlin", "server", "interface"], + trustBoundaries: [], + }, +} as const satisfies Record< + string, + { + title: string; + kind: FeatureSeed["kind"]; + tags: string[]; + trustBoundaries: FeatureSeed["trustBoundaries"]; + } +>; +type KotlinRoleKey = keyof typeof kotlinRoleDefinitions; +type KotlinRoleEvidence = { + role: KotlinRoleKey; + reason: string; + confidence: FeatureSeed["confidence"]; +}; +type KotlinDeclaration = { + kind: "class" | "interface" | "object"; + name: string; + supertypes: string[]; +}; +type KotlinFileInfo = { + packageName: string | null; + annotations: Set; + imports: Map; + declarations: KotlinDeclaration[]; + functionReturnTypes: Set; +}; export async function gradleSeeds(root: string): Promise { const roots = await discoverGradleRoots(root); @@ -146,6 +240,9 @@ async function gradleProjectSeeds(root: string, gradleRoot: string): Promise 0) { for (const group of partitionFileGroups(sourceRoot, testFiles, maxOwnedFiles)) { @@ -173,6 +270,136 @@ async function gradleProjectSeeds(root: string, gradleRoot: string): Promise { + 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 projectPackages = new Set( + kotlinFiles.flatMap(({ info }) => (info.packageName === null ? [] : [info.packageName])), + ); + + for (const { filePath, info } of kotlinFiles) { + const frameworkEvidence = kotlinFrameworkRoleEvidence(info, tags, projectPackages); + const evidence = + frameworkEvidence.length > 0 ? frameworkEvidence : kotlinPathRoleEvidence(filePath, tags); + for (const item of evidence) { + const byFile = matches.get(item.role) ?? new Map(); + const reasons = byFile.get(filePath) ?? []; + reasons.push({ reason: item.reason, confidence: item.confidence }); + byFile.set(filePath, reasons); + matches.set(item.role, byFile); + } + } + + const seeds: FeatureSeed[] = []; + for (const [role, byFile] of [...matches.entries()].toSorted(([left], [right]) => + left.localeCompare(right), + )) { + const definition = kotlinRoleDefinitions[role]; + const platform = role.startsWith("android-") ? "Android" : "server"; + const groups = kotlinRoleGroups(sourceRoot, byFile); + for (const { confidence, group, label, symbol } of groups) { + const tests = associatedGradleTests(group.files, testFiles); + seeds.push({ + title: `Kotlin ${platform} role ${definition.title} ${label}`, + summary: `Kotlin ${platform.toLowerCase()} ${definition.title} group ${label} with ${group.files.length} files, classified from Kotlin code evidence.`, + kind: definition.kind, + source: kotlinRoleSource(role), + confidence, + entryPath: buildFile, + symbol, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ + path, + reason: `kotlin ${definition.title} evidence: ${unique( + (byFile.get(path) ?? []).map((item) => item.reason), + ).join("; ")}`, + })), + contextFiles: tests.map((test) => ({ + path: test.path, + reason: "associated gradle test", + })), + tests, + tags: [...tags, ...definition.tags], + trustBoundaries: definition.trustBoundaries, + skipNearbyTests: true, + }); + } + } + return seeds; +} + +function kotlinRoleGroups( + sourceRoot: string, + byFile: Map>, +): Array<{ + confidence: FeatureSeed["confidence"]; + group: { label: string; files: string[] }; + label: string; + symbol: string; +}> { + const groups = kotlinFilesByConfidence(byFile).flatMap(([confidence, files]) => + partitionFileGroups(sourceRoot, files, maxOwnedFiles).map((group) => ({ + confidence, + group, + label: `${group.label} (${confidence})`, + symbol: group.label, + })), + ); + const symbolCounts = new Map(); + for (const group of groups) { + symbolCounts.set(group.symbol, (symbolCounts.get(group.symbol) ?? 0) + 1); + } + return groups.map((group) => ({ + ...group, + symbol: + (symbolCounts.get(group.symbol) ?? 0) > 1 + ? `${group.symbol} (${group.confidence})` + : group.symbol, + })); +} + +function kotlinFilesByConfidence( + byFile: Map>, +): Array<[FeatureSeed["confidence"], string[]]> { + const buckets = new Map(); + for (const [path, evidence] of byFile) { + const confidence = evidence.some((item) => item.confidence === "high") ? "high" : "medium"; + const files = buckets.get(confidence) ?? []; + files.push(path); + buckets.set(confidence, files); + } + const order = new Map([ + ["high", 0], + ["medium", 1], + ["low", 2], + ]); + return [...buckets.entries()].toSorted( + ([left], [right]) => (order.get(left) ?? 99) - (order.get(right) ?? 99), + ); +} + +function kotlinRoleSource(role: KotlinRoleKey): string { + if (role.startsWith("android-")) { + return `kotlin-android-role-${role.slice("android-".length)}`; + } + return `kotlin-server-role-${role.slice("server-".length)}`; +} + async function jvmRoleSeeds( root: string, buildFile: string, @@ -242,6 +469,332 @@ function jvmRoleEvidence(info: JavaFileInfo, projectPackages: Set): JvmR return dedupeEvidence(evidence); } +function kotlinFrameworkRoleEvidence( + info: KotlinFileInfo, + tags: string[], + projectPackages: Set, +): KotlinRoleEvidence[] { + const evidence: KotlinRoleEvidence[] = []; + const isAndroid = tags.includes("android"); + for (const annotation of info.annotations) { + if (isAndroid && ["Composable"].includes(annotation)) { + evidence.push({ + role: "android-ui-entrypoint", + reason: `annotation @${annotation}`, + confidence: "high", + }); + } + if (isAndroid && ["HiltViewModel"].includes(annotation)) { + evidence.push({ + role: "android-view-model", + reason: `annotation @${annotation}`, + confidence: "high", + }); + } + if (isAndroid && ["Entity", "Dao", "Database", "Embedded", "Relation"].includes(annotation)) { + evidence.push({ + role: "android-data-boundary", + reason: `Room annotation @${annotation}`, + confidence: "high", + }); + } + if ( + isAndroid && + [ + "AndroidEntryPoint", + "HiltAndroidApp", + "Module", + "InstallIn", + "Provides", + "Binds", + "Inject", + "Singleton", + "Component", + "DependencyGraph", + "BindingContainer", + "ContributesBinding", + ].includes(annotation) + ) { + evidence.push({ + role: "android-dependency-injection", + reason: `dependency injection annotation @${annotation}`, + confidence: "high", + }); + } + if ( + !isAndroid && + [ + "Controller", + "RestController", + "RequestMapping", + "GetMapping", + "PostMapping", + "PutMapping", + "DeleteMapping", + "PatchMapping", + "Path", + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + ].includes(annotation) + ) { + evidence.push({ + role: "server-web-entrypoint", + reason: `server web annotation @${annotation}`, + confidence: "high", + }); + } + if (!isAndroid && ["Service", "ApplicationScoped", "Singleton", "Named"].includes(annotation)) { + evidence.push({ + role: "server-application-service", + reason: `service annotation @${annotation}`, + confidence: "high", + }); + } + if (!isAndroid && ["Repository", "Table", "MappedSuperclass"].includes(annotation)) { + evidence.push({ + role: "server-persistence-boundary", + reason: `persistence annotation @${annotation}`, + confidence: "high", + }); + } + } + + for (const full of info.imports.values()) { + if ( + isAndroid && + (full.startsWith("android.app.") || + full.startsWith("android.content.BroadcastReceiver") || + full.startsWith("androidx.activity.") || + full.startsWith("androidx.appcompat.app.") || + full.startsWith("androidx.compose.") || + full.startsWith("androidx.fragment.app.") || + full.startsWith("androidx.lifecycle.LifecycleService")) + ) { + evidence.push({ + role: "android-ui-entrypoint", + reason: `Android UI import ${full}`, + confidence: "high", + }); + } + if ( + isAndroid && + (full === "androidx.lifecycle.ViewModel" || full === "androidx.lifecycle.AndroidViewModel") + ) { + evidence.push({ + role: "android-view-model", + reason: `Android ViewModel import ${full}`, + confidence: "high", + }); + } + if (isAndroid && full.startsWith("androidx.room.")) { + evidence.push({ + role: "android-data-boundary", + reason: `Room import ${full}`, + confidence: "high", + }); + } + if (isKotlinExternalClientImport(full)) { + evidence.push({ + role: isAndroid ? "android-external-client" : "server-external-client", + reason: `external client import ${full}`, + confidence: "high", + }); + } + 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.")) + ) { + const reason = full.startsWith("dev.zacsweers.metro.") + ? `Metro import ${full}` + : `dependency injection import ${full}`; + evidence.push({ + role: "android-dependency-injection", + reason, + confidence: "high", + }); + } + if ( + !isAndroid && + (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)) + ) { + evidence.push({ + role: "server-web-entrypoint", + reason: `server web import ${full}`, + confidence: "high", + }); + } + if ( + !isAndroid && + (/^(?:jakarta|javax)\.persistence\./u.test(full) || + full.startsWith("org.hibernate.") || + full.startsWith("org.jetbrains.exposed.") || + full.startsWith("org.jooq.") || + isSpringDataPersistenceImport(full) || + full.startsWith("java.sql.")) + ) { + evidence.push({ + role: "server-persistence-boundary", + reason: `persistence import ${full}`, + confidence: "high", + }); + } + } + + for (const declaration of info.declarations) { + for (const type of declaration.supertypes) { + if ( + isAndroid && + [ + "Activity", + "AppCompatActivity", + "ComponentActivity", + "Fragment", + "Service", + "BroadcastReceiver", + ].includes(type) + ) { + evidence.push({ + role: "android-ui-entrypoint", + reason: `inherits Android UI type ${type}`, + confidence: "high", + }); + } + if (isAndroid && ["ViewModel", "AndroidViewModel"].includes(type)) { + evidence.push({ + role: "android-view-model", + reason: `inherits Android ViewModel type ${type}`, + confidence: "high", + }); + } + if (isAndroid && ["RoomDatabase"].includes(type)) { + evidence.push({ + role: "android-data-boundary", + reason: `inherits Room type ${type}`, + confidence: "high", + }); + } + } + } + if (!isAndroid) { + evidence.push(...kotlinDeclarationRoleEvidence(info, projectPackages)); + evidence.push(...kotlinFunctionReturnRoleEvidence(info, projectPackages)); + } + + return dedupeKotlinEvidence(evidence); +} + +function kotlinDeclarationRoleEvidence( + info: KotlinFileInfo, + projectPackages: Set, +): KotlinRoleEvidence[] { + const evidence: KotlinRoleEvidence[] = []; + for (const declaration of info.declarations) { + if (declaration.kind === "interface") { + evidence.push({ + role: "server-extension-boundary", + reason: `interface declaration ${declaration.name}`, + confidence: "medium", + }); + } + for (const type of declaration.supertypes) { + const full = kotlinImportForType(info, type); + if (full !== undefined && isExternalProjectImport(full, projectPackages)) { + evidence.push({ + role: "server-framework-component", + reason: `inherits external type ${full}`, + confidence: "high", + }); + } + } + } + return evidence; +} + +function kotlinFunctionReturnRoleEvidence( + info: KotlinFileInfo, + projectPackages: Set, +): KotlinRoleEvidence[] { + const evidence: KotlinRoleEvidence[] = []; + for (const type of info.functionReturnTypes) { + const full = kotlinImportForType(info, type); + if (full !== undefined && isExternalProjectImport(full, projectPackages)) { + evidence.push({ + role: "server-framework-component", + reason: `returns external type ${full}`, + confidence: "high", + }); + } + } + return evidence; +} + +function kotlinImportForType(info: KotlinFileInfo, type: string): string | undefined { + const direct = info.imports.get(type); + if (direct !== undefined) { + return direct; + } + for (const full of info.imports.values()) { + if (full.endsWith(".*")) { + return `${full.slice(0, -1)}${type}`; + } + } + return undefined; +} + +function kotlinPathRoleEvidence(filePath: string, tags: string[]): KotlinRoleEvidence[] { + const normalized = normalize(filePath).toLowerCase(); + const isAndroid = tags.includes("android"); + const evidence: KotlinRoleEvidence[] = []; + if (isAndroid && /(^|\/)ui(\/|$)/u.test(normalized)) { + evidence.push({ + role: "android-ui-entrypoint", + reason: "path segment ui", + confidence: "medium", + }); + } + if (/(^|\/)(?:repository|data|database)(\/|$)/u.test(normalized)) { + evidence.push({ + role: isAndroid ? "android-data-boundary" : "server-persistence-boundary", + reason: "path segment data boundary", + confidence: "medium", + }); + } + if (/(^|\/)network(\/|$)/u.test(normalized)) { + evidence.push({ + role: isAndroid ? "android-external-client" : "server-external-client", + reason: "path segment network", + confidence: "medium", + }); + } + if (isAndroid && /(^|\/)di(\/|$)/u.test(normalized)) { + evidence.push({ + role: "android-dependency-injection", + reason: "path segment di", + confidence: "medium", + }); + } + if (!isAndroid && /(^|\/)domain(\/|$)/u.test(normalized)) { + evidence.push({ + role: "server-application-service", + reason: "path segment domain", + confidence: "medium", + }); + } + return evidence; +} + function parseJavaFile(source: string): JavaFileInfo { const stripped = stripJavaComments(source); const packageName = /^\s*package\s+([A-Za-z0-9_.]+)\s*;/mu.exec(stripped)?.[1] ?? null; @@ -281,6 +834,52 @@ function parseJavaFile(source: string): JavaFileInfo { }; } +function parseKotlinFile(source: string): KotlinFileInfo { + const stripped = stripKotlinComments(source); + const packageName = /^\s*package\s+([A-Za-z0-9_.]+)\s*;?/mu.exec(stripped)?.[1] ?? null; + const imports = new Map(); + for (const match of stripped.matchAll( + /^\s*import\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)(\.\*)?(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?\s*;?/gmu, + )) { + const target = match[1]; + const wildcard = match[2]; + const alias = match[3]; + const full = target === undefined ? undefined : `${target}${wildcard ?? ""}`; + const simple = alias ?? (wildcard === undefined ? target?.split(".").at(-1) : target); + if (full !== undefined && simple !== undefined) { + imports.set(simple, full); + } + } + + const annotations = 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) { + annotations.add(raw.split(".").at(-1) ?? 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, + )) { + const type = match[1]; + if (type !== undefined) { + functionReturnTypes.add(baseKotlinTypeName(stripGenericParameters(type))); + } + } + + return { + packageName, + annotations, + imports, + declarations: parseKotlinDeclarations(stripped), + functionReturnTypes, + }; +} + function parseJavaDeclarations(source: string): JavaDeclaration[] { const declarations: JavaDeclaration[] = []; const declarationPattern = @@ -301,6 +900,25 @@ function parseJavaDeclarations(source: string): JavaDeclaration[] { return declarations; } +function parseKotlinDeclarations(source: string): KotlinDeclaration[] { + const declarations: KotlinDeclaration[] = []; + const declarationPattern = + /\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*\([^{}]*?\))?(?:\s*:\s*([^={}\n]+))?/gsu; + for (const match of source.matchAll(declarationPattern)) { + const rawKind = match[3]; + const name = match[4]; + if (rawKind === undefined || name === undefined) { + continue; + } + declarations.push({ + kind: rawKind as KotlinDeclaration["kind"], + name, + supertypes: match[5] === undefined ? [] : kotlinTypeNames(match[5]), + }); + } + return declarations; +} + function annotationEvidence(info: JavaFileInfo): JvmRoleEvidence[] { const evidence: JvmRoleEvidence[] = []; for (const annotation of info.annotations) { @@ -423,12 +1041,69 @@ function isNetworkClientImport(full: string): boolean { ); } +function isKotlinExternalClientImport(full: string): boolean { + return ( + isNetworkClientImport(full) || + full.startsWith("retrofit2.") || + full.startsWith("okhttp3.") || + full.startsWith("io.ktor.client.") || + full.startsWith("io.grpc.") || + full.startsWith("software.amazon.awssdk.") || + full.startsWith("com.google.cloud.") || + full.startsWith("com.azure.") + ); +} + +function isSpringDataPersistenceImport(full: string): boolean { + return ( + full.startsWith("org.springframework.data.repository.") || + full.startsWith("org.springframework.data.jdbc.") || + full.startsWith("org.springframework.data.jpa.") || + full.startsWith("org.springframework.data.r2dbc.") || + full.startsWith("org.springframework.data.mongodb.") || + full.startsWith("org.springframework.data.redis.") || + full.startsWith("org.springframework.data.cassandra.") || + full.startsWith("org.springframework.data.elasticsearch.") || + full.startsWith("org.springframework.data.neo4j.") || + full.startsWith("org.springframework.data.couchbase.") + ); +} + function javaTypeNames(raw: string): string[] { return splitJavaTypeList(raw) .map((type) => baseJavaTypeName(stripGenericParameters(type))) .filter((type) => type.length > 0); } +function kotlinTypeNames(raw: string): string[] { + const parts: string[] = []; + let angleDepth = 0; + let parenDepth = 0; + let current = ""; + for (const char of raw) { + if (char === "<") { + angleDepth += 1; + } else if (char === ">") { + angleDepth = Math.max(0, angleDepth - 1); + } else if (char === "(") { + parenDepth += 1; + } else if (char === ")") { + parenDepth = Math.max(0, parenDepth - 1); + } + if (char === "," && angleDepth === 0 && parenDepth === 0) { + parts.push(current); + current = ""; + continue; + } + current += char; + } + parts.push(current); + return parts + .flatMap((type) => splitJavaTypeList(type)) + .map((type) => baseKotlinTypeName(stripGenericParameters(type))) + .filter((type) => type.length > 0); +} + function baseJavaTypeName(raw: string): string { return ( raw @@ -440,6 +1115,18 @@ function baseJavaTypeName(raw: string): string { ); } +function baseKotlinTypeName(raw: string): string { + return ( + raw + .replace(/\([^()]*\)/gu, "") + .replace(/\?.*$/su, "") + .split(".") + .at(-1) + ?.replace(/[^A-Za-z0-9_]/gu, "") + .trim() ?? "" + ); +} + function splitJavaTypeList(raw: string): string[] { const parts: string[] = []; let depth = 0; @@ -486,6 +1173,10 @@ function stripJavaComments(source: string): string { .replace(/\/\/.*$/gmu, ""); } +function stripKotlinComments(source: string): string { + return stripJavaComments(source); +} + function dedupeEvidence(evidence: JvmRoleEvidence[]): JvmRoleEvidence[] { const seen = new Set(); return evidence.filter((item) => { @@ -498,6 +1189,18 @@ function dedupeEvidence(evidence: JvmRoleEvidence[]): JvmRoleEvidence[] { }); } +function dedupeKotlinEvidence(evidence: KotlinRoleEvidence[]): KotlinRoleEvidence[] { + const seen = new Set(); + return evidence.filter((item) => { + const key = `${item.role}:${item.reason}:${item.confidence}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + function unique(values: string[]): string[] { return [...new Set(values)]; } @@ -627,10 +1330,7 @@ function gradleTags(buildFile: string, sourceFiles: string[]): string[] { ) { tags.push("kotlin"); } - if ( - sourceFiles.some((file) => file.endsWith("AndroidManifest.xml")) || - buildFile.includes("android") - ) { + if (sourceFiles.some((file) => file.endsWith("AndroidManifest.xml"))) { tags.push("android"); } return tags; From 8c6adb0ef552ddf2dd155f9b3c3d0da6866da5fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 23:47:07 +0100 Subject: [PATCH 02/62] fix(mapper): harden Kotlin role mapping --- src/mapper.test.ts | 130 +++++++++++++++++++++++++++++++++++++++++- src/mappers/gradle.ts | 80 ++++++++++++++------------ 2 files changed, 173 insertions(+), 37 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 7d6100a..b6d736d 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2626,6 +2626,22 @@ describe("mapFeatures", () => { "", ].join("\n"), ); + await writeFixture( + root, + "src/main/kotlin/com/example/config/AppConfig.kt", + [ + "package com.example.config", + "", + "import org.springframework.context.annotation.Bean", + "import org.springframework.context.annotation.Configuration", + "", + "@Configuration", + "class AppConfig {", + ' @Bean fun appName(): String = "orders"', + "}", + "", + ].join("\n"), + ); await writeFixture( root, "src/main/kotlin/com/example/network/FallbackClient.kt", @@ -2648,6 +2664,9 @@ describe("mapFeatures", () => { const persistence = result.features.find((feature) => feature.title.startsWith("Kotlin server role persistence boundary "), ); + const config = result.features.find((feature) => + feature.title.startsWith("Kotlin server role configuration "), + ); const clientFeatures = result.features.filter((feature) => feature.title.startsWith("Kotlin server role external client "), ); @@ -2658,8 +2677,19 @@ describe("mapFeatures", () => { ]); expect(service?.source).toBe("kotlin-server-role-application-service"); expect(persistence?.source).toBe("kotlin-server-role-persistence-boundary"); + expect(config?.source).toBe("kotlin-server-role-configuration"); + expect(config?.ownedFiles.map((file) => file.path)).toContain( + "src/main/kotlin/com/example/config/AppConfig.kt", + ); expect(clientFeatures.some((feature) => feature.confidence === "high")).toBe(true); - expect(clientFeatures.some((feature) => feature.confidence === "medium")).toBe(true); + expect( + clientFeatures.flatMap((feature) => feature.ownedFiles.map((file) => file.path)), + ).toEqual( + expect.arrayContaining([ + "src/main/kotlin/com/example/client/RemoteClient.kt", + "src/main/kotlin/com/example/network/FallbackClient.kt", + ]), + ); }); it("keeps Kotlin feature IDs stable when confidence changes", async () => { @@ -2709,6 +2739,104 @@ describe("mapFeatures", () => { expect(fallbackAfter?.featureId).toBe(fallbackBefore?.featureId); }); + 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 infer Android roles from non-Android Gradle module paths", async () => { const root = await fixtureRoot("clawpatch-kotlin-android-path-leak-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 7d288e7..21a428a 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -132,6 +132,12 @@ const kotlinRoleDefinitions = { tags: ["kotlin", "server", "external-api"], trustBoundaries: ["network", "external-api", "serialization"], }, + "server-configuration": { + title: "configuration", + kind: "config", + tags: ["kotlin", "server", "config"], + trustBoundaries: ["filesystem"], + }, "server-framework-component": { title: "framework component", kind: "library", @@ -287,9 +293,7 @@ async function kotlinRoleSeeds( const source = await readFile(join(root, filePath), "utf8"); kotlinFiles.push({ filePath, info: parseKotlinFile(source) }); } - const projectPackages = new Set( - kotlinFiles.flatMap(({ info }) => (info.packageName === null ? [] : [info.packageName])), - ); + const projectPackages = await gradleProjectPackages(root, sourceFiles, kotlinFiles); for (const { filePath, info } of kotlinFiles) { const frameworkEvidence = kotlinFrameworkRoleEvidence(info, tags, projectPackages); @@ -352,45 +356,24 @@ function kotlinRoleGroups( label: string; symbol: string; }> { - const groups = kotlinFilesByConfidence(byFile).flatMap(([confidence, files]) => - partitionFileGroups(sourceRoot, files, maxOwnedFiles).map((group) => ({ + return partitionFileGroups(sourceRoot, [...byFile.keys()], maxOwnedFiles).map((group) => { + const confidence = kotlinGroupConfidence(group.files, byFile); + return { confidence, group, - label: `${group.label} (${confidence})`, + label: group.label, symbol: group.label, - })), - ); - const symbolCounts = new Map(); - for (const group of groups) { - symbolCounts.set(group.symbol, (symbolCounts.get(group.symbol) ?? 0) + 1); - } - return groups.map((group) => ({ - ...group, - symbol: - (symbolCounts.get(group.symbol) ?? 0) > 1 - ? `${group.symbol} (${group.confidence})` - : group.symbol, - })); + }; + }); } -function kotlinFilesByConfidence( +function kotlinGroupConfidence( + files: string[], byFile: Map>, -): Array<[FeatureSeed["confidence"], string[]]> { - const buckets = new Map(); - for (const [path, evidence] of byFile) { - const confidence = evidence.some((item) => item.confidence === "high") ? "high" : "medium"; - const files = buckets.get(confidence) ?? []; - files.push(path); - buckets.set(confidence, files); - } - const order = new Map([ - ["high", 0], - ["medium", 1], - ["low", 2], - ]); - return [...buckets.entries()].toSorted( - ([left], [right]) => (order.get(left) ?? 99) - (order.get(right) ?? 99), - ); +): FeatureSeed["confidence"] { + return files.some((path) => (byFile.get(path) ?? []).some((item) => item.confidence === "high")) + ? "high" + : "medium"; } function kotlinRoleSource(role: KotlinRoleKey): string { @@ -400,6 +383,24 @@ function kotlinRoleSource(role: KotlinRoleKey): string { return `kotlin-server-role-${role.slice("server-".length)}`; } +async function gradleProjectPackages( + root: string, + sourceFiles: string[], + kotlinFiles: Array<{ filePath: string; info: KotlinFileInfo }>, +): 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 packages; +} + async function jvmRoleSeeds( root: string, buildFile: string, @@ -560,6 +561,13 @@ function kotlinFrameworkRoleEvidence( confidence: "high", }); } + if (!isAndroid && ["Configuration", "Bean", "ConfigurationProperties"].includes(annotation)) { + evidence.push({ + role: "server-configuration", + reason: `configuration annotation @${annotation}`, + confidence: "high", + }); + } } for (const full of info.imports.values()) { From 9dcda4a99178ff4bec0b62409d487b6fd94c85a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 23:53:05 +0100 Subject: [PATCH 03/62] fix(mapper): tighten Kotlin role edge cases --- src/mapper.test.ts | 75 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 20 ++++++++++-- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index b6d736d..a18da5a 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2859,6 +2859,46 @@ describe("mapFeatures", () => { ).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("maps Kotlin role evidence from wildcard imports", async () => { const root = await fixtureRoot("clawpatch-kotlin-wildcard-imports-"); await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":app")\n'); @@ -2911,6 +2951,41 @@ describe("mapFeatures", () => { expect(di?.ownedFiles[0]?.reason).toContain("org.koin.dsl.*"); }); + 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("maps server-side Kotlin declaration role evidence", async () => { const root = await fixtureRoot("clawpatch-kotlin-declaration-role-map-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 21a428a..b95b2aa 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -202,7 +202,7 @@ async function gradleProjectSeeds(root: string, gradleRoot: string): Promise isGradleTestFile(moduleRoot, file)); - const tags = gradleTags(buildFile, sourceFiles); + const tags = await gradleTags(root, buildFile, sourceFiles); seeds.push({ title: `Gradle module ${moduleRoot}`, @@ -293,6 +293,9 @@ async function kotlinRoleSeeds( const source = await readFile(join(root, filePath), "utf8"); kotlinFiles.push({ filePath, info: parseKotlinFile(source) }); } + if (kotlinFiles.length === 0) { + return []; + } const projectPackages = await gradleProjectPackages(root, sourceFiles, kotlinFiles); for (const { filePath, info } of kotlinFiles) { @@ -753,6 +756,9 @@ function kotlinImportForType(info: KotlinFileInfo, type: string): string | undef if (direct !== undefined) { return direct; } + if (info.declarations.some((declaration) => declaration.name === type)) { + return undefined; + } for (const full of info.imports.values()) { if (full.endsWith(".*")) { return `${full.slice(0, -1)}${type}`; @@ -1330,7 +1336,11 @@ function associatedGradleTests(files: string[], testFiles: string[]): SeedTestRe .map((path) => ({ path, command: null })); } -function gradleTags(buildFile: string, sourceFiles: string[]): string[] { +async function gradleTags( + root: string, + buildFile: string, + sourceFiles: string[], +): Promise { const tags = ["gradle"]; if ( buildFile.endsWith(".kts") || @@ -1338,7 +1348,11 @@ function gradleTags(buildFile: string, sourceFiles: string[]): string[] { ) { tags.push("kotlin"); } - if (sourceFiles.some((file) => file.endsWith("AndroidManifest.xml"))) { + const buildSource = await readFile(join(root, buildFile), "utf8").catch(() => ""); + if ( + sourceFiles.some((file) => file.endsWith("AndroidManifest.xml")) || + /\bcom\.android\.(?:application|library|dynamic-feature|test)\b/u.test(buildSource) + ) { tags.push("android"); } return tags; From 95e7ec46af51fc53de4fc429281404e3398cbadd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 23:58:13 +0100 Subject: [PATCH 04/62] fix(mapper): avoid Kotlin Android false positives --- src/mapper.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++-- src/mappers/gradle.ts | 18 +++++++++++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index a18da5a..65f3457 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2594,9 +2594,9 @@ describe("mapFeatures", () => { [ "package com.example.app", "", - "import jakarta.inject.Singleton", + "import org.springframework.stereotype.Component", "", - "@Singleton", + "@Component", "class BillingService", "", ].join("\n"), @@ -2676,6 +2676,9 @@ describe("mapFeatures", () => { { path: "src/test/kotlin/com/example/api/OrderControllerTest.kt", command: null }, ]); expect(service?.source).toBe("kotlin-server-role-application-service"); + expect(service?.ownedFiles.map((file) => file.path)).toContain( + "src/main/kotlin/com/example/app/BillingService.kt", + ); expect(persistence?.source).toBe("kotlin-server-role-persistence-boundary"); expect(config?.source).toBe("kotlin-server-role-configuration"); expect(config?.ownedFiles.map((file) => file.path)).toContain( @@ -2899,6 +2902,46 @@ describe("mapFeatures", () => { ).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("maps Kotlin role evidence from wildcard imports", async () => { const root = await fixtureRoot("clawpatch-kotlin-wildcard-imports-"); await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":app")\n'); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index b95b2aa..3af36c3 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -550,7 +550,10 @@ function kotlinFrameworkRoleEvidence( confidence: "high", }); } - if (!isAndroid && ["Service", "ApplicationScoped", "Singleton", "Named"].includes(annotation)) { + if ( + !isAndroid && + ["Service", "Component", "ApplicationScoped", "Singleton", "Named"].includes(annotation) + ) { evidence.push({ role: "server-application-service", reason: `service annotation @${annotation}`, @@ -1351,13 +1354,24 @@ async function gradleTags( const buildSource = await readFile(join(root, buildFile), "utf8").catch(() => ""); if ( sourceFiles.some((file) => file.endsWith("AndroidManifest.xml")) || - /\bcom\.android\.(?:application|library|dynamic-feature|test)\b/u.test(buildSource) + hasAppliedAndroidPlugin(buildSource) ) { tags.push("android"); } return tags; } +function hasAppliedAndroidPlugin(buildSource: string): boolean { + return buildSource.split(/\r?\n/u).some((line) => { + if (/\bapply\s+false\b/u.test(line)) { + return false; + } + return /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/u.test( + line, + ); + }); +} + function isGradleSourceFile(path: string): boolean { const normalized = normalize(path); return ( From ca70eb4477ca5fdab255a58fdaac7e70fd15ea21 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 00:03:06 +0100 Subject: [PATCH 05/62] fix(mapper): reduce Kotlin role false positives --- src/mapper.test.ts | 78 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 64 ++++++++++++++++++++++++++--------- 2 files changed, 127 insertions(+), 15 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 65f3457..f3ca2ea 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2942,6 +2942,46 @@ describe("mapFeatures", () => { ).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("maps Kotlin role evidence from wildcard imports", async () => { const root = await fixtureRoot("clawpatch-kotlin-wildcard-imports-"); await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":app")\n'); @@ -3029,6 +3069,44 @@ describe("mapFeatures", () => { ).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("maps server-side Kotlin declaration role evidence", async () => { const root = await fixtureRoot("clawpatch-kotlin-declaration-role-map-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 3af36c3..4de0e9f 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -297,9 +297,15 @@ async function kotlinRoleSeeds( return []; } const projectPackages = await gradleProjectPackages(root, sourceFiles, kotlinFiles); + const kotlinPackageTypes = kotlinPackageDeclarations(kotlinFiles); for (const { filePath, info } of kotlinFiles) { - const frameworkEvidence = kotlinFrameworkRoleEvidence(info, tags, projectPackages); + const frameworkEvidence = kotlinFrameworkRoleEvidence( + info, + tags, + projectPackages, + kotlinPackageTypes, + ); const evidence = frameworkEvidence.length > 0 ? frameworkEvidence : kotlinPathRoleEvidence(filePath, tags); for (const item of evidence) { @@ -404,6 +410,21 @@ async function gradleProjectPackages( return packages; } +function kotlinPackageDeclarations( + kotlinFiles: Array<{ filePath: string; info: KotlinFileInfo }>, +): Map> { + 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); + } + return declarations; +} + async function jvmRoleSeeds( root: string, buildFile: string, @@ -477,6 +498,7 @@ function kotlinFrameworkRoleEvidence( info: KotlinFileInfo, tags: string[], projectPackages: Set, + kotlinPackageTypes: Map>, ): KotlinRoleEvidence[] { const evidence: KotlinRoleEvidence[] = []; const isAndroid = tags.includes("android"); @@ -702,8 +724,8 @@ function kotlinFrameworkRoleEvidence( } } if (!isAndroid) { - evidence.push(...kotlinDeclarationRoleEvidence(info, projectPackages)); - evidence.push(...kotlinFunctionReturnRoleEvidence(info, projectPackages)); + evidence.push(...kotlinDeclarationRoleEvidence(info, projectPackages, kotlinPackageTypes)); + evidence.push(...kotlinFunctionReturnRoleEvidence(info, projectPackages, kotlinPackageTypes)); } return dedupeKotlinEvidence(evidence); @@ -712,6 +734,7 @@ function kotlinFrameworkRoleEvidence( function kotlinDeclarationRoleEvidence( info: KotlinFileInfo, projectPackages: Set, + kotlinPackageTypes: Map>, ): KotlinRoleEvidence[] { const evidence: KotlinRoleEvidence[] = []; for (const declaration of info.declarations) { @@ -723,7 +746,7 @@ function kotlinDeclarationRoleEvidence( }); } for (const type of declaration.supertypes) { - const full = kotlinImportForType(info, type); + const full = kotlinImportForType(info, type, kotlinPackageTypes); if (full !== undefined && isExternalProjectImport(full, projectPackages)) { evidence.push({ role: "server-framework-component", @@ -739,10 +762,11 @@ function kotlinDeclarationRoleEvidence( function kotlinFunctionReturnRoleEvidence( info: KotlinFileInfo, projectPackages: Set, + kotlinPackageTypes: Map>, ): KotlinRoleEvidence[] { const evidence: KotlinRoleEvidence[] = []; for (const type of info.functionReturnTypes) { - const full = kotlinImportForType(info, type); + const full = kotlinImportForType(info, type, kotlinPackageTypes); if (full !== undefined && isExternalProjectImport(full, projectPackages)) { evidence.push({ role: "server-framework-component", @@ -754,12 +778,20 @@ function kotlinFunctionReturnRoleEvidence( return evidence; } -function kotlinImportForType(info: KotlinFileInfo, type: string): string | undefined { +function kotlinImportForType( + info: KotlinFileInfo, + type: string, + kotlinPackageTypes: Map>, +): string | undefined { const direct = info.imports.get(type); if (direct !== undefined) { return direct; } - if (info.declarations.some((declaration) => declaration.name === type)) { + const packageName = info.packageName ?? ""; + if ( + info.declarations.some((declaration) => declaration.name === type) || + kotlinPackageTypes.get(packageName)?.has(type) === true + ) { return undefined; } for (const full of info.imports.values()) { @@ -1362,14 +1394,16 @@ async function gradleTags( } function hasAppliedAndroidPlugin(buildSource: string): boolean { - return buildSource.split(/\r?\n/u).some((line) => { - if (/\bapply\s+false\b/u.test(line)) { - return false; - } - return /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/u.test( - line, - ); - }); + return stripJavaComments(buildSource) + .split(/\r?\n/u) + .some((line) => { + if (/\bapply\s+false\b/u.test(line)) { + return false; + } + return /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/u.test( + line, + ); + }); } function isGradleSourceFile(path: string): boolean { From 680b217f43fafa88914c3c7879428c7dd5d72403 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 00:07:52 +0100 Subject: [PATCH 06/62] fix(mapper): ignore Kotlin builtins for wildcard roles --- src/mapper.test.ts | 44 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index f3ca2ea..4180427 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3034,6 +3034,50 @@ describe("mapFeatures", () => { expect(di?.ownedFiles[0]?.reason).toContain("org.koin.dsl.*"); }); + 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 list(): String = "ok"', + "}", + "", + ].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 local Kotlin declarations through wildcard imports", async () => { const root = await fixtureRoot("clawpatch-kotlin-local-wildcard-type-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 4de0e9f..a8991b9 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -794,6 +794,9 @@ function kotlinImportForType( ) { return undefined; } + if (isKotlinBuiltinType(type)) { + return undefined; + } for (const full of info.imports.values()) { if (full.endsWith(".*")) { return `${full.slice(0, -1)}${type}`; @@ -844,6 +847,40 @@ function kotlinPathRoleEvidence(filePath: string, tags: string[]): KotlinRoleEvi return evidence; } +function isKotlinBuiltinType(type: string): boolean { + return [ + "Any", + "Array", + "Boolean", + "Byte", + "Char", + "CharSequence", + "Collection", + "Comparable", + "Double", + "Float", + "Int", + "Iterable", + "List", + "Long", + "Map", + "MutableCollection", + "MutableList", + "MutableMap", + "MutableSet", + "Nothing", + "Number", + "Pair", + "Result", + "Sequence", + "Set", + "Short", + "String", + "Triple", + "Unit", + ].includes(type); +} + function parseJavaFile(source: string): JavaFileInfo { const stripped = stripJavaComments(source); const packageName = /^\s*package\s+([A-Za-z0-9_.]+)\s*;/mu.exec(stripped)?.[1] ?? null; From e6baf37e9d0b4a6aa40c86b8726dec2696e19af9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 00:12:41 +0100 Subject: [PATCH 07/62] fix(mapper): handle mixed Gradle Kotlin edge cases --- src/mapper.test.ts | 73 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 30 +++++++++++++++--- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 4180427..4d08fe0 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2902,6 +2902,41 @@ describe("mapFeatures", () => { ).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("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"); @@ -3151,6 +3186,44 @@ describe("mapFeatures", () => { ).toBe(false); }); + 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("maps server-side Kotlin declaration role evidence", async () => { const root = await fixtureRoot("clawpatch-kotlin-declaration-role-map-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index a8991b9..57e0806 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -297,7 +297,7 @@ async function kotlinRoleSeeds( return []; } const projectPackages = await gradleProjectPackages(root, sourceFiles, kotlinFiles); - const kotlinPackageTypes = kotlinPackageDeclarations(kotlinFiles); + const kotlinPackageTypes = await kotlinPackageDeclarations(root, sourceFiles, kotlinFiles); for (const { filePath, info } of kotlinFiles) { const frameworkEvidence = kotlinFrameworkRoleEvidence( @@ -410,9 +410,11 @@ async function gradleProjectPackages( return packages; } -function kotlinPackageDeclarations( +async function kotlinPackageDeclarations( + root: string, + sourceFiles: string[], kotlinFiles: Array<{ filePath: string; info: KotlinFileInfo }>, -): Map> { +): Promise>> { const declarations = new Map>(); for (const { info } of kotlinFiles) { const packageName = info.packageName ?? ""; @@ -422,6 +424,16 @@ function kotlinPackageDeclarations( } 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; } @@ -1437,8 +1449,16 @@ function hasAppliedAndroidPlugin(buildSource: string): boolean { if (/\bapply\s+false\b/u.test(line)) { return false; } - return /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/u.test( - line, + return ( + /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/u.test( + line, + ) || + /\bapply\s+plugin:\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']/u.test( + line, + ) || + /\bapply\s*\(\s*plugin\s*=\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)/u.test( + line, + ) ); }); } From 3b60cf4e81dd87fe36206f29841e3c710cbde271 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 00:23:53 +0100 Subject: [PATCH 08/62] fix(mapper): avoid Kotlin role stdlib false positives --- src/mapper.test.ts | 73 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 13 ++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 4d08fe0..f6da442 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2977,6 +2977,46 @@ describe("mapFeatures", () => { ).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 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"); @@ -3113,6 +3153,39 @@ describe("mapFeatures", () => { ).toBe(true); }); + 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("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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 57e0806..9c3dac6 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -797,7 +797,7 @@ function kotlinImportForType( ): string | undefined { const direct = info.imports.get(type); if (direct !== undefined) { - return direct; + return isKotlinStdlibImport(direct) ? undefined : direct; } const packageName = info.packageName ?? ""; if ( @@ -811,7 +811,10 @@ function kotlinImportForType( } for (const full of info.imports.values()) { if (full.endsWith(".*")) { - return `${full.slice(0, -1)}${type}`; + const wildcardType = `${full.slice(0, -1)}${type}`; + if (!isKotlinStdlibImport(wildcardType)) { + return wildcardType; + } } } return undefined; @@ -893,6 +896,10 @@ function isKotlinBuiltinType(type: string): boolean { ].includes(type); } +function isKotlinStdlibImport(full: string): boolean { + return full.startsWith("kotlin."); +} + function parseJavaFile(source: string): JavaFileInfo { const stripped = stripJavaComments(source); const packageName = /^\s*package\s+([A-Za-z0-9_.]+)\s*;/mu.exec(stripped)?.[1] ?? null; @@ -1446,7 +1453,7 @@ function hasAppliedAndroidPlugin(buildSource: string): boolean { return stripJavaComments(buildSource) .split(/\r?\n/u) .some((line) => { - if (/\bapply\s+false\b/u.test(line)) { + if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(line)) { return false; } return ( From 300839663524768c0c510f5af9fa79b0140d38fd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 00:30:47 +0100 Subject: [PATCH 09/62] fix(mapper): tighten Kotlin role parsing edges --- src/mapper.test.ts | 116 ++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 77 ++++++++++++++-------------- 2 files changed, 155 insertions(+), 38 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index f6da442..f522a41 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3017,6 +3017,48 @@ describe("mapFeatures", () => { ).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 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"); @@ -3109,6 +3151,49 @@ describe("mapFeatures", () => { expect(di?.ownedFiles[0]?.reason).toContain("org.koin.dsl.*"); }); + 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 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"); @@ -3347,6 +3432,37 @@ describe("mapFeatures", () => { expect(framework?.ownedFiles[0]?.reason).toContain("external type org.scheduler."); }); + 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("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 9c3dac6..af15ae3 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -559,25 +559,7 @@ function kotlinFrameworkRoleEvidence( confidence: "high", }); } - if ( - !isAndroid && - [ - "Controller", - "RestController", - "RequestMapping", - "GetMapping", - "PostMapping", - "PutMapping", - "DeleteMapping", - "PatchMapping", - "Path", - "GET", - "POST", - "PUT", - "DELETE", - "PATCH", - ].includes(annotation) - ) { + if (!isAndroid && isKotlinServerWebAnnotation(info, annotation)) { evidence.push({ role: "server-web-entrypoint", reason: `server web annotation @${annotation}`, @@ -900,6 +882,28 @@ function isKotlinStdlibImport(full: string): boolean { return full.startsWith("kotlin."); } +function isKotlinServerWebAnnotation(info: KotlinFileInfo, annotation: string): boolean { + if ( + [ + "Controller", + "RestController", + "RequestMapping", + "GetMapping", + "PostMapping", + "PutMapping", + "DeleteMapping", + "PatchMapping", + ].includes(annotation) + ) { + return true; + } + if (!["Path", "GET", "POST", "PUT", "DELETE", "PATCH"].includes(annotation)) { + return false; + } + const full = info.imports.get(annotation); + return full !== undefined && /^(?:javax|jakarta)\.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; @@ -1008,7 +1012,7 @@ function parseJavaDeclarations(source: string): JavaDeclaration[] { function parseKotlinDeclarations(source: string): KotlinDeclaration[] { const declarations: KotlinDeclaration[] = []; const declarationPattern = - /\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*\([^{}]*?\))?(?:\s*:\s*([^={}\n]+))?/gsu; + /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\([^{}]*?\))|(?:\s*\([^{}]*?\)))?(?:\s*:\s*([^={}\n]+))?/gsu; for (const match of source.matchAll(declarationPattern)) { const rawKind = match[3]; const name = match[4]; @@ -1450,24 +1454,21 @@ async function gradleTags( } function hasAppliedAndroidPlugin(buildSource: string): boolean { - return stripJavaComments(buildSource) - .split(/\r?\n/u) - .some((line) => { - if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(line)) { - return false; - } - return ( - /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/u.test( - line, - ) || - /\bapply\s+plugin:\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']/u.test( - line, - ) || - /\bapply\s*\(\s*plugin\s*=\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)/u.test( - line, - ) - ); - }); + const source = stripJavaComments(buildSource).replace( + /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?(?:(?!\bid\s*\(?\s*["']).)*?(?:\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\))/gsu, + "", + ); + return ( + /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/u.test( + source, + ) || + /\bapply\s+plugin:\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']/u.test( + source, + ) || + /\bapply\s*\(\s*plugin\s*=\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)/u.test( + source, + ) + ); } function isGradleSourceFile(path: string): boolean { From b8f2b0105efe75acd79a944218bce28b6b70d40e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 00:39:11 +0100 Subject: [PATCH 10/62] fix(mapper): preserve Kotlin role edge cases --- src/mapper.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 3 +- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index f522a41..11eee46 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2902,6 +2902,42 @@ describe("mapFeatures", () => { ).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("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"); @@ -3463,6 +3499,36 @@ describe("mapFeatures", () => { 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("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 af15ae3..e20939e 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1208,7 +1208,6 @@ function kotlinTypeNames(raw: string): string[] { } parts.push(current); return parts - .flatMap((type) => splitJavaTypeList(type)) .map((type) => baseKotlinTypeName(stripGenericParameters(type))) .filter((type) => type.length > 0); } @@ -1455,7 +1454,7 @@ async function gradleTags( function hasAppliedAndroidPlugin(buildSource: string): boolean { const source = stripJavaComments(buildSource).replace( - /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?(?:(?!\bid\s*\(?\s*["']).)*?(?:\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\))/gsu, + /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?(?:(?!\b(?:id|alias)\s*\().)*?(?:\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\))/gsu, "", ); return ( From 21b6dfcbfa9277d890b5c6e383501bb1aea90e24 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 00:44:02 +0100 Subject: [PATCH 11/62] fix(mapper): keep Groovy Android plugin detection --- src/mapper.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 11eee46..d878455 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2938,6 +2938,42 @@ describe("mapFeatures", () => { 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("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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index e20939e..02b983a 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1454,7 +1454,7 @@ async function gradleTags( function hasAppliedAndroidPlugin(buildSource: string): boolean { const source = stripJavaComments(buildSource).replace( - /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?(?:(?!\b(?:id|alias)\s*\().)*?(?:\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\))/gsu, + /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?(?:(?!\b(?:id|alias)\s*(?:\(|["'])).)*?(?:\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\))/gsu, "", ); return ( From 63a0f782dd0425a5132a6a582432f3fd8ee98115 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 00:49:30 +0100 Subject: [PATCH 12/62] fix(mapper): refine Android Kotlin role evidence --- src/mapper.test.ts | 75 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 41 ++++++++++++++++------- 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index d878455..2ce8795 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2974,6 +2974,42 @@ describe("mapFeatures", () => { 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("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"); @@ -3223,6 +3259,45 @@ describe("mapFeatures", () => { expect(di?.ownedFiles[0]?.reason).toContain("org.koin.dsl.*"); }); + 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 02b983a..5fbc690 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -545,8 +545,6 @@ function kotlinFrameworkRoleEvidence( "InstallIn", "Provides", "Binds", - "Inject", - "Singleton", "Component", "DependencyGraph", "BindingContainer", @@ -636,8 +634,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.")) @@ -1453,14 +1449,29 @@ async function gradleTags( } function hasAppliedAndroidPlugin(buildSource: string): boolean { - const source = stripJavaComments(buildSource).replace( - /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?(?:(?!\b(?:id|alias)\s*(?:\(|["'])).)*?(?:\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\))/gsu, - "", - ); + const source = stripJavaComments(buildSource); + const lines = source.split(/\r?\n/u); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (!androidPluginIdPattern().test(line)) { + continue; + } + let applyFalse = /\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(line); + for (let next = index + 1; next < lines.length; next += 1) { + const nextLine = lines[next] ?? ""; + if (isGradlePluginDeclarationLine(nextLine)) { + break; + } + if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(nextLine)) { + applyFalse = true; + break; + } + } + if (!applyFalse) { + return true; + } + } return ( - /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/u.test( - source, - ) || /\bapply\s+plugin:\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']/u.test( source, ) || @@ -1470,6 +1481,14 @@ function hasAppliedAndroidPlugin(buildSource: string): boolean { ); } +function androidPluginIdPattern(): RegExp { + return /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/u; +} + +function isGradlePluginDeclarationLine(line: string): boolean { + return /^\s*(?:id\s*(?:\(|["'])|alias\s*\(|[A-Za-z_][A-Za-z0-9_.]*\s*\()/u.test(line); +} + function isGradleSourceFile(path: string): boolean { const normalized = normalize(path); return ( From 4c7d4631028627bcfa187cc7a2c57738bf1ef1a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 00:58:52 +0100 Subject: [PATCH 13/62] fix(mapper): preserve Kotlin role edge cases --- CHANGELOG.md | 1 + src/mapper.test.ts | 97 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 52 +++++++++++++++-------- 3 files changed, 132 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d106edc..a379246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Added security ownership, CodeQL, Dependabot, dependency review, and a private disclosure policy for repository automation and package integrity, plus fixed the first CodeQL mapper sanitizer finding. - Added JVM semantic role mapping from Java annotations, imports, inheritance, interfaces, and method signatures. +- Added Kotlin JVM and Android semantic role mapping for Gradle projects, including Android UI, ViewModel, data, external client, and DI slices, thanks @mrmans0n. - Added Ruby and Rails feature mapping while excluding legacy Rails secrets from reviewable config, thanks @inertia186. - Fixed Ruby/Rails project detection so `gems.rb` uses Bundler commands and Rails JavaScript roots avoid duplicate Node feature queues. - Improved Python mapping for `setup.cfg`/`setup.py` project metadata and console scripts, plus `black --check .` format defaults. diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 2ce8795..29d9b05 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3010,6 +3010,36 @@ describe("mapFeatures", () => { 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("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"); @@ -3341,6 +3371,35 @@ describe("mapFeatures", () => { ).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("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"); @@ -3579,6 +3638,44 @@ describe("mapFeatures", () => { expect(framework?.ownedFiles[0]?.reason).toContain("external type org.scheduler."); }); + 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\ninterface UserRepository { fun users(): List }\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); + }); + 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 5fbc690..7bb1b08 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -173,6 +173,7 @@ type KotlinDeclaration = { type KotlinFileInfo = { packageName: string | null; annotations: Set; + qualifiedAnnotations: Set; imports: Map; declarations: KotlinDeclaration[]; functionReturnTypes: Set; @@ -306,8 +307,10 @@ async function kotlinRoleSeeds( projectPackages, kotlinPackageTypes, ); - const evidence = - frameworkEvidence.length > 0 ? frameworkEvidence : kotlinPathRoleEvidence(filePath, tags); + const pathEvidence = kotlinPathRoleEvidence(filePath, tags); + const evidence = frameworkEvidence.every((item) => item.role === "server-extension-boundary") + ? [...frameworkEvidence, ...pathEvidence] + : frameworkEvidence; for (const item of evidence) { const byFile = matches.get(item.role) ?? new Map(); const reasons = byFile.get(filePath) ?? []; @@ -897,7 +900,11 @@ function isKotlinServerWebAnnotation(info: KotlinFileInfo, annotation: string): return false; } const full = info.imports.get(annotation); - return full !== undefined && /^(?:javax|jakarta)\.ws\.rs\./u.test(full); + return ( + (full !== undefined && /^(?:javax|jakarta)\.ws\.rs\./u.test(full)) || + info.qualifiedAnnotations.has(`javax.ws.rs.${annotation}`) || + info.qualifiedAnnotations.has(`jakarta.ws.rs.${annotation}`) + ); } function parseJavaFile(source: string): JavaFileInfo { @@ -957,12 +964,16 @@ function parseKotlinFile(source: string): KotlinFileInfo { } const annotations = new Set(); + const qualifiedAnnotations = 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) { annotations.add(raw.split(".").at(-1) ?? raw); + if (raw.includes(".")) { + qualifiedAnnotations.add(raw); + } } } @@ -979,6 +990,7 @@ function parseKotlinFile(source: string): KotlinFileInfo { return { packageName, annotations, + qualifiedAnnotations, imports, declarations: parseKotlinDeclarations(stripped), functionReturnTypes, @@ -1453,23 +1465,27 @@ function hasAppliedAndroidPlugin(buildSource: string): boolean { const lines = source.split(/\r?\n/u); for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ""; - if (!androidPluginIdPattern().test(line)) { - continue; - } - let applyFalse = /\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(line); - for (let next = index + 1; next < lines.length; next += 1) { - const nextLine = lines[next] ?? ""; - if (isGradlePluginDeclarationLine(nextLine)) { - break; + for (const match of line.matchAll(androidPluginIdPattern())) { + const start = match.index ?? 0; + const segmentEnd = line.indexOf(";", start); + const sameLineSegment = line.slice(start, segmentEnd === -1 ? undefined : segmentEnd); + let applyFalse = /\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(sameLineSegment); + if (segmentEnd === -1) { + for (let next = index + 1; next < lines.length; next += 1) { + const nextLine = lines[next] ?? ""; + if (isGradlePluginDeclarationLine(nextLine)) { + break; + } + if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(nextLine)) { + applyFalse = true; + break; + } + } } - if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(nextLine)) { - applyFalse = true; - break; + if (!applyFalse) { + return true; } } - if (!applyFalse) { - return true; - } } return ( /\bapply\s+plugin:\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']/u.test( @@ -1482,7 +1498,7 @@ function hasAppliedAndroidPlugin(buildSource: string): boolean { } function androidPluginIdPattern(): RegExp { - return /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/u; + return /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/gu; } function isGradlePluginDeclarationLine(line: string): boolean { From 7338196a90a6f9b23e44b05042dec9540611e7a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 01:05:32 +0100 Subject: [PATCH 14/62] fix(mapper): tighten Android Kotlin role detection --- src/mapper.test.ts | 113 ++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 7 ++- 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 29d9b05..d5ba7dc 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3040,6 +3040,36 @@ describe("mapFeatures", () => { 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 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"); @@ -3115,6 +3145,46 @@ describe("mapFeatures", () => { ).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"); @@ -3237,6 +3307,49 @@ describe("mapFeatures", () => { ).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("maps Kotlin role evidence from wildcard imports", async () => { const root = await fixtureRoot("clawpatch-kotlin-wildcard-imports-"); await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":app")\n'); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 7bb1b08..1ac6d79 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -600,7 +600,6 @@ function kotlinFrameworkRoleEvidence( full.startsWith("android.content.BroadcastReceiver") || full.startsWith("androidx.activity.") || full.startsWith("androidx.appcompat.app.") || - full.startsWith("androidx.compose.") || full.startsWith("androidx.fragment.app.") || full.startsWith("androidx.lifecycle.LifecycleService")) ) { @@ -1465,7 +1464,7 @@ function hasAppliedAndroidPlugin(buildSource: string): boolean { const lines = source.split(/\r?\n/u); for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ""; - for (const match of line.matchAll(androidPluginIdPattern())) { + for (const match of line.matchAll(androidPluginDeclarationPattern())) { const start = match.index ?? 0; const segmentEnd = line.indexOf(";", start); const sameLineSegment = line.slice(start, segmentEnd === -1 ? undefined : segmentEnd); @@ -1497,8 +1496,8 @@ function hasAppliedAndroidPlugin(buildSource: string): boolean { ); } -function androidPluginIdPattern(): RegExp { - return /\bid\s*\(?\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\)?/gu; +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\.(?:application|library|dynamicFeature|dynamic-feature|test)|android(?:Application|Library|DynamicFeature|Test)|comAndroid(?:Application|Library|DynamicFeature|Test))[A-Za-z0-9_.]*\s*\))/gu; } function isGradlePluginDeclarationLine(line: string): boolean { From a254b789815e96473ea109701101a42c24016456 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 01:12:27 +0100 Subject: [PATCH 15/62] fix(mapper): narrow Android UI import roles --- src/mapper.test.ts | 35 +++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 24 +++++++++++++++--------- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index d5ba7dc..1638d54 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3350,6 +3350,41 @@ describe("mapFeatures", () => { ).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("maps Kotlin role evidence from wildcard imports", async () => { const root = await fixtureRoot("clawpatch-kotlin-wildcard-imports-"); await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":app")\n'); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 1ac6d79..3e5f370 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -594,15 +594,7 @@ function kotlinFrameworkRoleEvidence( } for (const full of info.imports.values()) { - if ( - isAndroid && - (full.startsWith("android.app.") || - full.startsWith("android.content.BroadcastReceiver") || - full.startsWith("androidx.activity.") || - full.startsWith("androidx.appcompat.app.") || - full.startsWith("androidx.fragment.app.") || - full.startsWith("androidx.lifecycle.LifecycleService")) - ) { + if (isAndroid && isAndroidUiEntrypointImport(full)) { evidence.push({ role: "android-ui-entrypoint", reason: `Android UI import ${full}`, @@ -1170,6 +1162,20 @@ function isKotlinExternalClientImport(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 isSpringDataPersistenceImport(full: string): boolean { return ( full.startsWith("org.springframework.data.repository.") || From 6d30cd92ca8d0443f28844c0a990261edb34e8c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 01:19:50 +0100 Subject: [PATCH 16/62] fix(mapper): preserve Kotlin path roles with framework evidence --- src/mapper.test.ts | 18 +++++++++++++++++- src/mappers/gradle.ts | 14 ++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 1638d54..d33fda6 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3798,7 +3798,14 @@ describe("mapFeatures", () => { await writeFixture( root, "src/main/kotlin/com/example/repository/UserRepository.kt", - "package com.example.repository\ninterface UserRepository { fun users(): List }\n", + [ + "package com.example.repository", + "", + "import kotlinx.coroutines.flow.Flow", + "", + "interface UserRepository { fun users(): Flow }", + "", + ].join("\n"), ); const project = await detectProject(root); @@ -3822,6 +3829,15 @@ describe("mapFeatures", () => { ), ), ).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 () => { diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 3e5f370..af2ccdc 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -307,10 +307,16 @@ async function kotlinRoleSeeds( projectPackages, kotlinPackageTypes, ); - const pathEvidence = kotlinPathRoleEvidence(filePath, tags); - const evidence = frameworkEvidence.every((item) => item.role === "server-extension-boundary") - ? [...frameworkEvidence, ...pathEvidence] - : frameworkEvidence; + const pathEvidence = kotlinPathRoleEvidence(filePath, tags).filter( + (item) => + !frameworkEvidence.some((evidenceItem) => evidenceItem.role === item.role) && + !( + tags.includes("android") && + frameworkEvidence.length > 0 && + item.role === "android-ui-entrypoint" + ), + ); + const evidence = [...frameworkEvidence, ...pathEvidence]; for (const item of evidence) { const byFile = matches.get(item.role) ?? new Map(); const reasons = byFile.get(filePath) ?? []; From fb576efcc48e9393fec498399715bb45a7f31dc9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 01:26:38 +0100 Subject: [PATCH 17/62] fix(mapper): refine Kotlin role fallback edges --- src/mapper.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++++- src/mappers/gradle.ts | 22 +++++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index d33fda6..3c6779c 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3350,6 +3350,49 @@ describe("mapFeatures", () => { ).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 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"); @@ -3563,7 +3606,7 @@ describe("mapFeatures", () => { "@RestController", "class OrderController {", ' @GetMapping("/orders")', - ' fun list(): String = "ok"', + ' fun body(): ByteArray = "ok".encodeToByteArray()', "}", "", ].join("\n"), diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index af2ccdc..52062f4 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -312,8 +312,10 @@ async function kotlinRoleSeeds( !frameworkEvidence.some((evidenceItem) => evidenceItem.role === item.role) && !( tags.includes("android") && - frameworkEvidence.length > 0 && - item.role === "android-ui-entrypoint" + item.role === "android-ui-entrypoint" && + frameworkEvidence.some((evidenceItem) => + ["android-data-boundary", "android-view-model"].includes(evidenceItem.role), + ) ), ); const evidence = [...frameworkEvidence, ...pathEvidence]; @@ -845,17 +847,24 @@ function isKotlinBuiltinType(type: string): boolean { "Any", "Array", "Boolean", + "BooleanArray", "Byte", + "ByteArray", "Char", + "CharArray", "CharSequence", "Collection", "Comparable", "Double", + "DoubleArray", "Float", + "FloatArray", "Int", + "IntArray", "Iterable", "List", "Long", + "LongArray", "Map", "MutableCollection", "MutableList", @@ -868,9 +877,18 @@ function isKotlinBuiltinType(type: string): boolean { "Sequence", "Set", "Short", + "ShortArray", "String", "Triple", + "UByte", + "UByteArray", + "UInt", + "UIntArray", + "ULong", + "ULongArray", "Unit", + "UShort", + "UShortArray", ].includes(type); } From 6fb89d89a40808953d942c0e8225b58e71915fc6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 01:39:08 +0100 Subject: [PATCH 18/62] fix(mapper): resolve Kotlin Android supertypes --- src/mapper.test.ts | 31 ++++++++++++++++++++++ src/mappers/gradle.ts | 60 +++++++++++++++++++++++++++++++------------ 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 587dd3e..b39a0f1 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3428,6 +3428,37 @@ describe("mapFeatures", () => { ).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 role evidence from wildcard imports", async () => { const root = await fixtureRoot("clawpatch-kotlin-wildcard-imports-"); await writeFixture(root, "settings.gradle.kts", 'pluginManagement {}\ninclude(":app")\n'); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 52062f4..a054509 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -682,31 +682,21 @@ function kotlinFrameworkRoleEvidence( for (const declaration of info.declarations) { for (const type of declaration.supertypes) { - if ( - isAndroid && - [ - "Activity", - "AppCompatActivity", - "ComponentActivity", - "Fragment", - "Service", - "BroadcastReceiver", - ].includes(type) - ) { + if (isAndroid && isAndroidUiEntrypointSupertype(info, type, kotlinPackageTypes)) { evidence.push({ role: "android-ui-entrypoint", reason: `inherits Android UI type ${type}`, confidence: "high", }); } - if (isAndroid && ["ViewModel", "AndroidViewModel"].includes(type)) { + if (isAndroid && isAndroidViewModelSupertype(info, type, kotlinPackageTypes)) { evidence.push({ role: "android-view-model", reason: `inherits Android ViewModel type ${type}`, confidence: "high", }); } - if (isAndroid && ["RoomDatabase"].includes(type)) { + if (isAndroid && isAndroidRoomSupertype(info, type, kotlinPackageTypes)) { evidence.push({ role: "android-data-boundary", reason: `inherits Room type ${type}`, @@ -775,6 +765,9 @@ function kotlinImportForType( type: string, kotlinPackageTypes: Map>, ): string | undefined { + if (type.includes(".")) { + return isKotlinStdlibImport(type) ? undefined : type; + } const direct = info.imports.get(type); if (direct !== undefined) { return isKotlinStdlibImport(direct) ? undefined : direct; @@ -1200,6 +1193,32 @@ function isAndroidUiEntrypointImport(full: string): boolean { ].includes(full); } +function isAndroidUiEntrypointSupertype( + info: KotlinFileInfo, + type: string, + kotlinPackageTypes: Map>, +): boolean { + const full = kotlinImportForType(info, type, kotlinPackageTypes); + return full !== undefined && isAndroidUiEntrypointImport(full); +} + +function isAndroidViewModelSupertype( + info: KotlinFileInfo, + type: string, + kotlinPackageTypes: Map>, +): boolean { + const full = kotlinImportForType(info, type, kotlinPackageTypes); + return full === "androidx.lifecycle.ViewModel" || full === "androidx.lifecycle.AndroidViewModel"; +} + +function isAndroidRoomSupertype( + info: KotlinFileInfo, + type: string, + kotlinPackageTypes: Map>, +): boolean { + return kotlinImportForType(info, type, kotlinPackageTypes) === "androidx.room.RoomDatabase"; +} + function isSpringDataPersistenceImport(full: string): boolean { return ( full.startsWith("org.springframework.data.repository.") || @@ -1244,9 +1263,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 { @@ -1272,6 +1289,17 @@ function baseKotlinTypeName(raw: string): string { ); } +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; From 30d5b78dc68c71257193776efc6aa75e9166518c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 01:45:52 +0100 Subject: [PATCH 19/62] fix(mapper): resolve Android version catalog aliases --- src/mapper.test.ts | 40 +++++++++++++++ src/mappers/gradle.ts | 114 +++++++++++++++++++++++++++++++++++------- 2 files changed, 136 insertions(+), 18 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index b39a0f1..981b07a 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3070,6 +3070,46 @@ describe("mapFeatures", () => { 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("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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index a054509..7620314 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1507,39 +1507,91 @@ async function gradleTags( ) { tags.push("kotlin"); } - const buildSource = await readFile(join(root, buildFile), "utf8").catch(() => ""); + const [buildSource, androidAliases] = await Promise.all([ + readFile(join(root, buildFile), "utf8").catch(() => ""), + androidVersionCatalogPluginAliases(root, buildFile), + ]); if ( sourceFiles.some((file) => file.endsWith("AndroidManifest.xml")) || - hasAppliedAndroidPlugin(buildSource) + hasAppliedAndroidPlugin(buildSource, androidAliases) ) { tags.push("android"); } return tags; } -function hasAppliedAndroidPlugin(buildSource: string): boolean { +async function androidVersionCatalogPluginAliases( + root: string, + buildFile: string, +): Promise> { + const aliases = new Set(); + for (const path of versionCatalogPaths(buildFile)) { + 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): string[] { + const paths = new Set(); + let dir = dirname(buildFile); + while (true) { + paths.add(dir === "." ? "gradle/libs.versions.toml" : `${dir}/gradle/libs.versions.toml`); + if (dir === ".") { + break; + } + dir = dirname(dir); + } + return [...paths]; +} + +function parseAndroidPluginAliases(source: string): Set { + const aliases = new Set(); + let inPlugins = false; + 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) { + inPlugins = section === "plugins"; + continue; + } + if (!inPlugins || !/com\.android\.(?:application|library|dynamic-feature|test)/u.test(line)) { + continue; + } + const alias = /^([A-Za-z0-9_.-]+)\s*=/u.exec(line)?.[1]; + if (alias !== undefined) { + aliases.add(normalizeVersionCatalogAlias(alias)); + } + } + return aliases; +} + +function hasAppliedAndroidPlugin(buildSource: string, androidAliases: Set): boolean { const source = stripJavaComments(buildSource); const lines = source.split(/\r?\n/u); for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ""; for (const match of line.matchAll(androidPluginDeclarationPattern())) { const start = match.index ?? 0; - const segmentEnd = line.indexOf(";", start); - const sameLineSegment = line.slice(start, segmentEnd === -1 ? undefined : segmentEnd); - let applyFalse = /\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(sameLineSegment); - if (segmentEnd === -1) { - for (let next = index + 1; next < lines.length; next += 1) { - const nextLine = lines[next] ?? ""; - if (isGradlePluginDeclarationLine(nextLine)) { - break; - } - if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(nextLine)) { - applyFalse = true; - break; - } - } + if (!hasGradleApplyFalse(lines, index, start)) { + return true; } - if (!applyFalse) { + } + for (const match of line.matchAll(/\balias\s*\(\s*libs\.plugins\.([A-Za-z0-9_.]+)\s*\)/gu)) { + const alias = match[1]; + if ( + alias !== undefined && + androidAliases.has(normalizeVersionCatalogAlias(alias)) && + !hasGradleApplyFalse(lines, index, match.index ?? 0) + ) { return true; } } @@ -1554,10 +1606,36 @@ function hasAppliedAndroidPlugin(buildSource: string): boolean { ); } +function hasGradleApplyFalse(lines: string[], index: number, start: number): boolean { + const line = lines[index] ?? ""; + const segmentEnd = line.indexOf(";", start); + const sameLineSegment = line.slice(start, segmentEnd === -1 ? undefined : segmentEnd); + if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(sameLineSegment)) { + return true; + } + if (segmentEnd !== -1) { + return false; + } + for (let next = index + 1; next < lines.length; next += 1) { + const nextLine = lines[next] ?? ""; + if (isGradlePluginDeclarationLine(nextLine)) { + return false; + } + if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(nextLine)) { + return true; + } + } + return false; +} + 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\.(?:application|library|dynamicFeature|dynamic-feature|test)|android(?:Application|Library|DynamicFeature|Test)|comAndroid(?:Application|Library|DynamicFeature|Test))[A-Za-z0-9_.]*\s*\))/gu; } +function normalizeVersionCatalogAlias(alias: string): string { + return alias.replace(/[-_]/gu, ".").toLowerCase(); +} + function isGradlePluginDeclarationLine(line: string): boolean { return /^\s*(?:id\s*(?:\(|["'])|alias\s*\(|[A-Za-z_][A-Za-z0-9_.]*\s*\()/u.test(line); } From 5bc548d846307b74d0131bdf26479e2b3c18510e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 01:53:59 +0100 Subject: [PATCH 20/62] fix(mapper): harden Kotlin return type role detection --- src/mapper.test.ts | 65 ++++++++++++++++++++ src/mappers/gradle.ts | 136 +++++++++++++++++++++++++++--------------- 2 files changed, 153 insertions(+), 48 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 981b07a..5ee6812 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3662,6 +3662,38 @@ describe("mapFeatures", () => { 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("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"); @@ -3706,6 +3738,39 @@ describe("mapFeatures", () => { ).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 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 7620314..6aadfac 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -7,6 +7,92 @@ import { FeatureSeed, SeedTestRef } from "./types.js"; const maxOwnedFiles = 12; const maxTests = 8; +const kotlinBuiltinTypes = new Set([ + "AbstractMethodError", + "Appendable", + "Any", + "Array", + "ArrayDeque", + "ArrayIndexOutOfBoundsException", + "ArrayList", + "AssertionError", + "AutoCloseable", + "Boolean", + "BooleanArray", + "Byte", + "ByteArray", + "Char", + "CharArray", + "CharSequence", + "Class", + "ClassCastException", + "Cloneable", + "Collection", + "Comparable", + "ConcurrentModificationException", + "Double", + "DoubleArray", + "Enum", + "Error", + "Exception", + "Float", + "FloatArray", + "HashMap", + "HashSet", + "IllegalArgumentException", + "IllegalStateException", + "IndexOutOfBoundsException", + "Int", + "IntArray", + "Iterable", + "Iterator", + "Lazy", + "LinkedHashMap", + "LinkedHashSet", + "List", + "ListIterator", + "Long", + "LongArray", + "Map", + "MatchGroup", + "MatchGroupCollection", + "MatchResult", + "MutableCollection", + "MutableIterable", + "MutableIterator", + "MutableList", + "MutableListIterator", + "MutableMap", + "MutableSet", + "NoSuchElementException", + "NullPointerException", + "Nothing", + "Number", + "Pair", + "Regex", + "Result", + "RuntimeException", + "Sequence", + "Set", + "Short", + "ShortArray", + "String", + "StringBuffer", + "StringBuilder", + "Throwable", + "Triple", + "UByte", + "UByteArray", + "UInt", + "UIntArray", + "UIntRange", + "ULong", + "ULongArray", + "ULongRange", + "Unit", + "UShort", + "UShortArray", +]); const jvmRoleDefinitions = { "web-entrypoint": { title: "web entrypoint", @@ -836,53 +922,7 @@ function kotlinPathRoleEvidence(filePath: string, tags: string[]): KotlinRoleEvi } function isKotlinBuiltinType(type: string): boolean { - return [ - "Any", - "Array", - "Boolean", - "BooleanArray", - "Byte", - "ByteArray", - "Char", - "CharArray", - "CharSequence", - "Collection", - "Comparable", - "Double", - "DoubleArray", - "Float", - "FloatArray", - "Int", - "IntArray", - "Iterable", - "List", - "Long", - "LongArray", - "Map", - "MutableCollection", - "MutableList", - "MutableMap", - "MutableSet", - "Nothing", - "Number", - "Pair", - "Result", - "Sequence", - "Set", - "Short", - "ShortArray", - "String", - "Triple", - "UByte", - "UByteArray", - "UInt", - "UIntArray", - "ULong", - "ULongArray", - "Unit", - "UShort", - "UShortArray", - ].includes(type); + return kotlinBuiltinTypes.has(type); } function isKotlinStdlibImport(full: string): boolean { @@ -991,7 +1031,7 @@ function parseKotlinFile(source: string): KotlinFileInfo { )) { const type = match[1]; if (type !== undefined) { - functionReturnTypes.add(baseKotlinTypeName(stripGenericParameters(type))); + functionReturnTypes.add(kotlinTypeReferenceName(type)); } } From 8fc4fa7407d511f49a698c0b3a8e115cc86a3dcb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 02:01:39 +0100 Subject: [PATCH 21/62] fix(mapper): cover Kotlin default range types --- src/mapper.test.ts | 43 ++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 5ee6812..2e087a0 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3771,6 +3771,49 @@ describe("mapFeatures", () => { ).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(): IntRange = 1..3", + "}", + "", + ].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 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 6aadfac..25188de 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -9,6 +9,16 @@ const maxOwnedFiles = 12; const maxTests = 8; const kotlinBuiltinTypes = new Set([ "AbstractMethodError", + "AbstractCollection", + "AbstractIterator", + "AbstractList", + "AbstractMap", + "AbstractMutableCollection", + "AbstractMutableList", + "AbstractMutableMap", + "AbstractMutableSet", + "AbstractSet", + "Annotation", "Appendable", "Any", "Array", @@ -19,44 +29,65 @@ const kotlinBuiltinTypes = new Set([ "AutoCloseable", "Boolean", "BooleanArray", + "BooleanIterator", "Byte", "ByteArray", + "ByteIterator", "Char", "CharArray", + "CharIterator", + "CharProgression", + "CharRange", "CharSequence", "Class", "ClassCastException", "Cloneable", "Collection", "Comparable", + "Comparator", "ConcurrentModificationException", + "DeepRecursiveFunction", + "DeepRecursiveScope", "Double", "DoubleArray", + "DoubleIterator", "Enum", "Error", "Exception", "Float", "FloatArray", + "FloatIterator", + "Grouping", "HashMap", "HashSet", + "IndexedValue", "IllegalArgumentException", "IllegalStateException", "IndexOutOfBoundsException", "Int", "IntArray", + "IntIterator", + "IntProgression", + "IntRange", "Iterable", "Iterator", "Lazy", + "LazyThreadSafetyMode", "LinkedHashMap", "LinkedHashSet", "List", "ListIterator", "Long", "LongArray", + "LongIterator", + "LongProgression", + "LongRange", "Map", "MatchGroup", "MatchGroupCollection", + "MatchNamedGroupCollection", "MatchResult", + "MutableEntry", "MutableCollection", "MutableIterable", "MutableIterator", @@ -65,33 +96,46 @@ const kotlinBuiltinTypes = new Set([ "MutableMap", "MutableSet", "NoSuchElementException", + "NoWhenBranchMatchedException", "NullPointerException", "Nothing", + "NotImplementedError", "Number", "Pair", + "RandomAccess", "Regex", + "RegexOption", "Result", "RuntimeException", "Sequence", "Set", "Short", "ShortArray", + "ShortIterator", "String", "StringBuffer", "StringBuilder", + "SubclassOptInRequired", "Throwable", "Triple", "UByte", "UByteArray", + "UByteIterator", "UInt", "UIntArray", + "UIntIterator", + "UIntProgression", "UIntRange", "ULong", "ULongArray", + "ULongIterator", + "ULongProgression", "ULongRange", "Unit", + "UninitializedPropertyAccessException", "UShort", "UShortArray", + "UShortIterator", ]); const jvmRoleDefinitions = { "web-entrypoint": { From 1bf612a9ce99c5e32895b2472c4b661965046ffe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 02:08:13 +0100 Subject: [PATCH 22/62] fix(mapper): prefer local Kotlin wildcard types --- src/mapper.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 6 ++++ 2 files changed, 77 insertions(+) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 2e087a0..7585fe3 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3551,6 +3551,38 @@ describe("mapFeatures", () => { expect(di?.ownedFiles[0]?.reason).toContain("org.koin.dsl.*"); }); + 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"); @@ -3920,6 +3952,45 @@ describe("mapFeatures", () => { ).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("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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 25188de..f8833dc 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -912,6 +912,11 @@ function kotlinImportForType( if (isKotlinBuiltinType(type)) { return undefined; } + for (const full of info.imports.values()) { + if (full.endsWith(".*") && kotlinPackageTypes.get(full.slice(0, -2))?.has(type) === true) { + return undefined; + } + } for (const full of info.imports.values()) { if (full.endsWith(".*")) { const wildcardType = `${full.slice(0, -1)}${type}`; @@ -1255,6 +1260,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.") || From 147310ded6ff50377f97cec258deefe9e3a9004b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 02:16:33 +0100 Subject: [PATCH 23/62] fix(mapper): exclude Kotlin JVM default types --- src/mapper.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 39 +++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 7585fe3..58651d6 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3846,6 +3846,70 @@ describe("mapFeatures", () => { ).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 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index f8833dc..f0ea03a 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -20,6 +20,7 @@ const kotlinBuiltinTypes = new Set([ "AbstractSet", "Annotation", "Appendable", + "ArithmeticException", "Any", "Array", "ArrayDeque", @@ -41,6 +42,8 @@ const kotlinBuiltinTypes = new Set([ "CharSequence", "Class", "ClassCastException", + "ClassLoader", + "ClassNotFoundException", "Cloneable", "Collection", "Comparable", @@ -62,13 +65,19 @@ const kotlinBuiltinTypes = new Set([ "HashSet", "IndexedValue", "IllegalArgumentException", + "IllegalMonitorStateException", "IllegalStateException", + "IllegalThreadStateException", "IndexOutOfBoundsException", + "InheritableThreadLocal", "Int", + "Integer", "IntArray", "IntIterator", "IntProgression", "IntRange", + "InterruptedException", + "InternalError", "Iterable", "Iterator", "Lazy", @@ -83,6 +92,7 @@ const kotlinBuiltinTypes = new Set([ "LongProgression", "LongRange", "Map", + "Math", "MatchGroup", "MatchGroupCollection", "MatchNamedGroupCollection", @@ -95,18 +105,34 @@ const kotlinBuiltinTypes = new Set([ "MutableListIterator", "MutableMap", "MutableSet", + "NegativeArraySizeException", + "NoClassDefFoundError", "NoSuchElementException", + "NoSuchFieldError", + "NoSuchFieldException", + "NoSuchMethodError", + "NoSuchMethodException", "NoWhenBranchMatchedException", "NullPointerException", "Nothing", "NotImplementedError", "Number", + "Object", + "OutOfMemoryError", + "Package", "Pair", + "Process", + "ProcessBuilder", "RandomAccess", + "Readable", + "ReflectiveOperationException", "Regex", "RegexOption", "Result", + "Runnable", + "Runtime", "RuntimeException", + "SecurityException", "Sequence", "Set", "Short", @@ -116,8 +142,13 @@ const kotlinBuiltinTypes = new Set([ "StringBuffer", "StringBuilder", "SubclassOptInRequired", + "System", + "Thread", + "ThreadGroup", + "ThreadLocal", "Throwable", "Triple", + "TypeNotPresentException", "UByte", "UByteArray", "UByteIterator", @@ -133,9 +164,14 @@ const kotlinBuiltinTypes = new Set([ "ULongRange", "Unit", "UninitializedPropertyAccessException", + "UnknownError", + "UnsatisfiedLinkError", + "UnsupportedClassVersionError", + "UnsupportedOperationException", "UShort", "UShortArray", "UShortIterator", + "Void", ]); const jvmRoleDefinitions = { "web-entrypoint": { @@ -896,7 +932,8 @@ function kotlinImportForType( kotlinPackageTypes: Map>, ): string | undefined { if (type.includes(".")) { - return isKotlinStdlibImport(type) ? undefined : type; + const rootType = type.split(".")[0] ?? type; + return isKotlinStdlibImport(type) || isKotlinBuiltinType(rootType) ? undefined : type; } const direct = info.imports.get(type); if (direct !== undefined) { From 091f37665fde65dacc0b64d91e6e5e599357304e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 02:25:37 +0100 Subject: [PATCH 24/62] fix(mapper): keep nested Kotlin local types local --- src/mapper.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 39 ++++++++++++++++++++++++++++++++------- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 58651d6..967b507 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2840,6 +2840,42 @@ describe("mapFeatures", () => { ).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("does not infer Android roles from non-Android Gradle module paths", async () => { const root = await fixtureRoot("clawpatch-kotlin-android-path-leak-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index f0ea03a..7b4c367 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -931,9 +931,23 @@ function kotlinImportForType( type: string, kotlinPackageTypes: Map>, ): string | undefined { - if (type.includes(".")) { - const rootType = type.split(".")[0] ?? type; - return isKotlinStdlibImport(type) || isKotlinBuiltinType(rootType) ? undefined : type; + const [rootType, ...nestedParts] = type.split("."); + const isNestedType = nestedParts.length > 0; + if (rootType === undefined || rootType.length === 0) { + return undefined; + } + if (isKotlinStdlibImport(type) || isKotlinBuiltinType(rootType)) { + return undefined; + } + if (isNestedType && /^[a-z]/u.test(rootType)) { + return type; + } + if (isNestedType) { + const directRoot = info.imports.get(rootType); + if (directRoot !== undefined) { + const full = `${directRoot}.${nestedParts.join(".")}`; + return isKotlinStdlibImport(full) ? undefined : full; + } } const direct = info.imports.get(type); if (direct !== undefined) { @@ -941,19 +955,30 @@ function kotlinImportForType( } const packageName = info.packageName ?? ""; if ( - info.declarations.some((declaration) => declaration.name === type) || - kotlinPackageTypes.get(packageName)?.has(type) === true + info.declarations.some((declaration) => declaration.name === rootType) || + kotlinPackageTypes.get(packageName)?.has(rootType) === true ) { return undefined; } - if (isKotlinBuiltinType(type)) { + if (!isNestedType && isKotlinBuiltinType(type)) { return undefined; } for (const full of info.imports.values()) { - if (full.endsWith(".*") && kotlinPackageTypes.get(full.slice(0, -2))?.has(type) === true) { + 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(".*")) { + const wildcardType = `${full.slice(0, -1)}${type}`; + if (!isKotlinStdlibImport(wildcardType)) { + return wildcardType; + } + } + } + return type; + } for (const full of info.imports.values()) { if (full.endsWith(".*")) { const wildcardType = `${full.slice(0, -1)}${type}`; From 985a8df4070cebe7f68417cd6f4c832ed9fa4fa7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 02:33:31 +0100 Subject: [PATCH 25/62] fix(mapper): honor explicit Kotlin builtin shadows --- src/mapper.test.ts | 32 ++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 5 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 967b507..6d4a54c 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3979,6 +3979,38 @@ describe("mapFeatures", () => { ).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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 7b4c367..833e55e 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -936,7 +936,7 @@ function kotlinImportForType( if (rootType === undefined || rootType.length === 0) { return undefined; } - if (isKotlinStdlibImport(type) || isKotlinBuiltinType(rootType)) { + if (isKotlinStdlibImport(type)) { return undefined; } if (isNestedType && /^[a-z]/u.test(rootType)) { @@ -953,6 +953,9 @@ function kotlinImportForType( if (direct !== undefined) { return isKotlinStdlibImport(direct) ? undefined : direct; } + if (isKotlinBuiltinType(rootType)) { + return undefined; + } const packageName = info.packageName ?? ""; if ( info.declarations.some((declaration) => declaration.name === rootType) || From bfaaab0b93d2df3517c4d199f6f6c5181226e8ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 02:42:37 +0100 Subject: [PATCH 26/62] fix(mapper): support dotted Android catalog aliases --- src/mapper.test.ts | 80 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 6 ++-- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 6d4a54c..c12b32e 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3146,6 +3146,86 @@ describe("mapFeatures", () => { ).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 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 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 833e55e..0a07a1d 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1708,6 +1708,7 @@ function versionCatalogPaths(buildFile: string): string[] { 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) { @@ -1715,13 +1716,14 @@ function parseAndroidPluginAliases(source: string): Set { } const section = /^\[([^\]]+)\]$/u.exec(line)?.[1]; if (section !== undefined) { - inPlugins = section === "plugins"; + inPlugins = section === "plugins" || section.startsWith("plugins."); + pluginTableAlias = section.startsWith("plugins.") ? section.slice("plugins.".length) : null; continue; } if (!inPlugins || !/com\.android\.(?:application|library|dynamic-feature|test)/u.test(line)) { continue; } - const alias = /^([A-Za-z0-9_.-]+)\s*=/u.exec(line)?.[1]; + const alias = pluginTableAlias ?? /^([A-Za-z0-9_.-]+?)(?:\.id)?\s*=/u.exec(line)?.[1]; if (alias !== undefined) { aliases.add(normalizeVersionCatalogAlias(alias)); } From 984d1062265c193737f85fd73b00b30b000ee3d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 02:49:43 +0100 Subject: [PATCH 27/62] fix(mapper): parse Kotlin function-typed parameters --- src/mapper.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 4 +-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index c12b32e..15efa6a 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3842,6 +3842,40 @@ describe("mapFeatures", () => { ); }); + 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"); @@ -4376,6 +4410,36 @@ describe("mapFeatures", () => { 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 0a07a1d..d42e737 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1141,7 +1141,7 @@ function parseKotlinFile(source: string): KotlinFileInfo { 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) { @@ -1182,7 +1182,7 @@ function parseJavaDeclarations(source: string): JavaDeclaration[] { function parseKotlinDeclarations(source: string): KotlinDeclaration[] { const declarations: KotlinDeclaration[] = []; const declarationPattern = - /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\([^{}]*?\))|(?:\s*\([^{}]*?\)))?(?:\s*:\s*([^={}\n]+))?/gsu; + /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^={}\n]+))?/gsu; for (const match of source.matchAll(declarationPattern)) { const rawKind = match[3]; const name = match[4]; From 8ab65038206638016a7bb0c6f0ee4e189684b682 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 02:59:17 +0100 Subject: [PATCH 28/62] fix(mapper): handle named Kotlin supertype args --- src/mapper.test.ts | 30 ++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 15efa6a..2ca1812 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4470,6 +4470,36 @@ describe("mapFeatures", () => { 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 d42e737..540715a 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1182,7 +1182,7 @@ function parseJavaDeclarations(source: string): JavaDeclaration[] { function parseKotlinDeclarations(source: string): KotlinDeclaration[] { const declarations: KotlinDeclaration[] = []; const declarationPattern = - /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^={}\n]+))?/gsu; + /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^{}\n]+))?/gsu; for (const match of source.matchAll(declarationPattern)) { const rawKind = match[3]; const name = match[4]; From 0becffaed980784ade39a66b20b391b4eaf9bcff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 03:09:23 +0100 Subject: [PATCH 29/62] fix(mapper): resolve nested Android catalog aliases --- src/mapper.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 13 +++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 2ca1812..95b3ec1 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3226,6 +3226,52 @@ describe("mapFeatures", () => { ).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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 540715a..02d5130 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1723,7 +1723,7 @@ function parseAndroidPluginAliases(source: string): Set { if (!inPlugins || !/com\.android\.(?:application|library|dynamic-feature|test)/u.test(line)) { continue; } - const alias = pluginTableAlias ?? /^([A-Za-z0-9_.-]+?)(?:\.id)?\s*=/u.exec(line)?.[1]; + const alias = androidPluginAliasForLine(line, pluginTableAlias); if (alias !== undefined) { aliases.add(normalizeVersionCatalogAlias(alias)); } @@ -1731,6 +1731,17 @@ function parseAndroidPluginAliases(source: string): Set { return aliases; } +function androidPluginAliasForLine( + line: string, + pluginTableAlias: string | null, +): string | undefined { + const rawKey = /^([A-Za-z0-9_.-]+?)(?:\.id)?\s*=/u.exec(line)?.[1]; + if (pluginTableAlias === null || rawKey === undefined || rawKey === "id") { + return pluginTableAlias ?? rawKey; + } + return `${pluginTableAlias}.${rawKey}`; +} + function hasAppliedAndroidPlugin(buildSource: string, androidAliases: Set): boolean { const source = stripJavaComments(buildSource); const lines = source.split(/\r?\n/u); From 336932164d2e6af97bdfdc19233ab75876604a66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 03:16:45 +0100 Subject: [PATCH 30/62] fix(mapper): ignore child Android apply scopes --- src/mapper.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 95b3ec1..e87abf6 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3307,6 +3307,46 @@ describe("mapFeatures", () => { ).toBe(false); }); + 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 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 02d5130..e6ff8c6 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1764,14 +1764,35 @@ function hasAppliedAndroidPlugin(buildSource: string, androidAliases: Set Date: Sun, 17 May 2026 03:26:51 +0100 Subject: [PATCH 31/62] fix(mapper): tighten Gradle Android role detection --- src/mapper.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++++- src/mappers/gradle.ts | 12 +++++-- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index e87abf6..0e00f4b 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3307,6 +3307,41 @@ describe("mapFeatures", () => { ).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("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"); @@ -3347,6 +3382,46 @@ describe("mapFeatures", () => { ).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"); @@ -4053,7 +4128,8 @@ describe("mapFeatures", () => { "", "@RestController", "class RangeController {", - " fun ids(): IntRange = 1..3", + " fun ids(): ClosedRange = 1..3", + ' fun version(): KotlinVersion = KotlinVersion(1, 9, 0, "stable")', "}", "", ].join("\n"), diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index e6ff8c6..57d2483 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -36,6 +36,8 @@ const kotlinBuiltinTypes = new Set([ "ByteIterator", "Char", "CharArray", + "CharCategory", + "CharDirection", "CharIterator", "CharProgression", "CharRange", @@ -45,6 +47,8 @@ const kotlinBuiltinTypes = new Set([ "ClassLoader", "ClassNotFoundException", "Cloneable", + "ClosedFloatingPointRange", + "ClosedRange", "Collection", "Comparable", "Comparator", @@ -80,6 +84,7 @@ const kotlinBuiltinTypes = new Set([ "InternalError", "Iterable", "Iterator", + "KotlinVersion", "Lazy", "LazyThreadSafetyMode", "LinkedHashMap", @@ -119,6 +124,7 @@ const kotlinBuiltinTypes = new Set([ "Number", "Object", "OutOfMemoryError", + "OpenEndRange", "Package", "Pair", "Process", @@ -1769,7 +1775,7 @@ function hasAppliedAndroidPlugin(buildSource: string, androidAliases: Set Date: Sun, 17 May 2026 03:33:45 +0100 Subject: [PATCH 32/62] fix(mapper): treat sibling Kotlin modules as local --- src/mapper.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 49 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 0e00f4b..0f6b294 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2840,6 +2840,55 @@ describe("mapFeatures", () => { ).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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 57d2483..0eea523 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -362,6 +362,7 @@ 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 seeds: FeatureSeed[] = []; for (const moduleRoot of moduleRoots) { const buildFile = await gradleBuildFile(root, moduleRoot); @@ -420,7 +421,15 @@ async function gradleProjectSeeds(root: string, gradleRoot: string): Promise 0) { @@ -449,6 +458,19 @@ async function gradleProjectSeeds(root: string, gradleRoot: string): Promise { + const files = new Set(); + for (const moduleRoot of moduleRoots) { + 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, @@ -456,6 +478,7 @@ async function kotlinRoleSeeds( sourceFiles: string[], testFiles: string[], tags: string[], + projectSourceFiles: string[], ): Promise { const matches = new Map< KotlinRoleKey, @@ -469,8 +492,13 @@ async function kotlinRoleSeeds( if (kotlinFiles.length === 0) { return []; } - const projectPackages = await gradleProjectPackages(root, sourceFiles, kotlinFiles); - const kotlinPackageTypes = await kotlinPackageDeclarations(root, sourceFiles, kotlinFiles); + const projectKotlinFiles = await gradleKotlinFiles(root, projectSourceFiles, kotlinFiles); + const projectPackages = await gradleProjectPackages(root, projectSourceFiles, projectKotlinFiles); + const kotlinPackageTypes = await kotlinPackageDeclarations( + root, + projectSourceFiles, + projectKotlinFiles, + ); for (const { filePath, info } of kotlinFiles) { const frameworkEvidence = kotlinFrameworkRoleEvidence( @@ -539,6 +567,21 @@ async function kotlinRoleSeeds( return seeds; } +async function gradleKotlinFiles( + root: string, + sourceFiles: string[], + parsedFiles: Array<{ filePath: string; info: KotlinFileInfo }>, +): 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>, From 1366c161992b0412dc1ed80bc35563282f71390d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 03:40:49 +0100 Subject: [PATCH 33/62] fix(mapper): strip nested Kotlin block comments --- src/mapper.test.ts | 33 +++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 0f6b294..cbab9e1 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2695,6 +2695,39 @@ describe("mapFeatures", () => { ); }); + 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 feature IDs stable when confidence changes", async () => { const root = await fixtureRoot("clawpatch-kotlin-role-id-stability-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 0eea523..1725a35 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1551,7 +1551,32 @@ function stripJavaComments(source: string): string { } function stripKotlinComments(source: string): string { - return stripJavaComments(source); + let stripped = ""; + let index = 0; + let depth = 0; + while (index < source.length) { + const pair = source.slice(index, index + 2); + if (pair === "/*") { + depth += 1; + stripped += " "; + index += 2; + continue; + } + if (depth > 0) { + if (pair === "*/") { + depth = Math.max(0, depth - 1); + stripped += " "; + index += 2; + } else { + stripped += " "; + index += 1; + } + continue; + } + stripped += source[index]; + index += 1; + } + return stripped.replace(/\/\/.*$/gmu, ""); } function dedupeEvidence(evidence: JvmRoleEvidence[]): JvmRoleEvidence[] { From aec4fac0f25615453d7a7c7dfab686953fff5b0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 03:46:41 +0100 Subject: [PATCH 34/62] fix(mapper): cache Kotlin project role indexes --- src/mapper.test.ts | 37 ++++++++++++++++++++++++++++ src/mappers/gradle.ts | 57 +++++++++++++++++++++++++++---------------- 2 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index cbab9e1..ef46b3c 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4085,6 +4085,43 @@ describe("mapFeatures", () => { ); }); + 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 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 1725a35..a3a897d 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -350,6 +350,12 @@ type KotlinFileInfo = { declarations: KotlinDeclaration[]; functionReturnTypes: Set; }; +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); @@ -363,6 +369,7 @@ 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); @@ -428,7 +435,7 @@ 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) { @@ -478,34 +500,27 @@ async function kotlinRoleSeeds( sourceFiles: string[], testFiles: string[], tags: string[], - projectSourceFiles: 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 projectKotlinFiles = await gradleKotlinFiles(root, projectSourceFiles, kotlinFiles); - const projectPackages = await gradleProjectPackages(root, projectSourceFiles, projectKotlinFiles); - const kotlinPackageTypes = await kotlinPackageDeclarations( - root, - projectSourceFiles, - projectKotlinFiles, - ); for (const { filePath, info } of kotlinFiles) { const frameworkEvidence = kotlinFrameworkRoleEvidence( info, tags, - projectPackages, - kotlinPackageTypes, + projectIndex.packages, + projectIndex.packageTypes, ); const pathEvidence = kotlinPathRoleEvidence(filePath, tags).filter( (item) => @@ -570,8 +585,8 @@ async function kotlinRoleSeeds( async function gradleKotlinFiles( root: string, sourceFiles: string[], - parsedFiles: Array<{ filePath: string; info: KotlinFileInfo }>, -): Promise> { + 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)) { @@ -621,7 +636,7 @@ function kotlinRoleSource(role: KotlinRoleKey): string { async function gradleProjectPackages( root: string, sourceFiles: string[], - kotlinFiles: Array<{ filePath: string; info: KotlinFileInfo }>, + kotlinFiles: ParsedKotlinFile[], ): Promise> { const packages = new Set( kotlinFiles.flatMap(({ info }) => (info.packageName === null ? [] : [info.packageName])), @@ -639,7 +654,7 @@ async function gradleProjectPackages( async function kotlinPackageDeclarations( root: string, sourceFiles: string[], - kotlinFiles: Array<{ filePath: string; info: KotlinFileInfo }>, + kotlinFiles: ParsedKotlinFile[], ): Promise>> { const declarations = new Map>(); for (const { info } of kotlinFiles) { @@ -1231,7 +1246,7 @@ function parseJavaDeclarations(source: string): JavaDeclaration[] { function parseKotlinDeclarations(source: string): KotlinDeclaration[] { const declarations: KotlinDeclaration[] = []; const declarationPattern = - /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^{}\n]+))?/gsu; + /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^{}]+?)(?=\s*(?:\{|\n\s*(?:@[A-Za-z_][A-Za-z0-9_.]*(?:\([^(){}]*\))?\s*)*(?:(?:data|sealed|open|abstract|final|inner|value|annotation)\s+)*(?:enum\s+)?(?:fun\s+)?(?:class|interface|object)\s+|$)))?/gsu; for (const match of source.matchAll(declarationPattern)) { const rawKind = match[3]; const name = match[4]; From 53231e0269237554a5b81063ef1debfbad40508b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 03:54:28 +0100 Subject: [PATCH 35/62] fix(mapper): bound bodyless Kotlin supertypes --- src/mapper.test.ts | 34 ++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index ef46b3c..84fcdf6 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4122,6 +4122,40 @@ describe("mapFeatures", () => { ); }); + 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 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index a3a897d..412fc64 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1246,7 +1246,7 @@ function parseJavaDeclarations(source: string): JavaDeclaration[] { function parseKotlinDeclarations(source: string): KotlinDeclaration[] { const declarations: KotlinDeclaration[] = []; const declarationPattern = - /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^{}]+?)(?=\s*(?:\{|\n\s*(?:@[A-Za-z_][A-Za-z0-9_.]*(?:\([^(){}]*\))?\s*)*(?:(?:data|sealed|open|abstract|final|inner|value|annotation)\s+)*(?:enum\s+)?(?:fun\s+)?(?:class|interface|object)\s+|$)))?/gsu; + /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^{}]+?)(?=\s*(?:\{|\n\s*(?:@[A-Za-z_][A-Za-z0-9_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal|const|lateinit)\s+)*(?:(?:(?:data|sealed|open|abstract|final|inner|value|annotation)\s+)*(?:enum\s+)?(?:fun\s+)?(?:class|interface|object)|fun|val|var)\s+|$)))?/gsu; for (const match of source.matchAll(declarationPattern)) { const rawKind = match[3]; const name = match[4]; From 6f749c188e477126bd9d9413cfaad446f5a7f6bc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:02:52 +0100 Subject: [PATCH 36/62] fix(mapper): avoid false Android Gradle tags --- src/mapper.test.ts | 87 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 25 +++++++------ 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 84fcdf6..fc5d9d0 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3666,6 +3666,50 @@ describe("mapFeatures", () => { ).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"); @@ -3706,6 +3750,49 @@ describe("mapFeatures", () => { ).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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 412fc64..b2dd756 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1757,7 +1757,7 @@ async function gradleTags( ]); if ( sourceFiles.some((file) => file.endsWith("AndroidManifest.xml")) || - hasAppliedAndroidPlugin(buildSource, androidAliases) + hasAppliedAndroidPlugin(buildSource, androidAliases, buildFile.endsWith(".kts")) ) { tags.push("android"); } @@ -1831,8 +1831,12 @@ function androidPluginAliasForLine( return `${pluginTableAlias}.${rawKey}`; } -function hasAppliedAndroidPlugin(buildSource: string, androidAliases: Set): boolean { - const source = stripJavaComments(buildSource); +function hasAppliedAndroidPlugin( + buildSource: string, + androidAliases: Set, + isKotlinDsl: boolean, +): boolean { + const source = isKotlinDsl ? stripKotlinComments(buildSource) : stripJavaComments(buildSource); const lines = source.split(/\r?\n/u); for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ""; @@ -1890,20 +1894,19 @@ function hasGradleApplyFalse(lines: string[], index: number, start: number): boo const line = lines[index] ?? ""; const segmentEnd = line.indexOf(";", start); const sameLineSegment = line.slice(start, segmentEnd === -1 ? undefined : segmentEnd); - if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(sameLineSegment)) { - return true; - } if (segmentEnd !== -1) { - return false; + return /\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(sameLineSegment); } + const segments = [sameLineSegment]; for (let next = index + 1; next < lines.length; next += 1) { const nextLine = lines[next] ?? ""; if (isGradlePluginDeclarationLine(nextLine)) { - return false; - } - if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(nextLine)) { - return true; + break; } + segments.push(nextLine); + } + if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(segments.join("\n"))) { + return true; } return false; } From 3c7a5db8624acbc26fca37351725549d7d131e38 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:09:45 +0100 Subject: [PATCH 37/62] fix(mapper): strip Gradle comments around strings --- src/mapper.test.ts | 38 ++++++++++++++++++++++++++ src/mappers/gradle.ts | 63 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index fc5d9d0..c31477f 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3424,6 +3424,44 @@ describe("mapFeatures", () => { ).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("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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index b2dd756..0bb3a4e 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1836,7 +1836,7 @@ function hasAppliedAndroidPlugin( androidAliases: Set, isKotlinDsl: boolean, ): boolean { - const source = isKotlinDsl ? stripKotlinComments(buildSource) : stripJavaComments(buildSource); + const source = stripGradleBuildComments(buildSource, isKotlinDsl); const lines = source.split(/\r?\n/u); for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ""; @@ -1860,6 +1860,67 @@ function hasAppliedAndroidPlugin( return hasDirectAndroidApplyPlugin(source); } +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*["']com\.android\.(?:application|library|dynamic-feature|test)["']|apply\s*\(\s*plugin\s*(?:=|:)\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\))/gu; From 7ca5be9197d862efc95e02e5260a9f3adda55a7f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:17:28 +0100 Subject: [PATCH 38/62] fix(mapper): handle Kotlin wildcard and catalog aliases --- src/mapper.test.ts | 78 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 18 ++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index c31477f..db999dd 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3268,6 +3268,46 @@ describe("mapFeatures", () => { ).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"); @@ -4677,6 +4717,44 @@ describe("mapFeatures", () => { ).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("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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 0bb3a4e..5759392 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1038,6 +1038,9 @@ function kotlinImportForType( 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 (!isKotlinStdlibImport(wildcardType)) { return wildcardType; @@ -1048,6 +1051,9 @@ function kotlinImportForType( } 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 (!isKotlinStdlibImport(wildcardType)) { return wildcardType; @@ -1809,6 +1815,11 @@ function parseAndroidPluginAliases(source: string): Set { pluginTableAlias = section.startsWith("plugins.") ? section.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; } @@ -1820,6 +1831,13 @@ function parseAndroidPluginAliases(source: string): Set { return aliases; } +function androidTopLevelPluginAliasForLine(line: string): string | undefined { + if (!/com\.android\.(?:application|library|dynamic-feature|test)/u.test(line)) { + return undefined; + } + return /^plugins\.([A-Za-z0-9_.-]+?)(?:\.id)?\s*=/u.exec(line)?.[1]; +} + function androidPluginAliasForLine( line: string, pluginTableAlias: string | null, From 121d2f8d147c20d7dd43a2c3a62227bb12de366e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:20:52 +0100 Subject: [PATCH 39/62] style(test): format merged mapper tests --- src/mapper.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 8894b88..4a18195 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3769,7 +3769,6 @@ 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"); From e43a77772aeeb7b7d664c5f41c076a25d5ddb990 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:26:30 +0100 Subject: [PATCH 40/62] fix(mapper): preserve Android block fallback --- src/mapper.test.ts | 90 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 11 ++++++ 2 files changed, 101 insertions(+) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 4a18195..5756423 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4025,6 +4025,96 @@ describe("mapFeatures", () => { ).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("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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 4452bfb..5159261 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1769,6 +1769,7 @@ async function gradleTags( ]); if ( sourceFiles.some((file) => file.endsWith("AndroidManifest.xml")) || + hasAndroidExtensionBlock(buildSource, buildFile.endsWith(".kts")) || hasAppliedAndroidPlugin(buildSource, androidAliases, buildFile.endsWith(".kts")) ) { tags.push("android"); @@ -1956,6 +1957,16 @@ function hasDirectAndroidApplyPlugin(source: string): boolean { 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[] = []; for (let index = 0; index < offset; index += 1) { From 946afd84d34b3b8afa804416dd0da884551ef483 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:34:46 +0100 Subject: [PATCH 41/62] fix(mapper): harden Gradle plugin and annotation detection --- src/mapper.test.ts | 100 +++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 113 ++++++++++++++++++++++++------------------ 2 files changed, 164 insertions(+), 49 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 5756423..6a038fa 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4073,6 +4073,45 @@ describe("mapFeatures", () => { ).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"); @@ -4524,6 +4563,41 @@ describe("mapFeatures", () => { ).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"); @@ -5232,6 +5306,32 @@ describe("mapFeatures", () => { ).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", "", "@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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 5159261..9d16ee7 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1115,7 +1115,7 @@ function isKotlinStdlibImport(full: string): boolean { function isKotlinServerWebAnnotation(info: KotlinFileInfo, annotation: string): boolean { if ( - [ + ![ "Controller", "RestController", "RequestMapping", @@ -1124,18 +1124,40 @@ function isKotlinServerWebAnnotation(info: KotlinFileInfo, annotation: string): "PutMapping", "DeleteMapping", "PatchMapping", + "Path", + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", ].includes(annotation) ) { - return true; - } - if (!["Path", "GET", "POST", "PUT", "DELETE", "PATCH"].includes(annotation)) { return false; } - const full = info.imports.get(annotation); + for (const full of info.qualifiedAnnotations) { + if (full.split(".").at(-1) === annotation) { + return isKotlinServerWebImport(full); + } + } + 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 !== undefined && /^(?:javax|jakarta)\.ws\.rs\./u.test(full)) || - info.qualifiedAnnotations.has(`javax.ws.rs.${annotation}`) || - info.qualifiedAnnotations.has(`jakarta.ws.rs.${annotation}`) + 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) ); } @@ -1862,24 +1884,20 @@ function hasAppliedAndroidPlugin( isKotlinDsl: boolean, ): boolean { const source = stripGradleBuildComments(buildSource, isKotlinDsl); - const lines = source.split(/\r?\n/u); - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? ""; - for (const match of line.matchAll(androidPluginDeclarationPattern())) { - const start = match.index ?? 0; - if (!hasGradleApplyFalse(lines, index, start)) { - return true; - } + for (const match of source.matchAll(androidPluginDeclarationPattern())) { + const start = match.index ?? 0; + if (!hasGradleApplyFalse(source, start)) { + return true; } - for (const match of line.matchAll(/\balias\s*\(\s*libs\.plugins\.([A-Za-z0-9_.]+)\s*\)/gu)) { - const alias = match[1]; - if ( - alias !== undefined && - androidAliases.has(normalizeVersionCatalogAlias(alias)) && - !hasGradleApplyFalse(lines, index, match.index ?? 0) - ) { - return true; - } + } + for (const match of source.matchAll(/\balias\s*\(\s*libs\.plugins\.([A-Za-z0-9_.]+)\s*\)/gu)) { + const alias = match[1]; + if ( + alias !== undefined && + androidAliases.has(normalizeVersionCatalogAlias(alias)) && + !hasGradleApplyFalse(source, match.index ?? 0) + ) { + return true; } } return hasDirectAndroidApplyPlugin(source); @@ -1948,7 +1966,7 @@ function stripGradleBuildComments(source: string, supportsNestedBlockComments: b function hasDirectAndroidApplyPlugin(source: string): boolean { const pattern = - /\b(?:apply\s+plugin:\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']|apply\s*\(\s*plugin\s*(?:=|:)\s*["']com\.android\.(?:application|library|dynamic-feature|test)["']\s*\))/gu; + /\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; @@ -1986,37 +2004,34 @@ function isInsideGradleChildProjectBlock(source: string, offset: number): boolea return scopes.includes(true); } -function hasGradleApplyFalse(lines: string[], index: number, start: number): boolean { - const line = lines[index] ?? ""; - const segmentEnd = line.indexOf(";", start); - const sameLineSegment = line.slice(start, segmentEnd === -1 ? undefined : segmentEnd); - if (segmentEnd !== -1) { - return /\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(sameLineSegment); - } - const segments = [sameLineSegment]; - for (let next = index + 1; next < lines.length; next += 1) { - const nextLine = lines[next] ?? ""; - if (isGradlePluginDeclarationLine(nextLine)) { - break; - } - segments.push(nextLine); - } - if (/\bapply\s+false\b|\.\s*apply\s*\(\s*false\s*\)/u.test(segments.join("\n"))) { - return true; - } - return false; +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\.(?:application|library|dynamicFeature|dynamic-feature|test)|android(?:Application|Library|DynamicFeature|Test)|comAndroid(?:Application|Library|DynamicFeature|Test))[A-Za-z0-9_.]*\s*\))/gu; } -function normalizeVersionCatalogAlias(alias: string): string { - return alias.replace(/[-_]/gu, ".").toLowerCase(); +function gradlePluginInvocationEnd(source: string, start: number): number { + const candidates = [ + source.indexOf(";", start), + source.indexOf("\n id", start + 1), + source.indexOf("\n id", start + 1), + source.indexOf("\nid", start + 1), + source.indexOf("\n alias", start + 1), + source.indexOf("\n alias", start + 1), + source.indexOf("\nalias", start + 1), + source.indexOf("\n kotlin", start + 1), + source.indexOf("\n kotlin", start + 1), + source.indexOf("\nkotlin", start + 1), + ].filter((index) => index !== -1); + return candidates.length === 0 ? source.length : Math.min(...candidates); } -function isGradlePluginDeclarationLine(line: string): boolean { - return /^\s*(?:id\s*(?:\(|["'])|alias\s*\(|[A-Za-z_][A-Za-z0-9_.]*\s*\()/u.test(line); +function normalizeVersionCatalogAlias(alias: string): string { + return alias.replace(/[-_]/gu, ".").toLowerCase(); } function isGradleSourceFile(path: string): boolean { From 917b36b83127f92b04f9f87e5dda4c31cd03e39b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:41:38 +0100 Subject: [PATCH 42/62] fix(mapper): bound Gradle plugin apply-false scan --- src/mapper.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 17 ++++------------- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 6a038fa..f68f7ef 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4292,6 +4292,45 @@ describe("mapFeatures", () => { 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 9d16ee7..665d550 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -2015,19 +2015,10 @@ function androidPluginDeclarationPattern(): RegExp { } function gradlePluginInvocationEnd(source: string, start: number): number { - const candidates = [ - source.indexOf(";", start), - source.indexOf("\n id", start + 1), - source.indexOf("\n id", start + 1), - source.indexOf("\nid", start + 1), - source.indexOf("\n alias", start + 1), - source.indexOf("\n alias", start + 1), - source.indexOf("\nalias", start + 1), - source.indexOf("\n kotlin", start + 1), - source.indexOf("\n kotlin", start + 1), - source.indexOf("\nkotlin", start + 1), - ].filter((index) => index !== -1); - return candidates.length === 0 ? source.length : Math.min(...candidates); + const next = /[;}]|\n\s*(?:id\s*(?:\(|["'])|alias\s*\(|kotlin\s*\()/u.exec( + source.slice(start + 1), + ); + return next === null ? source.length : start + 1 + next.index; } function normalizeVersionCatalogAlias(alias: string): string { From faf45d48abc1253aeff0ec2e68ba1c77a647a415 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:49:26 +0100 Subject: [PATCH 43/62] fix(mapper): preserve strong Kotlin role evidence --- src/mapper.test.ts | 74 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 20 ++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index f68f7ef..26a9f5d 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 665d550..f6b38e4 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -522,8 +522,17 @@ async function kotlinRoleSeeds( projectIndex.packages, projectIndex.packageTypes, ); + const hasStrongServerRole = + !tags.includes("android") && + frameworkEvidence.some( + (item) => + item.confidence === "high" && + item.role !== "server-framework-component" && + item.role !== "server-extension-boundary", + ); const pathEvidence = kotlinPathRoleEvidence(filePath, tags).filter( (item) => + !hasStrongServerRole && !frameworkEvidence.some((evidenceItem) => evidenceItem.role === item.role) && !( tags.includes("android") && @@ -908,6 +917,17 @@ function kotlinFrameworkRoleEvidence( confidence: "high", }); } + if ( + !isAndroid && + (full.startsWith("org.springframework.context.annotation.") || + full.startsWith("org.springframework.boot.context.properties.")) + ) { + evidence.push({ + role: "server-configuration", + reason: `configuration import ${full}`, + confidence: "high", + }); + } } for (const declaration of info.declarations) { From b6ff7c4c15bf640df6df8c71c53a9b1e9ea5bb0a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:57:20 +0100 Subject: [PATCH 44/62] fix(mapper): keep Gradle Android fallback detection --- src/mapper.test.ts | 100 ++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 8 ++-- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 26a9f5d..592b45e 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4435,6 +4435,32 @@ describe("mapFeatures", () => { 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 resolved version-catalog plugin aliases", async () => { const root = await fixtureRoot("clawpatch-kotlin-android-plugin-catalog-"); await writeFixture(root, "settings.gradle.kts", "pluginManagement {}\n"); @@ -4784,6 +4810,80 @@ describe("mapFeatures", () => { 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index f6b38e4..b0d65da 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -2012,9 +2012,9 @@ function isInsideGradleChildProjectBlock(source: string, offset: number): boolea if (char === "{") { const prefix = source.slice(Math.max(0, index - 100), index).trimEnd(); const childProjectScope = - /\b(?:subprojects|allprojects)\s*$/u.test(prefix) || - /\b(?:subprojects|allprojects)\.configureEach\s*$/u.test(prefix) || - /\bconfigure\s*\(\s*(?:subprojects|allprojects)\s*\)\s*$/u.test(prefix) || + /\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 === "}") { @@ -2031,7 +2031,7 @@ function hasGradleApplyFalse(source: string, start: number): boolean { } 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\.(?:application|library|dynamicFeature|dynamic-feature|test)|android(?:Application|Library|DynamicFeature|Test)|comAndroid(?:Application|Library|DynamicFeature|Test))[A-Za-z0-9_.]*\s*\))/gu; + 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 { From 67b9c1c3395c02bcaa3a49d9b1cb5bdbf81c0ecb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 05:05:51 +0100 Subject: [PATCH 45/62] fix(mapper): scan Kotlin wildcard role candidates --- src/mapper.test.ts | 57 ++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 80 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 7 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 592b45e..c9cc058 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4461,6 +4461,33 @@ describe("mapFeatures", () => { 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"); @@ -5574,6 +5601,36 @@ describe("mapFeatures", () => { 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index b0d65da..0d35cb7 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1083,6 +1083,63 @@ function kotlinImportForType( return undefined; } +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((full) => !isKotlinStdlibImport(full)); +} + function kotlinPathRoleEvidence(filePath: string, tags: string[]): KotlinRoleEvidence[] { const normalized = normalize(filePath).toLowerCase(); const isAndroid = tags.includes("android"); @@ -1155,8 +1212,8 @@ function isKotlinServerWebAnnotation(info: KotlinFileInfo, annotation: string): return false; } for (const full of info.qualifiedAnnotations) { - if (full.split(".").at(-1) === annotation) { - return isKotlinServerWebImport(full); + if (full.split(".").at(-1) === annotation && isKotlinServerWebImport(full)) { + return true; } } const imported = info.imports.get(annotation); @@ -1471,8 +1528,7 @@ function isAndroidUiEntrypointSupertype( type: string, kotlinPackageTypes: Map>, ): boolean { - const full = kotlinImportForType(info, type, kotlinPackageTypes); - return full !== undefined && isAndroidUiEntrypointImport(full); + return kotlinTypeMatchesImport(info, type, kotlinPackageTypes, isAndroidUiEntrypointImport); } function isAndroidViewModelSupertype( @@ -1480,8 +1536,13 @@ function isAndroidViewModelSupertype( type: string, kotlinPackageTypes: Map>, ): boolean { - const full = kotlinImportForType(info, type, kotlinPackageTypes); - return full === "androidx.lifecycle.ViewModel" || full === "androidx.lifecycle.AndroidViewModel"; + return kotlinTypeMatchesImport( + info, + type, + kotlinPackageTypes, + (full) => + full === "androidx.lifecycle.ViewModel" || full === "androidx.lifecycle.AndroidViewModel", + ); } function isAndroidRoomSupertype( @@ -1489,7 +1550,12 @@ function isAndroidRoomSupertype( type: string, kotlinPackageTypes: Map>, ): boolean { - return kotlinImportForType(info, type, kotlinPackageTypes) === "androidx.room.RoomDatabase"; + return kotlinTypeMatchesImport( + info, + type, + kotlinPackageTypes, + (full) => full === "androidx.room.RoomDatabase", + ); } function isSpringDataPersistenceImport(full: string): boolean { From aaef073fef6414529ba817ae2aa7b93235db0b02 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 05:14:37 +0100 Subject: [PATCH 46/62] fix(mapper): preserve Gradle and Kotlin local scopes --- src/mapper.test.ts | 65 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 31 +++++++++++++++------ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index c9cc058..a2f66d9 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4837,6 +4837,43 @@ describe("mapFeatures", () => { 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"); @@ -5920,6 +5957,34 @@ describe("mapFeatures", () => { ).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 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 0d35cb7..ed77e59 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1023,6 +1023,13 @@ function kotlinImportForType( if (isKotlinStdlibImport(type)) { return undefined; } + const packageName = info.packageName ?? ""; + if ( + info.declarations.some((declaration) => declaration.name === rootType) || + kotlinPackageTypes.get(packageName)?.has(rootType) === true + ) { + return undefined; + } if (isNestedType && /^[a-z]/u.test(rootType)) { return type; } @@ -1040,13 +1047,6 @@ function kotlinImportForType( if (isKotlinBuiltinType(rootType)) { return undefined; } - const packageName = info.packageName ?? ""; - if ( - info.declarations.some((declaration) => declaration.name === rootType) || - kotlinPackageTypes.get(packageName)?.has(rootType) === true - ) { - return undefined; - } if (!isNestedType && isKotlinBuiltinType(type)) { return undefined; } @@ -2073,8 +2073,23 @@ function hasAndroidExtensionBlock(buildSource: string, isKotlinDsl: boolean): bo 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]; + 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 = From 96d305f30af5e22c56da542b8ea50a1a8b575f06 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 05:22:15 +0100 Subject: [PATCH 47/62] fix(mapper): continue Kotlin and Gradle scans --- src/mapper.test.ts | 73 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 43 ++++++++++++++++++++----- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index a2f66d9..33c1c51 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -5068,6 +5068,46 @@ describe("mapFeatures", () => { ).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"); @@ -6233,6 +6273,39 @@ describe("mapFeatures", () => { ); }); + 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index ed77e59..b35771e 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -7,6 +7,7 @@ import { FeatureSeed, SeedTestRef } from "./types.js"; const maxOwnedFiles = 12; const maxTests = 8; +const emptyProjectPackages = new Set(); const kotlinBuiltinTypes = new Set([ "AbstractMethodError", "AbstractCollection", @@ -1062,7 +1063,7 @@ function kotlinImportForType( continue; } const wildcardType = `${full.slice(0, -1)}${type}`; - if (!isKotlinStdlibImport(wildcardType)) { + if (isKotlinExternalCandidateImport(wildcardType)) { return wildcardType; } } @@ -1075,7 +1076,7 @@ function kotlinImportForType( continue; } const wildcardType = `${full.slice(0, -1)}${type}`; - if (!isKotlinStdlibImport(wildcardType)) { + if (isKotlinExternalCandidateImport(wildcardType)) { return wildcardType; } } @@ -1137,7 +1138,7 @@ function kotlinWildcardImportCandidates( return [...info.imports.values()] .filter((full) => full.endsWith(".*") && !kotlinPackageTypes.has(full.slice(0, -2))) .map((full) => `${full.slice(0, -1)}${type}`) - .filter((full) => !isKotlinStdlibImport(full)); + .filter(isKotlinExternalCandidateImport); } function kotlinPathRoleEvidence(filePath: string, tags: string[]): KotlinRoleEvidence[] { @@ -1190,6 +1191,10 @@ 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 ( ![ @@ -2116,10 +2121,34 @@ function androidPluginDeclarationPattern(): RegExp { } function gradlePluginInvocationEnd(source: string, start: number): number { - const next = /[;}]|\n\s*(?:id\s*(?:\(|["'])|alias\s*\(|kotlin\s*\()/u.exec( - source.slice(start + 1), - ); - return next === null ? source.length : start + 1 + next.index; + 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*\()/u.test(source.slice(index + 1)) + ) { + return index; + } + } + return source.length; } function normalizeVersionCatalogAlias(alias: string): string { From 1d42a5793efb9df32464480f05a02340aaee294f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 05:33:10 +0100 Subject: [PATCH 48/62] fix(mapper): suppress stale Kotlin path roles --- src/mapper.test.ts | 138 ++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 11 +++- 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 33c1c51..47b4cd3 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -5443,6 +5443,76 @@ describe("mapFeatures", () => { ).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"); @@ -5811,6 +5881,74 @@ describe("mapFeatures", () => { ); }); + 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index b35771e..41400de 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -531,9 +531,18 @@ async function kotlinRoleSeeds( 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") && @@ -1356,7 +1365,7 @@ function parseJavaDeclarations(source: string): JavaDeclaration[] { function parseKotlinDeclarations(source: string): KotlinDeclaration[] { const declarations: KotlinDeclaration[] = []; const declarationPattern = - /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^{}]+?)(?=\s*(?:\{|\n\s*(?:@[A-Za-z_][A-Za-z0-9_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal|const|lateinit)\s+)*(?:(?:(?:data|sealed|open|abstract|final|inner|value|annotation)\s+)*(?:enum\s+)?(?:fun\s+)?(?:class|interface|object)|fun|val|var)\s+|$)))?/gsu; + /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^{}]+?)(?=\s*(?:\{|\n\s*(?:@[A-Za-z_][A-Za-z0-9_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal|const|lateinit|suspend|inline|tailrec|operator|infix|external)\s+)*(?:(?:(?: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]; From 0723c31eb44bb22312ee254027c1aa332a83a964 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 05:41:23 +0100 Subject: [PATCH 49/62] fix(mapper): respect Kotlin strings while stripping comments --- src/mapper.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 60 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 47b4cd3..fa03959 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3876,6 +3876,67 @@ describe("mapFeatures", () => { ).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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 41400de..5a95462 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1703,9 +1703,12 @@ function stripKotlinComments(source: string): string { 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); - if (pair === "/*") { + const triple = source.slice(index, index + 3); + if (stringMode === null && pair === "/*") { depth += 1; stripped += " "; index += 2; @@ -1717,15 +1720,66 @@ function stripKotlinComments(source: string): string { 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 += source[index]; + stripped += char; index += 1; } - return stripped.replace(/\/\/.*$/gmu, ""); + return stripped; } function dedupeEvidence(evidence: JvmRoleEvidence[]): JvmRoleEvidence[] { From 241df109ae1974bccc154132eb95b54fe20dd6e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 05:53:07 +0100 Subject: [PATCH 50/62] fix(mapper): parse quoted Gradle catalog aliases --- src/mapper.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 12 ++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index fa03959..a274c7a 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4589,6 +4589,46 @@ describe("mapFeatures", () => { ).toBe(false); }); + 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("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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 5a95462..d91591d 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -2018,20 +2018,28 @@ function androidTopLevelPluginAliasForLine(line: string): string | undefined { if (!/com\.android\.(?:application|library|dynamic-feature|test)/u.test(line)) { return undefined; } - return /^plugins\.([A-Za-z0-9_.-]+?)(?:\.id)?\s*=/u.exec(line)?.[1]; + return tomlPluginAliasKey( + /^plugins\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_.-]+?))(?:\.id)?\s*=/u.exec(line), + ); } function androidPluginAliasForLine( line: string, pluginTableAlias: string | null, ): string | undefined { - const rawKey = /^([A-Za-z0-9_.-]+?)(?:\.id)?\s*=/u.exec(line)?.[1]; + 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 hasAppliedAndroidPlugin( buildSource: string, androidAliases: Set, From 4e65392158d43ffbaa9a8c02454d9157c44397aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 06:14:43 +0100 Subject: [PATCH 51/62] fix(mapper): respect imported local Kotlin object types --- src/mapper.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 6 +++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index a274c7a..045f53b 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -6264,6 +6264,42 @@ describe("mapFeatures", () => { ).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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index d91591d..0d74e96 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1040,9 +1040,6 @@ function kotlinImportForType( ) { return undefined; } - if (isNestedType && /^[a-z]/u.test(rootType)) { - return type; - } if (isNestedType) { const directRoot = info.imports.get(rootType); if (directRoot !== undefined) { @@ -1050,6 +1047,9 @@ function kotlinImportForType( return isKotlinStdlibImport(full) ? undefined : full; } } + if (isNestedType && /^[a-z]/u.test(rootType)) { + return type; + } const direct = info.imports.get(type); if (direct !== undefined) { return isKotlinStdlibImport(direct) ? undefined : direct; From 4fd68ec65dd760c175666282631393d6c96dd043 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 06:23:37 +0100 Subject: [PATCH 52/62] fix(mapper): limit Gradle alias plugin scans --- src/mapper.test.ts | 45 +++++++++++++++++++++ src/mappers/gradle.ts | 94 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 126 insertions(+), 13 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 045f53b..0c00ee3 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4629,6 +4629,51 @@ describe("mapFeatures", () => { ).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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 0d74e96..eca20b8 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -2046,25 +2046,93 @@ function hasAppliedAndroidPlugin( isKotlinDsl: boolean, ): boolean { const source = stripGradleBuildComments(buildSource, isKotlinDsl); - for (const match of source.matchAll(androidPluginDeclarationPattern())) { - const start = match.index ?? 0; - if (!hasGradleApplyFalse(source, start)) { - return true; + 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 source.matchAll(/\balias\s*\(\s*libs\.plugins\.([A-Za-z0-9_.]+)\s*\)/gu)) { - const alias = match[1]; - if ( - alias !== undefined && - androidAliases.has(normalizeVersionCatalogAlias(alias)) && - !hasGradleApplyFalse(source, match.index ?? 0) - ) { - 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; From da6710dd17e9b50477fab05f04b53cd76e046b84 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 06:34:09 +0100 Subject: [PATCH 53/62] fix(mapper): bound Kotlin and Gradle declaration scans --- src/mapper.test.ts | 70 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 4 +-- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 0c00ee3..2ef2eed 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4397,6 +4397,42 @@ describe("mapFeatures", () => { 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 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"); @@ -6027,6 +6063,40 @@ describe("mapFeatures", () => { ); }); + 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index eca20b8..8bd674c 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1365,7 +1365,7 @@ function parseJavaDeclarations(source: string): JavaDeclaration[] { function parseKotlinDeclarations(source: string): KotlinDeclaration[] { const declarations: KotlinDeclaration[] = []; const declarationPattern = - /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal)\s+)?constructor\s*\((?:[^(){}]|\([^(){}]*\))*\))|(?:\s*\((?:[^(){}]|\([^(){}]*\))*\)))?(?:\s*:\s*([^{}]+?)(?=\s*(?:\{|\n\s*(?:@[A-Za-z_][A-Za-z0-9_.]*(?:\([^(){}]*\))?\s*)*(?:(?:public|private|protected|internal|const|lateinit|suspend|inline|tailrec|operator|infix|external)\s+)*(?:(?:(?:data|sealed|open|abstract|final|inner|value|annotation)\s+)*(?:enum\s+)?(?:fun\s+)?(?:class|interface|object)|fun|val|var|typealias)\s+|$)))?/gsu; + /\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_.]*(?:\([^(){}]*\))?\s*)*(?:(?: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]; @@ -2282,7 +2282,7 @@ function gradlePluginInvocationEnd(source: string, start: number): number { } if ( char === "\n" && - /^\s*(?:id\s*(?:\(|["'])|alias\s*\(|kotlin\s*\()/u.test(source.slice(index + 1)) + /^\s*(?:id\s*(?:\(|["'])|alias\s*\(|kotlin\s*\(|`[^`]+`)/u.test(source.slice(index + 1)) ) { return index; } From 9ce2f286e418434d6050817b02fd9ae1025427db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 06:43:07 +0100 Subject: [PATCH 54/62] fix(mapper): preserve qualified Kotlin annotation intent --- src/mapper.test.ts | 12 +++++++++--- src/mappers/gradle.ts | 44 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 2ef2eed..13c9ba7 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -5882,9 +5882,15 @@ describe("mapFeatures", () => { await writeFixture( root, "src/main/kotlin/com/example/api/LocalController.kt", - ["package com.example.api", "", "@com.acme.RestController", "class LocalController", ""].join( - "\n", - ), + [ + "package com.example.api", + "", + "import org.springframework.web.bind.annotation.*", + "", + "@com.acme.RestController", + "class LocalController", + "", + ].join("\n"), ); const project = await detectProject(root); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 8bd674c..22b5c46 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -347,6 +347,7 @@ type KotlinFileInfo = { packageName: string | null; annotations: Set; qualifiedAnnotations: Set; + unqualifiedAnnotations: Set; imports: Map; declarations: KotlinDeclaration[]; functionReturnTypes: Set; @@ -850,7 +851,7 @@ function kotlinFrameworkRoleEvidence( } } - for (const full of info.imports.values()) { + for (const [importedName, full] of info.imports.entries()) { if (isAndroid && isAndroidUiEntrypointImport(full)) { evidence.push({ role: "android-ui-entrypoint", @@ -900,11 +901,10 @@ function kotlinFrameworkRoleEvidence( } if ( !isAndroid && - (full.startsWith("org.springframework.web.bind.annotation.") || + (isKotlinServerWebAnnotationImportUsed(info, importedName, full) || full.startsWith("io.ktor.server.") || full.startsWith("org.http4k.") || - full.startsWith("io.javalin.") || - /^(?:jakarta|javax)\.ws\.rs\./u.test(full)) + full.startsWith("io.javalin.")) ) { evidence.push({ role: "server-web-entrypoint", @@ -1230,6 +1230,12 @@ function isKotlinServerWebAnnotation(info: KotlinFileInfo, annotation: string): 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); @@ -1242,12 +1248,34 @@ function isKotlinServerWebAnnotation(info: KotlinFileInfo, annotation: string): 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 ( - full.startsWith("org.springframework.web.bind.annotation.") || + isKotlinServerWebAnnotationImport(full) || full.startsWith("io.ktor.server.") || full.startsWith("org.http4k.") || - full.startsWith("io.javalin.") || + full.startsWith("io.javalin.") + ); +} + +function isKotlinServerWebAnnotationImport(full: string): boolean { + return ( + full.startsWith("org.springframework.web.bind.annotation.") || /^(?:jakarta|javax)\.ws\.rs\./u.test(full) ); } @@ -1310,6 +1338,7 @@ function parseKotlinFile(source: string): KotlinFileInfo { const annotations = new Set(); 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, )) { @@ -1318,6 +1347,8 @@ function parseKotlinFile(source: string): KotlinFileInfo { annotations.add(raw.split(".").at(-1) ?? raw); if (raw.includes(".")) { qualifiedAnnotations.add(raw); + } else { + unqualifiedAnnotations.add(raw); } } } @@ -1336,6 +1367,7 @@ function parseKotlinFile(source: string): KotlinFileInfo { packageName, annotations, qualifiedAnnotations, + unqualifiedAnnotations, imports, declarations: parseKotlinDeclarations(stripped), functionReturnTypes, From 9e0b0dba95ced40ff039a3e5d3943561bded7b1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 06:52:29 +0100 Subject: [PATCH 55/62] fix(mapper): skip settings-only roots in Kotlin index --- src/mapper.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 3 +++ 2 files changed, 42 insertions(+) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 13c9ba7..062cc79 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3603,6 +3603,45 @@ 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 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 22b5c46..0945619 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -485,6 +485,9 @@ async function gradleKotlinProjectIndex( 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) From 69caf1eab204f7ff670fd75f3508a3f90dfcd07c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 07:01:54 +0100 Subject: [PATCH 56/62] fix(mapper): scope Gradle catalog aliases to root --- src/mapper.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 12 +++++++----- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 062cc79..5736364 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4664,6 +4664,49 @@ describe("mapFeatures", () => { ).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("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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 0945619..86bb0ec 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -385,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}`, @@ -1964,6 +1964,7 @@ function associatedGradleTests(files: string[], testFiles: string[]): SeedTestRe async function gradleTags( root: string, + gradleRoot: string, buildFile: string, sourceFiles: string[], ): Promise { @@ -1976,7 +1977,7 @@ async function gradleTags( } const [buildSource, androidAliases] = await Promise.all([ readFile(join(root, buildFile), "utf8").catch(() => ""), - androidVersionCatalogPluginAliases(root, buildFile), + androidVersionCatalogPluginAliases(root, gradleRoot, buildFile), ]); if ( sourceFiles.some((file) => file.endsWith("AndroidManifest.xml")) || @@ -1990,10 +1991,11 @@ async function gradleTags( async function androidVersionCatalogPluginAliases( root: string, + gradleRoot: string, buildFile: string, ): Promise> { const aliases = new Set(); - for (const path of versionCatalogPaths(buildFile)) { + for (const path of versionCatalogPaths(buildFile, gradleRoot)) { const source = await readFile(join(root, path), "utf8").catch(() => null); if (source === null) { continue; @@ -2005,12 +2007,12 @@ async function androidVersionCatalogPluginAliases( return aliases; } -function versionCatalogPaths(buildFile: string): string[] { +function versionCatalogPaths(buildFile: string, gradleRoot: string): string[] { const paths = new Set(); let dir = dirname(buildFile); while (true) { paths.add(dir === "." ? "gradle/libs.versions.toml" : `${dir}/gradle/libs.versions.toml`); - if (dir === ".") { + if (dir === gradleRoot) { break; } dir = dirname(dir); From eb9b509a7d74add6e1c4e04eed84624c4638cae4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 07:09:06 +0100 Subject: [PATCH 57/62] docs(changelog): keep Kotlin hardening unreleased --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91cef7f..2f19995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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.1.1 - 2026-05-17 - Added the `acpx` provider for routing review, fix, and revalidate through ACP-compatible coding agents, thanks @mvanhorn. @@ -20,7 +24,6 @@ - Added first-pass Python mapping for project metadata, console scripts, source groups, pytest suites, and conservative validation defaults, thanks @xiamx. - Improved Python mapping for `setup.cfg`/`setup.py` project metadata and console scripts, plus `black --check .` format defaults. - Added Kotlin semantic role mapping for Gradle projects, including Android UI, ViewModel, data, external client, dependency injection, and server-side role slices, thanks @mrmans0n. -- 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. - Added JVM semantic role mapping from Java annotations, imports, inheritance, interfaces, and method signatures. - Detected Java/Kotlin language and default Gradle build/test commands for root Gradle projects. - Added generic C/C++ feature mapping for standalone `main()` files, CMake `add_executable` / `add_library` targets, and autotools `bin_PROGRAMS` / `lib_LTLIBRARIES` targets, thanks @iliaal. From 23514ba95546e0018902dca74e0cef2d34ba4834 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 07:20:46 +0100 Subject: [PATCH 58/62] fix(mapper): scope Gradle catalogs to root --- src/mapper.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 15 ++++----------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 5736364..6f2e260 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4707,6 +4707,49 @@ describe("mapFeatures", () => { 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 86bb0ec..c67a025 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -2007,17 +2007,10 @@ async function androidVersionCatalogPluginAliases( return aliases; } -function versionCatalogPaths(buildFile: string, gradleRoot: string): string[] { - const paths = new Set(); - let dir = dirname(buildFile); - while (true) { - paths.add(dir === "." ? "gradle/libs.versions.toml" : `${dir}/gradle/libs.versions.toml`); - if (dir === gradleRoot) { - break; - } - dir = dirname(dir); - } - return [...paths]; +function versionCatalogPaths(_buildFile: string, gradleRoot: string): string[] { + return [ + gradleRoot === "." ? "gradle/libs.versions.toml" : `${gradleRoot}/gradle/libs.versions.toml`, + ]; } function parseAndroidPluginAliases(source: string): Set { From 67fd61e91120171ba2e34e9422c9de8c34af8a9d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 07:37:12 +0100 Subject: [PATCH 59/62] fix(mapper): parse quoted Gradle catalog tables --- src/mapper.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 6cff47d..4bc5717 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -4955,6 +4955,46 @@ describe("mapFeatures", () => { ).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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index c67a025..ebf5865 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -2024,8 +2024,11 @@ function parseAndroidPluginAliases(source: string): Set { } const section = /^\[([^\]]+)\]$/u.exec(line)?.[1]; if (section !== undefined) { - inPlugins = section === "plugins" || section.startsWith("plugins."); - pluginTableAlias = section.startsWith("plugins.") ? section.slice("plugins.".length) : null; + const sectionKey = tomlDottedKey(section); + inPlugins = sectionKey === "plugins" || sectionKey.startsWith("plugins."); + pluginTableAlias = sectionKey.startsWith("plugins.") + ? sectionKey.slice("plugins.".length) + : null; continue; } const topLevelPluginAlias = androidTopLevelPluginAliasForLine(line); @@ -2070,6 +2073,41 @@ 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, From eaed8226cc6979510db81b61deeb97737832e758 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 07:45:28 +0100 Subject: [PATCH 60/62] fix(mapper): handle reordered Kotlin constructor modifiers --- src/mapper.test.ts | 31 +++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 4bc5717..e0208d3 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -6990,6 +6990,37 @@ describe("mapFeatures", () => { 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index ebf5865..b73ca20 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1400,7 +1400,7 @@ function parseJavaDeclarations(source: string): JavaDeclaration[] { function parseKotlinDeclarations(source: string): KotlinDeclaration[] { const declarations: KotlinDeclaration[] = []; 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_.]*(?:\([^(){}]*\))?\s*)*(?:(?: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; + /\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]; From 9a70d9fa1dbc83e98422dea0f3c28fb9150571fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 08:03:30 +0100 Subject: [PATCH 61/62] fix(mapper): isolate nested Gradle roots --- src/mapper.test.ts | 78 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 15 ++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index e0208d3..6232109 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3642,6 +3642,48 @@ describe("mapFeatures", () => { 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("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"); @@ -4472,6 +4514,42 @@ describe("mapFeatures", () => { 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index b73ca20..8af1155 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1915,6 +1915,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); } @@ -1922,6 +1925,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}`; @@ -2350,7 +2361,9 @@ function gradlePluginInvocationEnd(source: string, start: number): number { } if ( char === "\n" && - /^\s*(?:id\s*(?:\(|["'])|alias\s*\(|kotlin\s*\(|`[^`]+`)/u.test(source.slice(index + 1)) + /^\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; } From 0126ec67dc1a4ae1c2d8635f0ab659786acab923 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 08:12:13 +0100 Subject: [PATCH 62/62] fix(mapper): discover nested Gradle settings roots --- src/mapper.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/gradle.ts | 33 +++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 6232109..ef1b06b 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3684,6 +3684,53 @@ describe("mapFeatures", () => { ); }); + 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"); diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index 8af1155..1625fff 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1868,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)) { @@ -1889,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);