Reject non-uniform JsonDerivedType registrations against a generic base (JsonPolymorphism follow-up)#129294
Reject non-uniform JsonDerivedType registrations against a generic base (JsonPolymorphism follow-up)#129294eiriktsarpalis wants to merge 20 commits into
Conversation
…ization PR #127318 introduced an open-generics polymorphism unifier that surfaced several pathological registration patterns the existing throw/warn surface didn't handle cleanly. This commit adds targeted handling for the closed- derived case and adds coverage that demonstrates the pre-existing open-derived diagnostic behavior for the remaining patterns. ## What changed * Closed-derived `[JsonDerivedType]` registrations whose base spec doesn't match the current closed base (e.g. `Cat:Animal<int>` registered on `Animal<string>` via `Animal<T>`) are now silently filtered. Reflection drops them via `IsAssignableFrom`; source-gen drops them via `HasImplicitConversion`. Previously the registration leaked past the open-generic resolver and either threw at `PolymorphicTypeResolver.UpdateDerivedTypes` or emitted a confusing diagnostic. * When closed-derived filtering empties the derived-types list AND the user did not write `[JsonPolymorphic]`, the type silently degrades to non- polymorphic. If `[JsonPolymorphic]` is present the user has explicitly opted in, so the normal "no derived types" path runs. ## What was kept * Open-derived failures (NotAssignable, UnboundParameter, UnificationFailed, ConstraintViolation, AmbiguousMatch) and open-derived registrations on non-generic bases continue to throw `InvalidOperationException` (reflection) and emit `SYSLIB1229` (source-gen) exactly as in PR #127318. These are genuine misregistrations that warrant a loud diagnostic. ## Tests * New reflection tests covering the three pathological scenarios called out in the PR feedback: closed-derived mismatch with int/string/bool specializations, `where T : IEnumerable<int>` constraint failing for `Base<string>`, and `Cat<T,T2>` on `Animal<T>`. * Mixed-scenario tests verifying that filtered closed derived plus a surviving open derived continue to round-trip correctly. * New source-gen runtime tests for the same scenarios, using `#pragma warning disable SYSLIB1229` around the bad open-derived registrations to verify that the warning is suppressible and the post- warning runtime metadata is benign. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Tagging subscribers to this area: @dotnet/area-system-text-json |
There was a problem hiding this comment.
Pull request overview
This PR adjusts System.Text.Json polymorphism metadata generation for generic base specializations so that closed [JsonDerivedType] registrations that don’t match the current constructed generic base are silently filtered (instead of causing exceptions / diagnostics). It also ensures that when filtering leaves no derived types and there was no explicit [JsonPolymorphic], the specialization is treated as non-polymorphic to avoid downstream “empty options” failures.
Changes:
- Reflection resolver: filter out mismatched closed derived registrations for generic base specializations and demote to non-polymorphic when filtering empties the list (unless
[JsonPolymorphic]was explicitly declared). - Source generator parser: mirror the closed-derived filtering for generic base specializations.
- Add reflection + source-gen runtime tests covering specialization-specific closed-derived registrations, constraint/unification failure behaviors, and mixed open/closed scenarios.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs | Filters mismatched closed derived types for generic base specializations and demotes to non-polymorphic when filtering empties derived types without explicit [JsonPolymorphic]. |
| src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs | Adds source-gen-side filtering of mismatched closed derived registrations for generic base specializations (mirrors reflection behavior). |
| src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs | Adds reflection-based test coverage for specialization-specific filtering and existing open-derived failure modes. |
| src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs | Adds source-gen runtime tests validating emitted metadata behavior (filtered derived types and non-polymorphic demotion). |
This comment has been minimized.
This comment has been minimized.
- Source-gen filter: classify conversion via Compilation.ClassifyConversion
so identity+implicit-reference (matching System.Type.IsAssignableFrom)
is enforced instead of HasImplicitConversion, which would have admitted
user-defined implicit operators that the runtime resolver rejects.
Drops the INamedTypeSymbol pattern on the derived side so other closed
shapes (e.g. arrays) are filtered consistently with reflection.
- Test renames: ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOn{Int,String}
-> ...OnSpecAnimalOf{Int,String} to match the SpecAnimal<T> fixture name.
- Drop System.Collections.Generic.* qualifications in test code (already imported).
- New test: ClosedDerivedTypes_AllFilteredButPolymorphicAttributeExplicit_Throws
covering the [JsonPolymorphic]-is-explicit branch in PopulatePolymorphismMetadata.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This comment has been minimized.
This comment has been minimized.
Reject any [JsonDerivedType(typeof(Derived<>))] registration whose derived type does not apply to every specialization of the open base. This replaces the per-closure silent-filter approach with a single, deterministic check performed at metadata-resolution time, so introducing a new closure can no longer surface a previously-hidden warning or runtime failure. A derived registration is universal iff: - every type parameter on the derived flows into a base type parameter slot (no concrete pinning, no constructed-type pinning); and - the derived's constraints on each parameter are implied by the base's constraints on the substituted base parameter slot. Closed derived registrations on a generic base are also rejected unless the closed derived is structurally assignable to every closure of the base. The check is implemented identically in the source generator (JsonSourceGenerator.Parser.cs + RoslynExtensions.cs) and in the reflection resolver (DefaultJsonTypeInfoResolver.Helpers.cs + ReflectionExtensions.cs). SYSLIB1229 is reused for the source-gen diagnostic with new message variants; reflection throws InvalidOperationException with the matching text. Tests: - Flip the per-closure resolves/filters tests from PR #129294 to expect rejection. - Add a catalog of universality patterns covering parameter collapse, reorder, constraint subsumption, multi-ancestor interface impls, nested enclosing types, transitive inheritance, array pinning, partial pinning, open-on-non-generic, and programmatic API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| if (baseType.IsGenericType) | ||
| { | ||
| ThrowHelper.ThrowInvalidOperationException_OpenGenericDerivedTypeCouldNotBeResolved( | ||
| baseType, entry.DerivedType, SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase); | ||
| } |
| public T? Item { get; set; } | ||
| } | ||
|
|
||
| // ---- Universal-applicability tests (PR #129294, B-strict rule) ---- |
| if (!ancestor.TryUnifyWith(baseTypeDefinition, substitution)) | ||
| { | ||
| failureReason = SR.Format(SR.Polymorphism_OpenGeneric_Reason_UnboundParameter, required.Name); | ||
| // Some position pins a concrete type (e.g. Base<T, int>) or a constructed | ||
| // pattern (e.g. Base<List<T>>) that cannot match a free base parameter. | ||
| failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; | ||
| return false; |
This comment has been minimized.
This comment has been minimized.
The universal-applicability check for [JsonDerivedType(typeof(D<>))] on a generic base is a property of the open base/derived definitions alone, but ProcessTypeCustomAttributes runs once per closed type referenced via [JsonSerializable]. Without dedup, registering three closures of the same base (e.g. Base<int>, Base<string>, Base<double>) caused SYSLIB1229 to fire three times for the same misregistration. Track diagnosed (open base definition, open derived definition) pairs in a HashSet field on the per-context Parser instance, and emit the diagnostic only on first occurrence per pair. The reflection side is unaffected because it throws immediately, which aborts the first closure that hits the failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per PR review feedback, the constraint subsumption logic was solving cases that C# itself prevents. When a derived's type parameter is identified with a base's type parameter (the canonical mapping our unification produces), C# already forces the derived to declare at least the base's constraints. So "derived has fewer constraints than base" is unreachable through valid C#, and exact match is behaviorally equivalent to subsumption for every C#-expressible case in the universal- applicability regime. Exact match is also forward-compatible: if C# adds new constraint kinds (e.g. notnull -> some future runtime-visible constraint), the subsumption logic would silently admit universality when the new kind appears on base but not derived; exact match rejects flag mismatches by default, which is the conservative direction. Source-gen side (RoslynExtensions.AreConstraintsEquivalent): - Compares HasReferenceTypeConstraint, HasValueTypeConstraint, HasUnmanagedTypeConstraint, HasConstructorConstraint for equality. - Compares ConstraintTypes sets order-independently via HashSet after substituting derived constraints into base-param terms. - Drops the IsBaseConstraintSetImplying helper, the System.Object / System.ValueType carve-outs, and the special interplay rules. Reflection side (ReflectionExtensions.AreConstraintsEquivalent): - Compares (GenericParameterAttributes & SpecialConstraintMask) directly for equality. - Compares GetGenericParameterConstraints() sets via HashSet after substitution. - Drops the typeof(object) / typeof(ValueType) / IsAssignableFrom paths. - Documents the known intentional asymmetry around the unmanaged-as- modreq encoding (reflection can't see it; MakeGenericType catches any remaining mismatch at closure time). Renames the SR string Polymorphism_OpenGeneric_Reason_ConstraintNarrowing to Polymorphism_OpenGeneric_Reason_ConstraintMismatch, updates the user- facing wording from "stricter than" to "differ from" on both Strings.resx files and all 13 xlf locale files, and renames the corresponding tests in JsonSourceGeneratorDiagnosticsTests and PolymorphicTests.CustomType- Hierarchies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This comment has been minimized.
This comment has been minimized.
Per PR review feedback, the private nested BaseDerivedDefinitionPairComparer was just a hand-rolled tuple comparer that combined two SymbolEqualityComparer.Default instances. Move it to RoslynExtensions as a general CreateTupleComparer<T1, T2>(first, second) combinator, and use it with SymbolEqualityComparer.Default for both elements. The resulting comparer is cached in a static readonly field rather than being constructed per Parser instance, since the comparer itself is stateless. Tuple type widened from (ITypeSymbol, ITypeSymbol) to (ISymbol, ISymbol) to match SymbolEqualityComparer's IEqualityComparer<ISymbol> shape (the type parameter is invariant, so the narrower variant required an unsound cast). The HashSet's call sites pass ITypeSymbol values which convert implicitly via tuple element conversion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| if (baseType.IsGenericType) | ||
| { | ||
| ThrowHelper.ThrowInvalidOperationException_OpenGenericDerivedTypeCouldNotBeResolved( | ||
| baseType, entry.DerivedType, SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase); | ||
| } |
| /// Registrations that pin a particular specialization (e.g. <c>Derived<T> : Base<T, int></c>) | ||
| /// are rejected: such registrations would silently work for one base specialization | ||
| /// and break for another, which we treat as a misregistration regardless of which | ||
| /// specialization is currently being constructed. |
This comment has been minimized.
This comment has been minimized.
All three call sites live in ProcessTypeCustomAttributes; place the helper at the method end per repo convention so it stays scoped to its caller. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Whether SYSLIB1229 fires at all is rare; defer the HashSet allocation until the first diagnostic is recorded. Uses the C# 'field' keyword so the property has an implicit backing field with no separate declaration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Definition is self-explanatory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move the �aseType is not INamedTypeSymbol { IsGenericType: true } check
from the open-derived call site in ProcessTypeCustomAttributes into the
resolver helper itself. The helper now accepts ITypeSymbol baseType and
emits the same Reason_NotAssignable failure when the base is not a
generic named type. Drops one explicit ReportOpenGenericDerivedTypeDiagnostic
call from the caller and removes the corresponding nested if/continue
block, leaving a single uniform open-derived failure path.
Behavior is unchanged. The closed-derived-on-generic-base check at the
same call site is intentionally left out of the helper; that branch
applies only when the derived registration is closed and continues to
short-circuit before the resolver is invoked.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This comment has been minimized.
This comment has been minimized.
Generalize TryResolveOpenGenericDerivedType into TryResolveDerivedType,
which now handles all four (open|closed derived) x (generic|non-generic
base) cases:
- open derived + generic base -> universal unification (closure or
localized failure)
- open derived + non-generic -> Reason_NotAssignable
- closed derived + generic base -> Reason_ClosedDerivedOnGenericBase
- closed derived + non-generic -> pass-through, normal downstream
assignability check applies
This collapses the open-derived and closed-derived-on-generic-base
branches in ProcessTypeCustomAttributes into one call site with a
single ReportOpenGenericDerivedTypeDiagnostic invocation. Behavior is
unchanged.
The reflection-side wrapper still splits the dispatch differently (it
pre-splits the base into definition + args and inlines the closed-derived
and non-generic-base checks at the caller so the per-derived loop can
reuse cached args). The lockstep requirement only covers the inner open-
derived unification core, not the four-case dispatch shape; docstring
clarified accordingly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
With closed-derived-on-generic-base folded into TryResolveDerivedType, the local function had only one caller and was just a dedup-wrapper around ReportDiagnostic. Inline it; move the dedup rationale onto the DiagnosedOpenDerivedRegistrations field declaration where the set itself is defined. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The per-ancestor unification, canonical-substitution consistency check, constraint-equivalence loop, and closure construction are pure type-theory logic; they don't depend on parser state, [JsonDerivedType] attribute parsing, or diagnostic emission. Move them into RoslynExtensions.TryResolveOpenGenericDerivedType (an extension on Compilation). JsonSourceGenerator.Parser.TryResolveDerivedType retains the four-case open/closed x generic/non-generic dispatch (because the closed-derived / non-generic-base early-outs are parser-shaped) and delegates the open + generic case to the new helper. The reflection-side resolver's MIRRORS cross-reference is updated to point at the new location (and was already stale -- it still named the old TryResolveOpenGenericDerivedType in Parser, which had since been renamed to TryResolveDerivedType). No behavior change. All 27 polymorphism / open-generic / derived-type source-gen unit tests still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Renames the SR identifier `Polymorphism_OpenGeneric_Reason_NonUniversalPinning` to `Polymorphism_OpenGeneric_Reason_NonUniformPinning` and updates all comments, doc-comments, and message text to use "uniform" / "uniformly" in place of "universal" / "universally". Adds a new SR identifier `Polymorphism_OpenGeneric_Reason_NonUniformUnification` so the pinning case (no unifier exists for the given ancestor-base pair) and the non-pure-renaming case (a unifier exists but maps a derived parameter to something other than a base parameter) can be distinguished in diagnostics. With the rigid-target unification used here the latter case is essentially unreachable today; the separate diagnostic exists so any future relaxation of `TryUnifyWith` surfaces with a precise message rather than getting silently lumped under pinning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Restructures the source-generator dispatcher `Parser.TryResolveDerivedType` so every successful arm flows through a single `baseType.IsAssignableFrom(resolvedDerivedType)` post-condition. The check has two effects: * For the closed-derived / non-generic-base arm (the common `[JsonDerivedType(typeof(Cat))] class Animal` shape), outright invalid pairs such as `[JsonDerivedType(typeof(int))] class Foo` or `[JsonDerivedType(typeof(Unrelated))] interface IShape` are now caught at metadata-resolution time as `SYSLIB1229` with reason `Polymorphism_OpenGeneric_Reason_NotAssignable`. Previously these registrations passed through unchanged and only failed at first serialization via `PolymorphicTypeResolver.IsSupportedDerivedType`. * For the open-derived / generic-base arm the post-condition is defensive: closure construction already substitutes derived parameters with the matching base type arguments so the matching ancestor of the resolved closed derived type is exactly `baseType`. The check guards against a future unification-logic bug producing a non-assignable closure. Adds five unit tests in `JsonSourceGeneratorDiagnosticsTests.cs` covering: closed derived type not assignable to a non-generic class base; a regression guard for the well-formed closed-derived happy path; closed derived assignable via interface implementation (happy path); closed derived that does not implement an interface base; and a value-type derived registered against a reference-type base. Also fixes an unintentional bad registration in the shared test file `JsonCreationHandlingTests.Object.cs` (test class `BaseClassRecursive` was declaring derived types from an unrelated hierarchy due to what appears to be a copy-paste from the neighbouring `BaseClassWithPolymorphism` class). The test's intent is to verify the `JsonObjectCreationHandling.Populate` plus polymorphism conflict throws `InvalidOperationException` at runtime; with the registrations corrected to a real subtype the conflict still throws and the test passes on both reflection and source-generator paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| if (baseType.IsGenericType) | ||
| { | ||
| ThrowHelper.ThrowInvalidOperationException_OpenGenericDerivedTypeCouldNotBeResolved( | ||
| baseType, entry.DerivedType, SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase); | ||
| } |
| if (!TryResolveDerivedType( | ||
| derivedType, typeToGenerate.Type, | ||
| out ITypeSymbol? resolvedDerivedType, out string? failureReason)) | ||
| { | ||
| if (!TryResolveOpenGenericDerivedType( | ||
| unboundDerived, typeToGenerate.Type, | ||
| out INamedTypeSymbol? resolvedType, out string? failureReason)) | ||
| if (DiagnosedOpenDerivedRegistrations.Add((typeToGenerate.Type.OriginalDefinition, derivedType.OriginalDefinition))) | ||
| { | ||
| ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, attributeData.GetLocation(), derivedType.ToDisplayString(), typeToGenerate.Type.ToDisplayString(), failureReason); | ||
| continue; | ||
| } |
| // SYSLIB1229 dedup set: emit at most once per (open base definition, open derived | ||
| // definition) pair across the lifetime of this Parser. Whether a derived registration | ||
| // applies uniformly to a generic base is a property of the open forms alone; without | ||
| // dedup we'd spam the same diagnostic for every closure of the same base referenced via | ||
| // JsonSerializableAttribute. |
| if (baseType is INamedTypeSymbol { IsGenericType: true }) | ||
| { | ||
| failureReason = SR.Polymorphism_OpenGeneric_Reason_AmbiguousMatch; | ||
| // Closed derived registered against a generic base, e.g. | ||
| // [JsonDerivedType(typeof(Cat))] class Animal<T>; class Cat : Animal<int>; | ||
| // The same JsonDerivedType attribute lives on the open base definition and is | ||
| // shared across every closure, so a closed derived necessarily pins one | ||
| // specialization (here Animal<int>) and would silently apply for that closure | ||
| // and break for every other (Animal<string>, Animal<DateTime>, ...). Reject at | ||
| // metadata-resolution time. | ||
| failureReason = SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase; | ||
| return false; |
🤖 Copilot Code Review — PR #129294Note This review was generated by GitHub Copilot. Holistic AssessmentMotivation: Well-justified. The PR addresses real failure modes discovered through stress-testing of #127318's open-generics polymorphism: crash, silent miscompilation, and confusing diagnostics for pathological Approach: Sound. The implementation mirrors the resolver logic across source-gen (Roslyn symbols) and reflection ( Summary: Detailed Findings✅ Correctness — Core algorithm is soundThe per-ancestor independent unification with subsequent coincidence check is a correct implementation of the uniform-applicability concept. Key properties verified:
✅ Mirror implementations are consistentThe source-gen ( ✅ Diagnostic dedup is well-designedThe ✅ Drive-by test fix is correctThe fix to ✅ Test coverage is comprehensiveThe test additions cover:
💡 Dead resource strings in gen project
|
Note
The code in this PR was AI/Copilot-generated. The PR author reviewed the changes before opening this PR.
Follow-up to #127318
While stress-testing the open-generics polymorphism support added in #127318, a handful of pathological
[JsonDerivedType]registrations surfaced where the source generator either crashed, silently produced ill-formed metadata, or emitted a confusingSYSLIB1229. The shared root cause: a derived registration that does not apply uniformly to every specialization of the open generic base it is attached to — for example, a closed derived attached to an open generic base, or an open derived whose base specification pins one of the base's type arguments to a concrete type.This PR formalises a uniform-applicability rule: a
[JsonDerivedType]attribute on an open generic base is only well-formed if it admits exactly one canonical mapping from each derived type parameter to a base type parameter that simultaneously satisfies every matching ancestor of the derived type, with derived constraints exactly matching the corresponding base parameter constraints. Anything that does not meet this bar is rejected at compile time withSYSLIB1229(source generator) and atConfigure()time withInvalidOperationException(reflection resolver), instead of being silently filtered, silently miscompiled, or only surfacing on the first serialization call.Motivating cases
Each of the cases below is from the original triage list. Before this PR they either crashed, emitted a confusing diagnostic, or silently produced wrong metadata. After this PR they each emit
SYSLIB1229(source generator) and throwInvalidOperationException(reflection) with a precise reason string:1. Closed derived on an open generic base
The attribute lives on the open base definition
Animal<T>and is therefore shared across every closure ofAnimal.Catonly applies toAnimal<int>;Dogonly applies toAnimal<string>; neither applies to (e.g.)Animal<bool>. There is no single uniform answer the resolver can give.→
SYSLIB1229with reasonPolymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase:2. Open derived with a constraint that the base's open form does not guarantee
Derived<T>is only constructible forT : IEnumerable<int>, butBase<T>has no such constraint, so the registration would silently work forBase<List<int>>and break forBase<string>.→
SYSLIB1229with reasonPolymorphism_OpenGeneric_Reason_ConstraintMismatch:3. Multiple derived registrations with different arities
The
Cat<T, U>registration has an unbound parameterUthe base cannot pin down.→
SYSLIB1229with reasonPolymorphism_OpenGeneric_Reason_UnboundParameter:4. Closed derived registered against a non-generic base that the derived does not actually inherit from
Previously this passed through the source generator silently and only failed at first serialization (inside
PolymorphicTypeResolver.IsSupportedDerivedType). Now the source generator catches it at metadata-resolution time.→
SYSLIB1229with reasonPolymorphism_OpenGeneric_Reason_NotAssignable:What's actually in this PR
RoslynExtensions.TryResolveOpenGenericDerivedTypeand reflectionDefaultJsonTypeInfoResolver.Helpers.TryResolveOpenGenericDerivedType). Per-ancestor unifications are computed independently and then verified to coincide — this is what catches asymmetric multi-interface implementations likeD<U1, U2> : IBase<U1, U2>, IBase<U2, U1>where each ancestor admits a unifier on its own but no single canonical answer covers both.where T : IComparable<T>on the derived matcheswhere U : IComparable<U>on the base only after the renaming substitution has been applied to both occurrences). The compile-time-onlynotnullconstraint is intentionally ignored.TryResolveDerivedTypedispatcher: every successful arm — including the common closed-derived / non-generic-base arm — now flows through a singlebaseType.IsAssignableFrom(resolvedDerivedType)gate. This is the change that catches case 4 above.NonUniversalPinningreason has been renamed toNonUniformPinning(no unifier exists for the ancestor-base pair) and an additionalNonUniformUnificationreason has been carved out for the case where a unifier exists but maps a derived parameter to something other than a base parameter (a pure renaming check). The new reason is essentially unreachable today; it exists so any future relaxation ofTryUnifyWithsurfaces with a precise diagnostic.Patterns that remain admitted
Everything that was already admitted by #127318 continues to work. The complete admitted set is:
[JsonDerivedType(typeof(D<>))] class B<T>; class D<T> : B<T>;[JsonDerivedType(typeof(D<,>))] class B<T1,T2>; class D<U,V> : B<V,U>;[JsonDerivedType(typeof(D<>))] class B<T>; class D<T> : B<List<T>>;[JsonDerivedType(typeof(D<>))] interface IB<T>; class D<T> : IB<T>;[JsonDerivedType(typeof(Outer<>.Leaf<>))] class B<T>; class Outer<T> { class Leaf<U> : B<(T,U)>; }[JsonDerivedType(typeof(D<>))] class B<T> where T : struct; class D<T> : B<T> where T : struct;[JsonDerivedType(typeof(D<>))] class B<U> where U : IComparable<U>; class D<T> : B<T> where T : IComparable<T>;[JsonDerivedType(typeof(Cat))] class Animal; class Cat : Animal;(non-generic base, closed derived — the common case)Diagnostic table
Polymorphism_OpenGeneric_Reason_NotAssignablePolymorphism_OpenGeneric_Reason_UnificationFailedPolymorphism_OpenGeneric_Reason_NonUniformPinningPolymorphism_OpenGeneric_Reason_NonUniformUnification(new)Polymorphism_OpenGeneric_Reason_UnboundParameter'{0}'of the derived type is not bound by the base type's argumentsPolymorphism_OpenGeneric_Reason_AmbiguousMatchPolymorphism_OpenGeneric_Reason_ConstraintMismatch'{0}'declares constraints that differ from the constraints on the corresponding base type parameter'{1}'Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBaseAll reasons are surfaced through
SYSLIB1229(source generator) andInvalidOperationExceptionfromThrowHelper.ThrowInvalidOperationException_OpenGenericDerivedTypeCouldNotBeResolved(reflection).SYSLIB1229is#pragma-suppressible; suppressing it causes the offending registration to be dropped from the generated metadata.Tests
Added coverage across both worlds:
PolymorphicTests.CustomTypeHierarchies.cs) — uniform vs non-uniform variants of every pattern above, plus a position-pinning matrix, parameter collapse / reorder cases, transitive inheritance, self-referential constraints, and a multi-interface-construction case (D<U1,U2> : IBase<U1,U2>, IBase<U2,U1>) that exercises the per-ancestor coincidence check.System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs) — mirrors the reflection fixture, with#pragma warning disable SYSLIB1229annotations on the contexts that intentionally exercise the failure path so the fixture compiles.JsonSourceGeneratorDiagnosticsTests.cs) — oneOpenGenericDerivedType_*_WarnsWithSYSLIB1229test per reason, plus five newClosedDerivedType_*tests covering the post-condition assignability check (closed derived not assignable to a non-generic class base / interface base, value type registered against a reference type base, plus regression guards for the well-formed happy paths so the post-condition isn't accidentally over-eager).Local test runs on
main:System.Text.Json.Tests(net11.0 Release)System.Text.Json.Tests(net481 Release)System.Text.Json.SourceGeneration.Roslyn4.4.Tests(net11.0 Release)System.Text.Json.SourceGeneration.Roslyn4.4.Tests(net481 Release)System.Text.Json.SourceGeneration.Roslyn3.11.Tests(net481 Release)System.Text.Json.SourceGeneration.Roslyn4.4.Unit.Tests(net11.0 Release)Drive-by test fix
tests/Common/JsonCreationHandlingTests.Object.cshad two[JsonDerivedType]registrations onBaseClassRecursivethat referenced types from an unrelated hierarchy (BaseClassWithPolymorphismandDerivedClass_DerivingFrom_BaseClassWithPolymorphism) — what appears to be a copy-paste from the neighbouringBaseClassWithPolymorphismclass. The test's intent is to verify that theJsonObjectCreationHandling.Populateplus polymorphism combination throwsInvalidOperationExceptionat runtime; with the registrations corrected to a real subtype the conflict still throws and the test passes on both reflection and source-generator paths.Branch name
The local branch name still carries the
pr/127318/copilot/...prefix because the session's PR association couldn't be reset prior to opening this PR. The branch is otherwise disentangled from #127318.cc @eiriktsarpalis