Skip to content

Reject non-uniform JsonDerivedType registrations against a generic base (JsonPolymorphism follow-up)#129294

Draft
eiriktsarpalis wants to merge 20 commits into
mainfrom
pr/127318/copilot/support-open-generics-serialization
Draft

Reject non-uniform JsonDerivedType registrations against a generic base (JsonPolymorphism follow-up)#129294
eiriktsarpalis wants to merge 20 commits into
mainfrom
pr/127318/copilot/support-open-generics-serialization

Conversation

@eiriktsarpalis

@eiriktsarpalis eiriktsarpalis commented Jun 11, 2026

Copy link
Copy Markdown
Member

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 confusing SYSLIB1229. 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 with SYSLIB1229 (source generator) and at Configure() time with InvalidOperationException (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 throw InvalidOperationException (reflection) with a precise reason string:

1. Closed derived on an open generic base

[JsonDerivedType(typeof(Cat))]
[JsonDerivedType(typeof(Dog))]
class Animal<T>;
class Cat : Animal<int>;
class Dog : Animal<string>;

The attribute lives on the open base definition Animal<T> and is therefore shared across every closure of Animal. Cat only applies to Animal<int>; Dog only applies to Animal<string>; neither applies to (e.g.) Animal<bool>. There is no single uniform answer the resolver can give.

SYSLIB1229 with reason Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase:

a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base.

2. Open derived with a constraint that the base's open form does not guarantee

[JsonDerivedType(typeof(Derived<>))]
class Base<T>;
class Derived<T> : Base<T> where T : IEnumerable<int>;

Derived<T> is only constructible for T : IEnumerable<int>, but Base<T> has no such constraint, so the registration would silently work for Base<List<int>> and break for Base<string>.

SYSLIB1229 with reason Polymorphism_OpenGeneric_Reason_ConstraintMismatch:

the derived type's type parameter 'T' declares constraints that differ from the constraints on the corresponding base type parameter 'T'

3. Multiple derived registrations with different arities

[JsonDerivedType(typeof(Cat<>))]
[JsonDerivedType(typeof(Cat<,>))]
class Animal<T>;
class Cat<T> : Animal<T>;
class Cat<T, U> : Animal<T>;

The Cat<T, U> registration has an unbound parameter U the base cannot pin down.

SYSLIB1229 with reason Polymorphism_OpenGeneric_Reason_UnboundParameter:

the type parameter 'U' of the derived type is not bound by the base type's arguments

4. Closed derived registered against a non-generic base that the derived does not actually inherit from

class Base;
[JsonDerivedType(typeof(int))]
class Foo : Base; // typo: should be typeof(FooImpl)
class FooImpl : Foo;

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.

SYSLIB1229 with reason Polymorphism_OpenGeneric_Reason_NotAssignable:

the derived type is not assignable to the base type

What's actually in this PR

  1. Uniform-applicability resolver (mirrored in source-gen RoslynExtensions.TryResolveOpenGenericDerivedType and reflection DefaultJsonTypeInfoResolver.Helpers.TryResolveOpenGenericDerivedType). Per-ancestor unifications are computed independently and then verified to coincide — this is what catches asymmetric multi-interface implementations like D<U1, U2> : IBase<U1, U2>, IBase<U2, U1> where each ancestor admits a unifier on its own but no single canonical answer covers both.
  2. Exact constraint-match check between each derived type parameter and the matched base type parameter, applied recursively to F-bounded constraints (so where T : IComparable<T> on the derived matches where U : IComparable<U> on the base only after the renaming substitution has been applied to both occurrences). The compile-time-only notnull constraint is intentionally ignored.
  3. Closed-derived-on-generic-base early-out that rejects the pattern in case 1 above without going through the unification machinery (which would have rejected it anyway, but with a less actionable message).
  4. Post-condition assignability check in the source-generator's TryResolveDerivedType dispatcher: every successful arm — including the common closed-derived / non-generic-base arm — now flows through a single baseType.IsAssignableFrom(resolvedDerivedType) gate. This is the change that catches case 4 above.
  5. Diagnostic split: the previous catch-all NonUniversalPinning reason has been renamed to NonUniformPinning (no unifier exists for the ancestor-base pair) and an additional NonUniformUnification reason 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 of TryUnifyWith surfaces with a precise diagnostic.
  6. Terminology refresh: SR strings, doc comments, code comments, and test method names have been updated from universal / universally to uniform / uniformly throughout the polymorphism subsystem. "Uniform" is a better fit for the property being checked (the resolver works the same way for every specialization of the base) and matches the uniformity terminology already used in System.Linq.Expressions and the runtime's generic dictionary code.

Patterns that remain admitted

Everything that was already admitted by #127318 continues to work. The complete admitted set is:

Pattern Worked example
Matching arity, identity binding [JsonDerivedType(typeof(D<>))] class B<T>; class D<T> : B<T>;
Reordered parameters [JsonDerivedType(typeof(D<,>))] class B<T1,T2>; class D<U,V> : B<V,U>;
Wrapped / nested type arguments [JsonDerivedType(typeof(D<>))] class B<T>; class D<T> : B<List<T>>;
Generic interface base [JsonDerivedType(typeof(D<>))] interface IB<T>; class D<T> : IB<T>;
Type parameters from enclosing generic types [JsonDerivedType(typeof(Outer<>.Leaf<>))] class B<T>; class Outer<T> { class Leaf<U> : B<(T,U)>; }
Identical constraints on identified parameters [JsonDerivedType(typeof(D<>))] class B<T> where T : struct; class D<T> : B<T> where T : struct;
F-bounded constraints, identical after renaming [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) unchanged

Diagnostic table

Reason ID Message
Polymorphism_OpenGeneric_Reason_NotAssignable the derived type is not assignable to the base type
Polymorphism_OpenGeneric_Reason_UnificationFailed the base type's arguments do not match the derived type's base specification
Polymorphism_OpenGeneric_Reason_NonUniformPinning the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments
Polymorphism_OpenGeneric_Reason_NonUniformUnification (new) the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter
Polymorphism_OpenGeneric_Reason_UnboundParameter the type parameter '{0}' of the derived type is not bound by the base type's arguments
Polymorphism_OpenGeneric_Reason_AmbiguousMatch the derived type matches the base type through multiple ancestors
Polymorphism_OpenGeneric_Reason_ConstraintMismatch the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}'
Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base

All reasons are surfaced through SYSLIB1229 (source generator) and InvalidOperationException from ThrowHelper.ThrowInvalidOperationException_OpenGenericDerivedTypeCouldNotBeResolved (reflection). SYSLIB1229 is #pragma-suppressible; suppressing it causes the offending registration to be dropped from the generated metadata.

Tests

Added coverage across both worlds:

  • Reflection (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.
  • Source-gen runtime (System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs) — mirrors the reflection fixture, with #pragma warning disable SYSLIB1229 annotations on the contexts that intentionally exercise the failure path so the fixture compiles.
  • Source-gen diagnostics (JsonSourceGeneratorDiagnosticsTests.cs) — one OpenGenericDerivedType_*_WarnsWithSYSLIB1229 test per reason, plus five new ClosedDerivedType_* 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:

Suite Result
System.Text.Json.Tests (net11.0 Release) 53012/0/0
System.Text.Json.Tests (net481 Release) 52774/0/0
System.Text.Json.SourceGeneration.Roslyn4.4.Tests (net11.0 Release) 8337/0/0
System.Text.Json.SourceGeneration.Roslyn4.4.Tests (net481 Release) 8250/0/0
System.Text.Json.SourceGeneration.Roslyn3.11.Tests (net481 Release) 465/0/0
System.Text.Json.SourceGeneration.Roslyn4.4.Unit.Tests (net11.0 Release) 250/0/0

Drive-by test fix

tests/Common/JsonCreationHandlingTests.Object.cs had two [JsonDerivedType] registrations on BaseClassRecursive that referenced types from an unrelated hierarchy (BaseClassWithPolymorphism and DerivedClass_DerivingFrom_BaseClassWithPolymorphism) — what appears to be a copy-paste from the neighbouring BaseClassWithPolymorphism class. The test's intent is to verify that the JsonObjectCreationHandling.Populate plus polymorphism combination 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.

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

…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>
Copilot AI review requested due to automatic review settings June 11, 2026 14:57
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-json
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs Outdated
@github-actions

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>
@github-actions

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>
Copilot AI review requested due to automatic review settings June 12, 2026 12:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 4 comments.

Comment on lines +150 to +154
if (baseType.IsGenericType)
{
ThrowHelper.ThrowInvalidOperationException_OpenGenericDerivedTypeCouldNotBeResolved(
baseType, entry.DerivedType, SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase);
}
Comment thread src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs Outdated
public T? Item { get; set; }
}

// ---- Universal-applicability tests (PR #129294, B-strict rule) ----
Comment on lines +252 to 257
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;
@github-actions

This comment has been minimized.

eiriktsarpalis and others added 2 commits June 12, 2026 15:44
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>
@github-actions

This comment has been minimized.

eiriktsarpalis and others added 2 commits June 12, 2026 18:13
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>
Copilot AI review requested due to automatic review settings June 12, 2026 15:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.

Comment on lines +150 to +154
if (baseType.IsGenericType)
{
ThrowHelper.ThrowInvalidOperationException_OpenGenericDerivedTypeCouldNotBeResolved(
baseType, entry.DerivedType, SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase);
}
Comment on lines +191 to +194
/// Registrations that pin a particular specialization (e.g. <c>Derived&lt;T&gt; : Base&lt;T, int&gt;</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.
Comment thread src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs Outdated
@github-actions

This comment has been minimized.

eiriktsarpalis and others added 6 commits June 12, 2026 18:43
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>
@github-actions

This comment has been minimized.

eiriktsarpalis and others added 7 commits June 12, 2026 20:00
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>
Copilot AI review requested due to automatic review settings June 12, 2026 19:47
@eiriktsarpalis eiriktsarpalis changed the title Filter closed-derived registrations that don't match the base specialization (JsonPolymorphism follow-up) Reject non-uniform JsonDerivedType registrations against a generic base (JsonPolymorphism follow-up) Jun 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.

Comment on lines +150 to +154
if (baseType.IsGenericType)
{
ThrowHelper.ThrowInvalidOperationException_OpenGenericDerivedTypeCouldNotBeResolved(
baseType, entry.DerivedType, SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase);
}
Comment on lines +953 to 960
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;
}
Comment on lines +65 to +69
// 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.
Comment on lines +1102 to 1112
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;
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #129294

Note

This review was generated by GitHub Copilot.

Holistic Assessment

Motivation: 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 [JsonDerivedType] registrations. The uniform-applicability rule is a principled design decision that makes the system more predictable and prevents silent per-closure failures.

Approach: Sound. The implementation mirrors the resolver logic across source-gen (Roslyn symbols) and reflection (System.Type), with comprehensive documentation about intentional asymmetries. Extracting the core unification into RoslynExtensions.TryResolveOpenGenericDerivedType and adding a top-level TryResolveDerivedType dispatcher that handles all four cases (open/closed × generic/non-generic base) is a good separation of concerns.

Summary: ⚠️ Needs Human Review. The code is well-structured, thoroughly tested, and the design rationale is clearly documented. However, this is a deliberate behavioral breaking change from patterns that were accepted in #127318 (e.g. Derived<T> : Base<List<T>>, Derived<T> : Base<T, int>). While these patterns were added in the same release cycle and the change is well-motivated, a human reviewer should confirm the breaking-change stance is acceptable at this stage and whether any documentation/announcement is needed beyond the PR description. Additionally, there are two minor dead-resource-string items noted below.


Detailed Findings

✅ Correctness — Core algorithm is sound

The per-ancestor independent unification with subsequent coincidence check is a correct implementation of the uniform-applicability concept. Key properties verified:

  • Each ancestor's unification is computed independently via TryUnifyWith, which performs structural rigid-target matching.
  • The canonical substitution consistency check (SubstitutionsEqual) catches asymmetric multi-interface implementations like D<U1, U2> : IBase<U1, U2>, IBase<U2, U1> where each ancestor admits a unifier but no single answer covers both.
  • The constraint-equivalence check (AreConstraintsEquivalent) handles F-bounded constraints correctly by applying substitution before comparison, and intentionally ignores notnull (compile-time only).
  • The post-condition baseType.IsAssignableFrom(resolvedDerivedType) catches invalid closed-derived registrations at compile time that previously only surfaced at first serialization.

✅ Mirror implementations are consistent

The source-gen (RoslynExtensions.TryResolveOpenGenericDerivedType + AreConstraintsEquivalent) and reflection (DefaultJsonTypeInfoResolver.Helpers.TryResolveOpenGenericDerivedType + ReflectionExtensions.AreConstraintsEquivalent) implementations follow the same algorithm structure. The documented asymmetry around unmanaged constraints (reflection can't observe the modreq) is properly handled with a MakeGenericType fallback.

✅ Diagnostic dedup is well-designed

The DiagnosedOpenDerivedRegistrations set (keyed by (BaseDefinition, DerivedDefinition)) prevents diagnostic spam when the same open base is referenced via multiple [JsonSerializable] closures. Using field ??= new(...) for lazy initialization is clean.

✅ Drive-by test fix is correct

The fix to JsonCreationHandlingTests.Object.cs (changing typeof(BaseClassWithPolymorphism)typeof(BaseClassRecursive) and typeof(DerivedClass_DerivingFrom_BaseClassWithPolymorphism)typeof(DerivedClass_DerivingFrom_BaseClassRecursive)) is a legitimate copy-paste error correction. Verified that DerivedClass_DerivingFrom_BaseClassRecursive actually inherits from BaseClassRecursive (line 561).

✅ Test coverage is comprehensive

The test additions cover:

  • All diagnostic reason codes with dedicated source-gen unit tests
  • Positive and negative variants for the reflection runtime tests (pattern catalog A–K)
  • Source-gen runtime tests verifying that suppressed SYSLIB1229 drops registrations from metadata
  • Multi-interface ancestor scenarios (D1–D4)
  • Constraint matrix (C1–C8)
  • Nested enclosing types (E1–E2), transitive inheritance (F1–F2), and programmatic API parity (K)

💡 Dead resource strings in gen project

Polymorphism_OpenGeneric_Reason_ConstraintViolation and Polymorphism_OpenGeneric_Reason_UnificationFailed are defined in gen/Resources/Strings.resx (and all xlf files) but are no longer referenced by any C# code in the source generator project. The old code that used them was removed in this PR. They are still referenced in the src/ project (reflection side: ConstraintViolation in the MakeGenericType catch block), so they should remain there but could be cleaned from the gen resources. This is a minor follow-up item, not blocking.

⚠️ Breaking change scope — requires maintainer confirmation

This PR intentionally rejects patterns that were accepted by #127318:

  • Wrapped type args: Derived<T> : Base<List<T>> (previously resolved Base<List<string>>Derived<string>)
  • Partial concretization: Derived<T> : Base<T, int> (previously resolved Base<string, int>Derived<string>)
  • Array shape pinning: Derived<T> : Base<T[]> (previously resolved Base<int[]>Derived<int>)
  • Closed derived on generic base: [JsonDerivedType(typeof(Cat))] class Animal<T>

Since #127318 was merged for .NET 10 (still in preview), this is the right time to tighten the rules. The design rationale (preventing silent failures when a different base closure is constructed) is sound. However, the test changes show that several previously-passing tests now assert ThrowsAsync<InvalidOperationException>, which confirms the behavioral shift. A maintainer should confirm:

  1. This breaking-change scope is acceptable at the current release stage.
  2. Whether a breaking-change doc should be filed (even for preview-to-preview changes, per the repo's conventions).

💡 Minor: NestedGeneric_TypeParameterInEnclosing test intent shift

The test NestedGeneric_TypeParameterInEnclosing_ThrowsForNonUniformPinning (previously _Resolves) was originally added to verify that the source-gen correctly handles type parameters in enclosing types. Under the new rules this pattern (NestedDerivedParamInEnclosing<T> : NestedBaseB<NestedOuterB<T>.NestedBoxB<int>>) is correctly rejected because it pins TInner to int. The test name and comment update accurately reflect the new semantic. No action needed — just noting for reviewer awareness.

Note

🔒 Integrity filter blocked 1 item

The following item was blocked because it doesn't meet the GitHub integrity level.

To allow these resources, lower min-integrity in your GitHub frontmatter:

tools:
  github:
    min-integrity: approved  # merged | approved | unapproved | none

Generated by Code Review for issue #129294 · ● 22.2M ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants