From fe8580ca56a0a9dd5f3da359be4115fb8da78e59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 21:43:20 +0000 Subject: [PATCH] chore: share the KMP purity gate, fix stale doc paths and dead imports after the split Follow-ups from a review of the commons/commonsUI split: - `verifyKmpPurity` was copy-pasted into quartz, commons and commonsUI and the three copies had already drifted (checked dirs, hint text). It now lives once in gradle/kmp-purity.gradle.kts and each module applies it; the checked-dir list is the union, filtered by existence. Verified the shared task still fails on a deliberate java.util.UUID reference in commons. - amethyst/src/main/res/CLAUDE.md and the compose-expert catalog reference still pointed at the pre-split composeResources / ui paths. - Same-package imports left behind in the files moved to commons.feeds and commons.model are removed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD --- .../references/shared-composables-catalog.md | 6 +- amethyst/src/main/res/CLAUDE.md | 2 +- commons/build.gradle.kts | 70 +--------------- .../feeds/AdditiveComplexFeedFilter.kt | 2 - .../amethyst/commons/model/ParentNote.kt | 1 - .../amethyst/commons/model/ParentNoteTest.kt | 2 - .../commons/model/ReplyContextTest.kt | 2 - .../amethyst/commons/model/StubCache.kt | 4 - commonsUI/build.gradle.kts | 69 +--------------- gradle/kmp-purity.gradle.kts | 81 +++++++++++++++++++ quartz/build.gradle.kts | 77 +----------------- 11 files changed, 93 insertions(+), 223 deletions(-) create mode 100644 gradle/kmp-purity.gradle.kts diff --git a/.claude/skills/compose-expert/references/shared-composables-catalog.md b/.claude/skills/compose-expert/references/shared-composables-catalog.md index 19f7938fd6..337fe06b99 100644 --- a/.claude/skills/compose-expert/references/shared-composables-catalog.md +++ b/.claude/skills/compose-expert/references/shared-composables-catalog.md @@ -1,11 +1,11 @@ # Shared Composables Catalog -This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`. +This catalog documents shared UI components in `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/` (the Compose half of the shared layer; headless state stays in `commons`). ## Directory Structure ``` -commons/src/commonMain/kotlin/.../commons/ui/ +commonsUI/src/commonMain/kotlin/.../commons/ui/ ├── components/ # Reusable UI components ├── screens/ # Screen-level composables ├── theme/ # Theming and styling @@ -232,7 +232,7 @@ private val pathData1 = PathData { ``` **roboBuilder** - Custom ImageVector.Builder DSL -- Located in: `commons/robohash/` +- Located in: `commonsUI/.../commons/robohash/` - Pattern: Builder-based, composable paths - Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each) - Colors: Dynamic (fgColor parameter) + Black constants diff --git a/amethyst/src/main/res/CLAUDE.md b/amethyst/src/main/res/CLAUDE.md index 69b914d9ee..8d3ceec4a4 100644 --- a/amethyst/src/main/res/CLAUDE.md +++ b/amethyst/src/main/res/CLAUDE.md @@ -43,7 +43,7 @@ It diffs every locale's resource names against its tree's default `values/` and exits non-zero listing any orphan. It covers **both** Crowdin-managed resource systems — the Android res trees (`amethyst/src/main/res`, `commons/src/androidMain/res`) and the Compose-Multiplatform catalog -(`commons/src/commonMain/composeResources`) — and says which consequence applies: +(`commonsUI/src/commonMain/composeResources`) — and says which consequence applies: lint only polices the Android trees, but an orphan in the Compose catalog is the same mistake and leaves a dead translation behind. diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 1c044bed5d..7e8b0dda6c 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -202,71 +202,5 @@ tasks.withType().configureEach { } } -// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale -// there. Commons gains this gate once FeedDefinitionSerializer.kt has been -// migrated off Jackson; future commonMain code must not reintroduce JVM-only -// JSON / HTTP deps. -val verifyKmpPurity by tasks.registering { - group = "verification" - description = "Fails if iOS-targeted source sets import JVM-only deps." - val checkedDirs = - listOf( - "src/commonMain", "src/commonTest", - "src/appleMain", "src/appleTest", - "src/nativeMain", "src/nativeTest", - "src/iosMain", "src/iosTest", - "src/iosArm64Main", "src/iosArm64Test", - "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", - "src/linuxMain", "src/linuxTest", - "src/linuxX64Main", "src/linuxX64Test", - "src/macosMain", "src/macosTest", - "src/macosArm64Main", "src/macosArm64Test", - ).map { layout.projectDirectory.dir(it).asFile } - .filter { it.exists() } - inputs.files(checkedDirs) - doLast { - // Each pattern is paired with a short hint so the failure message - // points at the canonical KMP replacement. - val forbidden = - listOf( - "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", - "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", - "System.currentTimeMillis" to "use TimeUtils.now()", - "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", - "java.util.UUID" to "use kotlin.uuid.Uuid", - "kotlin.jvm.Synchronized" to "use KmpLock.withLock {}", - // The bare call, not just the annotation: `synchronized(lock) {}` resolves - // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and - // only fails at the iOS compile step. Catch it here instead. - "synchronized(" to "`synchronized` is JVM-only — use KmpLock.withLock {}", - "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", - ) - val offenders = - checkedDirs.flatMap { dir -> - dir.walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .flatMap { file -> - file.readLines().withIndex().mapNotNull { (idx, line) -> - val trimmed = line.trimStart() - // Skip KDoc / line-comment lines — those legitimately - // mention forbidden names (migration notes, doc refs). - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { - return@mapNotNull null - } - forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> - "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" - } - } - } - } - if (offenders.isNotEmpty()) { - throw GradleException( - "iOS-targeted source sets must not reference JVM-only APIs. " + - "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + - offenders.joinToString("\n "), - ) - } - } -} - -tasks.named("check").configure { dependsOn(verifyKmpPurity) } +// iOS purity gate — shared task, see gradle/kmp-purity.gradle.kts. +apply(from = rootProject.file("gradle/kmp-purity.gradle.kts")) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/AdditiveComplexFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/AdditiveComplexFeedFilter.kt index 84053ca5fa..d2003dd179 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/AdditiveComplexFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/AdditiveComplexFeedFilter.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.feeds -import com.vitorpamplona.amethyst.commons.feeds.FeedFilter - abstract class AdditiveComplexFeedFilter : FeedFilter() { abstract fun updateListWith( oldList: List, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNote.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNote.kt index b377e6f4f4..62ba73e534 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNote.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNote.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.model -import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNoteTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNoteTest.kt index 117c5d525d..6e295c5080 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNoteTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ParentNoteTest.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.model -import com.vitorpamplona.amethyst.commons.model.AddressableNote -import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip22Comments.CommentEvent diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ReplyContextTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ReplyContextTest.kt index afdd0077e2..4d1e1a6555 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ReplyContextTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ReplyContextTest.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.model -import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlin.test.Test import kotlin.test.assertEquals diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/StubCache.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/StubCache.kt index 1ab49b42eb..3d0774767d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/StubCache.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/StubCache.kt @@ -20,10 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.model -import com.vitorpamplona.amethyst.commons.model.AddressableNote -import com.vitorpamplona.amethyst.commons.model.Channel -import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.Address diff --git a/commonsUI/build.gradle.kts b/commonsUI/build.gradle.kts index 8fe797c39e..0bfcd17344 100644 --- a/commonsUI/build.gradle.kts +++ b/commonsUI/build.gradle.kts @@ -224,70 +224,5 @@ compose.resources { generateResClass = always } -// iOS purity gate — same shape as :quartz / :commons verifyKmpPurity. -// commonMain here must stay free of JVM-only JSON / HTTP deps. -val verifyKmpPurity by tasks.registering { - group = "verification" - description = "Fails if iOS-targeted source sets import JVM-only deps." - val checkedDirs = - listOf( - "src/commonMain", "src/commonTest", - "src/appleMain", "src/appleTest", - "src/nativeMain", "src/nativeTest", - "src/iosMain", "src/iosTest", - "src/skikoMain", "src/skikoTest", - "src/iosArm64Main", "src/iosArm64Test", - "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", - "src/linuxMain", "src/linuxTest", - "src/linuxX64Main", "src/linuxX64Test", - "src/macosMain", "src/macosTest", - "src/macosArm64Main", "src/macosArm64Test", - ).map { layout.projectDirectory.dir(it).asFile } - .filter { it.exists() } - inputs.files(checkedDirs) - doLast { - // Each pattern is paired with a short hint so the failure message - // points at the canonical KMP replacement. - val forbidden = - listOf( - "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", - "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", - "System.currentTimeMillis" to "use TimeUtils.now()", - "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", - "java.util.UUID" to "use kotlin.uuid.Uuid", - "kotlin.jvm.Synchronized" to "use KmpLock.withLock {}", - // The bare call, not just the annotation: `synchronized(lock) {}` resolves - // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and - // only fails at the iOS compile step. Catch it here instead. - "synchronized(" to "`synchronized` is JVM-only — use KmpLock.withLock {}", - "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", - ) - val offenders = - checkedDirs.flatMap { dir -> - dir.walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .flatMap { file -> - file.readLines().withIndex().mapNotNull { (idx, line) -> - val trimmed = line.trimStart() - // Skip KDoc / line-comment lines — those legitimately - // mention forbidden names (migration notes, doc refs). - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { - return@mapNotNull null - } - forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> - "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" - } - } - } - } - if (offenders.isNotEmpty()) { - throw GradleException( - "iOS-targeted source sets must not reference JVM-only APIs. " + - "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + - offenders.joinToString("\n "), - ) - } - } -} - -tasks.named("check").configure { dependsOn(verifyKmpPurity) } +// iOS purity gate — shared task, see gradle/kmp-purity.gradle.kts. +apply(from = rootProject.file("gradle/kmp-purity.gradle.kts")) diff --git a/gradle/kmp-purity.gradle.kts b/gradle/kmp-purity.gradle.kts new file mode 100644 index 0000000000..eb5caafa3e --- /dev/null +++ b/gradle/kmp-purity.gradle.kts @@ -0,0 +1,81 @@ +// iOS purity gate shared by every KMP library module that targets Apple +// (`:quartz`, `:commons`, `:commonsUI`). Apply it from the module's build +// script with +// +// apply(from = rootProject.file("gradle/kmp-purity.gradle.kts")) +// +// It registers `verifyKmpPurity` (wired into `check`) which fails when an +// iOS-targeted source set references a JVM-only API. `verifyKmpPurity` is the +// fast Linux pre-check that runs in the lint job; the macOS iOS compile job is +// the authoritative one. Keeping the pattern table in one file means a new +// forbidden API lands in every module at once instead of drifting per copy. + +val verifyKmpPurity by tasks.registering { + group = "verification" + description = "Fails if iOS-targeted source sets import JVM-only deps." + // Every source set that feeds an Apple target. Modules that don't have a + // given directory are skipped by the exists() filter, so the list is the + // union across modules (e.g. only :commonsUI has skikoMain today). + val checkedDirs = + listOf( + "src/commonMain", "src/commonTest", + "src/appleMain", "src/appleTest", + "src/nativeMain", "src/nativeTest", + "src/iosMain", "src/iosTest", + "src/skikoMain", "src/skikoTest", + "src/iosArm64Main", "src/iosArm64Test", + "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", + "src/linuxMain", "src/linuxTest", + "src/linuxX64Main", "src/linuxX64Test", + "src/macosMain", "src/macosTest", + "src/macosArm64Main", "src/macosArm64Test", + ).map { layout.projectDirectory.dir(it).asFile } + .filter { it.exists() } + inputs.files(checkedDirs) + doLast { + // Each pattern is paired with a short hint so the failure message + // points at the canonical KMP replacement. + val forbidden = + listOf( + "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", + "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", + "System.currentTimeMillis" to "use TimeUtils.now()", + "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", + "java.util.UUID" to "use kotlin.uuid.Uuid", + "kotlin.jvm.Synchronized" to "use a KMP lock primitive (KmpLock.withLock {} in commons)", + // The bare call, not just the annotation: `synchronized(lock) {}` resolves + // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and + // only fails at the iOS compile step. Catch it here instead. + "synchronized(" to "`synchronized` is JVM-only — use a KMP lock primitive (KmpLock.withLock {} in commons)", + "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", + ) + val offenders = + checkedDirs.flatMap { dir -> + dir + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines().withIndex().mapNotNull { (idx, line) -> + val trimmed = line.trimStart() + // Skip KDoc / line-comment lines — those legitimately + // mention forbidden names (migration notes, doc refs). + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { + return@mapNotNull null + } + forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> + "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" + } + } + } + } + if (offenders.isNotEmpty()) { + throw GradleException( + "iOS-targeted source sets must not reference JVM-only APIs. " + + "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + + offenders.joinToString("\n "), + ) + } + } +} + +tasks.named("check").configure { dependsOn(verifyKmpPurity) } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 9253f2c607..e3a2a1889e 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -383,79 +383,10 @@ dependencies { // Scope: source sets whose code is compiled for at least one non-JVM // target. Excludes jvmAndroid, jvmMain, androidMain (and their tests), // where Jackson and OkHttp are legitimately used. -val verifyKmpPurity by tasks.registering { - group = "verification" - description = "Fails if iOS-targeted source sets import JVM-only deps." - val checkedDirs = - listOf( - "src/commonMain", - "src/commonTest", - "src/appleMain", - "src/appleTest", - "src/nativeMain", - "src/nativeTest", - "src/iosMain", - "src/iosTest", - "src/iosArm64Main", - "src/iosArm64Test", - "src/iosSimulatorArm64Main", - "src/iosSimulatorArm64Test", - "src/linuxMain", - "src/linuxTest", - "src/linuxX64Main", - "src/linuxX64Test", - "src/macosMain", - "src/macosTest", - "src/macosArm64Main", - "src/macosArm64Test", - ).map { layout.projectDirectory.dir(it).asFile } - .filter { it.exists() } - inputs.files(checkedDirs) - doLast { - // Each pattern is paired with a short hint so the failure message - // points at the canonical KMP replacement. - val forbidden = - listOf( - "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", - "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", - "System.currentTimeMillis" to "use TimeUtils.now()", - "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", - "java.util.UUID" to "use kotlin.uuid.Uuid", - "kotlin.jvm.Synchronized" to "use a KMP lock primitive", - // The bare call, not just the annotation: `synchronized(lock) {}` resolves - // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and - // only fails at the iOS compile step. Catch it here instead. - "synchronized(" to "`synchronized` is JVM-only — use a KMP lock primitive", - "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", - ) - val offenders = - checkedDirs.flatMap { dir -> - dir - .walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .flatMap { file -> - file.readLines().withIndex().mapNotNull { (idx, line) -> - val trimmed = line.trimStart() - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { - return@mapNotNull null - } - forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> - "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" - } - } - } - } - if (offenders.isNotEmpty()) { - throw GradleException( - "iOS-targeted source sets must not reference JVM-only APIs. " + - "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + - offenders.joinToString("\n "), - ) - } - } -} - -tasks.named("check").configure { dependsOn(verifyKmpPurity) } +// +// The task itself is shared with :commons and :commonsUI — see +// gradle/kmp-purity.gradle.kts for the pattern table and checked dirs. +apply(from = rootProject.file("gradle/kmp-purity.gradle.kts")) mavenPublishing { // sources publishing is always enabled by the Kotlin Multiplatform plugin