iru-setup-kmp-app
Scaffold a new Kotlin Multiplatform mobile app (Android + iOS) at the repository root — Gradle Kotlin DSL wrapper, `settings.gradle.kts`, root `build.gradle.kts`, `gradle.properties` (holding the app
SKILL.md.claude/skills/iru-setup-kmp-app/SKILL.md
GitHubSetup KMP App
Generate a complete, buildable Kotlin Multiplatform (KMP) app for Android and iOS — a Gradle Kotlin-DSL build with a
shared/ KMP module, an androidApp/ application module and an iosApp/ SwiftUI host app generated by XcodeGen —
from the templates in this skill's reference/ files. Every template was rendered into throwaway projects (both
ui values) and proven against the real toolchain on 2026-10-15 (Gradle 9.8.1, AGP 9.4.1, Kotlin 2.4.21, Compose
Multiplatform 1.12.2, Xcode 27.0, XcodeGen 2.46.0) — see Known quirks / verification notes.
The layout is the scaffold contract the KMP coding and gate skills (iru-kmp-code-one-task, iru-kmp-test,
iru-kmp-coverage, iru-kmp-code-quality, iru-kmp-dokka, iru-kmp-bump-version) and
iru-setup-kmp-github-workflows detect and call — Step 8's contract table lists the confirmed task names and
report paths. It mirrors iru-setup-android-app (Gradle KTS, version catalog, env-driven signing, Update/Keep/
Replace) and iru-setup-apple-app (XcodeGen manifest, the generated .xcodeproj never committed, SwiftLint,
XCTest). Like iru-setup-android-app, it writes no publishing block — this is an app; a KMP library published to
Maven Central is out of scope.
Reference files — read only what this run needs
| Read | When |
|---|---|
reference/gradle-templates.md |
Always. Wrapper properties, settings.gradle.kts, gradle.properties, gradle/libs.versions.toml, root build.gradle.kts (Kover aggregation + sonar {}), detekt.yml, .editorconfig. |
reference/shared-templates.md |
Always. shared/build.gradle.kts and every shared/src/* file (+ the ui: compose App.kt/MainViewController.kt). |
reference/android-templates.md |
Always. androidApp/build.gradle.kts, manifest, resources, MainActivity.kt (per ui), the Robolectric test. |
reference/ios-templates.md |
Always. iosApp/project.yml, Config.xcconfig, Info.plist, the Swift sources and tests (per ui), .swiftlint.yml. |
Conditional-marker convention used in every reference file: a line // >>> <condition> (or # >>> <condition>)
opens a block that is written only when the condition holds, and the matching // <<< <condition> closes it; the
marker lines themselves are never written. Conditions are ui: compose, ui: native and sonar (= sonar is not
none). A file heading suffixed (ui: compose)/(ui: native) exists only in that variant. <placeholder> values
come from Step 5's table; <package-path> is <namespace> with dots turned into /.
Step 0 — Resolve inputs
Invoked stand-alone (/iru-setup-kmp-app) or by iru-setup-kmp-repository / the iru-setup-repository front door,
which pass resolved answers as key: value lines, e.g.:
ui: compose
app-name: My App
application-id: com.example.myapp
bundle-id: com.example.myapp
namespace: com.example.myapp
min-sdk: 26
ios-deployment-target: 18.5
license: MIT License
developer-name: Jane Doe
developer-email: jane@example.com
organization-url: https://github.com/example-org
open-source: yes
visibility: public
ci: yes
sonar: cloud
sonar-organization: example-org-github
sonar-project-key: example-org_my-app
sonar-host-url: https://sonarcloud.io
distribution: store
accessibility: enforce
mode: new
on-existing: ask
Recognized keys: ui (compose/native), app-name, application-id, bundle-id (default application-id),
namespace (default application-id — shared Kotlin package <namespace>, Android app package/namespace
<namespace>.android, shared Android namespace <namespace>.shared), project-name (rootProject.name, default
the repository name), min-sdk (default 26), compile-sdk/target-sdk (default Step 4's lookup),
ios-deployment-target (default Step 4's lookup), version-name (default 1.0.0-SNAPSHOT — the gitflow
integration-branch value; pass 1.0.0 for a main-only repository), version-code (default 1), team-id (Apple
Developer Team ID, default empty — set later in Config.xcconfig or supplied by CI), license, developer-name,
developer-email, organization-url, open-source (yes/no), visibility (public/private/internal —
only keys the sonar recommendation), ci (yes/no — only keys the sonar recommendation), sonar
(cloud/self-hosted/none) plus sonar-organization/sonar-project-key/sonar-host-url, distribution
(none/internal/store — recorded for the report; the store/TestFlight uploads live in
iru-setup-kmp-github-workflows' release.yml, so the build files are identical for every value), accessibility
(enforce/report/off/none, default enforce — the automated accessibility level of the UI tests, written to
.ui-quality.yml; never asked stand-alone, just defaulted), mode
(new/existing), on-existing (ask/keep/update/replace, default ask). Every key found is resolved —
skip its question in Step 2. Unknown keys from a full shared-args set (publish, ci-tests, pages, …) are
ignored and named in the report.
mode: existing never means "gap-fill silently": Step 1 still warns about, and asks about, every pre-existing file
group. Only on-existing skips that question (keep = create only missing files; update/replace applied to
every existing group; the warning is still printed). Orchestrators forward on-existing only when their own args
received it.
Accessibility level none and assistive-technology templates. accessibility takes enforce, report, off or
none (UI contract §8; the stand-alone question gains a fourth option, None — opt out entirely, e.g. a mandated
non-compliant design). With none: write accessibility: none to .ui-quality.yml, do not install or configure the
accessibility test tooling (the Android Accessibility Test Framework audit and the iOS accessibility audit), and omit the accessibility-specific attributes from the generated templates.
With enforce, report or off: include the assistive-technology attributes from the contract's §8 platform
subsections in every generated template — contentDescription/Modifier.semantics on Android, accessibilityLabel on iOS, scalable font sizes, reduced-motion guards — and, for enforce/report only, the automated checks. In
mode: existing, a prior .ui-quality.yml follows this skill's existing Update / Keep / Replace prompt and its
accessibility value is never silently flipped (Update sets it only when args supplied it).
Step 1 — Survey the repository
Check for every file Step 6 would write: settings.gradle.kts, root build.gradle.kts, gradle.properties,
gradle/libs.versions.toml, the wrapper (gradlew, gradlew.bat, gradle/wrapper/*), detekt.yml,
.editorconfig, shared/build.gradle.kts, shared/src/**, androidApp/build.gradle.kts, androidApp/src/**,
androidApp/proguard-rules.pro, iosApp/project.yml, iosApp/Configuration/Config.xcconfig, iosApp/iosApp/**,
iosApp/iosAppTests/**, iosApp/.swiftlint.yml, .ui-quality.yml, and any committed iosApp/*.xcodeproj. Then
tell the user which
of the two this run is doing:
Scaffolding from scratch — none exist: go to Step 2.
Updating an existing KMP project — any exists: warn, listing every file found by path, that each existing file group below will be decided individually (missing files are still created). Groups (only those with a file present):
settings.gradle.kts, rootbuild.gradle.kts,gradle/libs.versions.toml,gradle.properties, the Gradle wrapper,shared/build.gradle.kts,androidApp/build.gradle.kts, quality config (detekt.yml,.editorconfig,iosApp/.swiftlint.yml),iosApp/project.yml,iosApp/Configuration/Config.xcconfig,.ui-quality.yml, and the source skeleton (everything undershared/src,androidApp/src,iosApp/iosApp,iosApp/iosAppTests). Unlesson-existingdecides, ask withAskUserQuestion, one question per group (batch up to 4 per call), naming each concrete conflict with this run's choices (e.g. "shared/build.gradle.ktsappliesorg.jetbrains.composebut you choseui: native", "the rootbuild.gradle.ktshas asonar {}block but you chosesonar: none", ".ui-quality.ymlsaysaccessibility: reportbut you choseenforce", "gradle.propertieshas noVERSION_NAME/VERSION_CODE— the Android app's version is a literal inandroidApp/build.gradle.kts"):- Update (Recommended for build files, wrapper, quality config,
project.ymland.ui-quality.yml) — "Merge: add what's missing and correct outdated parts using this skill's template as reference — plugins, catalog aliases, the Kover/ detekt/ktlint/Dokka/lint/Sonar wiring, the version properties, toolchain versions bumped to Step 4's lookup; keep every dependency, target, source set, build type, scheme and setting this skill doesn't own. You review a diff; customizations survive, but a customized part this skill owns may be rewritten." For.ui-quality.yml, Update setsaccessibilityto this run's value and adds missing keys, keeping customizeddirections/themes. For the source skeleton, Update only creates its missing files. - Keep as is (Recommended for the source skeleton and
Config.xcconfig) — "Leave the file(s) untouched. Nothing you customized changes, but they may stay out of date or disagree with this run's choices — the report lists each mismatch." - Replace — "Regenerate from this skill's template. Matches the house pattern exactly, but every customization is lost (recoverable only from git history)." Never the default. Even on Replace, never overwrite a Kotlin/Swift source file unless its content exactly matches this skill's original template (i.e. it is still the untouched placeholder).
- Stop (first question only) — "End this skill now without changing any file." Report and end.
Ask about groups whose expected content depends on Step 2 answers (
ui,sonar) after Step 2 whenargsdidn't already resolve them. When updating, read the currentapplicationId, namespace, bundle id (Config.xcconfig'sBUNDLE_IDorproject.yml'sPRODUCT_BUNDLE_IDENTIFIER), app name and versions first and offer them as Step 2's defaults.- Update (Recommended for build files, wrapper, quality config,
A foreign KMP layout —
composeApp/build.gradle.kts(the JetBrains wizard's single module), or ashared/module onandroidTarget()+com.android.library, or aniosApp/iosApp.xcodeprojcommitted with noproject.yml: this is not this skill's scaffold. Warn explicitly and ask (AskUserQuestion) whether to add this skill's missing pieces alongside without touching the existing modules (e.g. only the quality wiring, Kover aggregation and version properties — each still per-group Update/Keep/Replace), or stop. Never convert or move an existing module, and never replace a committed.xcodeprojwith an XcodeGen one without an explicit yes.
Step 2 — Collect the required inputs
For anything Step 0 didn't resolve, ask directly (plain conversation, pre-filled from Step 1 when updating):
- app-name — launcher label on both platforms (
strings.xmlapp_name,Config.xcconfigAPP_NAME→CFBundleDisplayName). - application-id — Android
applicationId, reverse-DNS. - bundle-id — iOS
PRODUCT_BUNDLE_IDENTIFIER; offerapplication-idas the default. - namespace — Kotlin base package; offer
application-idas the default. - min-sdk (
26), compile-sdk/target-sdk and ios-deployment-target — offer Step 4's looked-up values. - Developer name/email, organizationUrl — recorded in the report (and seen by
iru-setup-readme).
Then the license via AskUserQuestion (unless resolved): MIT License (recommended for an app) / Apache License
2.0 / No license (proprietary) / Other (ask name + URL). If one is chosen, remind the user in Step 8 to add a root
LICENSE (iru-check-license can backfill headers).
Then ui (unless resolved), AskUserQuestion:
- Compose Multiplatform (Recommended) (
compose) — one Compose UI inshared/commonMain(App()), hosted byMainActivity.setContent { App() }on Android andComposeUIViewController { App() }on iOS. - Native UI (
native) —shared/holds logic and state only (Greeting,GreetingUiState); Android UI is Jetpack Compose (Compose BOM) inandroidApp/; iOS UI is SwiftUI iniosApp/over a smallGreetingModelObservableObjectwrapping the shared state.
Then open-source, sonar, distribution (skip any resolved). Stand-alone with none resolved, ask open-source
first and derive the sonar recommendation from it:
- open-source — Yes / No.
- sonar — SonarQube/SonarCloud via the
org.sonarqubeGradle plugin (./gradlew sonar, normally run byiru-setup-kmp-github-workflows' CI). Recommendcloudonly when open source andvisibilityis public (or unset); otherwise state SonarCloud is free only for public/open-source projects (private needs a paid plan), recommendnone, offerself-hosted(Community Build free, you run the server). Withci: no, say no CI would send reports (only a manual./gradlew sonarwith a token) and recommendnone; if still chosen, write the block and note it in Step 8.cloud→ asksonar.organization(suggest<owner>-github), defaultsonar.projectKey<owner>_<repo>and hosthttps://sonarcloud.io, confirming both;self-hosted→ ask host, key, and organization only if that server uses organizations;none→ no plugin, no block. - distribution —
none/internal/store. Informational for this skill (see Step 0); defaultstorewhen open source, otherwise recommendinternal. Say plainly that the release signing config is always written (it only activates when the fourSIGNING_*env vars exist) and that the Play/Firebase/TestFlight steps are generated byiru-setup-kmp-github-workflows.
Step 3 — Infer repository information
Parse git remote get-url origin (git@host:owner/repo.git or https://host/owner/repo.git) for <owner>/<repo>
(Sonar defaults, project-name default). No origin → ask.
Step 4 — Look up toolchain versions
Look every version up at run time; fall back to this table (verified working together on 2026-10-15) only when
a lookup fails, and say so in Step 8. Resolve AGP/Kotlin/Compose Multiplatform/Compose BOM as a set and re-run Step
7 once before trusting a newer combination — bare "latest of each" is not a verified combination (the same
discipline iru-setup-android-app documents).
| Component | Lookup | October 2026 fallback |
|---|---|---|
| Gradle | https://services.gradle.org/versions/current (version, checksum = distribution SHA-256) |
9.8.1, sha256 dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f |
AGP (also com.android.kotlin.multiplatform.library, com.android.lint) |
https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle/maven-metadata.xml — highest non-alpha/beta/rc, sort -V |
9.4.1 |
Kotlin (KMP plugin, Compose compiler plugin, kotlin-test) |
https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin/maven-metadata.xml (never search.maven.org solrsearch — stale) |
2.4.21 |
Compose Multiplatform (ui: compose) |
https://plugins.gradle.org/m2/org/jetbrains/compose/org.jetbrains.compose.gradle.plugin/maven-metadata.xml — sort -V, the file's own order is not chronological |
1.12.2 |
Compose Multiplatform Material 3 (ui: compose, versioned separately since CMP 1.10) |
https://repo1.maven.org/maven2/org/jetbrains/compose/material3/material3/maven-metadata.xml |
1.9.0 |
AndroidX Compose BOM (ui: native) |
https://dl.google.com/dl/android/maven2/androidx/compose/group-index.xml (compose-bom versions=) |
2026.09.00 |
androidx.activity:activity-compose |
https://dl.google.com/dl/android/maven2/androidx/activity/group-index.xml |
1.13.0 |
androidx.test:core-ktx |
https://dl.google.com/dl/android/maven2/androidx/test/group-index.xml |
1.7.0 |
| JUnit 4 | Maven Central junit/junit |
4.13.2 |
| Robolectric | https://repo1.maven.org/maven2/org/robolectric/robolectric/maven-metadata.xml |
4.17 (tests pinned to sdk=35, see quirks) |
AndroidX Compose UI (ui: compose — ui-test-manifest for the Robolectric UI tests; ui: native takes it from the BOM) |
https://dl.google.com/android/maven2/androidx/compose/ui/ui-test-manifest/maven-metadata.xml — highest stable, at most the line Compose Multiplatform maps to |
1.12.1 |
| detekt Gradle plugin | https://plugins.gradle.org/m2/io/gitlab/arturbosch/detekt/io.gitlab.arturbosch.detekt.gradle.plugin/maven-metadata.xml (dev.detekt 2.x is still alpha — don't adopt it by default) |
1.23.8 |
| ktlint Gradle plugin | https://plugins.gradle.org/m2/org/jlleitschuh/gradle/ktlint/org.jlleitschuh.gradle.ktlint.gradle.plugin/maven-metadata.xml |
14.2.0 |
| Kover | https://plugins.gradle.org/m2/org/jetbrains/kotlinx/kover/org.jetbrains.kotlinx.kover.gradle.plugin/maven-metadata.xml (sort -V — plain tail returns 0.9.9 before 0.9.11) |
0.9.11 |
| Dokka | https://repo1.maven.org/maven2/org/jetbrains/dokka/dokka-gradle-plugin/maven-metadata.xml |
2.2.0 |
SonarQube Gradle plugin (sonar ≠ none) |
https://plugins.gradle.org/m2/org/sonarqube/org.sonarqube.gradle.plugin/maven-metadata.xml |
7.5.0.8588 |
| compileSdk / targetSdk | the highest platforms/android-NN* in the local SDK, and at least what the Compose libraries' AAR metadata demand (see quirks) |
37 / 37 |
| iOS deployment target | the lowest installed iOS simulator runtime that the Compose/Kotlin-Native binaries are built for — xcrun simctl list runtimes available |
18.5 |
| XcodeGen (never installed by this skill) | https://formulae.brew.sh/api/formula/xcodegen.json .versions.stable |
2.46.0 |
| SwiftLint (never installed) | https://formulae.brew.sh/api/formula/swiftlint.json |
0.65.1 |
| Xcode (informational) | xcodebuild -version |
27.0 (27A266a) |
JDK: Gradle and AGP run on JDK 17 or 21 — both verified (export JAVA_HOME=$(/usr/libexec/java_home -v 21)); the
JVM target written into the build is 17.
Step 5 — Reference templates
Read the four reference files (table above). Placeholders:
| Placeholder | Source |
|---|---|
<project-name> |
Step 0/3 |
<namespace> / <package-path> |
Step 2 (<package-path> = dots → /; directory segment __pkg__ in the reference headings) |
<application-id> / <bundle-id> / <app-name> / <team-id> |
Step 2 / Step 0 |
<version-name> / <version-code> |
Step 0 (defaults 1.0.0-SNAPSHOT / 1) |
<marketing-version> |
<version-name> without -SNAPSHOT (App Store Connect rejects pre-release strings) |
<min-sdk> / <compile-sdk> / <target-sdk> / <ios-deployment-target> |
Step 2/4 |
<robolectric-sdk> |
the newest SDK the resolved Robolectric runs without failing (35 with Robolectric 4.17) |
<androidx-compose-ui-version> |
Step 4 (ui: compose only) |
<accessibility> |
Step 0 (default enforce) |
<gradle-version> / <gradle-distribution-sha256> / every <…-version> in the catalog |
Step 4 |
<sonar-organization> / <sonar-project-key> / <sonar-host-url> |
Step 2 |
Directory layout produced:
settings.gradle.kts build.gradle.kts gradle.properties gradle/libs.versions.toml gradle/wrapper/* gradlew(.bat)
detekt.yml .editorconfig .ui-quality.yml
shared/build.gradle.kts
shared/src/commonMain/kotlin/<package-path>/{Platform,Greeting,GreetingUiState}.kt (+ App.kt — ui: compose)
shared/src/commonTest/kotlin/<package-path>/{GreetingTest,GreetingUiStateTest}.kt (+ GreetingScreenTest.kt, uitest/UiScreenshots.kt — ui: compose)
shared/src/androidMain/kotlin/<package-path>/AndroidPlatform.kt
shared/src/androidHostTest/kotlin/<package-path>/AndroidPlatformTest.kt (+ uitest/UiTest.android.kt — ui: compose)
shared/src/iosMain/kotlin/<package-path>/IOSPlatform.kt (+ MainViewController.kt — ui: compose)
shared/src/iosTest/kotlin/<package-path>/IOSPlatformTest.kt (+ uitest/UiTest.ios.kt — ui: compose)
androidApp/build.gradle.kts androidApp/proguard-rules.pro
androidApp/src/main/AndroidManifest.xml androidApp/src/main/res/{values,drawable,mipmap-anydpi-v26}/*
androidApp/src/main/kotlin/<package-path>/android/MainActivity.kt
androidApp/src/test/kotlin/<package-path>/android/MainActivityTest.kt androidApp/src/test/resources/robolectric.properties
androidApp/src/test/kotlin/<package-path>/android/{GreetingScreenTest,uitest/UiScreenshots}.kt (ui: native)
iosApp/project.yml iosApp/Configuration/Config.xcconfig iosApp/.swiftlint.yml
iosApp/iosApp/{MainApp,ContentView}.swift iosApp/iosApp/Info.plist (+ GreetingModel.swift — ui: native)
iosApp/iosAppTests/iosAppTests.swift (+ GreetingScreenTests.swift, UIScreenshots.swift — ui: native)
iosApp/iosApp.xcodeproj is never written or committed — xcodegen generate (run in iosApp/) creates it, and
iru-setup-kmp-gitignore ignores it (the same convention as iru-setup-apple-app). local.properties is never
written (machine-specific; tell the user to let Android Studio create it or write sdk.dir=<sdk path>).
Step 6 — Scaffold the project
Apply Step 1's per-group decision to every file that already exists (Keep as is → skip; Update → merge after showing
git diff --no-index <current> <draft>; Replace → overwrite within Step 1's source-file limit). Files that didn't
exist are always written.
- Wrapper: fetch
gradlew,gradlew.batandgradle/wrapper/gradle-wrapper.jarfromhttps://raw.githubusercontent.com/gradle/gradle/v<gradle-version>/<path>(curl -fsSL -o, the jar is binary), or rungradle wrapper --gradle-version <gradle-version>if a local Gradle exists; then writegradle-wrapper.propertiesfrom the reference (it addsdistributionSha256Sum) andchmod +x gradlew. - Write the Gradle files,
detekt.yml,.editorconfigand.ui-quality.ymlfromreference/gradle-templates.md, applying theui/sonarmarkers and<accessibility>. - Write
shared/fromreference/shared-templates.md,androidApp/fromreference/android-templates.md,iosApp/fromreference/ios-templates.md— each with__pkg__replaced by<package-path>and only theui-matching variant files. - If
team-idis empty, say thatConfig.xcconfig'sTEAM_ID=must be filled in (or set in Xcode) before a device/archive build; simulator builds and tests don't need it.
Step 7 — Verify
Run through the iru-gate-runner agent (never inline — the logs are long), with JAVA_HOME on JDK 17 or 21 and
ANDROID_HOME/local.properties pointing at an SDK whose licenses are accepted. Warn first: the first run downloads
Gradle, the Android platform, the Kotlin/Native toolchain (1 GB under `/.konan`) and every dependency — several
minutes.
./gradlew build— compiles every target, runsallTests(on macOS this includesiosSimulatorArm64Test, which boots a simulator — serialize it behind this catalog's.integration-test.lockwhen other test stages may run concurrently), Android Lint (lintDebug/:shared:lint) and the debug/release assembles../gradlew koverXmlReport koverVerify detekt ktlintCheck :androidApp:lint :shared:lint :shared:dokkaGenerate :androidApp:assembleDebug— the gate set;koverVerifyenforces the 80% line bound.macOS only (otherwise report each as "unverified locally", never "passed"):
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64;cd iosApp && xcodegen generate(ifxcodegenis not installed, say so —brew install xcodegen— and mark the Xcode steps unverified; never install it yourself);swiftlint lint --strictiniosApp/when installed; thenxcodebuild test -project iosApp/iosApp.xcodeproj -scheme iosApp -destination 'platform=iOS Simulator,name=<device>' CODE_SIGNING_ALLOWED=NO, where<device>is an iPhone listed under the newest runtime inxcrun simctl list devices available(a barename=resolves againstOS:latest; a device that only exists on an older runtime fails with "Unable to find a device"). This also exercises theembedAndSignAppleFrameworkForXcodepre-build phase. Serialize it behind.integration-test.locktoo.UI captures: after 1–3,
.ui-screenshots/screen/GreetingScreen/must hold one PNG per state (default,long-content,rtl-content) ×ltr/rtl×light/darkper platform —--androidfromtestAndroidHostTest(ui: compose) ortestDebugUnitTest(ui: native),--iosfromiosSimulatorArm64Test(ui: compose) or thexcodebuild testrun (ui: native; addTEST_RUNNER_UI_SCREENSHOTS_DIR=<dir>to send them elsewhere). A re-run after deleting the directory needs--rerunafter each Gradle test task — Gradle restores up-to-date/cached test tasks without executing them, so no captures are written. iOS captures on a non-macOS host are "unverified locally".
Report pass/fail per command; quote error output verbatim for a failure only.
Step 8 — Report
Summarize: resolved ui, app-name, application-id, bundle-id, namespace, SDK levels, iOS deployment target,
VERSION_NAME/VERSION_CODE/MARKETING_VERSION, license (or none), and whether the run scaffolded from scratch,
updated an existing project, or stopped. Then, per Step 1 group, the action taken — created / updated / replaced /
kept as is — and whether it came from the user or on-existing, listing each mismatch of a kept group and, for a
replaced one, what customizations could have been lost (check git diff). .ui-quality.yml is listed with its
action and the accessibility level it holds.
State what was included versus omitted and why: ui; open-source/sonar (the coordinates written, or why none;
with ci: no, that no CI step sends reports); distribution (informational — release jobs come from
iru-setup-kmp-github-workflows; the SIGNING_STORE_FILE/SIGNING_STORE_PASSWORD/SIGNING_KEY_ALIAS/
SIGNING_KEY_PASSWORD env vars activate release signing); which Step 4 versions were live versus fallback; and any
ignored args keys.
Scaffold contract (confirmed by running it — downstream skills depend on these)
| What | Exact name / path |
|---|---|
| Shared Android host tests | :shared:testAndroidHostTest (source set androidHostTest, enabled by withHostTest {}) → shared/build/test-results/testAndroidHostTest/*.xml; :shared:testAndroid also exists |
| Shared iOS tests | :shared:iosSimulatorArm64Test → shared/build/test-results/iosSimulatorArm64Test/; :shared:allTests runs every target |
| Android app tests | :androidApp:testDebugUnitTest → androidApp/build/test-results/testDebugUnitTest/*.xml (Robolectric, sdk=35) |
| Compile tasks | :shared:compileAndroidMain, :shared:compileKotlinIosSimulatorArm64, :androidApp:compileDebugKotlin |
| Coverage | root koverXmlReport → build/reports/kover/report.xml (aggregates :shared + :androidApp; Composable/ComposableSingletons/BuildConfig excluded there and in each module's own kover {} block, so module and root numbers agree — 100% for both variants); koverVerify (80% line); per-module :shared:koverXmlReport / :shared:koverXmlReportAndroid, :androidApp:koverXmlReportDebug; koverPrintCoverage |
| detekt (1.23.8) | detekt per module with source set to every KMP source dir → <module>/build/reports/detekt/detekt.{xml,sarif}; type-resolution tasks :shared:detektAndroidMain, detektAndroidHostTest, detektIosArm64Main, detektIosSimulatorArm64Main, detektIosArm64Test, detektIosSimulatorArm64Test (no detektMetadataCommonMain task was registered in this combination) |
| ktlint | ktlintCheck (per source set: ktlintCommonMainSourceSetCheck, ktlintAndroidMainSourceSetCheck, ktlintIosMainSourceSetCheck, …, ktlintKotlinScriptCheck) → <module>/build/reports/ktlint/ |
| Android Lint | :androidApp:lint/lintDebug → androidApp/build/reports/lint-results-debug.{xml,sarif,html}; :shared:lint (via com.android.lint) → shared/build/reports/lint-results-androidMain.{xml,sarif} |
| Dokka 2 | :shared:dokkaGenerate (= dokkaGeneratePublicationHtml) → shared/build/dokka/html/index.html |
| iOS framework | :shared:linkDebugFrameworkIosSimulatorArm64 → shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework; Xcode phase :shared:embedAndSignAppleFrameworkForXcode → shared/build/xcode-frameworks/<CONFIGURATION>/<SDK_NAME>/ |
| iOS app | iosApp/project.yml → xcodegen generate → iosApp/iosApp.xcodeproj, scheme iosApp, test target iosAppTests, module iosApp |
| Versions | gradle.properties VERSION_NAME/VERSION_CODE; iosApp/Configuration/Config.xcconfig MARKETING_VERSION/CURRENT_PROJECT_VERSION |
| Debug APK | androidApp/build/outputs/apk/debug/androidApp-debug.apk |
Known quirks / verification notes
- Verified for real on 2026-10-15 in
$TMPDIR/iru-verify/kmp/iru-setup-kmp-app/composeand.../native(outside this repository), both withsonar: cloud(dummy coordinates — thesonartask itself was never run):./gradlew build,koverXmlReport koverVerify detekt ktlintCheck :androidApp:lint :shared:lint :shared:dokkaGenerate :androidApp:assembleDebug :androidApp:assembleRelease(unsigned — noSIGNING_*vars),:shared:allTests,:shared:linkDebugFrameworkIosSimulatorArm64,xcodegen generate(2.46.0, downloaded to a temp directory — not installed),swiftlint lint --strict(0.65.1, same), andxcodebuild teston an iPhone 18 Pro (iOS 27.0) simulator: 2 XCTest cases passed per variant. Root Kover line coverage 100% for both variants (compose: 27/27 lines, 7 classes incl.AppKtexecuted through the RobolectricMainActivitytest). Before the exclusions were repeated inshared's ownkover {}block,:shared:koverPrintCoveragealone reported 44% forui: compose(the composables are only executed by the app's Robolectric test) — keep the module-level filters. Thenativevariant was run on JDK 17,composeon JDK 21. - compileSdk must be ≥ 37 with this Compose Multiplatform/BOM generation: at 36,
checkDebugAarMetadataand:shared:checkAndroidMainAarMetadatafail ("Dependency 'androidx.compose.animation:animation-core-android:1.12.1' requires … version 37 or later"). Resolve compileSdk from the AAR metadata, not from habit. - Robolectric 4.17 + SDK 36 fails on JDK 21 (
Failed to interact with raw FileDescriptor internals) — the test is pinned tosdk=35inrobolectric.properties, independent of compileSdk/targetSdk. org.jetbrains.compose.ui.tooling.preview.Previewno longer exists in CMP 1.12 — the common@Previewisandroidx.compose.ui.tooling.preview.Preview(fromorg.jetbrains.compose.ui:ui-tooling-preview).- detekt
MatchingDeclarationNamefails on the wizard'sPlatform.android.kt/Platform.ios.ktnaming — the actuals live inAndroidPlatform.kt/IOSPlatform.ktinstead. - ktlint
chain-method-continuation(ktlint_official) flagslibs.versions.android.compileSdk.get().toInt()in build scripts —.editorconfigdisables that one rule for*.gradle.ktsonly. - AGP 9 lint DSL:
lint { xmlReport/sarifReport }are deprecated no-ops (reports are always written) — omitted. - The AGP KMP library plugin registers no
linttask;com.android.lintapplied tosharedadds:shared:lint. kotlin { android { withHostTest {} } }is the verified form (the coding agent also confirmed thewithHostTestBuilder {}spelling);androidHostTestis the source set either way. A Compose UI test (runComposeUiTest) incommonTestNPEs on the Android host test without Robolectric — which is whyui: composeruns its host tests under Robolectric through theUiTestbase class (see the UI contract wiring note below).- Kotlin default arguments are invisible from Swift —
Greetinghas an explicit no-arg constructor andGreetingUiState.Companionaninitial()overload, so Swift callsGreeting()/GreetingUiState.companion.initial(). - XcodeGen/TEST_HOST: renaming the app's
PRODUCT_NAME(e.g. to$(APP_NAME)) breaksiosAppTests("Could not find test host … iosApp.app/iosApp"); the product staysiosApp.appand the launcher label comes fromCFBundleDisplayName.ENABLE_USER_SCRIPT_SANDBOXING = NOis required for the Gradle pre-build phase. The test target only getsFRAMEWORK_SEARCH_PATHS(no-framework Shared) — the static framework is already linked into the host app. - iOS deployment target 18.0 produced
ld: warning: … built for newer 'iOS-simulator' version (18.5)from the Compose/Skia objects — 18.5 links clean. xcodebuild -destination 'platform=iOS Simulator,name=iPhone 16 Pro'failed although that device exists — only on the iOS 18.5 runtime; withoutOS=the newest runtime is assumed. Pick the device from the newest runtime.xcodebuild build -destination 'generic/platform=iOS Simulator'fails in the Gradle pre-build phase with "Xcode Requested Architecture Not Configured in Gradle … ios_x64" — a generic simulator build asks for x86_64 too. AddARCHS=arm64(verified), use a concrete simulator destination, or add aniosX64()target for Intel Macs.- SwiftLint:
unused_importbelongs underanalyzer_rules;sorted_importssorts@testable import iosAppfirst. - UI contract wiring verified on 2026-10-16 in
$TMPDIR/iru-verify/kmp/ui-contract/scaffold-{compose,native}(Robolectric 4.17,androidx.compose.ui:ui-test-manifest1.12.1, Xcode 27.0, iPhone 18 Pro on iOS 27.0):ui: compose—GreetingScreenTestincommonTest(androidx.compose.ui.test.v2.runComposeUiTest, extending theUiTestbase) passed undertestAndroidHostTestandiosSimulatorArm64Testand wrote 12--android+ 12--iosPNGs;./gradlew build koverVerify detekt ktlintCheck :shared:dokkaGeneratestayed green and Kover stayed at 100% (27/27 lines).ui: native— the RobolectricGreetingScreenTestwrote 12--androidPNGs,xcodebuild testran theImageRenderertest and wrote 12--iosPNGs (toTEST_RUNNER_UI_SCREENSHOTS_DIR, and to<repo>/.ui-screenshotsthrough#filePathwithout it),swiftlint lint --strictclean, Kover 100%. Findings baked into the templates:runComposeUiTestwithout Robolectric NPEs on the Android host;setContentmay be called only once per test on Android (the matrix drives amutableStateOf); the simulator only seesSIMCTL_CHILD_-prefixed variables;-Xexpect-actual-classessilences the Beta warning forexpect abstract class UiTest; Material'sButtonis 40dp tall but its touch bounds are 48dp (the check usestouchBoundsInRoot); ATF'senableAccessibilityChecks()exists only onComposeTestRuleand reported nothing under Robolectric, so the host tests use the semantics check instead. Not verified: an iOS accessibility audit forui: native(needs an XCUITest target the scaffold doesn't add). - Unverified locally, never a blocker: the
sonartask (needs a token and project), signed release builds, device/archive builds (need a Team ID and certificates), and anything distribution-related.
Finish with a Warn explicitly block:
- Review every generated file before building, committing or distributing; replace the placeholder launcher icon and
Greetingsample with real code. - Run
iru-setup-kmp-gitignore—iosApp/iosApp.xcodeproj,local.properties,.kotlin/, keystores and.p8keys must never be committed. Every clone runscd iosApp && xcodegen generatebefore opening Xcode. - Never commit a keystore; release signing reads the four
SIGNING_*env vars (CI decodes them from secrets). - With
sonar≠none, aSONAR_TOKENsecret and the Sonar project must exist before./gradlew sonarworks.