kotlin-native-nuget Help

Publishing Kotlin to C#

The forward direction takes a Kotlin/Native library and generates a C# API for it. You write Kotlin, the plugin ships a .nupkg a C# consumer can reference directly, no code generation step on their side.

Pipeline

At build time:

  1. KSP discovers public declarations. The KSP processor (nuget-processor/) walks every public class, function, and property in the compiled Kotlin/Native source set.

  2. Ordinary sync callables are planned once. Each ordinary synchronous function, method, constructor, property, companion, object method, extension, and value-class member is classified into a BridgeType and validated as a ForwardCallablePlan or ForwardPropertyPlan (ADR-062). Specialized protocols (suspend, Flow, lambda/callback, sealed helpers, generic declaration families) stay on named legacy routes; a sealed helper is the one exception at a property position, where the property planner now plans it directly instead (ADR-105): a sealed return still rides the legacy route unchanged. See Interfaces, abstract classes, and sealed classes: Limitations for exactly which sealed positions still skip.

  3. Dual projection from the plan. The same plan projects to CIR for C# (ADR-004) and to KotlinPoet @CName exports. A generation-time ABI contract check (ADR-055) compares both halves to the plan, and also refuses a Nullable<T> spelling on a DllImport (anything other than a by-value or return string?), which C# would otherwise compile but reject at the first call. It also refuses any bool on a DllImport (return, by-value, or out) not marshalled as UnmanagedType.I1, because the C# default is the 4-byte Win32 BOOL while Kotlin speaks a 1-byte C bool. The same check also covers the specialized legacy routes (ADR-078): since their DllImport declarations are raw renderer text rather than plan output, they are collected straight from the rendered Interop.cs and normalized the same way before being compared against their Kotlin @CName exports.

  4. CirRenderer emits Interop.cs. The C# source is generated once, at Kotlin build time, and shipped inside the package. There is no consumer-side codegen step, unlike the ClangSharpPInvokeGenerator-based approach from earlier phases (see ADR-001).

  5. KotlinPoet emits Bridges.kt. Kotlin-side @CName export wrappers are generated so every bridged declaration has a stable C ABI entry point.

  6. Kotlin/Native compiles and links shared libraries for each target platform.

  7. packNuget packages the generated C#, the native binaries, and metadata into a .nupkg.

Gradle Plugin (Kotlin side) NuGet Package C# Consumer ┌───────────────────────────────────┐ ┌────────────────┐ ┌──────────────┐ │ Compile Kotlin/Native │ │ native libs │ │ Add package │ │ KSP → plan → CIR → Interop.cs │────>│ Interop.cs │────>│ Build │ │ ↘ KotlinPoet → Bridges.kt │ └────────────────┘ │ Run │ │ Link shared libraries │ └──────────────┘ │ Package as .nupkg │ └───────────────────────────────────┘

Memory model

Kotlin/Native's GC and the .NET GC know nothing about each other. Every object that crosses the bridge needs an explicit ownership story:

  • Primitives are copied by value. No ownership concern.

  • Strings cross as UTF-8 const char*, copied immediately into a managed string via Marshal.PtrToStringUTF8. The pointer is never cached.

  • Objects are pinned Kotlin-side with StableRef.create(...), which returns an opaque COpaquePointer. The generated C# wrapper stores that pointer as _handle and implements IDisposable; disposing releases the StableRef. See ADR-003.

Every time an object-typed property or return value crosses the bridge, the generated code creates a new wrapper around a new StableRef rather than caching or reusing an existing one. This mirrors how Kotlin/Native's ObjC and Swift exports behave. Identity is not preserved (cat.Brother != cat.Brother even when both point at the same Kotlin object), and disposing one wrapper never cascades to another. See Classes and objects and ADR-005 for the concrete generated shape.

What ships in the .nupkg

Running packNuget for test-library produces this layout:

TestLibrary.1.0.0/ ├── contentFiles/cs/<tfm>/Interop.cs └── runtimes/ ├── osx-arm64/native/ └── win-x64/native/
  • contentFiles/cs/<tfm>/Interop.cs: the generated C# source, compiled directly into the consumer's own project (not a separate assembly). This is why the generated code has no external dependency beyond the .NET BCL: it becomes part of the consumer's compilation unit.

  • runtimes/{rid}/native/: the compiled Kotlin/Native shared libraries, one per supported target (osx-arm64, win-x64, ...). The .NET runtime resolves the correct native asset for the host RID automatically via [DllImport].

No consumer-side build step, SDK, or tool is required beyond referencing the package.

Package layout and namespace mapping

The Gradle DSL configures the root of the generated namespace tree:

nuget { publish { packageId = "MyCatLib" version = "1.0.0" authors = "yourname" description = "My Kotlin/Native library" rootPackage = "com.example.cats" } }

Kotlin sub-packages map relative to rootPackage, and the C# namespace root is the package's packageId. In test-library, rootPackage = "io.github.xxfast.kotlin.native.nuget.test", so:

Kotlin package

C# namespace

io.github.xxfast.kotlin.native.nuget.test

TestLibrary

io.github.xxfast.kotlin.native.nuget.test.cat

TestLibrary.Cat

io.github.xxfast.kotlin.native.nuget.test.math

TestLibrary.Math

io.github.xxfast.kotlin.native.nuget.test.mime

TestLibrary.Mime

Every generated declaration lands under its mapped namespace inside the single Interop.cs file.

The mapping has three cases (ADR-066 §5's 2026-09-13 amendment):

Kotlin package

C# namespace

rootPackage itself

<packageId>

under rootPackage (<root>.a.b)

<packageId>.A.B

outside rootPackage, admitted by an explicit include(...) or admit(...) (x.y.z)

<packageId>.X.Y.Z, the full package PascalCased

With rootPackage unset, every package collapses to <packageId> regardless, since there is no prefix to strip or compare against. Two exported types with the same name in different packages (pkg.a.Mood and pkg.b.Mood) would then be one C# name, so the build fails with ERROR_CSHARP_SIGNATURE_COLLISION naming both declarations; set rootPackage or rename one. The full-package case reaches an admitted dependency-module type the same way an in-root type does: Billboards, declared under rootPackage, returns a Billboard from dev.other.admitted, a package outside rootPackage that test-library/build.gradle.kts admits with include("io.github.xxfast.kotlin.native.nuget.test", "dev.other.admitted"):

class Billboards { fun current(): Billboard = Billboard("Oreo naps here. Mylo supervises.") }
public global::TestLibrary.Dev.Other.Admitted.Billboard Current()

Billboard itself is declared at namespace TestLibrary.Dev.Other.Admitted, the full Kotlin package PascalCased under the assembly's root namespace, not the bare Dev.Other.Admitted a package outside a module's own files might otherwise suggest: one NuGet package is one assembly, so every namespace under its packageId stays collision-free against any other assembly a consumer references. See the ADR for the two rejected alternatives.

With no include(...) set, the default scope is rootPackage itself, when one is configured, or every public declaration in the module when it isn't. publish { include(...); exclude(...) } narrows that to an explicit package-prefix allowlist. publish { exportMarkers(...) } is a separate, orthogonal escape list: it names @RequiresOptIn markers whose declarations keep exporting instead of being dropped, see Opt-in-marked declarations skip named.

The export set is not limited to the module's own files, either: it is a reachability closure that also walks into types declared in a dependency Gradle module (return types, parameter types, property types, type arguments of Flow<T> /collections, sealed subclasses, primary-constructor parameters), admitting each discovered type through the same include/exclude/rootPackage predicate, plus an additive admit(...) for a single dependency type or package that isn't worth including wholesale (ADR-154). See The nuget {} DSL for the full predicate, the include-vs-admit guidance, and the cross-module closure rules.

Export symbols

Every generated C entry point (the @CName on the Kotlin side, the [DllImport(EntryPoint = "...")] on the C# side) is <library>_<package>__<the declaration's own name> (ADR-163):

  • <library> is the sanitised packageId/libraryName the DllImport already names, lowercased with any run of non-[a-z0-9_] characters collapsed to one _. It is never empty, so no export is ever a bare user identifier.

  • <package> is the declaring Kotlin package relative to rootPackage, . replaced by _; empty and dropped (with its own _) for a declaration in rootPackage itself.

  • the rest is the declaration's own enclosing chain of simple names and member, exactly as before.

A root-package class Kitten in a library named Cats exports cats_kitten_create; the same class under com.example.cats.house exports cats_house__kitten_create. Nobody spells this symbol by hand: it is a private contract between the generated CNameExports.kt and the generated Interop.cs, and it is free to change release to release. It is documented here only because it explains two things a consumer or library author can otherwise be surprised by:

  • Two declarations with the same simple name in different packages both bind. a.Kitten and b.Kitten, or two rollCall() top-level functions, or two LoadState sealed classes, each in their own package, no longer collide the way they used to; each reaches its own generated namespace under the package mapping above.

  • A top-level function is never dropped for sharing a name with a C runtime or Win32 symbol. On mingwX64, ld.lld's MinGW auto-exporter silently removes any exported name that an import library on the link line already exports (signal, read, exit, Beep, ...), so a bare fun signal(dbm: Int) used to compile clean and then throw EntryPointNotFoundException at runtime with no build-time diagnostic. Because every export now carries the library segment, no generated symbol can ever collide with one of those names.

A sanitised library name of nuget fails the build with a named error instead of generating, since nuget_* is the fixed leading segment ADR-127 reserves for the runtime's own ABI.

Diagnostics

Not every Kotlin construct can be expressed as C#. When the generator meets one it cannot bridge, it names the member and the reason, at the author's own Kotlin source, rather than emitting invalid Kotlin or a C# API whose signature lies about its contract. A member the compiler itself wrote, not the author, is filtered out before any of this reporting runs and never appears as a diagnostic: that covers equals/hashCode/toString, a data class's copy/componentN, a hidden-deprecated member, and a compiler plugin's synthesized surface such as kotlinx.serialization's Companion.serializer() (#235). Every diagnostic carries a ForwardDiagnosticKind whose name encodes its severity:

  • SKIPPED_*: the member is warned about and omitted entirely from the generated C# API. Generation continues. This is the default for a construct the forward direction cannot express (an unsupported type, a Map/Set parameter, an unsupported generic/suspend combination, a value-class member a supertype declares, whether inherited, delegated or overridden, or a backticked name with a space or symbol and no @CSharpName, reported as SKIPPED_UNSUPPORTED_COMBINATION: "its name tug hard is not an identifier"; see Choosing the C# name). A Sequence<T> parameter or return is one of these: kotlin.sequences.Sequence is a named Unsupported stdlib type, so a callable using it at either position skips as SKIPPED_UNSUPPORTED_TYPE naming the callable, instead of vanishing from the generated API with no diagnostic at all. Any other kotlin.*/kotlinx.* stdlib type with no first-class C# mapping (see Primitives and strings and Collections for what is mapped) skips the same way, naming the type itself; a stdlib type wants a first-class mapping, not an export-scope change, so it gets no include(...) suggestion, unlike SKIPPED_UNEXPORTED_DEPENDENCY_TYPE below. SKIPPED_RESULT_TRY_COLLISION is the mild case: only the non-throwing TryX twin of a Result<T> return is dropped because its name is taken by a property, a constant or the type itself, and the throwing member still binds (see Exceptions).

  • INFO_*: the member still binds, under a documented assumption (for example, out/in variance on a class type parameter is dropped, or a stdlib bound such as Comparable<T> is dropped from the where clause as INFO_DROPPED_BOUND, but the member still generates).

  • ERROR_*: generation fails and CNameExports.kt (the Kotlin @CName export file) is never written, so packNuget never runs. Cases include two constructors, or two methods on one class, that render an identical C# signature (ADR-034), named ERROR_CSHARP_SIGNATURE_COLLISION. This also catches two constructors that differ only in reference-type nullability (constructor(from: Patient) next to constructor(from: Patient?)): C# does not treat a nullable reference annotation as part of a method's signature, so both would otherwise render, correctly but uncompilably, as Referral(Patient from) and Referral(Patient? from) (CS0111). Nullable value types are unaffected and keep working: constructor(n: Int) next to constructor(n: Int?) render genuinely distinct signatures and are not treated as a collision. A second case is a property and a method that render the same C# name on one generated type -- a top-level file class, object, class and companion, sealed base or arm, value class, or interface alike (ERROR_CSHARP_NAME_COLLISION, CS0102, resolved by renaming one or by giving one a different @CSharpName); the same diagnostic also fires when a declared member takes the C# name of an inherited member of the other kind (CS0108 hiding). See Classes and objects for the general rule and Top-level declarations for the file-class shape. @CSharpName adds two more fatal codes: ERROR_CSHARP_NAME_INVALID (the declared name is not a C# identifier) and ERROR_CSHARP_NAME_OVERRIDE_MISMATCH (an override declares a different name than the member it overrides); see Choosing the C# name. This page covers the third: ERROR_C_ENTRY_POINT_COLLISION, two different Kotlin declarations deriving the same underlying C entry point; see Two declarations can't share one C entry point below. A fourth, opt-in only: with publish { strictDependencyTypes = true }, an un-admitted dependency type that the ordinary default would only warn about and skip becomes ERROR_UNEXPORTED_DEPENDENCY_TYPE instead; see below. If a Kotlin declaration trips a generator bug rather than a documented limitation, the build fails with ERROR_INTERNAL_GENERATOR_FAILURE, naming the declaration, the underlying exception, and an exclude("<qualified name>") line that unblocks the build while the bug is reported and fixed; every other offending declaration in the same build is reported the same way, in the same run, rather than one at a time. A failure no single declaration owns is labelled this Kotlin module instead, with nothing to exclude: report it with the whole message (ADR-162).

Breaking in 0.9.0

Diagnostic codes now look the same in both directions. If you grep build logs, filter on a code, or parse diagnostics output, update it:

  • Reverse codes (the ones from binding a NuGet package) are SCREAMING_SNAKE, like the forward ones: skipped_indexer is now SKIPPED_INDEXER. The prefix sets severity in both directions: SKIPPED_, WARNING_, INFO_ or ERROR_.

  • Reverse console lines use the forward format, and the old w: prefix is gone: [nuget:SKIPPED_INDEXER] Skipping MimeMapping/MimeUtility.TypeMap(TypeMap): reason. hint.

  • NugetDiagnostics.json is now an object, {"schemaVersion": 1, "diagnostics": [...]}, instead of a bare array, and reverse-ir.json carries a root schemaVersion: 1. A reader fails with a clear message on a missing or higher version, so check schemaVersion before reading the rest.

A List/Map/Set parameter with an unsupported element/key/value type (see Collections) is skipped like this, naming the component that failed rather than the collection kind:

[nuget:SKIPPED_UNSUPPORTED_INPUT] Skipping Patient.setMoods: its COLLECTION type combination is not supported. the value type Collection? cannot be written into a Kotlin collection; use components that are primitives, Char, String, enums, exported class handles, value classes over those, or non-null nested collections of the same at Fixture.kt:4

A property whose declared type the property planner cannot bridge is skipped the same way, naming the property's own type, the same sentence a callable route prints for the same refusal. Cat.unsupported: Sequence<String> is the fixture:

[nuget:SKIPPED_UNSUPPORTED_PROPERTY] Skipping Cat.unsupported: its type `kotlin.sequences.Sequence` is not supported. kotlin.sequences.Sequence is a Kotlin stdlib type with no first-class C# mapping yet; expose a bridgeable type instead (include(...) is not the fix: an explicit include replaces the export scope rather than mapping the type) at Cat.kt:46

A const val in an enum class's companion object skips this same way, naming the companion. A suspend, generic or Flow-returning function in an enum class body or its companion object skips under the standard kinds (SKIPPED_UNSUPPORTED_COMBINATION for a suspend one), naming the enum; see Enums: Functions and companion members.

Two more places drop a member without any other sign, so each is named too. Members of an interface's companion object (functions, val and const val) are not bound, because the generated C# interface has no statics; each is skipped by name. The companion of a value class or a sealed class or arm is skipped the same way. A member that an exported class inherits from an unexported interface or base class, and that no route binds (a generic function, say), is named once per inheriting class, not on the hidden declaration.

A collection property is skipped the same way when one of its components (the element, or a map key or value) has no C# spelling. A sealed class component no longer falls into this bucket: since ADR-105 it binds, materialised through the ADR-009 FromHandle discriminator (see Sealed types as property types). A sealed interface component binds when the interface is eligible or its arms extend a class (see A sealed interface whose arms extend a class); one with an arm no route can construct has no discriminator to bind against, so it still skips. Since ADR-064's 2026-09-11 amendment the message comes from the element's own SEALED_POSITION reason, naming the sealed interface itself rather than the outer collection shape:

[nuget:SKIPPED_UNSUPPORTED_PROPERTY] Skipping tier1.sealedcollectionproperty.Album.filters: its sealed type `tier1.sealedcollectionproperty.Filter` has no generated C# discriminator. sealed type `tier1.sealedcollectionproperty.Filter` has no generated discriminator, so C# cannot reconstruct it: only an eligible sealed type inside the export scope gets one (ADR-009, ADR-112), and that binds at every position (ADR-105); export it from an included package, make every subclass a class or object (in the sealed type or beside it) with no other superclass and no second sealed interface (ADR-125), make every subclass of a sealed interface an exported, non-abstract, non-generic class none of which extends another (ADR-204), or accept a concrete subclass

SKIPPED_UNSUPPORTED_PROPERTY never fires for a property whose type is a lambda, suspend lambda, Flow, or StateFlow (nullable or not): those are unplannable by design and still bind through a named legacy route, so warning would tell a consumer a working property had vanished.

A const val's C# value comes from the compiler's own evaluated constant (see Top-level declarations), read through an internal KSP2 accessor this plugin's pinned KSP version is known to have. If a future KSP upgrade removes or renames that accessor, every const val in the build is skipped instead of rendering a wrong or unreadable value, named SKIPPED_UNREADABLE_CONST_VALUE. This is a build-environment problem, not something in the author's own Kotlin: pin the plugin's supported KSP version, or declare the value as a plain val instead, which binds as an ordinary get-only property.

Unrouted positions for a lambda, Flow, or a generic declaration

The legacy routes above (Flow/StateFlow and a lambda; a generic class instantiation was a third until it moved onto the plan) each bind at a handful of specific (owner, position) pairs, a class-method return or parameter, a top-level function return, and nowhere else: an object, an interface default, a secondary constructor, a collection element, or the same reason at the other position on a class method, used to vanish from both the Kotlin and C# output with no diagnostic at all. Since ADR-064's 2026-09-13 amendment every one of those positions is a named skip instead:

[nuget:SKIPPED_UNSUPPORTED_RETURN] Skipping DepotRegistry.flowReturnOnObject: a Flow/StateFlow binds at a class-method return and a property, and a `Flow<T>` of a primitive or String at a parameter, but not at this position. return the Flow from a class, object, companion, interface or top-level function, an extension, or a property, or take a `Flow<T>` as a parameter; no Flow route reaches this position at UnroutedPositionsSample.kt:68

A Flow<T> parameter of a primitive or String is not one of these: it binds at every ordinary position, see A Flow as a parameter.

A callable's own type parameter is one of these positions too, and skips SKIPPED_UNSUPPORTED_COMBINATION naming the structural mismatch rather than the position. A class, object, companion or sealed-class member declared fun <T> f(value: T): T binds (see Generic methods on a class); the skip is left for a generic method on an interface, enum or value class (an extension binds, see Extensions), and for a signature the route cannot carry, such as a T nested in a collection. The warning names which shape was refused.

A top-level fun f(): Flow<T> is a named skip and no member, the same as every other position above, rather than a build that only fails downstream in the consumer's own project.

One position is exempt on purpose and stays silent, because a legacy route genuinely re-emits it elsewhere: an interface default with a Flow/StateFlow return or a lambda parameter still re-emits on every class that implements the interface (just not on the generated C# interface itself, see ROADMAP.md).

A related skip, SKIPPED_UNSUPPORTED_PROPERTY, fires when an extension property's receiver type, not its declared type, is what the planner can't wire. See Extensions: Supported receivers for the full admitted set; a receiver outside it warns and the property is dropped entirely, naming the receiver rather than the property's own (usually fine) type:

[nuget:SKIPPED_UNSUPPORTED_PROPERTY] Skipping tier1.skipreceiver.Tin.Latch.label: its extension receiver type generic declaration tier1.skipreceiver.Tin.Latch is not a supported extension-property receiver. declare the property on a class, interface, generic class, type parameter, String, primitive, Char, enum, value class, collection, ByteArray, Uuid, Instant, Duration or bound C# interface receiver, each nullable or not, or expose a top-level getter function instead at <file>:<line>

The message names the declaration, the reason, an actionable hint, and, when KSP can resolve it, the file and line of the Kotlin declaration that was skipped, something the reverse direction's RirDiagnostic cannot carry, since it works from compiled metadata rather than source. See each forward page's own Limitations section for which named diagnostic fires where.

A nested class, object, interface, enum class, or value class under a class or object owner, an interface owner, or a sealed base/arm owner is declared as a real C# nested type, Outer.Nested, at any depth (under a generic class, on a non-generic holder beside it) (ADR-133, ADR-134); see Classes and objects: Nested types. A nested declaration under a still-deferred owner shape (an enum class, a generic interface or a generic sealed owner) still skips named, SKIPPED_NESTED_DECLARATION at the declaration, and a member typed with it skips SKIPPED_UNSUPPORTED_TYPE naming UNDECLARED_CLASS/UNDECLARED_ENUM/UNDECLARED_INTERFACE instead of being spelled as a dangling reference; see Enums: Nested enums and Interfaces, abstract and sealed classes: Nested interfaces.

A member positioned with a Kotlin object type, nested or top-level, skips named too, with the same SKIPPED_UNSUPPORTED_TYPE kind naming a new OBJECT_POSITION reason: an object renders as a C# static class, and C# forbids a static type at a parameter or return position (CS0722). This is not a nesting limitation; it applies to a top-level object the same way. See Classes and objects: An object at a member position stays CS0722.

Three more kinds cover the cross-module export closure (ADR-066, ADR-154; see The nuget {} DSL for the closure's own rules and the include-vs-admit guidance). A reachable dependency-module type the closure never admitted is skipped, naming the exact fix, from test-library's Storeroom.rimguard() (dev.other.bytype.Rimguard sits beside an admitted sibling, Waterbowl, but was never itself admitted):

[nuget:SKIPPED_UNEXPORTED_DEPENDENCY_TYPE] Skipping Storeroom.rimguard: its type `dev.other.bytype.Rimguard` is declared in a dependency module outside the export scope. add admit("dev.other.bytype.Rimguard") to nuget { publish { } } to export it (admit is additive and dependency-only; it takes a package prefix such as "dev.other.bytype" too), or exclude("dev.other.bytype.Rimguard") to record the omission as deliberate at Storeroom.kt:90

Admitting a type admits only that one declaration, not a package walk (ADR-154 §3): test-library/build.gradle.kts admits dev.other.bytype.Waterbowl by name, and Waterbowl still generates as a handle class with its other members intact, while its sibling Rimguard above stays a named skip. The same hint appears at a property position too, under SKIPPED_UNSUPPORTED_PROPERTY rather than this kind (Waterbowl.rim: Rimguard), because a property route reports its own kind regardless of why the type was refused.

admit(...) is only ever the right fix for a type the closure simply never admitted. Two other reasons the closure can refuse a dependency type fold into the same SKIPPED_UNEXPORTED_DEPENDENCY_TYPE kind but each get their own hint, since admit(...) would be wrong advice for either. Following ADR-109's own exclude("<pkg>") remedy for a duplicated type lands here:

[nuget:SKIPPED_UNEXPORTED_DEPENDENCY_TYPE] Skipping Newsroom.latest(): "dep.models" is excluded by exclude("dep.models") in nuget { publish { } }, so a callable reaching dep.models.TopStory is skipped by design; remove that exclude entry to export it here (neither include(...) nor admit(...) can override an exclude)

With neither rootPackage, include nor admit set, so the closure never crosses the module boundary at all, the hint names the setting that turns cross-module admission on instead:

[nuget:SKIPPED_UNEXPORTED_DEPENDENCY_TYPE] Skipping Newsroom.latest(): no rootPackage, include or admit entry is set, so nuget { publish { } } never crosses the module boundary and dep.models.TopStory stays out of the export set; add admit("dep.models.TopStory") to nuget { publish { } } (additive and dependency-only), or set rootPackage

And a dependency's own expect declaration says its actualization lives in that module and cannot be reached with admit(...) at all, naming the type instead of suggesting a scope change that cannot fix it.

With publish { strictDependencyTypes = true }, the first two refusals above (not admitted, cross-module admission off entirely) escalate from a warning to ERROR_UNEXPORTED_DEPENDENCY_TYPE and stop the build; the exclude(...)-caused refusal and the unreachable expect case stay warnings, since both are already a decision the author made on purpose. See The nuget {} DSL for the full set of refusal reasons.

When the scope admits none of the module's public declarations, the processor warns once with SKIPPED_ALL_DECLARATIONS, naming the scope and the packages it dropped, instead of returning silently with no Interop.cs in the package:

[nuget:SKIPPED_ALL_DECLARATIONS] Skipping DemoNative: the export scope (include("kotlin"), rootPackage = "sample") admits none of the module's 3 public declaration(s) in package(s) "sample", so no Interop.cs is generated. an explicit include replaces the rootPackage default rather than adding to it: list your own package(s) in include(...) as well, or drop include(...) to fall back to rootPackage

When at least one dependency-module type is admitted, the closure also emits one aggregate INFO_EXPORTED_FROM_DEPENDENCY line per KSP run rather than one line per type, naming the whole admitted set.

The closure follows two more edges through a nested type, closing a gap ADR-133 left when it shipped nested-type declaration with no closure change of its own. A member returning or taking a nested type climbs to its owner first, so a dependency member naming only Almanac.Page admits Almanac even when nothing anywhere returns Almanac itself; and once a nested type is declared under an admitted owner, the closure also walks its own member types, so Broadcast.Schedule.timetable(): Timetable admits the top-level dependency type Timetable on the strength of a member declared two levels down. Neither edge gives the nested type its own admission record: the owner is what the manifest and the generated C# name, exactly as Classes and objects: Nested types describes.

From Interop.cs, Almanac is declared at namespace level with Page nested inside it even though no member anywhere returns Almanac, and Timetable is declared at namespace level even though the only member naming it is two levels down, on Broadcast.Schedule:

public class Almanac : IDisposable, INugetHandle { // ... public class Page : IDisposable, INugetHandle { // ... } } public class Timetable : IDisposable, INugetHandle { // ... }

Duplicate-type hazard across two published packages

Two Gradle modules can each publish forward and each independently admit the same dependency-module type through their own reachability closure. Neither KSP run can see the other's export scope, so each package would silently declare its own unrelated C# copy of the same Kotlin type (ADR-109). The plugin closes that visibility gap: every forward publisher's include/exclude/rootPackage predicate is lowered into a nuget.publishedScopes KSP option (see The nuget {} DSL), and the processor matches every admitted dependency type against every other publisher's scope, by package, since a cross-module declaration carries no module identity. A match warns with WARNING_DUPLICATED_DEPENDENCY_TYPE. Nothing is skipped and the generated output does not change, so the message says "Duplicating", not the severity-keyed "Skipping" every other warning gets:

[nuget:WARNING_DUPLICATED_DEPENDENCY_TYPE] Duplicating dep.models.TopStory: the export closure admitted it from a dependency module, and the OtherLib NuGet package's export scope (rootPackage/include "dep.models") also covers it, so OtherLib declares its own copy (certainly if it is one of OtherLib's own types, otherwise whenever OtherLib's API reaches it) and a consumer referencing both packages sees two unrelated C# types for one Kotlin type, with no conversion between them. Kotlin objects cannot cross between two native libraries, so export it from exactly one package: publish a single umbrella module that depends on both, or add exclude("dep.models") to nuget { publish { } } here so only OtherLib declares it (callables reaching it are then skipped with SKIPPED_UNEXPORTED_DEPENDENCY_TYPE)

A dependency type the other publisher admits by name with admit(...) is matched too. The reason then reads export scope (admit "dev.other.bykind.SomeType") also covers dev.other.bykind.SomeType, and the hint suggests exclude("dev.other.bykind.SomeType") instead of the package.

The two workable remedies are the two the hint names: publish a single umbrella module that depends on both instead of two separate publishers, or exclude("<pkg>") from one of them so only the other declares it (the excluded module's own callables reaching that type then skip named with SKIPPED_UNEXPORTED_DEPENDENCY_TYPE). A shared "models" NuGet package both publishers depend on is not a remedy: two published packages are two separate Kotlin/Native runtimes in the consumer's process, so a handle minted in one is meaningless to the other's exports.

A related but distinct case: the reachability closure never walks a class's supertypes at all, so an exported class implementing an interface declared outside the export set, the Koin KoinComponent shape, used to render a base-list entry (" : IKoinComponent ") that nothing ever generates. Issue42Api : Issue42Component is the fixture: Issue42Component lives in a separate Gradle module the export set never admits.

[nuget:SKIPPED_UNEXPORTED_SUPERTYPE] Skipping Issue42Api : Issue42Component: supertype 'dev.other.core.Issue42Component' is not in the export set, so it has no generated C# interface; Issue42Api is generated without it and its public members are bound on Issue42Api directly. nothing callable is lost (Issue42Component's implemented members export as members of Issue42Api), but C# sees no Issue42Component type, so `is`/`as` against it is gone; note that include("...") does not help here — the export reachability closure never walks supertypes at Issue42Api.kt:15

That sentence holds when every inherited member binds. When one has no route, the warning says "bound on Issue42Api directly except member, which no route carries and which is named by its own warning", and "nothing callable is lost" becomes "only the members named separately are lost".

Unlike SKIPPED_UNEXPORTED_DEPENDENCY_TYPE, the hint does not point at include(...): the closure has no edge for supertypes, so admitting the dependency package changes nothing. The interface is dropped from the generated base list; the class's own members, and any defaulted member the interface declares, still export:

public class Issue42Api : IDisposable, INugetHandle { /* ... */ public string ComponentTag() // Issue42Component's defaulted method, still bound on the class { /* ... */ } }

An unexported base class dangles the same way, and is covered too: class Issue42Derived : UnexportedBase(), where UnexportedBase lives outside the export set, used to render public class Issue42Derived : UnexportedBase and fail CS0246. A base class carries real callable members, unlike a dropped interface, so the fix is a little different: the base is dropped from the C# base list entirely (Issue42Derived gets no base at all, just IDisposable, INugetHandle), and UnexportedBase's own public members are bound directly on Issue42Derived, with no override.

[nuget:SKIPPED_UNEXPORTED_SUPERTYPE] Skipping Issue42Derived : UnexportedBase: base class 'dev.other.core.UnexportedBase' is not in the export set, so it has no generated C# class; Issue42Derived is generated with no base at all and the base's public members are bound on Issue42Derived directly. nothing callable is lost (UnexportedBase's public members export as members of Issue42Derived), but C# sees no UnexportedBase type and no inheritance relation, so `is`/`as` against it and any other subclass's shared base are gone; UnexportedBase is declared in a dependency, and include("dev.other.core") alone will not admit it: the export reachability closure never walks supertypes, so it enters the export set only when an exported member also names it as a return, parameter or property type and its package is included at Issue42Derived.kt:16
// before: public class Issue42Derived : UnexportedBase -> CS0246 public class Issue42Derived : IDisposable, INugetHandle { public Issue42Derived() { /* ... */ } public string Label { get; } // UnexportedBase's own property, bound on the class public string Own() { /* ... */ } public virtual string Farewell(string name, bool warmly) { /* ... */ } public string Greet(string name) { /* ... */ } // UnexportedBase's own method, bound on the class public string Farewell(string name) { /* ... */ } // warmly omitted; Kotlin supplies false }

Farewell overrides UnexportedBase.farewell(name, warmly: Boolean = false): Kotlin forbids the override from restating the default, so the = false lives only on the dropped base. Issue42Derived widens warmly itself, reading the default flag off the root of the override chain, the same ADR-164 rule described under Constructor and method default parameters. An override of an exported C# base widens the same way, since a base and its override always share one C# signature.

The hint picks the clause that is true for the base at hand instead of hedging: a same-module base gets told to add include("...") alongside the existing rootPackage/include(...) scope, since that genuinely admits it, while a dependency base gets told include(...) alone will not, since the ADR-066 reachability closure never walks supertypes.

The base walk also follows a chain of unexported links to the nearest exported base, rather than stopping after one hop: class Dinghy : Skiff("Dinghy") with Skiff : Vessel(name), only Skiff outside the export set, renders public class Dinghy : Vessel, not base-less. Skiff's own public members (oars, row()) re-home onto Dinghy the same way UnexportedBase's did above; Vessel's members are inherited normally, with no re-homing. The diagnostic fires once per dropped link and its middle clause names the kept base instead of saying there is no base at all:

[nuget:SKIPPED_UNEXPORTED_SUPERTYPE] Skipping Dinghy : Skiff: base class 'io.github.xxfast.kotlin.native.nuget.hidden.Skiff' is not in the export set, so it has no generated C# class; Dinghy is generated extending Vessel, the nearest exported base, and Skiff's public members are bound on Dinghy directly. <hint unchanged> at Dinghy.kt:15

The quoted Issue42Derived block above is unchanged: a single dropped base with no exported base above it still reads "generated with no base at all", byte for byte. The clause only changes when a grand-base survives the walk.

Annotation classes skip named

A public annotation class has no route in the forward direction at all: there is no C# projection of a Kotlin annotation worth generating, so it always vanishes from the generated C#. Until ADR-064's 2026-09-07 amendment that vanishing was silent; now it skips named with SKIPPED_ANNOTATION_CLASS, once per public top-level annotation class:

[nuget:SKIPPED_ANNOTATION_CLASS] Skipping io.github.xxfast.kotlin.native.nuget.test.cat.Tagged: annotation classes are not bridged; there is no C# projection of a Kotlin annotation, so nothing is generated for it. usages of it on exported declarations are unaffected. Make it internal, or exclude(...) its package, if the warning is unwanted at Tagged.kt:19

Applying the annotation to an exported declaration costs that declaration nothing: the forward pipeline reads no annotation but kotlin.native.CName, so @Tagged("plaything") data class Toy(...) still generates its constructor, properties, Copy, Equals, HashCode and ToString exactly as if the annotation weren't there. An internal/private annotation class stays silent, matching every other bucket's visibility gate. An expect annotation class fires the same kind once, on the actual: the isExpect filter drops the expect half one line earlier, so only the actual reaches the bucket, with symbol pointing at the actual's own file.

Opt-in-marked declarations skip named

A declaration carrying its own @RequiresOptIn-meta-annotated marker is not part of the forward-exported surface, at any RequiresOptIn.Level (ADR-115). C# has no way to honour a Kotlin opt-in requirement: a Kotlin consumer of a marked declaration must acknowledge it with @OptIn or a compiler flag, while a C# consumer of the generated binding would see a plain public member with no signal at all. Exporting a marked declaration always erases the marker's purpose, so it is dropped instead and named with SKIPPED_OPT_IN_MARKER, once per dropped declaration, regardless of whether the marker is ERROR - or WARNING-level.

A marked class, object, interface, enum, or value class is never declared at all, refused at the same place a package-scope refusal is, so nothing in the generated Interop.cs names it. A marked member of an exported class, or a marked top-level function or property, skips per-callable instead, so the owning type still generates with everything else intact. A member whose type is a marked class carries its own reason, blaming the type rather than the member, since no include(...) change can ever bring a marked type into scope.

A stored-callback or listener addX/removeX pair is dropped whole if either half is marked, on a class or a sealed arm. Each half gets its own warning, so the unmarked removeX is named as well.

From test-library/.../issue113/Issue113Sample.kt:

@RequiresOptIn(level = RequiresOptIn.Level.ERROR, message = "Litter bookkeeping, not a public API") annotation class InternalApi data class Litter( val name: String, @property:InternalApi val extra: String, @LedgerApi val other: String, ) { @set:InternalApi var viaSetter: String = "unset" } @InternalApi class HouseRules(val motto: String) { fun describe(): String = "rules:$motto" }

HouseRules never appears in the generated C# at all. Litter.Extra, Litter.Other, and Litter.ViaSetter (the whole property, not just its setter) are all absent, while Litter.Name generates normally:

[nuget:SKIPPED_OPT_IN_MARKER] Skipping io.github.xxfast.kotlin.native.nuget.test.issue113.Litter.extra: it is marked with the opt-in marker `io.github.xxfast.kotlin.native.nuget.test.issue113.InternalApi`. a C# consumer has no way to opt in, so an opt-in-required declaration is not exported; remove the `io.github.xxfast.kotlin.native.nuget.test.issue113.InternalApi` annotation from it if it is meant to be part of the C# API, or leave it marked if it is library-internal at Issue113Sample.kt:116

@set:Marker is the only accessor position that compiles at all (@get: and @field: are frontend errors, Opt-in requirement marker annotation cannot be used on getter/... on field, so there is nothing to bridge there in the first place); it is invisible on the property declaration itself and readable only off the setter, so it drops the whole property rather than exporting it get-only.

A member whose type is marked blames the type instead, since no include(...) change can ever bring a marked type into scope. Shelter.rules() returns HouseRules, and the marker sits on HouseRules itself, not on rules():

[nuget:SKIPPED_OPT_IN_MARKER] Skipping io.github.xxfast.kotlin.native.nuget.test.issue113.Shelter.rules: its type `io.github.xxfast.kotlin.native.nuget.test.issue113.HouseRules` is marked with an opt-in marker. no C# type is declared for `io.github.xxfast.kotlin.native.nuget.test.issue113.HouseRules` (opt-in marker `io.github.xxfast.kotlin.native.nuget.test.issue113.InternalApi`), so every member typed with it is skipped rather than emitted as a dangling reference; remove the marker from `io.github.xxfast.kotlin.native.nuget.test.issue113.HouseRules`, or expose a type that is not opt-in-required instead

A marked primary-constructor val follows one invariant: the marked declaration never appears in a C# signature. A trailing marked parameter with a default is dropped from the widened constructor signature the same way a nullable Flow<T>? or sealed defaulted parameter is (ADR-164), and Kotlin always evaluates its default; an undefaulted or non-trailing marked parameter has no routable form at all, so the constructor itself is dropped, copy along with it, and the class stays reachable only through a Kotlin factory, with WARNING_NO_PUBLIC_CONSTRUCTOR naming OPT_IN_MARKER among the reasons. See Classes and objects: No public constructor.

A marker declared one Gradle module away resolves the same way: Cattery.crossModuleName, marked with CatteryInternalApi from :test-models, is absent from the generated C# exactly like a module-local marker would be. @OptIn(InternalApi::class) on a declaration is a marker consumer, not a marker member, and stays exported; @SubclassOptInRequired is not itself @RequiresOptIn-meta-annotated, so it does not match and stays exported either.

An escape list for markers meant to stay public

Not every @RequiresOptIn marker means "internal, keep this out of C#". publish { exportMarkers("com.example.ExperimentalFooApi") } names a marker whose declarations keep exporting through the ordinary route instead, with no SKIPPED_OPT_IN_MARKER diagnostic, as if the marker weren't there at all. Every marker not named keeps being dropped exactly as described above; this is an escape list, not a change to the default.

From test-library/.../issue113/ExportMarkersSample.kt:

@RequiresOptIn(level = RequiresOptIn.Level.ERROR, message = "Diet plans are still settling") @Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION) annotation class ExperimentalDiet class DietPlanner { fun plainName(): String = "Mylo" @ExperimentalDiet fun dietName(): String = "kibble" @LedgerApi fun ledgerName(): String = "ledger" }

test-library/build.gradle.kts waives exactly one marker:

nuget { publish { exportMarkers("io.github.xxfast.kotlin.native.nuget.test.issue113.ExperimentalDiet") } }

DietName() generates normally; LedgerName, behind the unlisted LedgerApi marker, does not:

public string PlainName() { ... } public string DietName() { ... }

An ERROR-level marker's waived declarations only compile inside the generated file because every configured marker is also appended to its own @OptIn list:

@file:OptIn(ExperimentalNativeApi::class, ExperimentalForeignApi::class, ExperimentalCoroutinesApi::class, ExperimentalDiet::class)

Entries are trusted, not validated against the resolver: a misspelt marker name waives nothing, and the SKIPPED_OPT_IN_MARKER diagnostic printed for the declaration still names the marker's real FQN. A waived marker also waives every type marked with it, since both reads go through the same optInMarkerName() choke point; there is no separate list for that case. The marker annotation class itself is never exported, only the declarations that use it.

An opt-in-marked parameter type takes every arity with it

A defaulted parameter whose own type is opt-in-marked is a stronger case than a marked property or default target: it makes the constructor or function illegal outright, since the widened signature still names the parameter's type. Kotlin propagates the opt-in requirement from a callee's declared parameter types, never from what a default expression happens to read, so a data class whose sole parameter is a marked-typed default has no callable constructor at all. It is skipped OPT_IN_MARKER_TYPE (still rendered as SKIPPED_OPT_IN_MARKER), and the class ends up factory-only; see Classes and objects: No public constructor.

From test-library/.../issue128/Issue128Sample.kt, where Grooming is an opt-in-marked enum declared one module away in :test-models:

@OptIn(CatteryInternalApi::class) data class GroomingPlan( val name: String = "Oreo", val grooming: Grooming = Grooming.DAILY, )
[nuget:SKIPPED_OPT_IN_MARKER] Skipping ...GroomingPlan.<init>: its type `...Grooming` is marked with an opt-in marker. no C# type is declared for `...Grooming` (opt-in marker `...CatteryInternalApi`), so every member typed with it is skipped rather than emitted as a dangling reference; remove the marker from `...Grooming`, or expose a type that is not opt-in-required instead [nuget:WARNING_NO_PUBLIC_CONSTRUCTOR] Keeping GroomingPlan: every public constructor is skipped (<init>: OPT_IN_MARKER_TYPE, <init>_2: OPT_IN_MARKER_TYPE, <init>_3: OPT_IN_MARKER_TYPE), so the generated C# class has only its internal handle constructor and C# cannot construct one.

GroomingPlan still generates, keeps Name, and carries only its internal handle constructor. The class line also carries the same detail as an XML-escaped <remarks> doc comment, so a consumer sees it as an IDE tooltip (ADR-064 2026-09-10 amendment):

/// <remarks> /// Cannot be constructed from C#: every Kotlin constructor of GroomingPlan was skipped by the bridge (&lt;init&gt;: OPT_IN_MARKER_TYPE, &lt;init&gt;_2: OPT_IN_MARKER_TYPE, &lt;init&gt;_3: OPT_IN_MARKER_TYPE). Instances come from Kotlin factories that return this type. /// </remarks> public class GroomingPlan : IDisposable, INugetHandle { internal IntPtr _handle; internal GroomingPlan(IntPtr handle, out NugetHandleTag tag) { tag = default; _handle = handle; }

The same check applies to a function's defaulted parameters, not only constructors: a top-level fun schedule(name: String = "Oreo", grooming: Grooming = Grooming.DAILY) has no callable signature either, and schedule is absent from the generated C# entirely.

A class with no reachable constructor stays, and says so

A class whose every public constructor is skipped, for any reason, still generates a C# type, exportedTypes admits a class by declaration, not by constructor outcome, and a Kotlin factory returning the class hands C# a usable instance regardless. What used to be silent is the class carrying only its internal Foo(IntPtr handle) constructor, with nothing in the build log explaining why. Since ADR-064's 2026-09-07 amendment, this warns once per class with WARNING_NO_PUBLIC_CONSTRUCTOR, its verb "Keeping" rather than the usual "Skipping" since the class itself is not skipped:

[nuget:WARNING_NO_PUBLIC_CONSTRUCTOR] Keeping GroomingPlan: every public constructor is skipped (<init>: OPT_IN_MARKER_TYPE, <init>_2: OPT_IN_MARKER_TYPE, <init>_3: OPT_IN_MARKER_TYPE), so the generated C# class has only its internal handle constructor and C# cannot construct one. the type is kept because instances can still come from Kotlin factories that return it (a top-level function, or a companion factory); expose one, or change the constructor parameters to types the bridge can express at Issue128Sample.kt:40

The warning still only reaches the library author's Gradle log and NugetDiagnostics.json; on its own, the 2026-09-07 amendment left a consumer opening GroomingPlan with no explanation anywhere in the assembly or IntelliSense. ADR-064's 2026-09-10 amendment closes that: the same skipped- constructor detail the warning names is also emitted as an XML-escaped /// <remarks> doc comment directly above the class line, using consumer-facing wording rather than the diagnostic's author-facing hint, so the constraint is visible as an IDE tooltip too. See Classes and objects: No public constructor for the rendered block.

Fires for every skip reason a constructor can go for, including a legacy-route deferral that never reaches droppedCallables on its own (no legacy route re-emits a constructor, so that family was silent in every channel before this amendment). A sealed type at a parameter position used to be one such reason, see A sealed type at a parameter position now binds below. Also fires for a sealed subclass arm of kind class whose every constructor is refused, the same reasons and the same <remarks> twin as a non-subclass type (ADR-148), see Interfaces, abstract classes, and sealed classes: Sealed classes and interfaces. Not fired for an abstract class or the interface-return backing wrapper, neither of which is handle-less by accident. See Classes and objects: No public constructor for the full GroomingPlan shape.

A sealed type at a parameter position now binds

A sealed type at a bare, nullable, or collection-component parameter position (including a constructor parameter) used to be dropped completely silently: the ForwardPlanSkipReason that covered it, SEALED_PROTOCOL, was a droppedFromCSharp = false legacy-route deferral on the assumption some other route re-emitted it, but no route ever did for a parameter. ADR-064's 2026-09-07 amendment first renamed the reason SEALED_POSITION, flipped droppedFromCSharp to true, and added SKIPPED_SEALED_POSITION so the skip was at least named; then ADR-105's 2026-09-07 amendment bridged the position itself, sharing the same sealedAsHandle() rewrite the property planner and the sealed-return route use. SKIPPED_SEALED_POSITION still fires, but only for a sealed type with no generated FromHandle discriminator at all (a sealed interface, or a sealed class outside the export scope), or a generic sealed type with no C# spelling at the position (A generic sealed hierarchy). See Interfaces, abstract classes, and sealed classes: A sealed type at a parameter position.

Two declarations can't share one C entry point

The native ABI is one flat namespace of @CName-exported C functions. Every export symbol is <library>_<declaring package relative to nuget.rootPackage>__<the declaration's own name> (Export symbols below), so a.Kitten and b.Kitten in two different packages of one library export as test_a__kitten_create and test_b__kitten_create and both bind; a same-simple-name collision across packages is no longer possible. What is left is a collision inside one package and owner: a member whose name happens to match a generated role, or two different declarations whose mangled names meet because a Kotlin identifier already contains _, this scheme's own separator. Two declarations that resolve to the same symbol used to abort packNuget with a raw IllegalArgumentException naming only the mangled symbol (radio_play_collect), not which Kotlin declarations were fighting over it. ADR-117 (issue #106) replaced that with a named ERROR_C_ENTRY_POINT_COLLISION, naming every owning declaration. A class method whose mangled name already spells a sibling top-level function's own name is one trigger that survives package qualification, because both declarations live in the same package:

package tier1.abicollision.suspend class Radio { suspend fun play(p: Player): Int = 1 } suspend fun radio_play(): Int = 2

The shape below is reconstructed from ForwardDiagnostic.format() ([nuget:<kind>] <verb> <location>: <reason>. <hint><at>) applied to the actual ForwardAbiCollision.reason/.hint text Tier1EntryPointCollisionTest asserts against, with the temp-file paths elided:

[nuget:ERROR_C_ENTRY_POINT_COLLISION] Error tier1.abicollision.suspend.Radio.play(Player): Forward ABI duplicate C# import for test_abicollision_suspend__radio_play_async; 2 Kotlin declarations export the same C entry point: - tier1.abicollision.suspend.Radio.play(Player) at .../A.kt:5 - tier1.abicollision.suspend.radio_play() at .../A.kt:8. The C entry point is the library name, the declaration's package relative to `nuget.rootPackage`, and its enclosing chain of simple names (ADR-163), so two same-named declarations in different packages no longer collide. What remains is a collision inside ONE package and owner: a member whose name matches a generated role (`fun dispose()` against the generated `Dispose`), a Kotlin `_` that reads as this scheme's own separator, or two routes claiming one member; rename one declaration. at .../A.kt:5

The trailing at line echoes the first owner's own location again, the same location logger.error attaches the diagnostic to; it is not a third declaration.

Every @CName export, on every route, names its exact owning declaration; there is no route left that can only point at a class-level range. An ordinary declaration (a constructor, a top-level function, a class method or property, a suspend method) names itself, with parameter types and file:line, as above; this includes a generic class's own declared methods and constructor (ADR-147), which collide the same way an ordinary class's do. A generated member, one Kotlin itself never declares, such as a sealed discriminator or a data-class equals/hashCode/toString, names the class (or sealed arm) that owns it plus a role in parentheses, since several such members can share one class or arm.

A method literally named dispose is a different kind, not this one: every handle class already declares public void Dispose() for free, so fun dispose() collides with that reserved signature during generation, and reports ERROR_CSHARP_SIGNATURE_COLLISION, the same kind and CS0111 wording Classes and objects uses for two same-signature constructors or methods, naming the method and "generated Dispose" as the two owners, located at the dispose() function (ADR-162). It fires earlier than the entry-point check on this page, so it never reaches ERROR_C_ENTRY_POINT_COLLISION at all; a sealed base's own dispose(), and a sealed arm's own dispose() colliding with the base's inherited Dispose(), both fire the same way, and both report in one round when a hierarchy has more than one. fun close() is unaffected: it renders Close() beside Dispose() with no collision.

The hint is always the same: rename one of the colliding declarations. Package qualification (ADR-163) closes every cross-package trigger; this diagnostic is the backstop for what qualification cannot tell apart, a collision inside one package and owner.

A nullable parameter names itself, instead of the return

A callable dropped because one of its parameters is a nullable type with no wire used to render as SKIPPED_UNSUPPORTED_RETURN, the same kind a nullable return with nowhere to put the absence gets, and the hint said "at this position" without saying which one. An author reading that message went looking at the return type, which was perfectly exportable, before finding the actual problem was a parameter. ADR-064's 2026-09-09 amendment (issue #131) gives the skip its own position: an input-position NULLABLE now renders SKIPPED_UNSUPPORTED_INPUT and names the parameter, a return-position one keeps SKIPPED_UNSUPPORTED_RETURN and its unnamed hint (there is no BridgeType-to-Kotlin-spelling renderer to name a return's type):

[nuget:SKIPPED_UNSUPPORTED_INPUT] Skipping io.github.xxfast.kotlin.native.nuget.test.issue131.hubWithEvents: its parameter `events` has a nullable type with no supported wire. the nullable parameter `events` has no wire at an input position; expose a non-nullable wrapper, or a separate has-value/value pair, instead at .../issue131/HubSample.kt:62

That skip is the arity that still carries events. The shorter overloads that omit it still bind: HubWithEvents() and HubWithEvents(Settings). See Function default parameters.

An extension receiver counts as an input position too, unnamed, since it has no author-written parameter name. A nullable exported class handle at a parameter position was never actually unsupported, on any route (null rides IntPtr.Zero); see Classes and objects: A nullable class handle parameter for the shape that does bind.

Where these messages appear

A diagnostic computed at generation time is only useful if it reaches the console. The processor writes every accumulated diagnostic to NugetDiagnostics.json (an object with schemaVersion and a diagnostics array), a declared KSP task output (ADR-100). Being a declared output, not just something printed during the task action, is what makes it survive an incremental build: it is restored on a cache hit and present after an UP-TO-DATE run, the two outcomes a normal, unchanged packNuget produces on every run after the first. A NugetReportDiagnosticsTask ahead of packNuget reads that file and re-emits every message through Gradle's own warning logger, so a skip is visible on every packNuget, cached or not, not only the build where KSP happened to run. This is a real packNuget --console=plain run against this repository's own fixture, KSP task UP-TO-DATE:

> Task :test-library:kspKotlinMacosArm64 UP-TO-DATE > Task :test-library:nugetReportDiagnostics [nuget:INFO_EXPORTED_FROM_DEPENDENCY] Note TestLibraryNative: the export closure admitted 19 type(s) from dependency modules: dev.other.admitted.Billboard, dev.other.bykind.Tuft, dev.other.bytype.Bedding, dev.other.bytype.PurrLevel, dev.other.bytype.Waterbowl, io.github.xxfast.kotlin.native.nuget.test.models.Almanac, io.github.xxfast.kotlin.native.nuget.test.models.Broadcast, io.github.xxfast.kotlin.native.nuget.test.models.Byline, io.github.xxfast.kotlin.native.nuget.test.models.Carton, io.github.xxfast.kotlin.native.nuget.test.models.CartonTag, io.github.xxfast.kotlin.native.nuget.test.models.Nap, io.github.xxfast.kotlin.native.nuget.test.models.Nap.Deep, io.github.xxfast.kotlin.native.nuget.test.models.Nap.Zoomies, io.github.xxfast.kotlin.native.nuget.test.models.Purr, io.github.xxfast.kotlin.native.nuget.test.models.StoryCode, io.github.xxfast.kotlin.native.nuget.test.models.StoryUri, io.github.xxfast.kotlin.native.nuget.test.models.Timetable, io.github.xxfast.kotlin.native.nuget.test.models.TopStory, io.github.xxfast.kotlin.native.nuget.test.models.Whisker. these are generated exactly like module-local types; narrow with exclude(...) if any of them should not be part of the public API [nuget:SKIPPED_UNEXPORTED_DEPENDENCY_TYPE] Skipping io.github.xxfast.kotlin.native.nuget.test.Newsroom.sponsor: its type `dev.other.core.Advertisement` is declared in a dependency module outside the export scope. add admit("dev.other.core.Advertisement") to nuget { publish { } } to export it (admit is additive and dependency-only; it takes a package prefix such as "dev.other.core" too), or exclude("dev.other.core.Advertisement") to record the omission as deliberate at /Users/xxfast/Developer/XXFAST/KMP/kotlin-native-nuget/test-library/src/nativeMain/kotlin/io/github/xxfast/kotlin/native/nuget/test/Newsroom.kt:69 [nuget:SKIPPED_INHERITED_MEMBER] Skipping io.github.xxfast.kotlin.native.nuget.test.models.StoryUri.length: it is a value class member that a supertype declares. a value class never exports a member a supertype declares, whether inherited, delegated (`by`) or explicitly overridden (ADR-082); call the supertype's API through the struct's underlying property from C#, or declare a member under a name or signature no supertype declares [nuget:SKIPPED_INHERITED_MEMBER] Skipping io.github.xxfast.kotlin.native.nuget.test.models.StoryUri.get: it is a value class member that a supertype declares. a value class never exports a member a supertype declares, whether inherited, delegated (`by`) or explicitly overridden (ADR-082); call the supertype's API through the struct's underlying property from C#, or declare a member under a name or signature no supertype declares [nuget:SKIPPED_INHERITED_MEMBER] Skipping io.github.xxfast.kotlin.native.nuget.test.models.StoryUri.subSequence: it is a value class member that a supertype declares. a value class never exports a member a supertype declares, whether inherited, delegated (`by`) or explicitly overridden (ADR-082); call the supertype's API through the struct's underlying property from C#, or declare a member under a name or signature no supertype declares [nuget:SKIPPED_UNSUPPORTED_PROPERTY] Skipping io.github.xxfast.kotlin.native.nuget.test.cat.Cat.unsupported: its type `kotlin.sequences.Sequence` is not supported. kotlin.sequences.Sequence is a Kotlin stdlib type with no first-class C# mapping yet; expose a bridgeable type instead (include(...) is not the fix: an explicit include replaces the export scope rather than mapping the type) at /Users/xxfast/Developer/XXFAST/KMP/kotlin-native-nuget/test-library/src/nativeMain/kotlin/io/github/xxfast/kotlin/native/nuget/test/cat/Cat.kt:46

Each skipped-declaration, warning and info diagnostic prints exactly once per packNuget, however many Kotlin targets are enabled. The kspKotlin{Target} task itself stays quiet about them (KSP shows them only at --debug), because a normal, unchanged packNuget reports that task FROM-CACHE then UP-TO-DATE, so a message printed during its action would be missing on every build after the first.

Two consequences for the library author:

  • Compiling, linking or running KSP on its own, without packNuget, prints nothing for skipped declarations. Run ./gradlew nugetReportDiagnostics to print them, or open NugetDiagnostics.json under build/generated/ksp/<target>/<target>Main/resources/. --info does not show them; --debug does, on a fresh configuration-cache entry.

  • ksp { allWarningsAsErrors = true } does not turn a skipped declaration into a build failure. Only a fatal ERROR_* diagnostic fails the build, and it prints on the KSP task's own console line.

Skipped declarations show up on the generated type

NugetDiagnostics.json and the Gradle log above are the library author's view, at build time. A consumer who only has the .nupkg never sees either. Since ADR-064's 2026-09-20 amendment (issue #249), a dropped property, method, or top-level function is also named on the generated type itself, as a <remarks> paragraph carrying the Kotlin member name, the diagnostic kind, and the reason, so it shows up in the consumer's own IDE tooltip. See Documentation comments: Dropped declarations for the generated shape. The paragraph never repeats the build-log line verbatim: no source file path, no author-facing hint, since those would ship the producer's own file tree to a consumer.

Limitations

AOT and trimming

The generated bindings are AOT-safe.

The generics bridge's reflection, the first blocker (ADR-038, Deferred), is fixed: ADR-094 (Accepted) replaced the type-erased generics bridge's GetField/Activator.CreateInstance reflection with a static factory registry. A build against a pre-ADR-094 version of these bindings (plugin 0.2.0) could still trigger the .NET trimmer's IL2075 on that GetField, mitigated with <TrimmerRootAssembly> on the project compiling Interop.cs; on the current generator this should no longer fire for that reason.

The second blocker, the forward callback surface, is fixed too (ADR-102, Accepted). Every place C# calls back into Kotlin, a per-call lambda parameter, a stored callback, a C#-implemented interface bridge, Flow/StateFlow collection, and suspend continuation resumption, used to hand Kotlin a pointer obtained from Marshal.GetFunctionPointerForDelegate, which needs the runtime to JIT a native-to-managed thunk the first time it is invoked from native code. A fully AOT-compiled runtime has none, which is what threw ExecutionEngineException on a Mono full-AOT Mac Catalyst build the first time a generated Flow was collected. The generated C# now dispatches every one of those shapes through a static [UnmanagedCallersOnly] thunk (one per delegate shape, NugetThunks), keyed off a GCHandle ctx pointer every callback ABI already threaded through unused. Kotlin needs no change:

internal static unsafe partial class NugetThunks { [UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })] internal static IntPtr NugetStringStringCallbackThunk(IntPtr a0, IntPtr a1) { try { return ((NugetStringStringCallback)GCHandle.FromIntPtr(a1).Target!)(a0, a1); } catch (Exception ex) { Environment.FailFast("nuget: unhandled exception in NugetStringStringCallback", ex); return default; } } internal static IntPtr NugetStringStringCallbackPtr => (IntPtr)(delegate* unmanaged[Cdecl]<IntPtr, IntPtr, IntPtr>)&NugetStringStringCallbackThunk; }
Last modified: 03 November 2026