M2b: Android skeleton, :core:model contract, :domain passing all vectors

Gradle project in android/ with every module of SPEC §6.2 (build-logic convention plugins, version catalog,
architecture check in check), :core:model types and repository interfaces of §14.2.4 and §14.5, :domain
implementing §14.5.2 and §14.5.4 (125 vector cases), scenario-driven fakes with the empty scenario, Room 3
entities and KvittDatabase v1 with exported schema, :app shell with Hilt, en/sv strings with MissingTranslation
as a lint error, Roborazzi goldens of the empty screen in light and dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:23:43 +02:00
parent 76ee957f41
commit c277ee37c2
93 changed files with 4915 additions and 1 deletions
+42
View File
@@ -0,0 +1,42 @@
plugins {
`kotlin-dsl`
}
dependencies {
compileOnly(libs.android.gradle.plugin)
compileOnly(libs.kotlin.gradle.plugin)
compileOnly(libs.compose.compiler.gradle.plugin)
}
gradlePlugin {
plugins {
register("kvittJvmLibrary") {
id = "kvitt.jvm.library"
implementationClass = "se.hagstedt.kvitt.buildlogic.JvmLibraryPlugin"
}
register("kvittAndroidLibrary") {
id = "kvitt.android.library"
implementationClass = "se.hagstedt.kvitt.buildlogic.AndroidLibraryPlugin"
}
register("kvittAndroidCompose") {
id = "kvitt.android.compose"
implementationClass = "se.hagstedt.kvitt.buildlogic.AndroidComposePlugin"
}
register("kvittAndroidFeature") {
id = "kvitt.android.feature"
implementationClass = "se.hagstedt.kvitt.buildlogic.AndroidFeaturePlugin"
}
register("kvittAndroidApplication") {
id = "kvitt.android.application"
implementationClass = "se.hagstedt.kvitt.buildlogic.AndroidApplicationPlugin"
}
register("kvittAndroidHilt") {
id = "kvitt.android.hilt"
implementationClass = "se.hagstedt.kvitt.buildlogic.HiltPlugin"
}
register("kvittArchitecture") {
id = "kvitt.architecture"
implementationClass = "se.hagstedt.kvitt.buildlogic.ArchitecturePlugin"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
@@ -0,0 +1,34 @@
package se.hagstedt.kvitt.buildlogic
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
/** The `:app` module: SDK levels from the catalog, Java 17 bytecode, shared lint rules. */
class AndroidApplicationPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.application")
pluginManager.apply("kvitt.architecture")
val catalog = libs
extensions.configure(ApplicationExtension::class.java) {
compileSdk = catalog.version("compileSdk").toInt()
defaultConfig {
minSdk = catalog.version("minSdk").toInt()
targetSdk = catalog.version("targetSdk").toInt()
}
compileOptions {
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17
targetCompatibility = org.gradle.api.JavaVersion.VERSION_17
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
lint { kvittLint(this) }
}
configureKotlinAndroid()
configureRobolectricJvm()
dependencies.add("testImplementation", catalog.library("junit"))
}
}
}
@@ -0,0 +1,47 @@
package se.hagstedt.kvitt.buildlogic
import com.android.build.api.dsl.Lint
import org.gradle.api.Project
import org.gradle.api.tasks.testing.Test
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
/**
* Lint rules shared by every Android module. A string key missing from `values-sv` (or present only there) fails the
* build (SPEC AC-19.1). No baseline file: findings are fixed, never silenced (CLAUDE.md › Anti-patterns).
*/
internal fun kvittLint(lint: Lint) {
lint.abortOnError = true
lint.checkReleaseBuilds = true
lint.error.add("MissingTranslation")
lint.error.add("ExtraTranslation")
}
/** Kotlin compiled by the Android Gradle Plugin's built-in Kotlin support: Java 17 bytecode, warnings are errors. */
internal fun Project.configureKotlinAndroid() {
tasks.withType(KotlinCompile::class.java).configureEach {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
}
/**
* A module without `src/test` (a module shell) has only the generated R classes on its unit-test classpath, which
* Gradle reports as "test sources present, no tests discovered". The guard stays on for every module with tests.
*/
internal fun Project.failOnNoTestsOnlyWhenTestsExist() {
val hasTests = file("src/test").exists()
tasks.withType(Test::class.java).configureEach { failOnNoDiscoveredTests.set(hasTests) }
}
/**
* Robolectric on the pinned JDK 25 reads FileDescriptor internals through jdk.internal.access; the module system
* blocks that unless the package is exported to the test classpath.
*/
internal fun Project.configureRobolectricJvm() {
tasks.withType(Test::class.java).configureEach {
jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED", "--add-opens=java.base/java.io=ALL-UNNAMED")
}
}
@@ -0,0 +1,30 @@
package se.hagstedt.kvitt.buildlogic
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
/** Jetpack Compose with Material 3 for an Android application or library module. */
class AndroidComposePlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
pluginManager.withPlugin("com.android.library") {
extensions.configure(LibraryExtension::class.java) { buildFeatures.compose = true }
}
pluginManager.withPlugin("com.android.application") {
extensions.configure(ApplicationExtension::class.java) { buildFeatures.compose = true }
}
val catalog = libs
val bom = dependencies.platform(catalog.library("androidx-compose-bom"))
dependencies.add("implementation", bom)
dependencies.add("testImplementation", bom)
dependencies.add("implementation", catalog.library("androidx-compose-ui"))
dependencies.add("implementation", catalog.library("androidx-compose-ui-graphics"))
dependencies.add("implementation", catalog.library("androidx-compose-ui-tooling-preview"))
dependencies.add("implementation", catalog.library("androidx-compose-material3"))
dependencies.add("debugImplementation", catalog.library("androidx-compose-ui-tooling"))
}
}
}
@@ -0,0 +1,28 @@
package se.hagstedt.kvitt.buildlogic
import org.gradle.api.Plugin
import org.gradle.api.Project
/**
* Feature module (SPEC §6.2, convention in android/README.md): Compose screens against the UI contract only.
* Features see `:core:model` and `:core:design`; `:core:fakes` only for debug previews and tests, so fakes never reach
* a release build. ViewModels are Hilt ViewModels obtained with `hiltViewModel()`.
*/
class AndroidFeaturePlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("kvitt.android.library")
pluginManager.apply("kvitt.android.compose")
pluginManager.apply("kvitt.android.hilt")
val catalog = libs
dependencies.add("implementation", dependencies.project(mapOf("path" to ":core:model")))
dependencies.add("implementation", dependencies.project(mapOf("path" to ":core:design")))
dependencies.add("debugImplementation", dependencies.project(mapOf("path" to ":core:fakes")))
dependencies.add("testImplementation", dependencies.project(mapOf("path" to ":core:fakes")))
dependencies.add("implementation", catalog.library("androidx-hilt-lifecycle-viewmodel-compose"))
dependencies.add("implementation", catalog.library("androidx-lifecycle-viewmodel-compose"))
dependencies.add("implementation", catalog.library("androidx-lifecycle-runtime-compose"))
dependencies.add("implementation", catalog.library("kotlinx-coroutines-core"))
}
}
}
@@ -0,0 +1,35 @@
package se.hagstedt.kvitt.buildlogic
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
/** Android library module: SDK levels from the catalog, Java 17 bytecode, shared lint rules. */
class AndroidLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("kvitt.architecture")
val catalog = libs
extensions.configure(LibraryExtension::class.java) {
namespace = kvittNamespace()
compileSdk = catalog.version("compileSdk").toInt()
defaultConfig {
minSdk = catalog.version("minSdk").toInt()
}
compileOptions {
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17
targetCompatibility = org.gradle.api.JavaVersion.VERSION_17
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
lint { kvittLint(this) }
}
configureKotlinAndroid()
configureRobolectricJvm()
failOnNoTestsOnlyWhenTestsExist()
dependencies.add("testImplementation", catalog.library("junit"))
}
}
}
@@ -0,0 +1,97 @@
package se.hagstedt.kvitt.buildlogic
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
/**
* Fails the build on a dependency edge outside SPEC §6.2, on `:core:fakes` in a release configuration, and on
* imports or fully qualified references outside kotlin.*, kotlinx.* and the project's packages in a pure module.
*/
@CacheableTask
abstract class ArchitectureCheckTask : DefaultTask() {
@get:Input
abstract val modulePath: Property<String>
/** Declared project dependencies as `configuration|:path`. */
@get:Input
abstract val projectDependencies: ListProperty<String>
/** Declared external dependencies of the main configurations as `configuration|group:name`. */
@get:Input
abstract val externalDependencies: ListProperty<String>
@get:Input
abstract val androidPluginApplied: Property<Boolean>
/** Main source files of the module (scanned only for pure modules). */
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val mainSources: ConfigurableFileCollection
@get:OutputFile
abstract val report: RegularFileProperty
@TaskAction
fun check() {
val path = modulePath.get()
val violations = mutableListOf<String>()
if (!ArchitectureRules.allowed.containsKey(path)) {
violations += "$path is not in ArchitectureRules.allowed; add it with its allowed edges (SPEC §6.2)"
}
val allowed = ArchitectureRules.allowed[path]
for (entry in projectDependencies.get()) {
val (configuration, target) = entry.split('|', limit = 2)
if (target == path) continue
if (allowed != null && target !in allowed) {
violations += "$path -> $target ($configuration) is not an allowed edge (SPEC §6.2)"
}
if (target == ArchitectureRules.FAKES && !ArchitectureRules.isTestOrDebugConfiguration(configuration)) {
violations += "$path -> $target in '$configuration': fakes only in test or debug configurations"
}
}
if (path in ArchitectureRules.pureModules) {
if (androidPluginApplied.get()) violations += "$path is pure Kotlin/JVM and must not apply an Android plugin"
for (entry in externalDependencies.get()) {
val (configuration, coordinate) = entry.split('|', limit = 2)
if (ArchitectureRules.pureExternalGroups.none { coordinate.startsWith("$it.") || coordinate.startsWith("$it:") }) {
violations += "$path uses $coordinate in '$configuration'; pure modules use only kotlin and kotlinx libraries"
}
}
mainSources.files.sortedBy { it.path }.forEach { file ->
file.readLines().forEachIndexed { index, raw ->
val line = raw.trim()
val where = "${file.name}:${index + 1}"
val import = IMPORT.find(line)?.groupValues?.get(1)
if (import != null) {
if (ArchitectureRules.pureImportPrefixes.none { import.startsWith(it) }) {
violations += "$path $where imports $import (only kotlin.*, kotlinx.*, se.hagstedt.kvitt.*)"
}
} else if (!line.startsWith("//") && !line.startsWith("*") && !line.startsWith("/*")) {
QUALIFIED.find(line)?.let { violations += "$path $where references ${it.value} (platform API)" }
}
}
}
}
val text = if (violations.isEmpty()) "OK $path\n" else violations.joinToString("\n", postfix = "\n")
report.get().asFile.apply { parentFile.mkdirs() }.writeText(text)
if (violations.isNotEmpty()) {
throw GradleException("Architecture check failed:\n" + violations.joinToString("\n") { " $it" })
}
}
private companion object {
val IMPORT = Regex("""^import\s+([\w.]+)""")
val QUALIFIED = Regex("""(?<![\w.])(java|javax|android|androidx|dalvik|sun)\.[a-z]\w*""")
}
}
@@ -0,0 +1,38 @@
package se.hagstedt.kvitt.buildlogic
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.ExternalModuleDependency
import org.gradle.api.artifacts.ProjectDependency
import org.gradle.api.plugins.BasePlugin
import org.gradle.language.base.plugins.LifecycleBasePlugin
/** Registers `architectureCheck` in every module and makes `check` depend on it (SPEC §6.2, owned by M2b). */
class ArchitecturePlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply(BasePlugin::class.java)
val configurations = configurations
val projectPath = path
val task = tasks.register("architectureCheck", ArchitectureCheckTask::class.java) {
group = LifecycleBasePlugin.VERIFICATION_GROUP
description = "Checks module dependency edges and pure-module imports (SPEC §6.2)."
modulePath.set(projectPath)
projectDependencies.set(provider {
configurations.flatMap { c ->
c.dependencies.withType(ProjectDependency::class.java).map { "${c.name}|${it.path}" }
}.distinct().sorted()
})
externalDependencies.set(provider {
configurations.filter { it.name in ArchitectureRules.mainConfigurations }.flatMap { c ->
c.dependencies.withType(ExternalModuleDependency::class.java).map { "${c.name}|${it.group}:${it.name}" }
}.distinct().sorted()
})
androidPluginApplied.set(provider { pluginManager.hasPlugin("com.android.library") || pluginManager.hasPlugin("com.android.application") })
mainSources.from(fileTree("src/main") { include("**/*.kt", "**/*.java") })
report.set(layout.buildDirectory.file("reports/architecture/check.txt"))
}
tasks.named(LifecycleBasePlugin.CHECK_TASK_NAME) { dependsOn(task) }
}
}
}
@@ -0,0 +1,40 @@
package se.hagstedt.kvitt.buildlogic
/**
* The module graph of SPEC §6.2 and CLAUDE.md › Architecture rules, as data. One entry per module; a module missing
* here fails the check, so a new module must be added on purpose.
*/
object ArchitectureRules {
const val FAKES = ":core:fakes"
private val features = listOf("onboarding", "home", "account", "expense", "settle", "analytics", "settings")
.map { ":feature:$it" }
/** Pure Kotlin/JVM modules: no Android, no java.time, no java.io; only kotlin.*, kotlinx.* and project imports. */
val pureModules: Set<String> = setOf(":core:model", ":domain")
/** Allowed project dependencies per module. `null` means any module (only `:app`). */
val allowed: Map<String, Set<String>?> = buildMap {
put(":core:model", emptySet())
put(":domain", setOf(":core:model"))
put(":core:design", setOf(":core:model"))
put(FAKES, setOf(":core:model", ":domain"))
put(":data", setOf(":core:model", ":domain"))
put(":adapter:fcm", setOf(":core:model"))
features.forEach { put(it, setOf(":core:model", ":core:design", FAKES)) }
put(":app", null)
}
/** Package prefixes a pure module may import. */
val pureImportPrefixes: List<String> = listOf("kotlin.", "kotlinx.", "se.hagstedt.kvitt.")
/** Group prefixes of external libraries a pure module may use in its main code. */
val pureExternalGroups: List<String> = listOf("org.jetbrains.kotlin", "org.jetbrains.kotlinx")
/** Configurations that feed main (release) code; everything else is a test, debug or tool configuration. */
val mainConfigurations: Set<String> = setOf("api", "implementation", "compileOnly", "compileOnlyApi", "runtimeOnly")
/** `:core:fakes` may appear only in test and debug configurations (fakes never reach a release build). */
fun isTestOrDebugConfiguration(name: String): Boolean =
name.contains("test", ignoreCase = true) || name.contains("debug", ignoreCase = true)
}
@@ -0,0 +1,19 @@
package se.hagstedt.kvitt.buildlogic
import org.gradle.api.Project
import org.gradle.api.artifacts.MinimalExternalModuleDependency
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.provider.Provider
internal val Project.libs: VersionCatalog
get() = extensions.getByType(VersionCatalogsExtension::class.java).named("libs")
internal fun VersionCatalog.version(name: String): String =
findVersion(name).orElseThrow { IllegalStateException("libs.versions.toml has no version '$name'") }.requiredVersion
internal fun VersionCatalog.library(alias: String): Provider<MinimalExternalModuleDependency> =
findLibrary(alias).orElseThrow { IllegalStateException("libs.versions.toml has no library '$alias'") }
/** Namespace of an Android module, derived from its path: `:feature:home` -> `se.hagstedt.kvitt.feature.home`. */
internal fun Project.kvittNamespace(): String = "se.hagstedt.kvitt." + path.removePrefix(":").replace(':', '.')
@@ -0,0 +1,16 @@
package se.hagstedt.kvitt.buildlogic
import org.gradle.api.Plugin
import org.gradle.api.Project
/** Hilt with KSP for an Android module. */
class HiltPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.devtools.ksp")
pluginManager.apply("com.google.dagger.hilt.android")
dependencies.add("implementation", libs.library("hilt-android"))
dependencies.add("ksp", libs.library("hilt-compiler"))
}
}
}
@@ -0,0 +1,32 @@
package se.hagstedt.kvitt.buildlogic
import org.gradle.api.JavaVersion
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.tasks.compile.JavaCompile
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
/** Pure Kotlin/JVM module (`:core:model`, `:domain`): no Android, bytecode Java 17 (SPEC §6.2). */
class JvmLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("org.jetbrains.kotlin.jvm")
pluginManager.apply("kvitt.architecture")
extensions.configure(JavaPluginExtension::class.java) {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.withType(JavaCompile::class.java).configureEach { options.release.set(17) }
extensions.configure(KotlinJvmProjectExtension::class.java) {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
freeCompilerArgs.add("-Xjdk-release=17")
allWarningsAsErrors.set(true)
}
}
dependencies.add("testImplementation", libs.library("junit"))
}
}
}