From f9c4a2da7a74bf7e21b046f4e37ef36e7e50b27f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:41:57 +0900 Subject: [PATCH 1/5] Test: Pin object functions, object-constant types and extension composable tags (#53) --- .../gradle/artifact/ComposableBindingTest.kt | 29 ++++ .../artifact/ObjectMemberBindingTest.kt | 67 +++++++++ .../artifact/objectfixture/ObjectFixtures.kt | 33 +++++ .../gradle/stubs/ObjectStubTest.kt | 131 ++++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt create mode 100644 python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt create mode 100644 python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableBindingTest.kt index bb3abb503..0a0de16eb 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableBindingTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableBindingTest.kt @@ -610,4 +610,33 @@ class ComposableBindingTest { } } } + + /** + * Issue #53: an extension composable's declared parameters carry **their own** boundary tags. + * + * The composable path tags every JVM slot, receiver first, and the declaration model lists the + * value parameters without the receiver; pairing them by index shifted every tag by one, so + * `RowScope.NavigationBarItem`'s `colors: NavigationBarItemColors` read the tag of the + * `alwaysShowLabel: Boolean` before it and the stub annotated it `bool`. + */ + @Test + fun anExtensionComposablesParametersAreTaggedByTheirOwnType() { + val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + val items = ArtifactScanner.scanDeclarations( + material3, + includePrefixes = listOf("androidx.compose.material3"), + classpath = composeClasspath(), + ).filter { it.simpleName == "NavigationBarItem" && it.bindingName != null } + assertTrue(items.isNotEmpty(), "NavigationBarItem is bound") + items.forEach { item -> + assertEquals("androidx.compose.foundation.layout.RowScope", item.receiver?.qualifiedName) + assertEquals("OBJECT", item.receiverBoundaryTag, "the receiver's own tag") + val byName = item.parameters.associateBy { it.name } + assertEquals("BOOLEAN", byName["selected"]?.boundaryTag) + byName["alwaysShowLabel"]?.let { assertEquals("BOOLEAN", it.boundaryTag) } + val colors = byName.getValue("colors") + assertEquals("androidx.compose.material3.NavigationBarItemColors", colors.type.qualifiedName) + assertEquals("OBJECT", colors.boundaryTag, "${item.bindingName}: colors is an object, not the Boolean before it") + } + } } diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt new file mode 100644 index 000000000..01e242e6f --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt @@ -0,0 +1,67 @@ +package python.multiplatform.gradle.artifact + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #53: the functions of a Kotlin `object` are bound, not only its constants. + * + * `Arrangement.spacedBy` is an instance method of the `Arrangement` singleton -- `INSTANCE.spacedBy` + * in bytecode -- so a walk that looked only at `ACC_STATIC` methods bound `Arrangement.End` and never + * `Arrangement.spacedBy`, and the stub (which renders what is bound) could not carry it. Generated + * Kotlin source calls it as `Arrangement.spacedBy(x)`, which the compiler lowers and mangles; nothing + * here looks a JVM method up by name. + */ +class ObjectMemberBindingTest { + + private val fixtureClasses: File + get() = File(Class.forName("fixture.artifactobject.Gap").protectionDomain.codeSource.location.toURI()) + + private fun declarations() = + ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactobject")) + + private val spacing = "fixture.artifactobject.Spacing" + + @Test + fun anObjectFunctionIsBoundUnderTheObjectsName() { + val describe = declarations().single { it.simpleName == "describe" } + assertEquals("$spacing.describe", describe.bindingName) + assertEquals(spacing, describe.owner) + assertTrue(describe.ownerIsClass) + assertEquals("FUNCTION", describe.kind) + assertEquals("fixture.artifactobject.Gap", describe.parameters.single().type.qualifiedName) + assertEquals("kotlin.String", describe.returnType.qualifiedName) + } + + @Test + fun theOverloadsOfAnObjectFunctionAreToldApart() { + val bound = declarations().filter { it.simpleName == "spacedBy" }.mapNotNull { it.bindingName }.sorted() + assertEquals(listOf("$spacing.spacedBy__Int", "$spacing.spacedBy__Int_String"), bound) + } + + @Test + fun theObjectsConstantIsStillBound() { + val tight = declarations().single { it.simpleName == "Tight" } + assertEquals("STATIC_GETTER", tight.kind) + assertEquals("$spacing.Tight", tight.bindingName) + } + + @Test + fun anObjectsToStringOverrideIsNotBound() { + assertFalse(declarations().any { it.simpleName == "toString" && it.bindingName != null }) + } + + @Test + fun aJvmStaticObjectMemberIsBoundOnce() { + val twice = declarations().filter { it.simpleName == "twice" } + assertEquals(listOf("fixture.artifactobject.StaticSpacing.twice"), twice.mapNotNull { it.bindingName }) + } + + @Test + fun anOrdinaryClassInstanceMethodIsStillNotBound() { + assertTrue(declarations().none { it.simpleName == "instanceMethod" && it.bindingName != null }) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt new file mode 100644 index 000000000..f59dafdf5 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt @@ -0,0 +1,33 @@ +// Fixture, not production source: compiled into this module's own test classes and walked as +// bytecode by `ObjectMemberBindingTest`, the way `defaultsfixture` is walked by `DefaultOmissionTest`. +// +// A package of its own, so that the tests which pin another fixture package's output as an exact +// list are not disturbed. +package fixture.artifactobject + +class Gap(val size: Int) + +/** The `Arrangement` shape: a constant, an overloaded function, and a function taking the object's own + * kind of value -- all *instance* methods of a singleton, which JVM bytecode cannot call statically. */ +object Spacing { + val Tight: Gap = Gap(1) + + fun spacedBy(count: Int): Gap = Gap(count) + + fun spacedBy(count: Int, label: String): Gap = Gap(count + label.length) + + fun describe(gap: Gap): String = "gap " + gap.size + + override fun toString(): String = "Spacing" +} + +/** `@JvmStatic` members are already static; they must not be bound twice. */ +object StaticSpacing { + @JvmStatic + fun twice(count: Int): Int = count * 2 +} + +/** Not an object: an ordinary class's instance methods need an instance Python cannot make here. */ +class Plain { + fun instanceMethod(count: Int): Int = count +} 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 new file mode 100644 index 000000000..152989ca3 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt @@ -0,0 +1,131 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import python.multiplatform.gradle.model.ValueClassModel +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #53: what a Kotlin `object`'s stub says. + * + * An object is a Python module (`androidx.compose.ui.Alignment`) whose attributes are its constants + * and functions. Its nested types (`Alignment.Horizontal`) are **also** named under that module, and + * the renderer used to refuse them: `androidx.compose.ui.Alignment` is a module, a type cannot be + * both, so every constant's type fell back to `Any`. The nested types belong in the object's own + * module -- `Alignment.Horizontal` is `androidx/compose/ui/Alignment/__init__.pyi`'s `Horizontal`. + */ +class ObjectStubTest { + + private val pkgUi = "androidx.compose.ui" + private val pkgLayout = "androidx.compose.foundation.layout" + private val alignment = "$pkgUi.Alignment" + private val arrangement = "$pkgLayout.Arrangement" + + private val alignmentHorizontal = KotlinTypeModel("$alignment.Horizontal") + private val arrangementBoth = KotlinTypeModel("$arrangement.HorizontalOrVertical") + private val float = KotlinTypeModel("kotlin.Float") + private val dp = KotlinTypeModel( + "androidx.compose.ui.unit.Dp", + valueClass = ValueClassModel(float, constructorIsPublic = true, propertyIsPublic = true), + ) + + private fun constant(binding: String, type: KotlinTypeModel) = DeclarationModel( + simpleName = binding.substringAfterLast('.'), + owner = binding.substringBeforeLast('.'), + ownerIsClass = true, + receiver = null, + parameters = emptyList(), + returnType = type, + returnBoundaryTag = "OBJECT", + bindingName = binding, + kind = "STATIC_GETTER", + ) + + private fun function(binding: String, type: KotlinTypeModel, vararg params: DeclaredParameter) = DeclarationModel( + simpleName = binding.substringAfterLast('.').substringBefore("__"), + owner = binding.substringBeforeLast('.'), + ownerIsClass = true, + receiver = null, + parameters = params.toList(), + returnType = type, + returnBoundaryTag = "OBJECT", + bindingName = binding, + ) + + private val end = constant("$alignment.End", alignmentHorizontal) + + private fun file(files: Map, module: String) = + files.getValue(module.replace('.', '/') + "/__init__.pyi") + + // ---------------------------------------------------- constants: the declared type, not Any + + @Test + fun anObjectConstantIsAnnotatedWithItsDeclaredNestedType() { + val text = file(renderKotlinFqnStubs(listOf(end)), alignment) + assertTrue(Regex("""^End: Horizontal$""", RegexOption.MULTILINE).containsMatchIn(text), text) + assertFalse("End: _t.Any" in text, text) + } + + @Test + fun theNestedTypeIsAClassInTheObjectsOwnModule() { + val text = file(renderKotlinFqnStubs(listOf(end)), alignment) + assertTrue("class Horizontal:" in text, text) + assertTrue("Kotlin: $alignment.Horizontal" in text, text) + } + + @Test + fun anotherModuleSpellsTheNestedTypeThroughTheObjectsModule() { + val column = function( + "$pkgLayout.Column", + KotlinTypeModel("kotlin.Unit"), + DeclaredParameter("horizontalAlignment", alignmentHorizontal, true, "OBJECT"), + ) + val files = renderKotlinFqnStubs(listOf(end, column)) + val layout = file(files, pkgLayout) + assertTrue("horizontalAlignment: $alignment.Horizontal = ..." in layout, layout) + assertTrue("import $alignment\n" in layout, layout) + } + + @Test + fun theObjectItselfStaysAnyBecauseItsNameIsTheModule() { + // `Alignment.Center: Alignment` -- the type is the module's own name; no class can stand there. + val center = constant("$alignment.Center", KotlinTypeModel(alignment)) + val text = file(renderKotlinFqnStubs(listOf(center)), alignment) + assertTrue("Center: _t.Any" in text, text) + } + + @Test + fun aNestedTypeWhoseNameAnObjectMemberTakesStaysAny() { + // The module already defines `Horizontal` as a constant; a class of that name would shadow it. + val clash = constant("$alignment.Horizontal", alignmentHorizontal) + val text = file(renderKotlinFqnStubs(listOf(clash)), alignment) + assertFalse("class Horizontal:" in text, text) + assertTrue("Horizontal: _t.Any" in text, text) + } + + // ---------------------------------------------------------------- functions of an object + + @Test + fun anObjectFunctionAndItsOverloadsAreInTheObjectsStub() { + val spacedByDp = function( + "$arrangement.spacedBy__Dp", arrangementBoth, + DeclaredParameter("space", dp, false, "FLOAT"), + ) + val spacedByAligned = function( + "$arrangement.spacedBy__Dp_Horizontal", KotlinTypeModel("$arrangement.Horizontal"), + DeclaredParameter("space", dp, false, "FLOAT"), + DeclaredParameter("alignment", alignmentHorizontal, false, "OBJECT"), + ) + val start = constant("$arrangement.Start", KotlinTypeModel("$arrangement.Horizontal")) + val text = file(renderKotlinFqnStubs(listOf(spacedByDp, spacedByAligned, start)), arrangement) + assertTrue("def spacedBy__Dp(space: androidx.compose.ui.unit.Dp | float) -> HorizontalOrVertical:" in text, text) + assertTrue("def spacedBy__Dp_Horizontal(" in text, text) + // The Kotlin name reaches both, as `@overload`s in table-key order. + assertTrue("@_t.overload\ndef spacedBy(space: androidx.compose.ui.unit.Dp | float) -> HorizontalOrVertical: ..." in text, text) + assertTrue("Start: Horizontal" in text, text) + assertFalse("_t.Any" in text, text) + } +} From 43314390337eedae976a3e976f5ec58dc9980529 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:41:58 +0900 Subject: [PATCH 2/5] Fix: Bind object functions and type stub constants and composable slots correctly (#53) --- .../gradle/artifact/ArtifactScanner.kt | 30 +++++++++++++++++-- .../gradle/stubs/PyiRendering.kt | 23 ++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) 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 dbe45fe38..2c8cde570 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 @@ -170,6 +170,13 @@ internal object ArtifactScanner { * `KotlinClassMetadata` instead. */ private const val KOTLIN_KIND_CLASS = 1 + /** What `kotlin.Any` gives every object and an `object` may override; not part of its API. */ + private val ANY_MEMBER_SIGNATURES = setOf( + "toString" to "()Ljava/lang/String;", + "hashCode" to "()I", + "equals" to "(Ljava/lang/Object;)Z", + ) + /** * Every binding this jar (or, for a test fixture, this directory of `.class` files -- see * `ArtifactClasspath`) offers, sorted by name. @@ -539,8 +546,17 @@ internal object ArtifactScanner { classpath: ArtifactClasspath, ): List { val bySignature = functions.associateBy { it.jvmSignature.name to it.jvmSignature.descriptor } + // A Kotlin `object`'s functions are instance methods of its singleton (issue #53: + // `Arrangement.spacedBy`); generated source reaches them as `Owner.name(...)`, so they bind + // like a static. A composable one is declined below -- its thunk is a static call. + val isObject = ownerIsClass && ownerNode.fields.any { it.name == "INSTANCE" && it.access.hasFlag(Opcodes.ACC_STATIC) } + val staticSignatures = ownerNode.methods.filter { it.access.hasFlag(Opcodes.ACC_STATIC) }.map { it.name to it.desc }.toSet() return ownerNode.methods - .filter { it.access.hasFlag(Opcodes.ACC_PUBLIC) && it.access.hasFlag(Opcodes.ACC_STATIC) } + .filter { it.access.hasFlag(Opcodes.ACC_PUBLIC) } + .filter { + it.access.hasFlag(Opcodes.ACC_STATIC) || + (isObject && (it.name to it.desc) !in staticSignatures && (it.name to it.desc) !in ANY_MEMBER_SIGNATURES) + } .filter { !it.access.hasFlag(Opcodes.ACC_SYNTHETIC) && !it.access.hasFlag(Opcodes.ACC_BRIDGE) } .filter { !it.name.startsWith("<") && '$' !in it.name } .mapNotNull { method -> @@ -555,6 +571,12 @@ internal object ArtifactScanner { declinedCandidate(owner, ownerIsClass, suspending, classpath, "suspend", isComposable(method)) } if (function.isSuspend) return@mapNotNull declinedCandidate(owner, ownerIsClass, function, classpath, "suspend", isComposable(method)) + if (!method.access.hasFlag(Opcodes.ACC_STATIC) && isComposable(method)) { + return@mapNotNull declinedCandidate( + owner, ownerIsClass, function, classpath, + "a @Composable member of an object: its generated call is a static invocation", true, + ) + } // A member whose Kotlin-declared parameter count does not match its JVM parameter // count has an implicit JVM parameter metadata does not account for -- a value // class's own instance turned into an unboxed receiver, or a composable's synthetic @@ -975,11 +997,15 @@ internal object ArtifactScanner { valueClassUnboxOwners = declaredSlots.map { it.unboxOwner } + List(syntheticTags.size) { null }, ), ), + // `paramTags` is slot-aligned, so an extension's receiver is slot 0 and `model.parameters` + // (receiver excluded) starts at slot 1. Reading it unshifted gave `NavigationBarItem`'s + // `colors` the tag of `alwaysShowLabel`: `bool` in the stub (issue #53). declaration = model.copy( bindingName = qualifiedName, returnBoundaryTag = returnTag, + receiverBoundaryTag = if (function.isExtension) paramTags.firstOrNull() else null, parameters = model.parameters.mapIndexed { index, parameter -> - parameter.copy(boundaryTag = paramTags.getOrNull(index)) + parameter.copy(boundaryTag = paramTags.getOrNull(index + (if (function.isExtension) 1 else 0))) }, ), ) diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt index 3c3c27dc0..0976fe8f7 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt @@ -33,6 +33,11 @@ import python.multiplatform.gradle.model.ValueClassModel * `PaddingValues` the interface) cannot be both in one Python module, and the runtime resolves the * name to the function, so such a type is annotated `Any`. * + * A Kotlin `object` (`Alignment`, `Arrangement`) is a module of its constants and functions, and the + * types nested in it (`Alignment.Horizontal`) are classes of that same module, so + * `Alignment.End` is annotated `Horizontal` rather than `Any` (issue #53). The object's own type + * (`Alignment.Center: Alignment`) has no class to name and stays `Any`. + * * ### Extension functions are also methods * * The runtime attaches an extension to the receiver's proxy class (`PythonxAdapter._attach`), reached @@ -253,8 +258,22 @@ private class StubRenderer(private val bound: List) { private fun classReference(type: KotlinTypeModel, ctx: ModuleOut): String = classReference(type.qualifiedName, type.valueClass, ctx) + /** + * [classRefOf], except that a type nested in a Kotlin `object` whose constants and functions are + * a module of their own (issue #53) is a class **of that module**: `Alignment.Horizontal` is + * `Horizontal` in `androidx.compose.ui.Alignment`, not a class `Alignment` in `androidx.compose.ui` + * (a module and a class cannot share the name, which is why this used to fall back to `Any`). + * The object's own type (`Alignment`) has no such home and stays unresolved here. + */ + private fun resolveRef(qualifiedName: String): ClassRef? { + val ref = classRefOf(qualifiedName) ?: return null + val objectModule = "${ref.pkg}.${ref.top}" + if (ref.path.size < 2 || objectModule !in moduleNames || ref.top in takenNames[ref.pkg].orEmpty()) return ref + return ClassRef(objectModule, ref.path.drop(1)) + } + private fun classReference(qualifiedName: String, valueClass: ValueClassModel?, ctx: ModuleOut): String { - val ref = classRefOf(qualifiedName) ?: return ANY + val ref = resolveRef(qualifiedName) ?: return ANY if (ref.top in takenNames[ref.pkg].orEmpty() || "${ref.pkg}.${ref.top}" in moduleNames) return ANY if (qualifiedName !in classes || classes[qualifiedName] == null) classes[qualifiedName] = valueClass ?: classes[qualifiedName] val path = ref.path.joinToString(".") @@ -408,7 +427,7 @@ private class StubRenderer(private val bound: List) { .groupBy({ it.first }, { it.second }) private fun renderClass(qualifiedName: String) { - val ref = classRefOf(qualifiedName) ?: return + val ref = resolveRef(qualifiedName) ?: return val ctx = out(ref.pkg) val node = ctx.node(ref.path) node.qualifiedName = qualifiedName From ad6a745c9c287d791016679df98e4f6fd8abe186 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:45:25 +0900 Subject: [PATCH 3/5] Test: An internal object's functions are neither bound nor declared (#53) --- .../multiplatform/gradle/artifact/ObjectMemberBindingTest.kt | 5 +++++ .../gradle/artifact/objectfixture/ObjectFixtures.kt | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt index 01e242e6f..be587db14 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt @@ -64,4 +64,9 @@ class ObjectMemberBindingTest { fun anOrdinaryClassInstanceMethodIsStillNotBound() { assertTrue(declarations().none { it.simpleName == "instanceMethod" && it.bindingName != null }) } + + @Test + fun anInternalObjectsMembersAreNotBoundOrDeclared() { + assertTrue(declarations().none { it.simpleName == "hiddenFunction" }) + } } diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt index f59dafdf5..4a00cee3d 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt @@ -31,3 +31,8 @@ object StaticSpacing { class Plain { fun instanceMethod(count: Int): Int = count } + +/** JVM-public, Kotlin-internal: generated source outside this module cannot name it. */ +internal object HiddenSpacing { + fun hiddenFunction(count: Int): Int = count +} From c492c07b79177581d47430c5669cd92313d5a90f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:45:25 +0900 Subject: [PATCH 4/5] Fix: Bind object functions only for objects public in Kotlin (#53) --- .../python/multiplatform/gradle/artifact/ArtifactScanner.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 2c8cde570..bbd13bbb9 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 @@ -385,6 +385,9 @@ internal object ArtifactScanner { functions = functionsOf(metadata.kmClass).filterNot { it.isExtension }, ownerNode = node, classpath = classpath, + // The rule `objectConstantCandidates` uses: Kotlin visibility from metadata, never the + // JVM flags (an `internal object` is a JVM-public class). + bindObjectMembers = metadata.kmClass.visibility == Visibility.PUBLIC, ) + constructorCandidates(metadata.kmClass, node, classpath) + objectConstantCandidates(metadata.kmClass, node, classpath) is KotlinClassMetadata.FileFacade -> kotlinCandidates( @@ -544,12 +547,13 @@ internal object ArtifactScanner { functions: List, ownerNode: ClassNode, classpath: ArtifactClasspath, + bindObjectMembers: Boolean = false, ): List { val bySignature = functions.associateBy { it.jvmSignature.name to it.jvmSignature.descriptor } // A Kotlin `object`'s functions are instance methods of its singleton (issue #53: // `Arrangement.spacedBy`); generated source reaches them as `Owner.name(...)`, so they bind // like a static. A composable one is declined below -- its thunk is a static call. - val isObject = ownerIsClass && ownerNode.fields.any { it.name == "INSTANCE" && it.access.hasFlag(Opcodes.ACC_STATIC) } + val isObject = ownerIsClass && bindObjectMembers && ownerNode.fields.any { it.name == "INSTANCE" && it.access.hasFlag(Opcodes.ACC_STATIC) } val staticSignatures = ownerNode.methods.filter { it.access.hasFlag(Opcodes.ACC_STATIC) }.map { it.name to it.desc }.toSet() return ownerNode.methods .filter { it.access.hasFlag(Opcodes.ACC_PUBLIC) } From 973a4707ed2523f7fe29e74ac2da7c3515b71ed3 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:56:05 +0900 Subject: [PATCH 5/5] Docs: Specify object functions and nested object-constant types (SPEC B-1, B-7, #53) --- docs/SPEC.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index ddaf6a2d4..6ea3e21b3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -221,6 +221,10 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. 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`. + 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 + skipped. `Status: implemented` — `GP/artifact/ObjectMemberBindingTest.kt`. - **B-2** The walker on **klibs** (Kotlin/Native libraries). `Status: partial` — `GP/artifact/KlibScannerTest.kt` and `ksp-fixtures/klib-artifact` assert that the scanned klib's declarations are declined with reasons; no klib declaration is bound at run time yet. @@ -261,7 +265,10 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `tools/stubs/check-stubs.sh` (mypy over the Compose stubs). A class whose name is also a function in its module is `Any`. A property is a `@property` (with a setter for a `var`) of its receiver's stub class, its docstring carrying its table key after `Kotlin property: `; where that class has no stub, - a comment with the same marker says so (`GP/stubs/PropertyStubTest.kt`). + 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`). - **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.