From 4449d372a927522466b66d103dc782f3fa26c4a5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:01:17 +0900 Subject: [PATCH 1/2] Test: Pin the extension-function receiver decline (#66) and Icons.Default.Add in the stubs (#68) consumer.py asserts the type with assert_type: the pre-#67 stubs (run 37097934282) fail it with 'Expression is of type Any, not ImageVector'; the #67 stubs (run 37098191598) pass. --- docs/SPEC.md | 4 +-- .../gradle/artifact/PropertyBindingTest.kt | 26 ++++++++++++++ .../propertyfixture/PropertyFixtures.kt | 3 ++ .../gradle/stubs/ObjectStubTest.kt | 36 +++++++++++++++++++ tools/stubs/consumer.py | 11 +++++- 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 6ea3e21b3..8e7bf1eac 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -220,7 +220,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. for a public setter with every class type parameter unbounded, `SETTER` entries `Owner.prop=`; and top-level extension property getters (`pkg.prop`, receiver in `receiverTypeName`). Properties take no part in overload naming or in a constructor's name check; a property key another binding already - holds is declined, and so is a property whose receiver has a supertype missing from the consumer's compile classpath (kotlinc cannot build its member scope). `Status: implemented` — `GP/artifact/PropertyBindingTest.kt`. + holds is declined, and so is a property or an extension function whose receiver has a supertype missing from the consumer's compile classpath (kotlinc cannot build its member scope; the reason names the missing supertype, #66). `Status: implemented` — `GP/artifact/PropertyBindingTest.kt` (properties and, #66, extension functions). The public instance functions of a Kotlin-public `object` bind under the object's name like a static (`Arrangement.spacedBy`, #53), except `Any`'s members, ones that also exist as a `@JvmStatic` static, and `@Composable` ones (declined); an `internal` or file-private object is @@ -268,7 +268,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. a comment with the same marker says so (`GP/stubs/PropertyStubTest.kt`). An object constant is annotated with its declared type even when that type is nested in the object (`Alignment.End: Horizontal`, a class of the object's own module; elsewhere `androidx.compose.ui.Alignment.Horizontal`); the object's own type - stays `Any` (#53, `GP/stubs/ObjectStubTest.kt`). + stays `Any` (#53, `GP/stubs/ObjectStubTest.kt`). An extension property whose receiver is a type nested in such an object is a `@property` of that nested class in the object's module (`Icons.Default: Filled`, `Filled.Add: ImageVector`, #68), not a marker comment in the package module. - **B-8** CI generates the stubs over the Compose version the build resolves and publishes them (`.github/workflows/stubs.yml`): workflow artifact `kotlin-stubs` on every push to `develop`, with a README naming the Compose version and the commit, and `kotlin-stubs.zip` on every `v*` tag's release. diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/PropertyBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/PropertyBindingTest.kt index 92b4b99d2..c7aadb5f3 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/PropertyBindingTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/PropertyBindingTest.kt @@ -207,6 +207,32 @@ class PropertyBindingTest { assertTrue("fixture.artifactproperty.reach" in withSupertype, withSupertype.toString()) } + /** + * #66: an extension *function* on the same receiver goes through the same cast, so it is declined + * the same way and bound once the classpath carries the supertype. + * + * Red before the fix: `probe` is bound (the first assertion fails). + */ + @Test + fun aReceiverWithASupertypeOffTheClasspathHasItsExtensionFunctionsDeclined() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.probe" !in names, names.toString()) + + val probe = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + .single { it.simpleName == "probe" } + assertEquals(null, probe.bindingName, probe.toString()) + val reason = assertNotNull(probe.declineReason, probe.toString()) + assertTrue("org.objectweb.asm.Opcodes" in reason && "classpath" in reason, reason) + + val asm = File(org.objectweb.asm.Opcodes::class.java.protectionDomain.codeSource.location.toURI()) + val withSupertype = ArtifactScanner.scanJar( + fixtureClasses, + includePrefixes = listOf("fixture.artifactproperty"), + classpath = listOf(fixtureClasses, asm), + ).map { it.name } + assertTrue("fixture.artifactproperty.probe" in withSupertype, withSupertype.toString()) + } + // -------------------------------------------------------------------------- the fragment /** A property is not an extension slot at the boundary: `isExtension = false`, receiver still named. */ diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/propertyfixture/PropertyFixtures.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/propertyfixture/PropertyFixtures.kt index 794a1edcb..114e11e9d 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/propertyfixture/PropertyFixtures.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/propertyfixture/PropertyFixtures.kt @@ -87,3 +87,6 @@ class Detached : org.objectweb.asm.Opcodes { /** The same receiver, read through an extension property. */ val Detached.reach: Int get() = level + +/** The same receiver, called through an extension function (#66). */ +fun Detached.probe(): Int = level diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt index 152989ca3..b12bf797a 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt @@ -128,4 +128,40 @@ class ObjectStubTest { assertTrue("Start: Horizontal" in text, text) assertFalse("_t.Any" in text, text) } + + // ------------------------------------------- an extension property on a type nested in an object + + /** + * #68: `Icons.Default.Add`. `Default` is an object constant of `Icons` typed `Icons.Filled`, and `Add` + * is an extension property declared in the package `...icons.filled` on that nested type. The stub + * class is `Filled` in the object's own module `...icons.Icons` (#53), and `Add` is its property. + * + * Not red against develop at fa2558e3: #53 already resolves the receiver into the object's module, + * and the regenerated Compose stubs (CI run 37098191598) carry `Default: Filled` and + * `Filled.Add`. The marker comment of #68 came from the stubs built before #53. This pins it. + */ + @Test + fun anExtensionPropertyOnATypeNestedInAnObjectIsAttributeOfThatTypesClass() { + val icons = "androidx.compose.material.icons.Icons" + val filled = KotlinTypeModel("$icons.Filled") + val default = constant("$icons.Default", filled) + val add = DeclarationModel( + simpleName = "Add", + owner = "androidx.compose.material.icons.filled", + ownerIsClass = false, + receiver = filled, + receiverBoundaryTag = "OBJECT", + parameters = emptyList(), + returnType = KotlinTypeModel("androidx.compose.ui.graphics.vector.ImageVector"), + returnBoundaryTag = "OBJECT", + bindingName = "androidx.compose.material.icons.filled.Add", + kind = "GETTER", + ) + val files = renderKotlinFqnStubs(listOf(default, add)) + val text = file(files, icons) + assertTrue(Regex("""^Default: Filled$""", RegexOption.MULTILINE).containsMatchIn(text), text) + assertTrue("class Filled:" in text, text) + assertTrue(" def Add(self) -> androidx.compose.ui.graphics.vector.ImageVector:" in text, text) + files.values.forEach { assertFalse("is not stubbed" in it, it) } + } } diff --git a/tools/stubs/consumer.py b/tools/stubs/consumer.py index c2d94cb62..2a6fc976e 100644 --- a/tools/stubs/consumer.py +++ b/tools/stubs/consumer.py @@ -7,9 +7,13 @@ and mypy runs with `warn_unused_ignores`, so a stub that stopped rejecting one fails the check too. """ +from typing import assert_type + from androidx.compose.foundation.layout import Column, Row, fillMaxWidth, padding__Dp -from androidx.compose.material3 import Checkbox, Text +from androidx.compose.material.icons import Icons +from androidx.compose.material3 import Checkbox, Icon, Text from androidx.compose.ui import Modifier +from androidx.compose.ui.graphics.vector import ImageVector # A chain started from the class, the way Kotlin writes `Modifier.padding(16.dp)`. chain: Modifier = Modifier.fillMaxWidth().padding(16.0).size(24.0) @@ -40,6 +44,11 @@ def content() -> None: Column(modifier=chain, content=lambda scope: None) Row(content=lambda scope: None) +# An extension property on a type nested in an object (issue #68): `Icons.Default` is `Icons.Filled`. +# `assert_type`, not an annotation: an `Any` (the stub before #53/#67) would satisfy an annotation. +assert_type(Icons.Default.Add, ImageVector) +Icon(Icons.Default.Add, contentDescription=None) + # Rejected on purpose. (`padding("sixteen")` is not among them: one `padding` overload takes a # `PaddingValues`, a type the stubs cannot name -- `PaddingValues(...)` the factory owns the name -- so # it is `Any` and accepts anything.) From 979921aa17459342f4e31002e91e05669f032160 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:01:17 +0900 Subject: [PATCH 2/2] Fix: Decline extension functions whose receiver has a supertype off the compile classpath (#66) --- .../multiplatform/gradle/artifact/ArtifactScanner.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt index caeb082e2..374f8bff3 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt @@ -1911,6 +1911,17 @@ internal object ArtifactScanner { if (function.hasUnsubstitutableTypeParameters) { return declined("a bounded or reified type parameter: kotlin.Any? cannot stand for it") } + // Issue #66: `receiver.alias(...)` resolves against the receiver's member scope, which needs + // every supertype of it -- the rule an extension property's receiver already gets. + if (function.isExtension) { + val receiverName = (function.receiverType?.classifier as? KmClassifier.Class)?.name + if (receiverName != null) { + val missing = classpath.unreachableSupertypeOf(receiverName.replace('.', '$')) + if (missing != null) { + return declined(unreachableReceiverReason(missing, receiverName.replace('/', '.'))) + } + } + } // Read before `resolveKotlinType` is asked anything, because for a function-typed parameter // it is the one that would answer -- and would answer "no". See [functionSlotOrNull].