0010: Extension testing, coexistence rules and API compatibility gates
Status
Accepted (2026-09-29, P2 spec specs/004-catalog-cli/; decision 3's opt-in clause amended by ADR-0017). Drafted as Proposed in
docs/research/2026-09-29-extension-testing/ §7; this version keeps its decisions 1–3, replaces its decision 4
("decide the hazards in a separate ADR") with the decision itself, and adds the P2/P3 split of the author kit.
Context
NetPrints loads extensions into per-extension, non-collectible ExtensionLoadContexts. Node kinds are namespaced
by manifest id, other contribution ids are first-wins with NPX006, duplicate extension ids are NPX004, and
dependsOn orders loading topologically. Compatibility is declared (netprintsApi major equal, minor ≤ host)
but not verified against binaries.
The research found two unpinned behaviours:
- Shared-prefix private dependency. Sharing is decided by name prefix only (
NetPrints,Microsoft.Build,Microsoft.CodeAnalysis,CommunityToolkit.Mvvm,System.Reactive,DynamicData,Avalonia, plusMicrosoft.Extensions.*.Abstractionsand the host's platform assemblies). A private dependency whose name merely starts with one of those prefixes (NetPrintsUnreal.Runtime.dll, a third-partyAvalonia.*add-on) is deferred to the Default context, where it does not exist, and fails withNPX007or aFileNotFoundExceptionat first use. NetPrintsUnreal plans assemblies namedNetPrints.Unreal.*. dependsOndoes not share types. It only orders loading. Extension B cannot resolve extension A's assembly (Private=falseleaves it out of B's folder), and if B ships a copy it gets a second type identity for A's types.
There is no multi-extension test suite, no kit for extension authors, and no API tracking. NetPrints.Core and
NetPrints.Reflection shipped in v0.1.0/v0.1.1; NetPrints.Extensibility and NetPrints.Serialization are
not packable yet (P3 publishes them).
Decision
- Author kit.
NetPrints.Extensibility.Testing(framework-agnosticExtensionTest<TExtension>, anExtensionHarness, a twelve-check conformance suite with a "noisy neighbour" co-load, and an optional xUnit adapter) is the author-facing kit.NetPrints.TestExtensionis tested only through it once it ships. - Host multi-extension suite. Fixture extensions (baseline pair with
dependsOn, id squatter, duplicate id, private dependency v1/v2, host-assembly skew, shared-prefix private dependency, type provider/consumer, throws-mid-register, native dependency) and scenarios (id conflicts, load-order permutation invariance of the registry and the generated C#, dependency-version isolation, extension-on-extension types, documents across extension subsets, failure isolation, scale, reload caching) run in the Core test job on every PR. A nightly job co-loads published third-party extensions once any exist. - API compatibility.
Microsoft.CodeAnalysis.PublicApiAnalyzerstracks the public API ofNetPrints.Extensibility,NetPrints.Core,NetPrints.Reflection,NetPrints.SerializationandNetPrints.Catalog. The API released inv0.1.1(Core, Reflection) is recorded as Shipped; everything else is Unshipped until its first release.- Unstable API carries
[Experimental]with these ids and a link to the extensions guide's "API stability" section:NPXE0001host channel,NPXE0002extension settings,NPXE0003class and member emitters,NPXE0004catalog engine and profiles. The repository's own projects opt in through one property (NetPrintsExperimentalOptIn, appended toNoWarninDirectory.Build.targets); that line is the only<NoWarn>the hygiene gate allows, and it may hold onlyNPXEids (ADR-0003 ledger row). Amended by ADR-0017: opt-in is per project and per id. PackageValidationBaselineVersionis set when the extension API is first published (P3), not in P2: 0.x packages may change, and the tracked API files already make every change visible in review. From then on a release check tiesExtensionApi.Versionbumps to Shipped/Unshipped changes.- Before the first third-party release,
netprints-verify(a static verifier of an extension's member references against given host versions) and a reusable author CI workflow.
- Coexistence rules (the two hazards).
- Share by what the host provides, not by name. An extension's dependency is taken from the host only
when the host provides it: the name is one of the host application's trusted platform assemblies, or an
assembly of that name is already loaded in the Default context (at the time the extension's context is created), or it belongs to a family the host
resolves itself (
Microsoft.Build*, via MSBuildLocator). Every other dependency resolves from the extension's own folder, whatever its name. The prefix list is removed. A copy of a host assembly inside an extension folder is ignored (the host's wins) with a logged warning. dependsOnshares types. An extension's load context resolves assemblies from the load contexts of the extensions it depends on — in declared order, then transitively depth-first — before its own folder. The dependency's assemblies are loaded once, in the dependency's context, so both extensions see one type identity. Consumers reference providers withPrivate=false; a shipped copy is ignored with a warning. With diamonds, the first dependency in declared order wins. A dependency's copy older than the version the extension was built against fails that extension's load withNPX008(equal or newer is accepted).- One new diagnostic code,
NPX008(amendment, G-R3). A host-skew failure (an extension compiled against a newer host assembly) stays that extension's load failure (NPX005when it surfaces inRegister); static detection isnetprints-verify's job. Both rules are pinned by the multi-extension suite before and after the loader change.
- Share by what the host provides, not by name. An extension's dependency is taken from the host only
when the host provides it: the name is one of the host application's trusted platform assemblies, or an
assembly of that name is already loaded in the Default context (at the time the extension's context is created), or it belongs to a family the host
resolves itself (
- What lands when. P2 ships decisions 2, 3 (tracking and
[Experimental]) and 4, plus the kit's internal core: anExtensionHarnessin the repository's test-support library, used by the suite. P3 ships the public kit (decision 1) together with the first packableNetPrints.Extensibility, and sets the validation baseline.netprints-verify, the author workflow, the nightly job and adotnet new netprints-extensiontemplate come before the first NetPrintsUnreal release.
Consequences
- NetPrintsUnreal can name its assemblies freely and split into cooperating extensions whose types flow between them with one identity.
- An extension can no longer rely on accidentally sharing a prefixed assembly the host does not actually ship; such an assembly now loads privately, which is what its author meant.
- Every public API change of the tracked libraries is a reviewed diff; experimental API needs an explicit opt-in outside the repository.
- The suite adds about a dozen small fixture projects and one test category to the Core job. Unload testing stays deferred until collectible contexts are adopted.
- The kit's public surface is designed once, in P3, against the harness that P2 already exercises.