Engineering

appleMain exists only if your targets justify it

The hierarchy template inserts nodes based on declared targets. A directory for a node that does not exist compiles nowhere and warns about nothing.

MobileByteSensei 10 min read
kmpgradlearchitecture

TL;DR

  • A file in src/appleMain/kotlin/ compiled into nothing, for five weeks, in a module whose only Apple target was iosArm64. No error, no warning, no "source set not found" — the directory was simply never part of any compilation.
  • The default hierarchy template inserts an intermediate node only when two or more of its children are declared. One Apple target means no appleMain, so the directory is just a directory on disk.
  • The symptom was a missing actual, reported at link time against a target that had never seen the file.
  • What reading on buys: how to ask Gradle what source sets actually exist, the one-line config that makes an undeclared directory an error, and why dependsOn by hand is the wrong fix.

The case this post is about

A module with one Apple target, because that is all the product shipped:

// core/keychain/build.gradle.kts
kotlin {
    androidTarget()
    iosArm64()                 // device only — the iOS team builds on device
    jvm("desktop")

    sourceSets {
        commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) }
    }
}

And a secure-storage bridge, authored where it seemed to belong:

// core/keychain/src/commonMain/kotlin/SecureStore.kt
expect class SecureStore {
    fun put(key: String, value: String)
    fun get(key: String): String?
}
// core/keychain/src/appleMain/kotlin/SecureStore.apple.kt   ← the file in question
import platform.Security.*

actual class SecureStore {
    actual fun put(key: String, value: String) { /* SecItemAdd … */ }
    actual fun get(key: String): String? { /* SecItemCopyMatching … */ }
}

appleMain because the implementation is Keychain Services, which is shared by every Apple platform — the reasoning was that it would still be right when macOS arrived.

The symptom

Five weeks later, the first iOS build after someone touched the module:

e: Expected class 'SecureStore' has no actual declaration in module <core:keychain> for 'Native'

Which reads as "you forgot to write the actual" — and the actual was right there, in the repository, in a directory named after the platform, with correct Keychain code in it.

The thing that made it survive five weeks is that nothing ever compiled it, so nothing ever complained:

# what touched src/appleMain/kotlin/SecureStore.apple.kt, 5 weeks
  git commits                              3
  compiler errors from the file            0
  compiler warnings                        0
  IDE errors (it resolves fine)            0
  compilations it participated in          0

Three commits to a file that had never been compiled. The IDE resolved it because IntelliJ indexes the directory regardless; it was the build that had no opinion.

And the search that followed looked in the wrong place for a day and a half:

  "is the KSP processor skipping it?"            4h
  "is this an incremental-compilation stale?"    3h   (clean build, same error)
  "is the expect in the wrong source set?"       2h
  asking Gradle what source sets exist          10m   ← the answer

Five weeks of a file receiving three commits while participating in zero compilations, with every available signal reporting nothing wrong

Why the obvious fix failed

The first fix was to add macosArm64() so appleMain would exist:

iosArm64()
macosArm64()               // "now appleMain has two children"

Which works, and ships a macOS target the product does not have — a whole extra link, test and publish surface added to make a directory name valid. It also only defers the question: a module with one Apple target is a legitimate shape, and it should not require a fictional second target.

The second fix was to declare the node by hand:

sourceSets {
    val appleMain by creating { dependsOn(commonMain.get()) }
    iosArm64Main.get().dependsOn(appleMain)
}

This compiles and is the thing you will find in older answers. It also fights the hierarchy template rather than using it — the moment a second Apple target is declared, the template inserts its own appleMain and you have two nodes with the same intent, and the error for that is about a duplicated dependsOn edge rather than about your manual node.

The third attempt was to rename the directory to iosArm64Main, which is correct and loses the thing the file was written for: the code is Keychain Services, which iosMain/appleMain describes and iosArm64Main does not.

The actual mechanism

The default hierarchy template is a tree of potential nodes, and a node is materialised by the targets you declare — not by directories on disk.

A node appears when the template's rule for it is satisfied. appleMain sits above iosMain, macosMain, tvosMain, watchosMain; iosMain sits above iosArm64Main, iosSimulatorArm64Main, iosX64Main. The template inserts an intermediate node when more than one of its children is present — a node with a single child would add a level that has exactly the same contents as the child, so the template collapses it:

  declared targets                       nodes that exist
  iosArm64                               iosArm64Main                          ← no iosMain, no appleMain
  iosArm64 + iosSimulatorArm64           iosMain → both                        ← still no appleMain
  iosArm64 + iosSimulatorArm64 + macosArm64
                                         appleMain → iosMain → both, + macosMain

So our module — one Apple target — had iosArm64Main and nothing above it. appleMain was never a source set.

And a directory for a source set that does not exist is not an error. Gradle source sets are declared, and their directory conventions follow from the declaration. There is no reverse mapping that scans src/ and asks whether each directory corresponds to something: an unclaimed directory is indistinguishable from src/notes/ or src/old-backup/. Nothing is looking.

Which makes the failure arrive at the wrong layer. The compiler for iosArm64 sees a commonMain expect with no actual in its own tree, and reports exactly that. It cannot report "there is an actual in a directory nobody compiled", because from its position that directory does not exist.

The -Xexpect-actual-classes warning is unrelated, which cost us an hour: it fires on matched expect/actual classes to flag that the feature is Beta. Its absence is not a signal that nothing is matched.

Four target declarations tested against which intermediate nodes the hierarchy template materialises, showing the more-than-one-child rule and a case where appleMain appears without iosMain

The fix

Ask the build what exists, author into a node that does, and make an unclaimed directory fail the build rather than sit there.

// core/keychain/build.gradle.kts
kotlin {
    androidTarget()
    iosArm64()
    jvm("desktop")

    // The template is applied by default; naming it here is documentation rather than a change.
    applyDefaultHierarchyTemplate()

    sourceSets {
        commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) }
    }
}

/**
 * An unclaimed src/<name>Main directory fails the build.
 *
 * src/appleMain/kotlin held a correct Keychain actual for five weeks and compiled into nothing,
 * because one Apple target means the template never materialises appleMain. Three commits
 * landed on a file that participated in zero compilations — no error, no warning, and the IDE
 * resolves it, so every signal available to a human said it was fine.
 *
 * This compares the directories on disk against the source sets that actually exist. It runs
 * in a doLast rather than as a compile-time check because there is no compiler hook for "a
 * directory nobody asked about".
 */
val checkSourceSetDirs by tasks.registering {
    // Read EAGERLY, here, at configuration time — plain values, not providers. A
    // `provider { kotlin.sourceSets… }` reads identically and is not the same thing: it
    // evaluates inside doLast, which captures the Kotlin extension and so the Project, and
    // makes this task configuration-cache incompatible. One such task disables the cache for
    // the entire build, which is a steep price for a check that runs once.
    val declared: Set<String> = kotlin.sourceSets.map { it.name }.toSet()

    // Only *Main / *Test names are claimed by the Kotlin plugin; src/ also legitimately holds
    // things like generated/, so an unknown-shaped directory is ignored rather than guessed at.
    val suspicious: List<String> = (layout.projectDirectory.dir("src").asFile
        .listFiles()?.filter { it.isDirectory }?.map { it.name } ?: emptyList())
        .filter { it.endsWith("Main") || it.endsWith("Test") }
        .filterNot { it in declared }

    doLast {
        if (suspicious.isNotEmpty()) {
            throw GradleException(
                "src/ holds directories for source sets that do not exist: $suspicious\n" +
                "  declared: ${declared.sorted()}\n" +
                "  These compile into NOTHING. Either declare the targets that materialise the " +
                "node (e.g. a second Apple target creates appleMain) or move the code into a " +
                "source set that exists."
            )
        }
    }
}

tasks.named("check") { dependsOn(checkSourceSetDirs) }

And the file moves to the node that does exist, with the reason recorded so the next person does not move it back:

// core/keychain/src/iosArm64Main/kotlin/SecureStore.ios.kt
//
// iosArm64Main, not appleMain: this module declares ONE Apple target, so the template
// materialises no iosMain and no appleMain. If a second Apple target is added, this moves up —
// checkSourceSetDirs will not complain either way, but `./gradlew printSourceSets` will show
// the node appearing.
import platform.Security.*

actual class SecureStore { /* … */ }

Plus the ten-minute diagnostic that should have been the first thing we ran:

// build-logic — available in every module
val printSourceSets by tasks.registering {
    doLast {
        kotlin.sourceSets.sortedBy { it.name }.forEach { ss ->
            val parents = ss.dependsOn.map { it.name }.sorted()
            println("${ss.name}${if (parents.isEmpty()) "" else "  →  $parents"}")
        }
    }
}

And the output it produced:

$ ./gradlew :core:keychain:printSourceSets -q
androidMain      →  [commonMain]
androidUnitTest  →  [commonTest]
commonMain
commonTest
desktopMain      →  [commonMain]
desktopTest      →  [commonTest]
iosArm64Main     →  [commonMain]        ← straight to commonMain. No iosMain. No appleMain.
iosArm64Test     →  [commonTest]

$ ./gradlew :core:keychain:check
FAILURE: src/ holds directories for source sets that do not exist: [appleMain]
  declared: [androidMain, androidUnitTest, commonMain, commonTest, desktopMain, desktopTest, iosArm64Main, iosArm64Test]
  These compile into NOTHING. …

$ # after moving the file
$ ./gradlew :core:keychain:check :core:keychain:compileKotlinIosArm64
BUILD SUCCESSFUL in 41s

And across the whole repository, run once:

$ ./gradlew check --continue 2>&1 | grep 'source sets that do not exist' -A1
core:keychain      [appleMain]
core:analytics     [nativeMain]        ← same class, different node: one native target
feature:scanner    [iosMain]           ← and one we had not noticed at all

Three modules. Two we did not know about.

How we proved it

That the node genuinely did not exist — the measurement that ended a day and a half of looking at KSP and incremental compilation:

$ ./gradlew :core:keychain:printSourceSets -q | grep -c apple
0

That the file participated in zero compilations. Established by asking the compile task what it was given, rather than inferring from the error:

$ ./gradlew :core:keychain:compileKotlinIosArm64 --info 2>&1 | grep -c 'SecureStore.apple.kt'
0
$ ./gradlew :core:keychain:compileKotlinIosArm64 --info 2>&1 | grep -c 'SecureStore.kt'
1                                                  ← the expect, from commonMain

That the template's rule is "more than one child", tested by declaring targets and reading back the tree rather than from the documentation:

  declared                                       appleMain?   iosMain?
  iosArm64                                        no           no
  iosArm64, iosSimulatorArm64                     no           yes
  iosArm64, macosArm64                            yes          no
  iosArm64, iosSimulatorArm64, macosArm64         yes          yes

Row three is the interesting one: two Apple targets on different Apple platforms materialise appleMain without materialising iosMain, because iosMain then has one child.

That an unclaimed directory produces no signal at all. The full inventory, because the absence of every one of these is why it lasted five weeks:

  signal                                   present?
  Kotlin compiler error                    no
  Kotlin compiler warning                  no
  Gradle configuration warning             no
  IDE red squiggle                         no  (IntelliJ indexes the directory)
  `./gradlew check`                        no  (before this change)
  git history                              no  (3 commits, all clean)

That dependsOn by hand collides with the template. Tried, since it is the fix most answers give:

$ # manual appleMain, then add iosSimulatorArm64()
e: Duplicate 'dependsOn' edge: iosArm64Main -> commonMain
   (via appleMain and via iosMain)

Which names neither the manual node nor the template, and sends the next person looking at commonMain.

What I cannot tell you is whether the check task catches a directory nested deeper — src/appleMain/kotlin/ is checked by its top-level name, so src/ios/appleMain/ would pass. We have no such directory and the check is deliberately shallow; a recursive scan would have to decide what src/androidMain/res/ means, and a false positive on a build-breaking check is worse than the gap.

And an honest consequence. The file now lives in iosArm64Main, which is less right than appleMain as a description of Keychain Services. When macOS arrives it has to move, and nothing reminds anyone — printSourceSets will show appleMain appearing, but only if someone runs it.

The compile task asked what files it was given, the manual parent node colliding with the template, and a repository-wide run finding the same class in three modules

What this generalizes to

A directory is not a declaration. When a build system materialises its structure from configuration, a directory that corresponds to nothing is invisible to every tool in the chain — the compiler never sees it, so it cannot warn, and the IDE indexes it, so it looks fine. The general move is to ask the build what it believes exists (printSourceSets here; the equivalent in any system is a task that dumps the resolved model) and then to make the mismatch between belief and disk an error, because the gap is the one thing no existing signal covers.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS