Summary
Resolver.getJvmName(accessor) returns getName for a property getter on a @JvmRecord Kotlin data class, but the actual JVM bytecode uses record component accessors (name(), not getName()). This causes downstream frameworks (e.g. Micronaut Data) that rely on getJvmName to generate incorrect reflective calls, resulting in NoSuchMethodError at runtime.
Environment
- KSP version: 2.2.21-2.0.5 (also reproducible on
main at 2.3.0)
- Kotlin version: 2.2.21+
- JDK: 17+
Reproducer
Given this Kotlin source:
@JvmRecord
data class Author(
val id: Int,
val name: String,
)
A KSP processor calling:
val cls = resolver.getClassDeclarationByName("Author")!!
val nameGetter = cls.getAllProperties().first { it.simpleName.asString() == "name" }.getter!!
val jvmName = resolver.getJvmName(nameGetter)
println(jvmName) // prints "getName" — should print "name"
Expected behavior
resolver.getJvmName(getter) should return "name" (the record component accessor), matching the actual JVM bytecode produced by the Kotlin compiler for @JvmRecord classes.
Actual behavior
Returns "getName" (the bean-style getter), which does not exist on the compiled Java record class.
Root cause
In ResolverAAImpl.getJvmName(accessor: KSPropertyAccessor) (line 445 of ResolverAAImpl.kt), the name is computed as:
val name = accessor.receiver.simpleName.asString()
val prefixedName = when (accessor) {
is KSPropertyGetter -> JvmAbi.getterName(name) // unconditionally → "getName"
is KSPropertySetter -> JvmAbi.setterName(name)
else -> ""
}
JvmAbi.getterName() unconditionally adds a get prefix. There is no check for whether the containing class is a @JvmRecord class, which suppresses the prefix at the JVM level.
Why KSP1 worked correctly
The KSP1 implementation (ResolverImpl) delegated to typeMapper.mapFunctionName(descriptor, OwnerKind.IMPLEMENTATION), which is the Kotlin compiler's internal KotlinTypeMapper. That function explicitly checks for java.lang.Record supertypes and returns the bare property name when the class is a record. KSP2 replaced this with a manual JvmAbi.getterName() call and lost the record-awareness.
Impact
Any KSP processor that uses getJvmName to determine how to reflectively access properties on @JvmRecord classes will get the wrong method name. Known affected framework: Micronaut Data's micronaut-inject-kotlin processor, which generates bean introspection code using getJvmName — causing NoSuchMethodError at runtime for all @JvmRecord entities.
Proposed fix
In ResolverAAImpl.getJvmName(accessor), before computing the prefixed name, check whether the containing class has the kotlin.jvm.JvmRecord annotation. If so, return the bare property name (matching the Kotlin compiler's bytecode output):
// Annotation classes and @JvmRecord data classes both use bare property names
// as accessor names (no get/set prefix).
if (containingClass?.classKind == ClassKind.ANNOTATION_CLASS ||
containingClass != null && containingClass.annotations.any {
it.annotationType.resolve().declaration.qualifiedName?.asString() == "kotlin.jvm.JvmRecord"
}
) {
return name
}
For setters, records don't have setters at all (record components are final), so the setter branch is unaffected.
Summary
Resolver.getJvmName(accessor)returnsgetNamefor a property getter on a@JvmRecordKotlin data class, but the actual JVM bytecode uses record component accessors (name(), notgetName()). This causes downstream frameworks (e.g. Micronaut Data) that rely ongetJvmNameto generate incorrect reflective calls, resulting inNoSuchMethodErrorat runtime.Environment
mainat 2.3.0)Reproducer
Given this Kotlin source:
A KSP processor calling:
Expected behavior
resolver.getJvmName(getter)should return"name"(the record component accessor), matching the actual JVM bytecode produced by the Kotlin compiler for@JvmRecordclasses.Actual behavior
Returns
"getName"(the bean-style getter), which does not exist on the compiled Java record class.Root cause
In
ResolverAAImpl.getJvmName(accessor: KSPropertyAccessor)(line 445 ofResolverAAImpl.kt), the name is computed as:JvmAbi.getterName()unconditionally adds agetprefix. There is no check for whether the containing class is a@JvmRecordclass, which suppresses the prefix at the JVM level.Why KSP1 worked correctly
The KSP1 implementation (
ResolverImpl) delegated totypeMapper.mapFunctionName(descriptor, OwnerKind.IMPLEMENTATION), which is the Kotlin compiler's internalKotlinTypeMapper. That function explicitly checks forjava.lang.Recordsupertypes and returns the bare property name when the class is a record. KSP2 replaced this with a manualJvmAbi.getterName()call and lost the record-awareness.Impact
Any KSP processor that uses
getJvmNameto determine how to reflectively access properties on@JvmRecordclasses will get the wrong method name. Known affected framework: Micronaut Data'smicronaut-inject-kotlinprocessor, which generates bean introspection code usinggetJvmName— causingNoSuchMethodErrorat runtime for all@JvmRecordentities.Proposed fix
In
ResolverAAImpl.getJvmName(accessor), before computing the prefixed name, check whether the containing class has thekotlin.jvm.JvmRecordannotation. If so, return the bare property name (matching the Kotlin compiler's bytecode output):For setters, records don't have setters at all (record components are final), so the setter branch is unaffected.