Skip to content

Commit a2955e0

Browse files
committed
fix: prevent duplicate TS identifiers from SerializersModule resolution
A @contextual type resolved via the SerializersModule was emitted twice: once as a `type Foo = any` placeholder (from the ContextualSerializer<Foo> descriptor) and again as the resolved `interface Foo`, producing a TS2300 duplicate-identifier compile error. Sealed-polymorphic subclasses were similarly emitted as flat top-level interfaces that duplicated the namespaced ones inside the discriminated namespace. Fixes: - Suppress the contextual placeholder from the extracted set when it resolves to a concrete descriptor via the module. When it cannot be resolved it is still kept, so the referencing field falls back to `type Foo = any` rather than a dangling reference. - Stop resolving sealed subclasses from the module (sealed classes are closed; subclasses are already rendered as a discriminated namespace). Module resolution is now scoped to open polymorphism only. - Tighten contextual name matching to require a package/separator boundary, so a placeholder for `Foo` no longer also matches `BarFoo` (which could re-introduce a duplicate via a different path). Tests: - Add end-to-end tests asserting on the generated TypeScript (the layer the original tests skipped), plus extractor-level tests pinning the placeholder suppression and the tightened matcher. Build: - Add the foojay toolchain resolver so local builds can auto-provision JDK 11 (required by the :docs:code toolchain) when it is not installed.
1 parent c0b7eae commit a2955e0

4 files changed

Lines changed: 200 additions & 35 deletions

File tree

modules/kxs-ts-gen-core/src/commonMain/kotlin/dev/adamko/kxstsgen/core/SerializerDescriptorsExtractor.kt

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,19 @@ fun interface SerializerDescriptorsExtractor {
5959
} else {
6060
val currentDescriptors = elementDescriptorsExtractor.elementDescriptors(current, moduleDescriptors)
6161
queue.addAll(currentDescriptors - extracted)
62-
extractDescriptors(queue.removeFirstOrNull(), queue, extracted + current, moduleDescriptors)
62+
63+
// A contextual descriptor is just a placeholder (e.g. `ContextualSerializer<Foo>`).
64+
// When it resolves to a concrete descriptor via the SerializersModule, that concrete
65+
// descriptor (e.g. `interface Foo`) is already queued above. The placeholder itself
66+
// must NOT be emitted as a declaration, otherwise it renders as `type Foo = any` and
67+
// collides with the resolved `interface Foo` (TS2300 duplicate identifier). When it
68+
// cannot be resolved we keep it, so the referencing field still resolves to
69+
// `type Foo = any` rather than an undefined type.
70+
val nextExtracted =
71+
if (current.kind == SerialKind.CONTEXTUAL && currentDescriptors.any()) extracted
72+
else extracted + current
73+
74+
extractDescriptors(queue.removeFirstOrNull(), queue, nextExtracted, moduleDescriptors)
6375
}
6476
}
6577
}
@@ -104,35 +116,55 @@ interface TsElementDescriptorsExtractor {
104116
StructureKind.MAP,
105117
StructureKind.OBJECT -> descriptor.elementDescriptors
106118

107-
PolymorphicKind.SEALED,
108-
PolymorphicKind.OPEN -> {
119+
PolymorphicKind.SEALED ->
120+
// Sealed subclasses are already part of the descriptor (sealed classes are closed)
121+
// and are rendered as a discriminated namespace by TsElementConverter. We only
122+
// traverse INTO the subclasses to pick up their property types - the subclass
123+
// declarations themselves must not be extracted, or they'd be emitted again as
124+
// top-level interfaces, duplicating the namespaced ones.
125+
descriptor.elementDescriptors
126+
.flatMap { it.elementDescriptors }
127+
.flatMap { it.elementDescriptors }
128+
129+
PolymorphicKind.OPEN ->
130+
// TODO OPEN-polymorphism-via-module is not implemented. resolvePolymorphicDescriptors
131+
// relies on isSubclassOf, which fails because the base type's package is lost when
132+
// the base name is parsed out of `<...>`. Until fixed, an @Polymorphic field whose
133+
// base type is only known via the module resolves to `type <Base> = any`.
109134
resolvePolymorphicDescriptors(descriptor, moduleDescriptors)
110-
}
111135
}
112136
}
113137

138+
/**
139+
* Resolve a contextual placeholder (e.g. `ContextualSerializer<Foo>`) to the concrete
140+
* descriptors registered in the [SerializersModule], by matching the placeholder's type
141+
* parameter (a simple name) against each module descriptor's serial name.
142+
*
143+
* The match requires a package/separator boundary before the simple name, so that a
144+
* placeholder for `Foo` does not also match `BarFoo`. (A loose `endsWith(simpleName)`
145+
* match can resolve to more than one descriptor; if two of those share a rendered name
146+
* the generator emits a TS2300 duplicate identifier.)
147+
*/
114148
private fun resolveContextualDescriptor(descriptor: SerialDescriptor, moduleDescriptors: Set<SerialDescriptor>): Iterable<SerialDescriptor> {
115149
val typeParameters = getTypeParametersFromDescriptor(descriptor)
116150
return typeParameters.flatMap { typeParam ->
117151
moduleDescriptors.filter { moduleDesc ->
118-
moduleDesc.serialName.endsWith(typeParam) ||
119-
moduleDesc.serialName.endsWith(".$typeParam")
152+
val serialName = moduleDesc.serialName
153+
serialName == typeParam || serialName.endsWith(".$typeParam")
120154
}
121155
}
122156
}
123157

124158
private fun resolvePolymorphicDescriptors(descriptor: SerialDescriptor, moduleDescriptors: Set<SerialDescriptor>): Iterable<SerialDescriptor> {
125159
val baseType = getPolymorphicBaseType(descriptor) ?: descriptor.serialName
126-
160+
127161
val subclassDescriptors = moduleDescriptors.filter { moduleDesc ->
128162
moduleDesc.kind.let { it is StructureKind.CLASS || it is StructureKind.OBJECT } &&
129163
isSubclassOf(moduleDesc.serialName, baseType)
130164
}
131165

132-
return subclassDescriptors + subclassDescriptors.flatMap { it.elementDescriptors } +
133-
descriptor.elementDescriptors
134-
.flatMap { it.elementDescriptors }
135-
.flatMap { it.elementDescriptors }
166+
// Emit each discovered subclass, plus its property-type descriptors.
167+
return subclassDescriptors + subclassDescriptors.flatMap { it.elementDescriptors }
136168
}
137169

138170
private fun getTypeParametersFromDescriptor(descriptor: SerialDescriptor): List<String> {
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package dev.adamko.kxstsgen.core
2+
3+
import dev.adamko.kxstsgen.KxsTsGenerator
4+
import io.kotest.core.spec.style.FunSpec
5+
import io.kotest.matchers.collections.shouldBeEmpty
6+
import io.kotest.matchers.shouldBe
7+
import io.kotest.matchers.string.shouldContain
8+
import io.kotest.matchers.string.shouldNotContain
9+
import kotlinx.serialization.Serializable
10+
import kotlinx.serialization.modules.SerializersModule
11+
12+
/**
13+
* End-to-end tests for [KxsTsGenerator] driven by a [SerializersModule].
14+
*
15+
* These assert on the *generated TypeScript* (not just the extracted descriptor set) so that
16+
* regressions like a `type Foo = any` placeholder colliding with a resolved `interface Foo`
17+
* (TS2300 duplicate identifier) are caught.
18+
*/
19+
class KxsTsGeneratorSerializersModuleTest : FunSpec({
20+
21+
context("contextual serializers") {
22+
23+
test("a contextual type registered in the module is emitted as an interface, with no duplicate type-alias") {
24+
val module = SerializersModule {
25+
contextual(ContextualExample.SomeType::class, ContextualExample.SomeType.serializer())
26+
}
27+
28+
val ts = KxsTsGenerator(serializersModule = module)
29+
.generate(ContextualExample.TypeHolder.serializer())
30+
31+
// the resolved type is generated as an interface...
32+
ts shouldContain "export interface SomeType {"
33+
// ...and the contextual placeholder must NOT also be rendered as `type SomeType = any`
34+
// (that would collide with the interface and produce a TS2300 duplicate identifier).
35+
ts shouldNotContain "type SomeType = any"
36+
// the referencing field must point at the resolved type
37+
ts shouldContain "required: SomeType;"
38+
39+
ts shouldBe """
40+
|export interface TypeHolder {
41+
| required: SomeType;
42+
|}
43+
|
44+
|export interface SomeType {
45+
| a: string;
46+
|}
47+
""".trimMargin()
48+
}
49+
50+
test("a contextual type NOT registered in the module falls back to `type Foo = any`") {
51+
val ts = KxsTsGenerator()
52+
.generate(ContextualExample.TypeHolder.serializer())
53+
54+
// no dangling reference: the placeholder becomes a type-alias to `any`...
55+
ts shouldContain "type SomeType = any"
56+
// ...and no interface is invented
57+
ts shouldNotContain "export interface SomeType {"
58+
}
59+
}
60+
61+
context("polymorphic serializers") {
62+
63+
test("a sealed hierarchy is rendered as a discriminated namespace, with no flat duplicate subclass") {
64+
val module = SerializersModule {
65+
polymorphic(SealedExample.Parent::class, SealedExample.SubClass::class, SealedExample.SubClass.serializer())
66+
}
67+
68+
val ts = KxsTsGenerator(serializersModule = module)
69+
.generate(SealedExample.Parent.serializer())
70+
71+
// the discriminated namespace, discriminator enum and union are all present...
72+
ts shouldContain "export namespace Parent {"
73+
ts shouldContain "export enum Type {"
74+
ts shouldContain "| Parent.SubClass"
75+
// ...the subclass lives (correctly) inside the namespace...
76+
ts shouldContain " export interface SubClass {"
77+
78+
// ...and there must be NO flat top-level duplicate of the subclass interface.
79+
// (A line starting with `export interface SubClass` at column 0 would be such a duplicate.)
80+
ts.lineSequence()
81+
.filter { it.startsWith("export interface SubClass") }
82+
.toList()
83+
.shouldBeEmpty()
84+
}
85+
}
86+
}) {
87+
@Suppress("unused")
88+
private object ContextualExample {
89+
@Serializable
90+
class SomeType(val a: String)
91+
92+
@Serializable
93+
class TypeHolder(
94+
@kotlinx.serialization.Contextual
95+
val required: SomeType,
96+
)
97+
}
98+
99+
@Suppress("unused")
100+
private object SealedExample {
101+
@Serializable
102+
sealed class Parent
103+
104+
@Serializable
105+
class SubClass(val x: String) : Parent()
106+
}
107+
}

modules/kxs-ts-gen-core/src/commonTest/kotlin/dev/adamko/kxstsgen/core/SerializerDescriptorsExtractorTest.kt

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import io.kotest.matchers.shouldBe
77
import kotlinx.serialization.Serializable
88
import kotlinx.serialization.builtins.serializer
99
import kotlinx.serialization.descriptors.SerialDescriptor
10+
import kotlinx.serialization.descriptors.SerialKind
1011
import kotlinx.serialization.modules.SerializersModule
1112

1213
class SerializerDescriptorsExtractorTest : FunSpec({
@@ -67,22 +68,46 @@ class SerializerDescriptorsExtractorTest : FunSpec({
6768
}
6869
}
6970

70-
test("Example5: polymorphic subclasses are extracted from SerializersModule") {
71+
test("Example4b: contextual placeholder is suppressed when resolvable from the module") {
7172
val module = SerializersModule {
72-
polymorphic(Example5.Parent::class, Example5.SubClass::class, Example5.SubClass.serializer())
73+
contextual(Example4.SomeType::class, Example4.SomeType.serializer())
7374
}
7475
val extractor = SerializerDescriptorsExtractor.default(module)
7576

76-
val actual = extractor(Example5.Parent.serializer())
77+
val actual = extractor(Example4.TypeHolder.serializer())
7778

78-
val subClassDescriptor = Example5.SubClass.serializer().descriptor
79-
withClue("Should contain SubClass descriptor from module") {
80-
actual.any { it.serialName == subClassDescriptor.serialName } shouldBe true
79+
// the resolved SomeType descriptor must be present...
80+
val someTypeDescriptor = Example4.SomeType.serializer().descriptor
81+
withClue("resolved SomeType descriptor should be present") {
82+
actual.any { it.serialName == someTypeDescriptor.serialName } shouldBe true
8183
}
84+
// ...and the contextual placeholder must NOT survive into the extracted set - otherwise it
85+
// renders as `type SomeType = any`, colliding with the resolved `interface SomeType`
86+
// (TS2300 duplicate identifier).
87+
withClue("no contextual placeholder should remain") {
88+
actual.none { it.kind == SerialKind.CONTEXTUAL } shouldBe true
89+
}
90+
}
8291

83-
val stringDescriptor = String.serializer().descriptor
84-
withClue("Should contain String descriptor from SubClass property") {
85-
actual.any { it.serialName == stringDescriptor.serialName } shouldBe true
92+
test("Example4c: contextual resolution matches by name boundary, not a loose suffix") {
93+
val module = SerializersModule {
94+
contextual(Example4c.Foo::class, Example4c.Foo.serializer())
95+
contextual(Example4c.BarFoo::class, Example4c.BarFoo.serializer())
96+
}
97+
val extractor = SerializerDescriptorsExtractor.default(module)
98+
99+
val actual = extractor(Example4c.Holder.serializer())
100+
101+
val fooDescriptor = Example4c.Foo.serializer().descriptor
102+
val barFooDescriptor = Example4c.BarFoo.serializer().descriptor
103+
104+
// A `@Contextual Foo` must resolve to Foo only, and must NOT also pick up the unrelated
105+
// BarFoo, whose simple name merely ends in "Foo".
106+
withClue("Should contain Foo") {
107+
actual.any { it.serialName == fooDescriptor.serialName } shouldBe true
108+
}
109+
withClue("Should NOT contain BarFoo") {
110+
actual.any { it.serialName == barFooDescriptor.serialName } shouldBe false
86111
}
87112
}
88113

@@ -98,18 +123,6 @@ class SerializerDescriptorsExtractorTest : FunSpec({
98123
}
99124
}
100125

101-
test("Example7: polymorphic without module registration does not extract subclasses") {
102-
val emptyModule = SerializersModule { }
103-
val extractor = SerializerDescriptorsExtractor.default(emptyModule)
104-
105-
val actual = extractor(Example5.Parent.serializer())
106-
107-
val subClassDescriptor = Example5.SubClass.serializer().descriptor
108-
withClue("Should NOT contain SubClass descriptor when not registered") {
109-
actual.any { it.serialName == subClassDescriptor.serialName } shouldBe false
110-
}
111-
}
112-
113126
}) {
114127
companion object {
115128
private infix fun Collection<SerialDescriptor>.shouldContainDescriptors(expected: Collection<SerialDescriptor>) {
@@ -184,11 +197,18 @@ private object Example4 {
184197
}
185198

186199

187-
private object Example5 {
200+
@Suppress("unused")
201+
private object Example4c {
188202

189203
@Serializable
190-
sealed class Parent
204+
class Foo(val a: String)
205+
206+
@Serializable
207+
class BarFoo(val b: String)
191208

192209
@Serializable
193-
class SubClass(val x: String) : Parent()
210+
class Holder(
211+
@kotlinx.serialization.Contextual
212+
val x: Foo,
213+
)
194214
}

settings.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ pluginManagement {
99
}
1010
}
1111

12+
plugins {
13+
// Allow Gradle to auto-download JVM toolchains (e.g. JDK 11 for :docs:code) when they
14+
// are not installed locally. https://docs.gradle.org/current/userguide/toolchains.html
15+
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
16+
}
17+
1218
dependencyResolutionManagement {
1319
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
1420

0 commit comments

Comments
 (0)