From 932f16f9f50194862b97bff63218556a330bcdbc Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 10 Jun 2026 21:50:34 +0300 Subject: [PATCH 01/20] Filter closed-derived registrations that don't match the base specialization 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` registered on `Animal` via `Animal`) 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` constraint failing for `Base`, and `Cat` on `Animal`. * 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> --- .../gen/JsonSourceGenerator.Parser.cs | 17 ++ .../DefaultJsonTypeInfoResolver.Helpers.cs | 50 +++- .../Serialization/PolymorphismTests.cs | 151 ++++++++++++ .../PolymorphicTests.CustomTypeHierarchies.cs | 225 ++++++++++++++++++ 4 files changed, 441 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 9d80e95598a98c..e74d09535e06c1 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -952,6 +952,23 @@ private void ProcessTypeCustomAttributes( derivedType = resolvedType!; } + else if (derivedType is INamedTypeSymbol namedDerived && + typeToGenerate.Type is INamedTypeSymbol { IsGenericType: true } && + !_knownSymbols.Compilation.HasImplicitConversion(namedDerived, typeToGenerate.Type)) + { + // Closed derived type whose base specialization differs from the + // current base specialization (e.g. closed Cat : Animal + // declared as [JsonDerivedType(typeof(Cat))] on the open + // Animal when the current type-to-generate is Animal). + // Silently drop so the same attribute set can target multiple + // specializations. Mirrors the reflection-side filter in + // DefaultJsonTypeInfoResolver.ResolveOpenGenericDerivedTypes. + // + // Note: closed derived types declared on a NON-generic base flow + // through to PolymorphicTypeResolver, which throws if they aren't + // assignable -- that's a true misregistration. + continue; + } TypeRef derivedTypeRef = EnqueueType(derivedType, typeToGenerate.Mode); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs index ef694d8351dda7..eb289056ce5ecb 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs @@ -89,7 +89,31 @@ internal static void PopulatePolymorphismMetadata(JsonTypeInfo typeInfo) if (options is not null) { + int originalDerivedTypeCount = options.DerivedTypes.Count; ResolveOpenGenericDerivedTypes(typeInfo.Type, options.DerivedTypes); + + // If closed-derived filtering removed every entry from a non-empty + // registration AND the user did not explicitly declare [JsonPolymorphic], + // demote the current specialization to non-polymorphic rather than + // emitting empty polymorphism options that would throw downstream. The + // typical scenario is a generic base def carrying closed [JsonDerivedType] + // attributes that only apply to specific specializations (e.g. Cat : + // Animal and Dog : Animal both attached to Animal) -- + // for an unrelated Animal, neither survives filtering and the user + // expects the type to serialize as plain base. Open-derived failures + // throw eagerly inside ResolveOpenGenericDerivedTypes, so we only reach + // this branch when the emptying was caused by silent closed-derived + // filtering. When [JsonPolymorphic] IS present the user has explicitly + // opted in, so we honor that intent and let the downstream "no derived + // types specified" error surface. + if (polymorphicAttribute is null && originalDerivedTypeCount > 0 && options.DerivedTypes.Count == 0) + { + options = null; + } + } + + if (options is not null) + { typeInfo.SetPolymorphismOptions(options); } @@ -120,11 +144,13 @@ private static void ResolveOpenGenericDerivedTypes(Type baseType, IList= 0; i--) { JsonDerivedType entry = derivedTypes[i]; - if (entry.DerivedType is null || !entry.DerivedType.IsGenericTypeDefinition) + if (entry.DerivedType is null) { // entry.DerivedType is annotated non-nullable, but the public // JsonDerivedType constructors do not validate the argument, so a @@ -135,6 +161,26 @@ private static void ResolveOpenGenericDerivedTypes(Type baseType, IList and Dog : Animal both attached to Animal). + // We deliberately do not throw here: the attribute is declared on the + // open generic definition, so it cannot statically know which closed + // base it will be paired with at runtime. Closed derived types on a + // non-generic base continue to flow through to PolymorphicTypeResolver, + // which throws if they aren't assignable -- that's a true misregistration. + if (baseType.IsGenericType && !baseType.IsAssignableFrom(entry.DerivedType)) + { + derivedTypes.RemoveAt(i); + } + + continue; + } + if (!baseType.IsGenericType) { ThrowHelper.ThrowInvalidOperationException_OpenGenericDerivedTypeCouldNotBeResolved( diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs index a8ae6c0991b189..f6b6d6b8c9118f 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs @@ -96,11 +96,110 @@ public static void OpenGenericDerivedType_PartiallyConcrete_RoundTrips() Assert.Equal("hello", d.Extra); } + [Fact] + public static void ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnIntSpec() + { + // For SourceGenSpecAnimal, only SourceGenSpecAnimal_Cat applies; the closed + // Dog : SourceGenSpecAnimal registration is silently filtered. + JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenSpecAnimalInt; + JsonPolymorphismOptions options = Assert.IsType(typeInfo.PolymorphismOptions); + JsonDerivedType derivedType = Assert.Single(options.DerivedTypes); + Assert.Equal(typeof(SourceGenSpecAnimal_Cat), derivedType.DerivedType); + + SourceGenSpecAnimal cat = new SourceGenSpecAnimal_Cat { Name = "Felix", Lives = 9 }; + string json = JsonSerializer.Serialize(cat, typeInfo); + Assert.Contains("\"$type\":\"cat\"", json); + } + + [Fact] + public static void ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnStringSpec() + { + // For SourceGenSpecAnimal, only SourceGenSpecAnimal_Dog applies. + JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenSpecAnimalString; + JsonPolymorphismOptions options = Assert.IsType(typeInfo.PolymorphismOptions); + JsonDerivedType derivedType = Assert.Single(options.DerivedTypes); + Assert.Equal(typeof(SourceGenSpecAnimal_Dog), derivedType.DerivedType); + + SourceGenSpecAnimal dog = new SourceGenSpecAnimal_Dog { Name = "Rex", Breed = "Husky" }; + string json = JsonSerializer.Serialize(dog, typeInfo); + Assert.Contains("\"$type\":\"dog\"", json); + } + + [Fact] + public static void ClosedDerivedTypes_AllFilteredForSpec_BecomesNonPolymorphic() + { + // For SourceGenSpecAnimal, NEITHER Cat nor Dog applies; filtering empties + // the derived-type list, so the source-gen-emitted type info must omit + // polymorphism options entirely (otherwise we would throw at runtime). + JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenSpecAnimalBool; + Assert.Null(typeInfo.PolymorphismOptions); + + string json = JsonSerializer.Serialize(new SourceGenSpecAnimal { Name = "Cookie" }, typeInfo); + Assert.DoesNotContain("$type", json); + Assert.Contains("\"Name\":\"Cookie\"", json); + } + + [Fact] + public static void OpenDerivedWithConstraint_FailingConstraintIsDroppedFromEmittedMetadata() + { + // SourceGenConstrainedDerived where T : struct, registered on + // SourceGenConstrainedBase. string is not a struct, so the source + // generator emits SYSLIB1229 (suppressed below) and drops the registration + // from generated metadata. The emitted typeInfo has no derived types and + // serializes the closed base as non-polymorphic. + JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenConstrainedBaseString; + Assert.Null(typeInfo.PolymorphismOptions); + + string json = JsonSerializer.Serialize(new SourceGenConstrainedBase { Value = "hi" }, typeInfo); + Assert.DoesNotContain("$type", json); + } + + [Fact] + public static void OpenDerivedWithConstraint_AppliesWhenConstraintIsSatisfied() + { + // SourceGenConstrainedDerived where T : struct, registered on + // SourceGenConstrainedBase. int satisfies struct -- the open derived + // resolves to SourceGenConstrainedDerived. + JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenConstrainedBaseInt; + JsonPolymorphismOptions options = Assert.IsType(typeInfo.PolymorphismOptions); + JsonDerivedType derivedType = Assert.Single(options.DerivedTypes); + Assert.Equal(typeof(SourceGenConstrainedDerived), derivedType.DerivedType); + + SourceGenConstrainedBase derived = new SourceGenConstrainedDerived { Value = 1, Extra = 2 }; + string json = JsonSerializer.Serialize(derived, typeInfo); + Assert.Contains("\"$type\":\"derived\"", json); + } + + [Fact] + public static void OpenDerivedWithExtraUnboundParameter_BadArmIsDroppedFromEmittedMetadata() + { + // Two registrations on the same base: SourceGenExtraParam_Cat resolves OK to + // SourceGenExtraParam_Cat; SourceGenExtraParam_Cat has an unbound + // T2 that the base does not pin down, so the source generator emits SYSLIB1229 + // (suppressed below) and drops it from the generated metadata. Only the well- + // formed "cat" arm survives in the emitted typeInfo. + JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenExtraParamAnimalInt; + JsonPolymorphismOptions options = Assert.IsType(typeInfo.PolymorphismOptions); + JsonDerivedType derivedType = Assert.Single(options.DerivedTypes); + Assert.Equal(typeof(SourceGenExtraParam_Cat), derivedType.DerivedType); + Assert.Equal("cat", derivedType.TypeDiscriminator); + + SourceGenExtraParamAnimal value = new SourceGenExtraParam_Cat { Name = "Felix", Tag = 7 }; + string json = JsonSerializer.Serialize(value, typeInfo); + Assert.Contains("\"$type\":\"cat\"", json); + } + [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] [JsonSerializable(typeof(SourceGenPolymorphicBase))] [JsonSerializable(typeof(SourceGenClassifiedAnimal))] [JsonSerializable(typeof(SourceGenPolymorphicIntList))] [JsonSerializable(typeof(SourceGenOpenGenericBase))] + [JsonSerializable(typeof(SourceGenSpecAnimal), TypeInfoPropertyName = "SourceGenSpecAnimalInt")] + [JsonSerializable(typeof(SourceGenSpecAnimal), TypeInfoPropertyName = "SourceGenSpecAnimalString")] + [JsonSerializable(typeof(SourceGenSpecAnimal), TypeInfoPropertyName = "SourceGenSpecAnimalBool")] + [JsonSerializable(typeof(SourceGenConstrainedBase), TypeInfoPropertyName = "SourceGenConstrainedBaseString")] + [JsonSerializable(typeof(SourceGenConstrainedBase), TypeInfoPropertyName = "SourceGenConstrainedBaseInt")] + [JsonSerializable(typeof(SourceGenExtraParamAnimal), TypeInfoPropertyName = "SourceGenExtraParamAnimalInt")] internal sealed partial class PolymorphismTestsContext : JsonSerializerContext { } @@ -118,6 +217,58 @@ public sealed class SourceGenOpenGenericDerived : SourceGenOpenGenericBase + { + public string? Name { get; set; } + } + + public sealed class SourceGenSpecAnimal_Cat : SourceGenSpecAnimal { public int Lives { get; set; } } + public sealed class SourceGenSpecAnimal_Dog : SourceGenSpecAnimal { public string? Breed { get; set; } } + + // Constraint-on-derived: the source generator emits SYSLIB1229 for the + // SourceGenConstrainedBase specialization because string does not satisfy + // the `where T : struct` constraint. The warning is benign here -- the bad + // registration is dropped from generated metadata and the closed base serializes + // as a plain (non-polymorphic) type. +#pragma warning disable SYSLIB1229 + [JsonDerivedType(typeof(SourceGenConstrainedDerived<>), "derived")] +#pragma warning restore SYSLIB1229 + public class SourceGenConstrainedBase + { + public T? Value { get; set; } + } + + public sealed class SourceGenConstrainedDerived : SourceGenConstrainedBase where T : struct + { + public int Extra { get; set; } + } + + [JsonDerivedType(typeof(SourceGenExtraParam_Cat<>), "cat")] + // SourceGenExtraParam_Cat has an extra unbound T2 that the single-parameter + // base cannot pin down. The source generator emits SYSLIB1229; suppress it here so + // we can verify the runtime drops the bad arm and keeps the well-formed "cat" arm. +#pragma warning disable SYSLIB1229 + [JsonDerivedType(typeof(SourceGenExtraParam_Cat<,>), "cat2")] +#pragma warning restore SYSLIB1229 + public class SourceGenExtraParamAnimal + { + public T? Tag { get; set; } + } + + public class SourceGenExtraParam_Cat : SourceGenExtraParamAnimal + { + public string? Name { get; set; } + } + + public class SourceGenExtraParam_Cat : SourceGenExtraParamAnimal + { + public T2? Extra { get; set; } + } + [JsonPolymorphic( TypeDiscriminatorPropertyName = "$kind", IgnoreUnrecognizedTypeDiscriminators = true, diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs index b9f948d193aae5..b50489645d7709 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs @@ -3367,6 +3367,231 @@ public class OpenGenericImpl_MultiCtor : OpenGenericImpl_MultiCtor_IntBase, I public T? Item { get; set; } } + // ---- Specialization-specific filtering scenarios (PR #127318 follow-up) ---- + + // Scenario 1: Closed derived types registered on an OPEN generic base where each + // derived only matches a specific base specialization (Cat:Animal, + // Dog:Animal). Resolver must drop the derived that does not match the + // current closed base specialization rather than throw. + + [JsonDerivedType(typeof(SpecAnimal_Cat), "cat")] + [JsonDerivedType(typeof(SpecAnimal_Dog), "dog")] + public class SpecAnimal + { + public string? Name { get; set; } + } + + public sealed class SpecAnimal_Cat : SpecAnimal { public int Lives { get; set; } } + public sealed class SpecAnimal_Dog : SpecAnimal { public string? Breed { get; set; } } + + [Fact] + public async Task ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnAnimalOfInt() + { + // For SpecAnimal, only SpecAnimal_Cat applies (Dog : Animal + // does not match Animal and must be filtered silently). + SpecAnimal cat = new SpecAnimal_Cat { Name = "Felix", Lives = 9 }; + string json = await Serializer.SerializeWrapper(cat); + JsonTestHelper.AssertJsonEqual("""{"$type":"cat","Lives":9,"Name":"Felix"}""", json); + + var roundTrippedCat = await Serializer.DeserializeWrapper>(json); + var typedCat = Assert.IsType(roundTrippedCat); + Assert.Equal(9, typedCat.Lives); + Assert.Equal("Felix", typedCat.Name); + } + + [Fact] + public async Task ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnAnimalOfString() + { + // For SpecAnimal, only SpecAnimal_Dog applies (Cat : Animal + // does not match Animal and must be filtered silently). + SpecAnimal dog = new SpecAnimal_Dog { Name = "Rex", Breed = "Husky" }; + string json = await Serializer.SerializeWrapper(dog); + JsonTestHelper.AssertJsonEqual("""{"$type":"dog","Breed":"Husky","Name":"Rex"}""", json); + + var roundTrippedDog = await Serializer.DeserializeWrapper>(json); + var typedDog = Assert.IsType(roundTrippedDog); + Assert.Equal("Husky", typedDog.Breed); + Assert.Equal("Rex", typedDog.Name); + } + + [Fact] + public async Task ClosedDerivedTypes_AllFilteredForSpecialization_SerializesAsBase() + { + // For SpecAnimal, NEITHER Cat nor Dog matches. After filtering, no + // derived types remain -- the type should silently serialize as a plain + // (non-polymorphic) base, with no $type discriminator and no throw. + SpecAnimal animal = new() { Name = "Cookie" }; + string json = await Serializer.SerializeWrapper(animal); + JsonTestHelper.AssertJsonEqual("""{"Name":"Cookie"}""", json); + + var roundTripped = await Serializer.DeserializeWrapper>(json); + Assert.Equal("Cookie", roundTripped.Name); + } + + // Scenario 2: Open derived with a constraint that fails for the current base + // specialization (Derived : Base where T : IEnumerable, on Base). + + [JsonDerivedType(typeof(ConstrainedDerived<>), "derived")] + public class ConstrainedBase + { + public T? Value { get; set; } + } + + public class ConstrainedDerived : ConstrainedBase where T : System.Collections.Generic.IEnumerable + { + public int Extra { get; set; } + } + + [Fact] + public async Task OpenDerivedWithConstraint_AppliesWhenConstraintIsSatisfied() + { + // List satisfies IEnumerable, so ConstrainedDerived> + // is a valid closure for ConstrainedBase>. + var derived = new ConstrainedDerived> + { + Value = new System.Collections.Generic.List { 1, 2, 3 }, + Extra = 42, + }; + ConstrainedBase> value = derived; + string json = await Serializer.SerializeWrapper(value); + JsonTestHelper.AssertJsonEqual("""{"$type":"derived","Extra":42,"Value":[1,2,3]}""", json); + } + + [Fact] + public async Task OpenDerivedWithConstraint_ThrowsWhenConstraintFails() + { + // string does NOT satisfy IEnumerable, so the open ConstrainedDerived + // registration cannot be closed against ConstrainedBase. The resolver + // surfaces this as InvalidOperationException at first-use of the closed base. + ConstrainedBase baseValue = new() { Value = "hello" }; + InvalidOperationException ex = await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(baseValue)); + + // The message should identify both the registration and the failure reason so the + // user can diagnose the registration without diving into source. + Assert.Contains("ConstrainedDerived", ex.Message); + Assert.Contains("ConstrainedBase", ex.Message); + } + + // Scenario 3: Open derived definitions with EXTRA unbound type parameters + // (Cat registered on Animal). The extra-parameter variant is not + // closeable against any specialization of the base and must surface a hard + // diagnostic. The well-formed variant (Cat) is verified separately on its + // own base type so the failing registration doesn't poison shared metadata. + + [JsonDerivedType(typeof(ExtraParam_Cat<>), "cat")] + public class ExtraParamAnimal + { + public T? Tag { get; set; } + } + + [JsonDerivedType(typeof(ExtraParam_Cat<,>), "cat2")] + public class ExtraParamAnimalWithBadRegistration + { + public T? Tag { get; set; } + } + + public class ExtraParam_Cat : ExtraParamAnimal + { + public string? Name { get; set; } + } + + public class ExtraParam_Cat : ExtraParamAnimalWithBadRegistration + { + public T2? Extra { get; set; } + } + + [Fact] + public async Task OpenDerivedWithoutExtraParameter_Resolves() + { + // Sanity-check companion to the throw test below: ExtraParam_Cat with no + // extra unbound parameter closes cleanly against ExtraParamAnimal. + ExtraParamAnimal value = new ExtraParam_Cat { Name = "Felix", Tag = 7 }; + string json = await Serializer.SerializeWrapper(value); + JsonTestHelper.AssertJsonEqual("""{"$type":"cat","Name":"Felix","Tag":7}""", json); + + var roundTripped = await Serializer.DeserializeWrapper>(json); + var typedCat = Assert.IsType>(roundTripped); + Assert.Equal("Felix", typedCat.Name); + Assert.Equal(7, typedCat.Tag); + } + + [Fact] + public async Task OpenDerivedWithExtraUnboundParameter_ThrowsInvalidOperationException() + { + // ExtraParam_Cat has an unbound T2 that the single-parameter base + // ExtraParamAnimalWithBadRegistration cannot pin down. The derived + // definition is structurally malformed for this base hierarchy regardless + // of which closed base is supplied, so the resolver surfaces a hard error. + ExtraParamAnimalWithBadRegistration value = new(); + InvalidOperationException ex = await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(value)); + + Assert.Contains("ExtraParam_Cat", ex.Message); + Assert.Contains("ExtraParamAnimalWithBadRegistration", ex.Message); + } + + // Mixed-scenario coverage: a single open base def carrying a mix of closed and + // well-formed open registrations. Closed registrations whose base spec does not + // match the current specialization must be silently filtered; well-formed open + // registrations must still resolve. (Registrations that would throw for the + // current specialization -- extra unbound params, failing constraints -- are + // covered above on dedicated bases so they don't poison the whole hierarchy.) + + [JsonDerivedType(typeof(MixedClosedInt), "closed-int")] + [JsonDerivedType(typeof(MixedClosedString), "closed-string")] + [JsonDerivedType(typeof(MixedOpenOk<>), "open-ok")] + public class MixedBase + { + public T? Value { get; set; } + } + + public sealed class MixedClosedInt : MixedBase { public int A { get; set; } } + public sealed class MixedClosedString : MixedBase { public string? B { get; set; } } + + public class MixedOpenOk : MixedBase { public T? C { get; set; } } + + [Fact] + public async Task MixedRegistrations_OnlyApplicableSurviveOnIntSpecialization() + { + // For MixedBase: closed-int matches, closed-string filtered, open-ok applies. + MixedBase closedInt = new MixedClosedInt { A = 1, Value = 10 }; + JsonTestHelper.AssertJsonEqual( + """{"$type":"closed-int","A":1,"Value":10}""", + await Serializer.SerializeWrapper(closedInt)); + + MixedBase openOk = new MixedOpenOk { C = 2, Value = 20 }; + JsonTestHelper.AssertJsonEqual( + """{"$type":"open-ok","C":2,"Value":20}""", + await Serializer.SerializeWrapper(openOk)); + } + + [Fact] + public async Task MixedRegistrations_OnlyApplicableSurviveOnStringSpecialization() + { + // For MixedBase: closed-int filtered, closed-string matches, open-ok applies. + MixedBase closedString = new MixedClosedString { B = "b", Value = "v" }; + JsonTestHelper.AssertJsonEqual( + """{"$type":"closed-string","B":"b","Value":"v"}""", + await Serializer.SerializeWrapper(closedString)); + + MixedBase openOk = new MixedOpenOk { C = "c", Value = "v" }; + JsonTestHelper.AssertJsonEqual( + """{"$type":"open-ok","C":"c","Value":"v"}""", + await Serializer.SerializeWrapper(openOk)); + } + + [Fact] + public async Task MixedRegistrations_ClosedBothFilteredOnUnrelatedSpecialization() + { + // For MixedBase: both closed registrations are filtered, open-ok still + // applies. Polymorphism remains active with a reduced derived-type set. + MixedBase openOk = new MixedOpenOk { C = true, Value = false }; + JsonTestHelper.AssertJsonEqual( + """{"$type":"open-ok","C":true,"Value":false}""", + await Serializer.SerializeWrapper(openOk)); + } + #endregion #region Generic Variance Tests From c5e3059ce7b34b58a9129b6b5282f405cbac1773 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 12:08:50 +0300 Subject: [PATCH 02/20] Address PR review feedback - 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 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> --- .../gen/JsonSourceGenerator.Parser.cs | 18 ++++++-- .../PolymorphicTests.CustomTypeHierarchies.cs | 46 +++++++++++++++---- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index e74d09535e06c1..10c3af33bb08d7 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -952,9 +952,8 @@ private void ProcessTypeCustomAttributes( derivedType = resolvedType!; } - else if (derivedType is INamedTypeSymbol namedDerived && - typeToGenerate.Type is INamedTypeSymbol { IsGenericType: true } && - !_knownSymbols.Compilation.HasImplicitConversion(namedDerived, typeToGenerate.Type)) + else if (typeToGenerate.Type is INamedTypeSymbol { IsGenericType: true } && + !IsClosedDerivedAssignableToBase(derivedType, typeToGenerate.Type)) { // Closed derived type whose base specialization differs from the // current base specialization (e.g. closed Cat : Animal @@ -1053,6 +1052,19 @@ namedUnionType is not null && } } + /// + /// Mirrors System.Type.IsAssignableFrom for the closed-derived filter in + /// ProcessTypeCustomAttributes. Restricting to identity and implicit + /// reference conversions excludes user-defined implicit operators that + /// HasImplicitConversion would otherwise admit, matching the runtime + /// polymorphism resolver's behavior. + /// + private bool IsClosedDerivedAssignableToBase(ITypeSymbol derivedType, ITypeSymbol baseType) + { + Conversion conversion = _knownSymbols.Compilation.ClassifyConversion(derivedType, baseType); + return conversion.IsIdentity || (conversion.IsImplicit && conversion.IsReference); + } + /// /// Source-gen-side resolver: closes against /// via structural unification at compile time. diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs index b50489645d7709..8f7490074f3f2d 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs @@ -3385,10 +3385,10 @@ public sealed class SpecAnimal_Cat : SpecAnimal { public int Lives { get; s public sealed class SpecAnimal_Dog : SpecAnimal { public string? Breed { get; set; } } [Fact] - public async Task ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnAnimalOfInt() + public async Task ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnSpecAnimalOfInt() { - // For SpecAnimal, only SpecAnimal_Cat applies (Dog : Animal - // does not match Animal and must be filtered silently). + // For SpecAnimal, only SpecAnimal_Cat applies (SpecAnimal_Dog : SpecAnimal + // does not match SpecAnimal and must be filtered silently). SpecAnimal cat = new SpecAnimal_Cat { Name = "Felix", Lives = 9 }; string json = await Serializer.SerializeWrapper(cat); JsonTestHelper.AssertJsonEqual("""{"$type":"cat","Lives":9,"Name":"Felix"}""", json); @@ -3400,10 +3400,10 @@ public async Task ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnA } [Fact] - public async Task ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnAnimalOfString() + public async Task ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnSpecAnimalOfString() { - // For SpecAnimal, only SpecAnimal_Dog applies (Cat : Animal - // does not match Animal and must be filtered silently). + // For SpecAnimal, only SpecAnimal_Dog applies (SpecAnimal_Cat : SpecAnimal + // does not match SpecAnimal and must be filtered silently). SpecAnimal dog = new SpecAnimal_Dog { Name = "Rex", Breed = "Husky" }; string json = await Serializer.SerializeWrapper(dog); JsonTestHelper.AssertJsonEqual("""{"$type":"dog","Breed":"Husky","Name":"Rex"}""", json); @@ -3428,6 +3428,32 @@ public async Task ClosedDerivedTypes_AllFilteredForSpecialization_SerializesAsBa Assert.Equal("Cookie", roundTripped.Name); } + // Variant of SpecAnimal where the user explicitly declared [JsonPolymorphic]. + // Even if all closed-derived registrations are filtered out for a given base + // specialization, the explicit opt-in must be preserved -- the runtime should + // surface the "no derived types specified" error rather than silently demote + // to non-polymorphic. This guards the polymorphicAttribute-is-null branch in + // PopulatePolymorphismMetadata against future regression. + + [JsonPolymorphic] + [JsonDerivedType(typeof(SpecAnimal_Cat), "cat")] + [JsonDerivedType(typeof(SpecAnimal_Dog), "dog")] + public class SpecAnimalExplicit + { + public string? Name { get; set; } + } + + [Fact] + public async Task ClosedDerivedTypes_AllFilteredButPolymorphicAttributeExplicit_Throws() + { + // For SpecAnimalExplicit, both closed derived attributes are filtered. + // Because [JsonPolymorphic] is declared, the user's intent to participate in + // polymorphism is preserved and the empty-derived-set error surfaces. + SpecAnimalExplicit animal = new() { Name = "Cookie" }; + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(animal)); + } + // Scenario 2: Open derived with a constraint that fails for the current base // specialization (Derived : Base where T : IEnumerable, on Base). @@ -3437,7 +3463,7 @@ public class ConstrainedBase public T? Value { get; set; } } - public class ConstrainedDerived : ConstrainedBase where T : System.Collections.Generic.IEnumerable + public class ConstrainedDerived : ConstrainedBase where T : IEnumerable { public int Extra { get; set; } } @@ -3447,12 +3473,12 @@ public async Task OpenDerivedWithConstraint_AppliesWhenConstraintIsSatisfied() { // List satisfies IEnumerable, so ConstrainedDerived> // is a valid closure for ConstrainedBase>. - var derived = new ConstrainedDerived> + var derived = new ConstrainedDerived> { - Value = new System.Collections.Generic.List { 1, 2, 3 }, + Value = new List { 1, 2, 3 }, Extra = 42, }; - ConstrainedBase> value = derived; + ConstrainedBase> value = derived; string json = await Serializer.SerializeWrapper(value); JsonTestHelper.AssertJsonEqual("""{"$type":"derived","Extra":42,"Value":[1,2,3]}""", json); } From 9875420631a3546f4262ce01be30602c2995fc76 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 15:20:06 +0300 Subject: [PATCH 03/20] Switch open-generics polymorphism to universal-applicability rule 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> --- .../gen/Helpers/RoslynExtensions.cs | 88 ++ .../gen/JsonSourceGenerator.Parser.cs | 304 +++---- .../gen/Resources/Strings.resx | 9 + .../gen/Resources/xlf/Strings.cs.xlf | 15 + .../gen/Resources/xlf/Strings.de.xlf | 15 + .../gen/Resources/xlf/Strings.es.xlf | 15 + .../gen/Resources/xlf/Strings.fr.xlf | 15 + .../gen/Resources/xlf/Strings.it.xlf | 15 + .../gen/Resources/xlf/Strings.ja.xlf | 15 + .../gen/Resources/xlf/Strings.ko.xlf | 15 + .../gen/Resources/xlf/Strings.pl.xlf | 15 + .../gen/Resources/xlf/Strings.pt-BR.xlf | 15 + .../gen/Resources/xlf/Strings.ru.xlf | 15 + .../gen/Resources/xlf/Strings.tr.xlf | 15 + .../gen/Resources/xlf/Strings.zh-Hans.xlf | 15 + .../gen/Resources/xlf/Strings.zh-Hant.xlf | 15 + .../src/Resources/Strings.resx | 9 + .../src/System/ReflectionExtensions.cs | 174 ++++ .../DefaultJsonTypeInfoResolver.Helpers.cs | 259 +++--- .../Serialization/PolymorphismTests.cs | 114 +-- .../JsonSourceGeneratorDiagnosticsTests.cs | 152 ++-- .../PolymorphicTests.CustomTypeHierarchies.cs | 853 ++++++++++++------ 22 files changed, 1471 insertions(+), 686 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs b/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs index a730e09f2f43b6..b44e33ccf544a7 100644 --- a/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs +++ b/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs @@ -524,6 +524,94 @@ public static bool TryValidateGenericConstraints( return true; } + /// + /// Returns if every constraint declared on + /// is implied by (i.e. weaker-than-or-equal-to) some + /// constraint declared on , after applying + /// to type-constraint references. Used by the + /// "universal derived registration" check to verify that a derived type's parameter + /// constraints will be satisfied for every valid specialization of the base. + /// + /// Special-constraint subsumption rules: + /// * where T : class requires base parameter to also have class. + /// * where T : struct requires base parameter to have struct OR unmanaged. + /// * where T : unmanaged requires base parameter to have unmanaged. + /// * where T : new() requires base parameter to have new(), struct, or unmanaged. + /// * notnull is intentionally ignored (not runtime-enforced; matches ). + /// + /// IMPORTANT: This implementation MIRRORS the reflection-side + /// System.Text.Json.Reflection.ReflectionExtensions.AreConstraintsImpliedBy. + /// Any change to subsumption rules MUST be applied on both sides. + /// + public static bool AreConstraintsImpliedBy( + this Compilation compilation, + ITypeParameterSymbol derivedParam, + ITypeParameterSymbol baseParam, + IReadOnlyDictionary substitution) + { + bool baseGivesStruct = baseParam.HasValueTypeConstraint || baseParam.HasUnmanagedTypeConstraint; + + if (derivedParam.HasReferenceTypeConstraint && !baseParam.HasReferenceTypeConstraint) + { + return false; + } + + if (derivedParam.HasValueTypeConstraint && !baseGivesStruct) + { + return false; + } + + if (derivedParam.HasUnmanagedTypeConstraint && !baseParam.HasUnmanagedTypeConstraint) + { + return false; + } + + if (derivedParam.HasConstructorConstraint && !(baseParam.HasConstructorConstraint || baseGivesStruct)) + { + return false; + } + + foreach (ITypeSymbol derivedConstraint in derivedParam.ConstraintTypes) + { + ITypeSymbol substituted = compilation.SubstituteTypeParameters(derivedConstraint, substitution); + if (!IsBaseConstraintSetImplying(compilation, baseParam, substituted, baseGivesStruct)) + { + return false; + } + } + + return true; + + static bool IsBaseConstraintSetImplying( + Compilation compilation, + ITypeParameterSymbol baseParam, + ITypeSymbol target, + bool baseGivesStruct) + { + // System.ValueType is implied by the `struct`/`unmanaged` special constraint. + if (baseGivesStruct && target.SpecialType == SpecialType.System_ValueType) + { + return true; + } + + // Object is satisfied by every type parameter trivially. + if (target.SpecialType == SpecialType.System_Object) + { + return true; + } + + foreach (ITypeSymbol baseConstraint in baseParam.ConstraintTypes) + { + if (compilation.HasImplicitConversion(baseConstraint, target)) + { + return true; + } + } + + return false; + } + } + /// /// Constructs using , accounting for /// nesting: the leading args bind enclosing-type parameters (outermost first) and the diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 10c3af33bb08d7..04c2303f6091c3 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -942,8 +942,16 @@ private void ProcessTypeCustomAttributes( if (derivedType is INamedTypeSymbol { IsUnboundGenericType: true } unboundDerived) { + if (typeToGenerate.Type is not INamedTypeSymbol { IsGenericType: true } constructedBase) + { + // Open derived registered against a non-generic base: no + // closure of the derived can ever be assignable to the base. + ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, attributeData.GetLocation(), derivedType.ToDisplayString(), typeToGenerate.Type.ToDisplayString(), SR.Polymorphism_OpenGeneric_Reason_NotAssignable); + continue; + } + if (!TryResolveOpenGenericDerivedType( - unboundDerived, typeToGenerate.Type, + unboundDerived, constructedBase, out INamedTypeSymbol? resolvedType, out string? failureReason)) { ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, attributeData.GetLocation(), derivedType.ToDisplayString(), typeToGenerate.Type.ToDisplayString(), failureReason); @@ -952,20 +960,18 @@ private void ProcessTypeCustomAttributes( derivedType = resolvedType!; } - else if (typeToGenerate.Type is INamedTypeSymbol { IsGenericType: true } && - !IsClosedDerivedAssignableToBase(derivedType, typeToGenerate.Type)) + else if (typeToGenerate.Type is INamedTypeSymbol { IsGenericType: true }) { - // Closed derived type whose base specialization differs from the - // current base specialization (e.g. closed Cat : Animal - // declared as [JsonDerivedType(typeof(Cat))] on the open - // Animal when the current type-to-generate is Animal). - // Silently drop so the same attribute set can target multiple - // specializations. Mirrors the reflection-side filter in - // DefaultJsonTypeInfoResolver.ResolveOpenGenericDerivedTypes. + // Closed (non-open) derived type registered against a generic base. + // The same JsonDerivedType attribute lives on the open base definition + // and is shared across every closed specialization; a closed derived + // type necessarily pins a specific specialization of the base. Reject + // at metadata-resolution time so this cannot silently work for one + // specialization and break for another. // - // Note: closed derived types declared on a NON-generic base flow - // through to PolymorphicTypeResolver, which throws if they aren't - // assignable -- that's a true misregistration. + // Closed derived types on a NON-generic base continue to flow through + // to the normal PolymorphicTypeResolver assignability check below. + ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, attributeData.GetLocation(), derivedType.ToDisplayString(), typeToGenerate.Type.ToDisplayString(), SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase); continue; } @@ -1053,56 +1059,48 @@ namedUnionType is not null && } /// - /// Mirrors System.Type.IsAssignableFrom for the closed-derived filter in - /// ProcessTypeCustomAttributes. Restricting to identity and implicit - /// reference conversions excludes user-defined implicit operators that - /// HasImplicitConversion would otherwise admit, matching the runtime - /// polymorphism resolver's behavior. - /// - private bool IsClosedDerivedAssignableToBase(ITypeSymbol derivedType, ITypeSymbol baseType) - { - Conversion conversion = _knownSymbols.Compilation.ClassifyConversion(derivedType, baseType); - return conversion.IsIdentity || (conversion.IsImplicit && conversion.IsReference); - } - - /// - /// Source-gen-side resolver: closes against - /// via structural unification at compile time. - /// Returns true when the registration can be closed to a unique concrete - /// type. Returns false with a localized - /// suitable for inclusion in a diagnostic when the derived type cannot be - /// resolved against this particular base. + /// Source-gen-side resolver: validates that applies + /// universally to every specialization of 's open + /// definition, and (when it does) produces the closed derived type for the specific + /// closure . + /// + /// "Universal" means: there is a single canonical substitution mapping each derived + /// type parameter to a base type parameter that simultaneously satisfies every + /// matching ancestor of the derived type, with every derived constraint implied by + /// (i.e. weaker-than-or-equal-to) the constraints on the corresponding base parameter. + /// Registrations that pin a particular specialization (e.g. Derived<T> : Base<T, int>) + /// 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 generated. + /// + /// Returns true with the closed derived type. Returns false with a + /// localized suitable for inclusion in + /// SYSLIB1229. /// /// IMPORTANT: This implementation MIRRORS the reflection resolver - /// DefaultJsonTypeInfoResolver.Helpers.TryResolveOpenGenericDerivedType - /// in src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs. - /// Both implementations -- the structural unbound pre-check, the per-ancestor - /// unification, and the ambiguity detection -- must be kept in lockstep so that - /// source-gen and reflection produce the same closed type for the same registration. - /// Any algorithmic change here must be applied in the reflection mirror as well. + /// DefaultJsonTypeInfoResolver.Helpers.TryResolveOpenGenericDerivedType in + /// src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs. + /// Both implementations -- the per-ancestor unification, the canonical-substitution + /// consistency check, and the constraint-subsumption rules -- must be kept in + /// lockstep so that source-gen and reflection produce the same closed type for the + /// same registration. /// private bool TryResolveOpenGenericDerivedType( INamedTypeSymbol unboundDerived, - ITypeSymbol baseType, + INamedTypeSymbol constructedBase, out INamedTypeSymbol? resolvedType, out string? failureReason) { resolvedType = null; failureReason = null; - if (baseType is not INamedTypeSymbol { IsGenericType: true } constructedBase) - { - failureReason = SR.Polymorphism_OpenGeneric_Reason_NotAssignable; - return false; - } - INamedTypeSymbol derivedDefinition = unboundDerived.OriginalDefinition; INamedTypeSymbol baseDefinition = constructedBase.OriginalDefinition; - // Collect every ancestor of the derived type definition whose original - // definition matches the base type definition. For classes there is at - // most one ancestor; for interfaces a derived type can implement the same - // interface definition multiple times with different type arguments. + // Every ancestor of the derived type definition whose original definition matches + // the base type definition. For classes there is at most one such ancestor; for + // interfaces a derived type can implement the same interface definition multiple + // times with different type arguments. List matchingBases = derivedDefinition .GetCompatibleGenericBaseTypes(baseDefinition) .ToList(); @@ -1113,160 +1111,142 @@ private bool TryResolveOpenGenericDerivedType( return false; } - // The full set of generic parameters we must bind includes the parameters - // of the derived type definition AS WELL AS those declared by enclosing - // generic types (Outer.Derived needs T bound from Outer). - List requiredParams = derivedDefinition.GetAllTypeParameters(); - ImmutableArray constructedBaseArgs = constructedBase.TypeArguments; + // The complete set of derived parameters that must be bound (enclosing + leaf). + List requiredDerivedParams = derivedDefinition.GetAllTypeParameters(); + List baseParams = baseDefinition.GetAllTypeParameters(); + var baseParamSet = new HashSet(baseParams, SymbolEqualityComparer.Default); - // Structural unbound pre-check: every required parameter must appear at least - // once somewhere in some matching ancestor's type arguments. If a parameter - // never appears at all, no closed base could ever bind it -- the derived - // definition is malformed regardless of which closed base it is registered - // against. - HashSet referencedParams = new(SymbolEqualityComparer.Default); - foreach (INamedTypeSymbol mb in matchingBases) - { - foreach (ITypeSymbol arg in mb.TypeArguments) - { - CollectReferencedParameters(arg, referencedParams); - } - } - foreach (ITypeParameterSymbol required in requiredParams) + // Per-ancestor independent substitutions; the universal answer must be a single + // canonical substitution agreed upon by every ancestor. + Dictionary? canonical = null; + + foreach (INamedTypeSymbol ancestor in matchingBases) { - if (!referencedParams.Contains(required)) + var substitution = new Dictionary( + requiredDerivedParams.Count, SymbolEqualityComparer.Default); + + if (!ancestor.TryUnifyWith(baseDefinition, substitution)) { - failureReason = string.Format( - CultureInfo.InvariantCulture, - SR.Polymorphism_OpenGeneric_Reason_UnboundParameter, - required.Name); + // Some position pins a concrete type (e.g. Base) or a constructed + // pattern (e.g. Base>) that cannot match a free base parameter. + failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; return false; } - } - - Dictionary? successfulSubstitution = null; - int successCount = 0; - - foreach (INamedTypeSymbol matchingBase in matchingBases) - { - ImmutableArray matchingBaseArgs = matchingBase.TypeArguments; - Debug.Assert(matchingBaseArgs.Length == constructedBaseArgs.Length, - "matchingBase and constructedBase share the same generic definition, so arity must match."); - var substitution = new Dictionary(requiredParams.Count, SymbolEqualityComparer.Default); - bool unified = true; - for (int i = 0; i < matchingBaseArgs.Length; i++) + foreach (ITypeParameterSymbol p in requiredDerivedParams) { - if (!matchingBaseArgs[i].TryUnifyWith(constructedBaseArgs[i], substitution)) + if (!substitution.TryGetValue(p, out ITypeSymbol? mapped)) { - unified = false; - break; + // E.g. D : IBase -- U2 is not bound by this ancestor. + failureReason = string.Format( + CultureInfo.InvariantCulture, + SR.Polymorphism_OpenGeneric_Reason_UnboundParameter, + p.Name); + return false; } - } - if (!unified) - { - continue; - } - - // Unification succeeded for every position. Every required parameter must be - // bound; otherwise the resulting closed type would have unbound type arguments - // (an unspeakable type). A sibling ancestor may still bind this parameter, so - // failure here is not fatal -- just move on to the next matching ancestor. - bool allBound = true; - foreach (ITypeParameterSymbol p in requiredParams) - { - if (!substitution.ContainsKey(p)) + if (mapped is not ITypeParameterSymbol mappedBaseParam || !baseParamSet.Contains(mappedBaseParam)) { - allBound = false; - break; + // Substitution value isn't one of the base's own type parameters -- + // happens when a derived ancestor binds a parameter to something + // structural (which TryUnifyWith would have rejected) or pinned. + // Treated as non-universal. + failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; + return false; } } - if (!allBound) - { - continue; - } - - successCount++; - if (successCount == 1) + if (canonical is null) { - successfulSubstitution = substitution; + canonical = substitution; } - else + else if (!SubstitutionsEqual(canonical, substitution)) { + // Two ancestors agree on independent bindings but produce different + // (derived -> base) mappings, e.g. D : IBase, IBase. + // There is no single canonical answer for an arbitrary base closure. failureReason = SR.Polymorphism_OpenGeneric_Reason_AmbiguousMatch; return false; } } - if (successCount == 0 || successfulSubstitution is null) + Debug.Assert(canonical is not null); + + // Constraint subsumption: every derived parameter's constraints must be implied + // by the constraints on the mapped base parameter so that any valid closure of + // the base also yields a valid closure of the derived. + foreach (ITypeParameterSymbol derivedParam in requiredDerivedParams) { - failureReason = SR.Polymorphism_OpenGeneric_Reason_UnificationFailed; - return false; + var mappedBaseParam = (ITypeParameterSymbol)canonical[derivedParam]; + if (!_knownSymbols.Compilation.AreConstraintsImpliedBy(derivedParam, mappedBaseParam, canonical)) + { + failureReason = string.Format( + CultureInfo.InvariantCulture, + SR.Polymorphism_OpenGeneric_Reason_ConstraintNarrowing, + derivedParam.Name, + mappedBaseParam.Name); + return false; + } } - // Validate constraints up front so the generated code will compile. - // Note: HasNotNullConstraint is not enforced because `notnull` is not a - // runtime-enforced constraint. Reflection MakeGenericType accepts e.g. - // string? for `where T : notnull`; source-gen must match that behavior. - if (!_knownSymbols.Compilation.TryValidateGenericConstraints(requiredParams, successfulSubstitution, out ITypeParameterSymbol? failedParam, out ITypeSymbol? failedArg)) + // Closure construction: substitute the canonical mapping then specialize each + // base parameter to the actual closed-base type argument. + var baseParamPosition = new Dictionary(SymbolEqualityComparer.Default); + for (int i = 0; i < baseParams.Count; i++) { - failureReason = string.Format( - CultureInfo.InvariantCulture, - SR.Polymorphism_OpenGeneric_Reason_ConstraintViolation, - failedParam.Name, - failedArg?.ToDisplayString() ?? string.Empty); - return false; + baseParamPosition[baseParams[i]] = i; } - // Build closedArgs in declaration order using the merged substitution. - ITypeSymbol[] closedArgs = new ITypeSymbol[requiredParams.Count]; - for (int i = 0; i < requiredParams.Count; i++) + List constructedBaseAllArgs = GetAllTypeArguments(constructedBase); + + var closedArgs = new ITypeSymbol[requiredDerivedParams.Count]; + for (int i = 0; i < requiredDerivedParams.Count; i++) { - closedArgs[i] = successfulSubstitution[requiredParams[i]]; + var mappedBaseParam = (ITypeParameterSymbol)canonical[requiredDerivedParams[i]]; + closedArgs[i] = constructedBaseAllArgs[baseParamPosition[mappedBaseParam]]; } - // Note: ConstructWithEnclosingTypeArguments takes the parameters in the order - // they appear when listed as TypeParameters on the type and on its enclosing types. - // GetAllTypeParameters preserves that order (outer-to-inner, declaration order). resolvedType = derivedDefinition.ConstructWithEnclosingTypeArguments(closedArgs); return true; - } - private static void CollectReferencedParameters(ITypeSymbol pattern, HashSet set) - { - switch (pattern) + static bool SubstitutionsEqual( + Dictionary a, + Dictionary b) { - case ITypeParameterSymbol tp: - set.Add(tp); - return; - - case IArrayTypeSymbol array: - CollectReferencedParameters(array.ElementType, set); - return; - - case IPointerTypeSymbol pointer: - CollectReferencedParameters(pointer.PointedAtType, set); - return; + if (a.Count != b.Count) + { + return false; + } - case INamedTypeSymbol { IsGenericType: true } named: - // Walk ContainingType to collect type parameters declared on enclosing - // generic types (e.g. T in Outer.Box). Roslyn's TypeArguments is - // leaf-only, while the reflection mirror uses Type.GetGenericArguments() - // which flattens enclosing + leaf. Without this recursion, the unbound - // pre-check would spuriously reject patterns whose only reference to a - // type parameter lives in the enclosing type. - if (named.ContainingType is { IsGenericType: true } containing) + foreach (KeyValuePair kvp in a) + { + if (!b.TryGetValue(kvp.Key, out ITypeSymbol? otherValue) || + !SymbolEqualityComparer.Default.Equals(kvp.Value, otherValue)) { - CollectReferencedParameters(containing, set); + return false; } + } - foreach (ITypeSymbol arg in named.TypeArguments) + return true; + } + + static List GetAllTypeArguments(INamedTypeSymbol type) + { + var result = new List(); + Append(type.ContainingType, result); + result.AddRange(type.TypeArguments); + return result; + + static void Append(INamedTypeSymbol? enclosing, List list) + { + if (enclosing is null) { - CollectReferencedParameters(arg, set); + return; } - return; + + Append(enclosing.ContainingType, list); + list.AddRange(enclosing.TypeArguments); + } } } diff --git a/src/libraries/System.Text.Json/gen/Resources/Strings.resx b/src/libraries/System.Text.Json/gen/Resources/Strings.resx index 53e5b8b6da5c33..05b99748386664 100644 --- a/src/libraries/System.Text.Json/gen/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/gen/Resources/Strings.resx @@ -231,13 +231,22 @@ the base type's arguments do not match the derived type's base specification + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the type parameter '{0}' of the derived type is not bound by the base type's arguments the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf index 37a3849df5a877..b877faf9c6a368 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf index c4c87bcad068ff..0ec2d6d3a17a88 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf index 35353d13ea067d..9a299189efb265 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf index a7e186f4481687..c3e61e1b0f376a 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf index 96df32b2a3b3db..e50916e15f071a 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf index 1daa27cd4d1a9c..95501f17661736 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf index 4674d4b373ec76..f37abb5f69c0b4 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf index 8e935683af738e..51d42b169fad90 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf index 6bfde0ea0a9625..541f5c03baf907 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf index 02588202f0d75e..beb8b65dbf1d58 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf index 10a1129fee1aea..9ff0d36395f5e1 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf index 91dd5b9d9f9e93..33478b6a4c4f50 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf index 23e2ae9e44198f..d92afc7c65e45e 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf @@ -142,11 +142,26 @@ the derived type matches the base type through multiple ancestors + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + + + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the constraint on type parameter '{0}' is not satisfied by '{1}' the constraint on type parameter '{0}' is not satisfied by '{1}' + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the derived type is not assignable to the base type the derived type is not assignable to the base type diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx index 2f6ded325a9cb8..d411670b4abedf 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -677,6 +677,9 @@ the base type's arguments do not match the derived type's base specification + + the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + the type parameter '{0}' of the derived type is not bound by the base type's arguments @@ -686,6 +689,12 @@ a type argument violates a generic constraint on the derived type + + the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + + a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + The polymorphic type '{0}' has already specified a type discriminator '{1}'. diff --git a/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs b/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs index def1570be7975c..d3482dcbc52ce8 100644 --- a/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs +++ b/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs @@ -319,5 +319,179 @@ public static bool TryUnifyWith(this Type pattern, Type target, IDictionary + /// Returns if the constraints on + /// (after substituting via ) are implied by the + /// constraints on . Used by the polymorphism resolver to + /// validate that every closure of the base type that respects the base's constraints + /// would also be a valid closure of the open derived type. + /// + /// IMPORTANT: This implementation MIRRORS + /// System.Text.Json.SourceGeneration.RoslynExtensions.AreConstraintsImpliedBy. + /// Any change to subsumption semantics must be applied on both sides. + /// + /// Known intentional asymmetry with the source-gen mirror: the C# unmanaged + /// constraint is encoded as a modreq and is not surfaced through the standard + /// reflection API, so the reflection check cannot enforce that a derived + /// unmanaged constraint is matched by a base unmanaged constraint. The + /// polymorphism resolver falls back on to surface + /// any remaining constraint violations at closure time. + /// + [RequiresUnreferencedCode("Reflects over derived and base generic parameter constraint types.")] + [RequiresDynamicCode("Substitutes type parameters in constraint types.")] + public static bool AreConstraintsImpliedBy( + Type derivedParam, + Type baseParam, + IReadOnlyDictionary substitution) + { + Debug.Assert(derivedParam.IsGenericParameter); + Debug.Assert(baseParam.IsGenericParameter); + + GenericParameterAttributes derivedFlags = derivedParam.GenericParameterAttributes & GenericParameterAttributes.SpecialConstraintMask; + GenericParameterAttributes baseFlags = baseParam.GenericParameterAttributes & GenericParameterAttributes.SpecialConstraintMask; + + bool baseHasReferenceType = (baseFlags & GenericParameterAttributes.ReferenceTypeConstraint) != 0; + bool baseHasValueType = (baseFlags & GenericParameterAttributes.NotNullableValueTypeConstraint) != 0; + bool baseHasDefaultCtor = (baseFlags & GenericParameterAttributes.DefaultConstructorConstraint) != 0; + + if ((derivedFlags & GenericParameterAttributes.ReferenceTypeConstraint) != 0 && !baseHasReferenceType) + { + return false; + } + + if ((derivedFlags & GenericParameterAttributes.NotNullableValueTypeConstraint) != 0 && !baseHasValueType) + { + return false; + } + + // 'struct' implies 'new()'. + if ((derivedFlags & GenericParameterAttributes.DefaultConstructorConstraint) != 0 && !baseHasDefaultCtor && !baseHasValueType) + { + return false; + } + + Type[] baseConstraintTypes = baseParam.GetGenericParameterConstraints(); + foreach (Type derivedConstraint in derivedParam.GetGenericParameterConstraints()) + { + Type substituted = SubstituteTypeParameters(derivedConstraint, substitution); + + // System.Object as a constraint is trivially implied by any reference whatsoever. + if (substituted == typeof(object)) + { + continue; + } + + // System.ValueType is implied when the base has the 'struct' constraint. + if (substituted == typeof(ValueType) && baseHasValueType) + { + continue; + } + + if (substituted.IsGenericParameter) + { + // The derived constraint substituted to (another) base parameter. That + // parameter must literally appear in the base's own type constraints for + // the substitution to be guaranteed valid for every closure. + bool implied = false; + foreach (Type bc in baseConstraintTypes) + { + if (bc == substituted) + { + implied = true; + break; + } + } + + if (!implied) + { + return false; + } + + continue; + } + + bool match = false; + foreach (Type baseConstraint in baseConstraintTypes) + { + if (substituted.IsAssignableFrom(baseConstraint)) + { + match = true; + break; + } + } + + if (!match) + { + return false; + } + } + + return true; + } + + /// + /// Walks a tree and substitutes any occurrence of a type parameter + /// (whose key is present in ) with its mapped value. + /// + [RequiresUnreferencedCode("Reflects over the type tree to build a substituted constraint type.")] + [RequiresDynamicCode("Constructs a new substituted generic type via MakeGenericType.")] + private static Type SubstituteTypeParameters(Type type, IReadOnlyDictionary substitution) + { + if (type.IsGenericParameter) + { + return substitution.TryGetValue(type, out Type? mapped) ? mapped : type; + } + + if (type.IsArray) + { + Type element = type.GetElementType()!; + Type substitutedElement = SubstituteTypeParameters(element, substitution); + if (substitutedElement == element) + { + return type; + } + +#if NET + return type.IsSZArray ? substitutedElement.MakeArrayType() : substitutedElement.MakeArrayType(type.GetArrayRank()); +#else + int rank = type.GetArrayRank(); + return rank == 1 ? substitutedElement.MakeArrayType() : substitutedElement.MakeArrayType(rank); +#endif + } + + if (type.IsPointer) + { + Type element = type.GetElementType()!; + Type substitutedElement = SubstituteTypeParameters(element, substitution); + return substitutedElement == element ? type : substitutedElement.MakePointerType(); + } + + if (type.IsByRef) + { + Type element = type.GetElementType()!; + Type substitutedElement = SubstituteTypeParameters(element, substitution); + return substitutedElement == element ? type : substitutedElement.MakeByRefType(); + } + + if (type.IsGenericType) + { + Type[] args = type.GetGenericArguments(); + Type[]? newArgs = null; + for (int i = 0; i < args.Length; i++) + { + Type substitutedArg = SubstituteTypeParameters(args[i], substitution); + if (substitutedArg != args[i]) + { + newArgs ??= (Type[])args.Clone(); + newArgs[i] = substitutedArg; + } + } + + return newArgs is null ? type : type.GetGenericTypeDefinition().MakeGenericType(newArgs); + } + + return type; + } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs index eb289056ce5ecb..4f0b305755bc72 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs @@ -89,31 +89,7 @@ internal static void PopulatePolymorphismMetadata(JsonTypeInfo typeInfo) if (options is not null) { - int originalDerivedTypeCount = options.DerivedTypes.Count; ResolveOpenGenericDerivedTypes(typeInfo.Type, options.DerivedTypes); - - // If closed-derived filtering removed every entry from a non-empty - // registration AND the user did not explicitly declare [JsonPolymorphic], - // demote the current specialization to non-polymorphic rather than - // emitting empty polymorphism options that would throw downstream. The - // typical scenario is a generic base def carrying closed [JsonDerivedType] - // attributes that only apply to specific specializations (e.g. Cat : - // Animal and Dog : Animal both attached to Animal) -- - // for an unrelated Animal, neither survives filtering and the user - // expects the type to serialize as plain base. Open-derived failures - // throw eagerly inside ResolveOpenGenericDerivedTypes, so we only reach - // this branch when the emptying was caused by silent closed-derived - // filtering. When [JsonPolymorphic] IS present the user has explicitly - // opted in, so we honor that intent and let the downstream "no derived - // types specified" error surface. - if (polymorphicAttribute is null && originalDerivedTypeCount > 0 && options.DerivedTypes.Count == 0) - { - options = null; - } - } - - if (options is not null) - { typeInfo.SetPolymorphismOptions(options); } @@ -144,9 +120,7 @@ private static void ResolveOpenGenericDerivedTypes(Type baseType, IList= 0; i--) + for (int i = 0; i < derivedTypes.Count; i++) { JsonDerivedType entry = derivedTypes[i]; @@ -163,19 +137,20 @@ private static void ResolveOpenGenericDerivedTypes(Type baseType, IList and Dog : Animal both attached to Animal). - // We deliberately do not throw here: the attribute is declared on the - // open generic definition, so it cannot statically know which closed - // base it will be paired with at runtime. Closed derived types on a - // non-generic base continue to flow through to PolymorphicTypeResolver, - // which throws if they aren't assignable -- that's a true misregistration. - if (baseType.IsGenericType && !baseType.IsAssignableFrom(entry.DerivedType)) + // Closed (non-open) derived type. If the base type is generic the same + // [JsonDerivedType] attribute lives on the open base definition and is + // shared across every closed specialization; a closed derived type + // necessarily pins a specific specialization of the base. Reject at + // metadata-resolution time so a registration cannot silently work for + // one specialization and break for another. + // + // Closed derived types on a NON-generic base continue to flow through + // to the PolymorphicTypeResolver assignability check (which validates + // and throws on true misregistration). + if (baseType.IsGenericType) { - derivedTypes.RemoveAt(i); + ThrowHelper.ThrowInvalidOperationException_OpenGenericDerivedTypeCouldNotBeResolved( + baseType, entry.DerivedType, SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase); } continue; @@ -203,28 +178,35 @@ private static void ResolveOpenGenericDerivedTypes(Type baseType, IList - /// Reflection-side resolver: closes against the - /// constructed base type identified by (, - /// ) via structural unification. + /// Reflection-side resolver: validates that applies + /// universally to every specialization of the open generic + /// , and (when it does) produces the closed + /// derived type for the specific closure identified by + /// . + /// + /// "Universal" means: there is a single canonical substitution mapping each derived + /// type parameter to a base type parameter that simultaneously satisfies every + /// matching ancestor of the derived type, with every derived constraint implied by + /// (i.e. weaker-than-or-equal-to) the constraints on the corresponding base parameter. + /// Registrations that pin a particular specialization (e.g. Derived<T> : Base<T, int>) + /// 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. /// /// IMPORTANT: This implementation MIRRORS the source-gen resolver /// JsonSourceGenerator.Parser.TryResolveOpenGenericDerivedType in - /// gen/JsonSourceGenerator.Parser.cs. Both implementations -- the structural - /// unbound pre-check, the per-ancestor unification, and the ambiguity - /// detection -- must be kept in lockstep so that reflection and source-gen - /// produce the same closed type for the same registration. Any algorithmic - /// change here must be applied in the source-gen mirror as well. + /// gen/JsonSourceGenerator.Parser.cs. Both implementations -- the per-ancestor + /// unification, the canonical-substitution consistency check, and the + /// constraint-subsumption rules -- must be kept in lockstep so that reflection and + /// source-gen produce the same closed type for the same registration. /// - /// Known intentional asymmetry with the source-gen mirror: source-gen rejects a - /// managed value type (e.g. a struct containing reference fields) supplied for a - /// where T : unmanaged constraint because emitting such a closed type would - /// produce a C# compile error (CS8377). The reflection resolver, by contrast, - /// delegates constraint validation to , which only - /// enforces the underlying value-type part of the constraint at runtime (the - /// unmanaged modreq is not surfaced through standard reflection metadata). - /// As a result, reflection accepts managed structs for unmanaged-constrained - /// derived types where source-gen rejects them. This divergence cannot be bridged - /// without emitting invalid C# code on the source-gen side. + /// Known intentional asymmetry with the source-gen mirror: standard reflection does + /// not surface the unmanaged generic constraint (it is encoded as a modreq), + /// so reflection's constraint-subsumption check cannot enforce that a derived + /// unmanaged constraint is matched by a base unmanaged constraint. + /// Source-gen, which inspects Roslyn's constraint metadata directly, can and does + /// enforce this. Reflection therefore falls back on + /// to surface any remaining constraint violations at closure time. /// [RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)] [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] @@ -238,10 +220,10 @@ private static bool TryResolveOpenGenericDerivedType( closedDerivedType = null; failureReason = null; - // Find every ancestor of the open derived type whose generic type definition matches - // the base type definition. For classes there is at most one such ancestor, but for - // interfaces a derived type can implement the same interface definition multiple times - // with different type arguments (e.g. Derived : IBase, IBase>). + // Every ancestor of the open derived type whose generic type definition matches + // the base type definition. For classes there is at most one such ancestor; for + // interfaces a derived type can implement the same interface definition multiple + // times with different type arguments (e.g. Derived : IBase, IBase>). List matchingBases = new(); foreach (Type match in openDerivedType.GetMatchingGenericBaseTypes(baseTypeDefinition)) { @@ -254,135 +236,124 @@ private static bool TryResolveOpenGenericDerivedType( return false; } - // The full set of generic parameters we must bind includes the parameters of the - // derived type itself plus any parameters declared by enclosing generic types - // (e.g. Outer.Derived needs T bound from the outer class). - // Type.GetGenericArguments() on an open generic type returns this complete set. + // The complete set of derived parameters that must be bound (enclosing + leaf + // flattened: Type.GetGenericArguments() on a nested generic returns enclosing+leaf). Type[] requiredParams = openDerivedType.GetGenericArguments(); + Type[] baseParams = baseTypeDefinition.GetGenericArguments(); + var baseParamSet = new HashSet(baseParams); - // Structural unbound pre-check: every required parameter must appear at least once - // somewhere in some matching ancestor's type arguments. If a parameter never appears - // at all, no closed base could ever bind it -- the derived definition is malformed - // regardless of which closed base it is registered against. - HashSet referencedParams = new(); - foreach (Type mb in matchingBases) - { - foreach (Type arg in mb.GetGenericArguments()) - { - CollectReferencedParameters(arg, referencedParams); - } - } - foreach (Type required in requiredParams) + // Per-ancestor independent substitutions; the universal answer must be a single + // canonical substitution agreed upon by every ancestor. + Dictionary? canonical = null; + + foreach (Type ancestor in matchingBases) { - if (!referencedParams.Contains(required)) + var substitution = new Dictionary(requiredParams.Length); + if (!ancestor.TryUnifyWith(baseTypeDefinition, substitution)) { - failureReason = SR.Format(SR.Polymorphism_OpenGeneric_Reason_UnboundParameter, required.Name); + // Some position pins a concrete type (e.g. Base) or a constructed + // pattern (e.g. Base>) that cannot match a free base parameter. + failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; return false; } - } - - Type[]? successfulArgs = null; - int successCount = 0; - - foreach (Type matchingBase in matchingBases) - { - Type[] matchingBaseArgs = matchingBase.GetGenericArguments(); - Debug.Assert(matchingBaseArgs.Length == baseTypeArgs.Length, - "matchingBase and baseTypeArgs share the same generic type definition, so arity must match."); - var substitution = new Dictionary(requiredParams.Length); - bool unified = true; - for (int i = 0; i < matchingBaseArgs.Length; i++) + foreach (Type p in requiredParams) { - if (!matchingBaseArgs[i].TryUnifyWith(baseTypeArgs[i], substitution)) + if (!substitution.TryGetValue(p, out Type? mapped)) { - unified = false; - break; + // E.g. D : IBase -- U2 is not bound by this ancestor. + failureReason = SR.Format(SR.Polymorphism_OpenGeneric_Reason_UnboundParameter, p.Name); + return false; } - } - - if (!unified) - { - continue; - } - // Unification succeeded for every position. Every required parameter of the - // derived type definition must be bound by this ancestor; otherwise the - // resulting closed type would have unbound type arguments (an unspeakable type). - // A sibling ancestor may still bind this parameter, so failure here is not fatal. - Type[] closedArgs = new Type[requiredParams.Length]; - bool allBound = true; - for (int i = 0; i < requiredParams.Length; i++) - { - if (!substitution.TryGetValue(requiredParams[i], out Type? boundArg)) + if (!mapped.IsGenericParameter || !baseParamSet.Contains(mapped)) { - allBound = false; - break; + // Substitution value isn't one of the base's own type parameters -- + // happens when a derived ancestor binds a parameter to a non-parameter + // structural target. Treated as non-universal. + failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; + return false; } - - closedArgs[i] = boundArg; } - if (!allBound) + if (canonical is null) { - continue; + canonical = substitution; } - - successCount++; - if (successCount == 1) + else if (!SubstitutionsEqual(canonical, substitution)) { - successfulArgs = closedArgs; + // Two ancestors agree on independent bindings but produce different + // (derived -> base) mappings, e.g. D : IBase, IBase. + // There is no single canonical answer for an arbitrary base closure. + failureReason = SR.Polymorphism_OpenGeneric_Reason_AmbiguousMatch; + return false; } - else + } + + Debug.Assert(canonical is not null); + + // Constraint subsumption: every derived parameter's constraints must be implied + // by the constraints on the mapped base parameter so that any valid closure of + // the base also yields a valid closure of the derived. + foreach (Type derivedParam in requiredParams) + { + Type mappedBaseParam = canonical[derivedParam]; + if (!ReflectionExtensions.AreConstraintsImpliedBy(derivedParam, mappedBaseParam, canonical)) { - failureReason = SR.Polymorphism_OpenGeneric_Reason_AmbiguousMatch; + failureReason = SR.Format(SR.Polymorphism_OpenGeneric_Reason_ConstraintNarrowing, + derivedParam.Name, mappedBaseParam.Name); return false; } } - if (successCount == 0 || successfulArgs is null) + // Closure construction: substitute the canonical mapping then specialize each + // base parameter slot to the actual closed-base type argument. + var baseParamPosition = new Dictionary(baseParams.Length); + for (int i = 0; i < baseParams.Length; i++) { - failureReason = SR.Polymorphism_OpenGeneric_Reason_UnificationFailed; - return false; + baseParamPosition[baseParams[i]] = i; + } + + Type[] closedArgs = new Type[requiredParams.Length]; + for (int i = 0; i < requiredParams.Length; i++) + { + Type mappedBaseParam = canonical[requiredParams[i]]; + closedArgs[i] = baseTypeArgs[baseParamPosition[mappedBaseParam]]; } try { - closedDerivedType = openDerivedType.MakeGenericType(successfulArgs); + closedDerivedType = openDerivedType.MakeGenericType(closedArgs); return true; } catch (Exception ex) when (ex is ArgumentException or TypeLoadException) { - // Constraint violation or load failure (e.g. unmanaged constraint, which is - // not observable via the standard reflection constraint metadata). We use a - // structured reason rather than ex.Message so that the outer template — which - // appends its own trailing period — never produces a double period. + // Defense in depth: MakeGenericType can still throw for constraints the + // subsumption check above cannot observe (e.g. derived `unmanaged` + // constraint, which is not surfaced by standard reflection metadata). + // Use a structured reason rather than ex.Message so the outer template -- + // which appends its own trailing period -- never produces a double period. failureReason = SR.Polymorphism_OpenGeneric_Reason_ConstraintViolation; return false; } } - private static void CollectReferencedParameters(Type pattern, HashSet set) + private static bool SubstitutionsEqual(Dictionary a, Dictionary b) { - if (pattern.IsGenericParameter) + if (a.Count != b.Count) { - set.Add(pattern); - return; - } - - if (pattern.HasElementType) - { - CollectReferencedParameters(pattern.GetElementType()!, set); - return; + return false; } - if (pattern.IsGenericType) + foreach (KeyValuePair kvp in a) { - foreach (Type arg in pattern.GetGenericArguments()) + if (!b.TryGetValue(kvp.Key, out Type? other) || other != kvp.Value) { - CollectReferencedParameters(arg, set); + return false; } } + + return true; } [RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)] diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs index f6b6d6b8c9118f..a4fabb19318280 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs @@ -75,62 +75,57 @@ public static void CollectionPolymorphismOptions_AreGenerated() } [Fact] - public static void OpenGenericDerivedType_PartiallyConcrete_RoundTrips() + public static void OpenGenericDerivedType_NonUniversalPinning_IsDroppedFromMetadata() { - // SourceGenOpenGenericDerived : SourceGenOpenGenericBase registered on - // SourceGenOpenGenericBase. Position 0 (T) unifies to string; - // position 1 (concrete int) matches. The generated metadata for the closed base - // must contain SourceGenOpenGenericDerived as the resolved derived type. + // SourceGenOpenGenericDerived : SourceGenOpenGenericBase pins T2 to + // int -- non-universal w.r.t. SourceGenOpenGenericBase. Source-gen + // emits SYSLIB1229 (suppressed on the fixture decoration) and drops the + // registration from the generated metadata; the closed base has no + // PolymorphismOptions and serializes plainly. JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenOpenGenericBaseStringInt32; - JsonPolymorphismOptions options = Assert.IsType(typeInfo.PolymorphismOptions); - JsonDerivedType derivedType = Assert.Single(options.DerivedTypes); - Assert.Equal(typeof(SourceGenOpenGenericDerived), derivedType.DerivedType); - Assert.Equal("derived", derivedType.TypeDiscriminator); + Assert.Null(typeInfo.PolymorphismOptions); SourceGenOpenGenericBase value = new SourceGenOpenGenericDerived { Extra = "hello" }; string json = JsonSerializer.Serialize(value, typeInfo); - Assert.Contains("\"$type\":\"derived\"", json); - - var result = JsonSerializer.Deserialize(json, typeInfo); - var d = Assert.IsType>(result); - Assert.Equal("hello", d.Extra); + Assert.DoesNotContain("$type", json); } [Fact] - public static void ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnIntSpec() + public static void ClosedDerivedOnGenericBase_IsDroppedOnIntSpec() { - // For SourceGenSpecAnimal, only SourceGenSpecAnimal_Cat applies; the closed - // Dog : SourceGenSpecAnimal registration is silently filtered. + // Under the universal-applicability rule, closed-derived registrations on an + // OPEN generic base (here SourceGenSpecAnimal_Cat : SourceGenSpecAnimal + // and Dog : SourceGenSpecAnimal) are non-universal -- they can never + // apply to every closure of the open base. Source-gen drops them from emitted + // metadata (with SYSLIB1229 suppressed on the fixture) so the closed base has + // no PolymorphismOptions and serializes plainly. JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenSpecAnimalInt; - JsonPolymorphismOptions options = Assert.IsType(typeInfo.PolymorphismOptions); - JsonDerivedType derivedType = Assert.Single(options.DerivedTypes); - Assert.Equal(typeof(SourceGenSpecAnimal_Cat), derivedType.DerivedType); + Assert.Null(typeInfo.PolymorphismOptions); SourceGenSpecAnimal cat = new SourceGenSpecAnimal_Cat { Name = "Felix", Lives = 9 }; string json = JsonSerializer.Serialize(cat, typeInfo); - Assert.Contains("\"$type\":\"cat\"", json); + Assert.DoesNotContain("$type", json); } [Fact] - public static void ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnStringSpec() + public static void ClosedDerivedOnGenericBase_IsDroppedOnStringSpec() { - // For SourceGenSpecAnimal, only SourceGenSpecAnimal_Dog applies. + // Companion to ClosedDerivedOnGenericBase_IsDroppedOnIntSpec for the string + // closure. Same rejection regardless of which closure is constructed. JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenSpecAnimalString; - JsonPolymorphismOptions options = Assert.IsType(typeInfo.PolymorphismOptions); - JsonDerivedType derivedType = Assert.Single(options.DerivedTypes); - Assert.Equal(typeof(SourceGenSpecAnimal_Dog), derivedType.DerivedType); + Assert.Null(typeInfo.PolymorphismOptions); SourceGenSpecAnimal dog = new SourceGenSpecAnimal_Dog { Name = "Rex", Breed = "Husky" }; string json = JsonSerializer.Serialize(dog, typeInfo); - Assert.Contains("\"$type\":\"dog\"", json); + Assert.DoesNotContain("$type", json); } [Fact] - public static void ClosedDerivedTypes_AllFilteredForSpec_BecomesNonPolymorphic() + public static void ClosedDerivedOnGenericBase_IsDroppedOnUnrelatedSpec() { - // For SourceGenSpecAnimal, NEITHER Cat nor Dog applies; filtering empties - // the derived-type list, so the source-gen-emitted type info must omit - // polymorphism options entirely (otherwise we would throw at runtime). + // The bool closure of SourceGenSpecAnimal -- no closed-derived registration + // would have matched even under per-closure filtering. The emitted typeInfo has + // no PolymorphismOptions and serializes plainly. JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenSpecAnimalBool; Assert.Null(typeInfo.PolymorphismOptions); @@ -140,13 +135,13 @@ public static void ClosedDerivedTypes_AllFilteredForSpec_BecomesNonPolymorphic() } [Fact] - public static void OpenDerivedWithConstraint_FailingConstraintIsDroppedFromEmittedMetadata() + public static void OpenDerivedWithNarrowerConstraint_IsDroppedOnUnsatisfyingSpec() { // SourceGenConstrainedDerived where T : struct, registered on - // SourceGenConstrainedBase. string is not a struct, so the source - // generator emits SYSLIB1229 (suppressed below) and drops the registration - // from generated metadata. The emitted typeInfo has no derived types and - // serializes the closed base as non-polymorphic. + // SourceGenConstrainedBase. The derived's struct constraint is narrower + // than the base's (none), so the registration is non-universal under B-strict. + // Source-gen emits SYSLIB1229 (suppressed on the fixture) and drops the + // registration; every closure of the base has null PolymorphismOptions. JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenConstrainedBaseString; Assert.Null(typeInfo.PolymorphismOptions); @@ -155,29 +150,32 @@ public static void OpenDerivedWithConstraint_FailingConstraintIsDroppedFromEmitt } [Fact] - public static void OpenDerivedWithConstraint_AppliesWhenConstraintIsSatisfied() + public static void OpenDerivedWithNarrowerConstraint_IsDroppedOnSatisfyingSpec() { - // SourceGenConstrainedDerived where T : struct, registered on - // SourceGenConstrainedBase. int satisfies struct -- the open derived - // resolves to SourceGenConstrainedDerived. + // Same fixture as above, applied to a closure (int) where the derived's + // struct constraint happens to hold. Under per-closure filtering this would + // have been accepted; under B-strict universal applicability it is rejected + // uniformly so that introducing a new closure (e.g. ) cannot break a + // working serialization site. JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenConstrainedBaseInt; - JsonPolymorphismOptions options = Assert.IsType(typeInfo.PolymorphismOptions); - JsonDerivedType derivedType = Assert.Single(options.DerivedTypes); - Assert.Equal(typeof(SourceGenConstrainedDerived), derivedType.DerivedType); + Assert.Null(typeInfo.PolymorphismOptions); - SourceGenConstrainedBase derived = new SourceGenConstrainedDerived { Value = 1, Extra = 2 }; - string json = JsonSerializer.Serialize(derived, typeInfo); - Assert.Contains("\"$type\":\"derived\"", json); + string json = JsonSerializer.Serialize(new SourceGenConstrainedBase { Value = 1 }, typeInfo); + Assert.DoesNotContain("$type", json); } [Fact] public static void OpenDerivedWithExtraUnboundParameter_BadArmIsDroppedFromEmittedMetadata() { - // Two registrations on the same base: SourceGenExtraParam_Cat resolves OK to - // SourceGenExtraParam_Cat; SourceGenExtraParam_Cat has an unbound - // T2 that the base does not pin down, so the source generator emits SYSLIB1229 - // (suppressed below) and drops it from the generated metadata. Only the well- - // formed "cat" arm survives in the emitted typeInfo. + // Two registrations on the same base: SourceGenExtraParam_Cat is universal + // and resolves to SourceGenExtraParam_Cat; SourceGenExtraParam_Cat + // has an extra unbound T2 that the single-parameter base cannot pin down, so + // source-gen emits SYSLIB1229 (suppressed below) and drops it from the + // generated metadata. Only the well-formed "cat" arm survives in the emitted + // typeInfo. + // + // This is the documented source-gen-vs-reflection asymmetry: source-gen drops + // bad arms per-attribute, reflection throws on the first bad arm. JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenExtraParamAnimalInt; JsonPolymorphismOptions options = Assert.IsType(typeInfo.PolymorphismOptions); JsonDerivedType derivedType = Assert.Single(options.DerivedTypes); @@ -205,7 +203,13 @@ internal sealed partial class PolymorphismTestsContext : JsonSerializerContext } } + // SourceGenOpenGenericDerived : SourceGenOpenGenericBase pins the second + // base parameter to a concrete type -- non-universal under B-strict. Source-gen emits + // SYSLIB1229 at compile time; suppress it here so we can verify the runtime drops the + // registration and the closed base serializes plainly. +#pragma warning disable SYSLIB1229 [JsonDerivedType(typeof(SourceGenOpenGenericDerived<>), "derived")] +#pragma warning restore SYSLIB1229 public class SourceGenOpenGenericBase { public T1? Value1 { get; set; } @@ -217,10 +221,16 @@ public sealed class SourceGenOpenGenericDerived : SourceGenOpenGenericBase { public string? Name { get; set; } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs index 8dfd87b307bccb..3c9dffabd8c9fa 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs @@ -1264,11 +1264,13 @@ public class MyDerived : MyBase } [Fact] - public void OpenGenericDerivedType_PartiallyConcrete_Resolves() + public void OpenGenericDerivedType_PartiallyConcrete_WarnsWithSYSLIB1229() { // Derived : Base registered on Base: - // position 0 (T) unifies with string, position 1 (concrete int) matches. - // Expected: closed type is MyDerived, no diagnostic. + // the derived pins position 1 (T2) to a concrete int, which makes the + // registration non-universal w.r.t. the open Base. Under the + // universal-applicability rule the source generator emits SYSLIB1229 + // regardless of which closure was registered. string source = """ using System.Text.Json.Serialization; @@ -1294,9 +1296,10 @@ public class MyDerived : MyBase """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); - Assert.Empty(result.Diagnostics.Where(d => d.Id == "SYSLIB1229")); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } [Fact] @@ -1368,9 +1371,12 @@ public class MyDerived : MyBase> } [Fact] - public void OpenGenericDerivedType_WrappedArgWithMatchingBase_CompilesSuccessfully() + public void OpenGenericDerivedType_WrappedArgWithMatchingBase_WarnsWithSYSLIB1229() { - // Derived : Base> registered on Base> unifies to Derived. + // Derived : Base> registered on Base>: + // the derived pins the leaf shape to List<>. The registration does not apply + // to closures of Base whose T is not a List<>, so it is rejected under the + // universal-applicability rule. string source = """ #nullable enable using System.Collections.Generic; @@ -1396,8 +1402,9 @@ public class MyDerived : MyBase> """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); - Assert.Empty(result.Diagnostics); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } [Fact] @@ -1432,9 +1439,11 @@ public class MyDerived : MyBase } [Fact] - public void OpenGenericDerivedType_PartialConcretization_CompilesSuccessfully() + public void OpenGenericDerivedType_PartialConcretization_WarnsWithSYSLIB1229() { - // Derived : Base registered on Base unifies to Derived. + // Derived : Base registered on Base: + // the derived pins T2 to int. The registration is rejected under the + // universal-applicability rule regardless of which closure was registered. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1458,15 +1467,17 @@ public class MyDerived : MyBase """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); - Assert.Empty(result.Diagnostics); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } [Fact] - public void OpenGenericDerivedType_ArrayTypeArg_CompilesSuccessfully() + public void OpenGenericDerivedType_ArrayTypeArg_WarnsWithSYSLIB1229() { - // Derived : Base registered on Base unifies to Derived. - // Exercises the IArrayTypeSymbol branch of structural unification. + // Derived : Base registered on Base: pins the leaf shape to + // an array. The registration does not apply to closures of Base whose T + // is not an array, so it is rejected under the universal-applicability rule. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1490,8 +1501,9 @@ public class MyDerived : MyBase """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); - Assert.Empty(result.Diagnostics); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } [Fact] @@ -1586,9 +1598,11 @@ public class MyDerived : MyBase where T : struct } [Fact] - public void OpenGenericDerivedType_ReferenceTypeConstraintSatisfied_CompilesSuccessfully() + public void OpenGenericDerivedType_ReferenceTypeConstraintNarrowing_WarnsWithSYSLIB1229() { - // Derived : Base where T : class, registered on Base. + // Derived : Base where T : class. Base has no constraint, so the + // derived's constraint is stricter than the base's. Rejected under the + // universal-applicability rule. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1612,14 +1626,17 @@ public class MyDerived : MyBase where T : class """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); - Assert.Empty(result.Diagnostics); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } [Fact] - public void OpenGenericDerivedType_InterfaceConstraintSatisfied_CompilesSuccessfully() + public void OpenGenericDerivedType_InterfaceConstraintNarrowing_WarnsWithSYSLIB1229() { - // Derived : Base where T : System.IComparable, registered on Base. + // Derived : Base where T : IComparable. Base has no constraint, so + // the derived's constraint is stricter than the base's. Rejected under the + // universal-applicability rule. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1643,15 +1660,17 @@ public class MyDerived : MyBase where T : System.IComparable """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); - Assert.Empty(result.Diagnostics); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } [Fact] - public void OpenGenericDerivedType_ArrayInsideGenericConstraint_CompilesSuccessfully() + public void OpenGenericDerivedType_ArrayInsideGenericConstraint_WarnsWithSYSLIB1229() { - // where T : IEnumerable with T=List, U=int. - // Exercises array substitution into a generic constraint type. + // Derived : Base where T : IEnumerable. Base has no + // constraint on T1, so the derived's constraint is stricter. Rejected under + // the universal-applicability rule. string source = """ #nullable enable using System.Collections.Generic; @@ -1676,16 +1695,18 @@ public class MyDerived : MyBase where T : IEnumerable """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); - Assert.Empty(result.Diagnostics); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } [Fact] - public void OpenGenericDerivedType_NestedInGenericOuter_CompilesSuccessfully() + public void OpenGenericDerivedType_NestedInGenericOuter_WarnsWithSYSLIB1229() { - // Outer.Middle.Leaf : Base<(T, U)> registered on Base<(int, string)>. - // Exercises ConstructEnclosing rebinding a non-generic intermediate type - // through a constructed generic outer. + // Outer.Middle.Leaf : Base<(T, U)>. The derived pins the closed base's + // single type argument to a tuple shape, so it does not apply to closures of + // Base whose T is not a (_, _) tuple. Rejected under the + // universal-applicability rule. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1715,8 +1736,9 @@ public class Leaf : Base<(T, U)> """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); - Assert.Empty(result.Diagnostics); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } [Fact] @@ -1786,17 +1808,12 @@ public class Impl : IDerived { } } [Fact] - public void OpenGenericDerivedType_MultipleInterfaceConstructions_NonAmbiguousResolution_CompilesSuccessfully() + public void OpenGenericDerivedType_MultipleInterfaceConstructions_DisagreeingSubstitutions_WarnsWithSYSLIB1229() { - // Impl reaches IBase<> twice: once via its own type-parameterized base (IBase) - // and once via inheritance from the non-generic IntBase (IBase). When resolved - // against the closed base IBase, only the IBase leg unifies (T=string); - // the IBase leg is incompatible. Resolution must succeed and produce - // Impl. The both-legs-match scenario (which IS ambiguous) is covered by - // OpenGenericDerivedType_AmbiguousMatch. Indirecting the IBase leg through a - // non-generic base class avoids C# CS0695 -- a class cannot directly declare two - // constructions of the same generic interface that could unify under any - // substitution. + // Impl reaches IBase<> via two ancestors: directly as IBase and via + // IntBase : IBase. The two ancestors produce disagreeing substitutions + // for the base's single type parameter (one binds it to T, the other to int). + // Rejected under the universal-applicability rule. string source = """ using System.Text.Json.Serialization; @@ -1817,8 +1834,9 @@ public class Impl : IntBase, IBase { } """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); - Assert.Empty(result.Diagnostics); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } [Fact] @@ -1947,15 +1965,13 @@ public class NestedDerivedMismatch : NestedBase.NestedBox } [Fact] - public void OpenGenericDerivedType_NestedGenericTypeParameterInEnclosing_CompilesSuccessfully() + public void OpenGenericDerivedType_NestedGenericTypeParameterInEnclosing_WarnsWithSYSLIB1229() { - // Pattern: NestedDerived : NestedBase.NestedBox>. - // Closed base: NestedBase.NestedBox>. - // T appears only in the ENCLOSING type's argument list. Pre-fix source-gen - // ignored ContainingType and reported SYSLIB1229 because T was never bound by - // the leaf-only TryUnifyWith walk. With the ContainingType walk in place, - // unification succeeds with T=string and the resolver closes NestedDerived to - // NestedDerived. No diagnostic expected. + // Pattern: NestedDerivedParamInEnclosing : NestedBaseB.NestedBoxB>. + // The derived pins the closed base's single type argument to an + // NestedOuterB<>.NestedBoxB shape. It does not apply to closures of + // NestedBaseB whose T is not of that shape, so it is rejected under the + // universal-applicability rule. string source = """ using System.Text.Json.Serialization; @@ -1979,19 +1995,18 @@ public class NestedDerivedParamInEnclosing : NestedBaseB.Nest """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); - Assert.Empty(result.Diagnostics); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } [Fact] - public void OpenGenericDerivedType_CovariantInterfaceConstraintSatisfied_CompilesSuccessfully() + public void OpenGenericDerivedType_CovariantInterfaceConstraintNarrowing_WarnsWithSYSLIB1229() { - // where T : IEnumerable. Closing T to List satisfies the - // constraint ONLY via IEnumerable covariance (IEnumerable is - // assignable to IEnumerable only by virtue of 'out T'). Pre-fix - // source-gen used identity-based interface containment for the constraint - // check and would have reported SYSLIB1229. With Compilation.HasImplicitConversion - // in place, source-gen matches reflection's behavior and accepts. + // ConstraintImpl : ConstraintBase where T : IEnumerable. + // The derived narrows the constraint (base has none). Even though specific + // closures (e.g. List via variance) would satisfy the constraint, + // the registration is rejected under the universal-applicability rule. string source = """ using System.Collections.Generic; using System.Text.Json.Serialization; @@ -2011,8 +2026,9 @@ public class ConstraintImpl : ConstraintBase where T : IEnumerable """; Compilation compilation = CompilationHelper.CreateCompilation(source); - JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); - Assert.Empty(result.Diagnostics); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs index 8f7490074f3f2d..33e442e0c9dd80 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs @@ -2787,16 +2787,16 @@ public class OpenGenericBase_ComplexArg public class OpenGenericDerived_ComplexArg : OpenGenericBase_ComplexArg; [Fact] - public async Task OpenGenericDerivedType_WrappedTypeArg_Works() + public async Task OpenGenericDerivedType_WrappedTypeArg_ThrowsForNonUniversalPinning() { - // Derived : Base> registered on Base> unifies to Derived. - OpenGenericBase_Wrapped> value = new OpenGenericDerived_Wrapped { Data = ["a", "b"] }; - string json = await Serializer.SerializeWrapper(value); - JsonTestHelper.AssertJsonEqual("""{"$type":"derived","Data":["a","b"]}""", json); - - var result = await Serializer.DeserializeWrapper>>(json); - Assert.IsType>(result); - Assert.Equal(new[] { "a", "b" }, ((OpenGenericDerived_Wrapped)result).Data); + // Derived : Base> structurally pins the base's only type parameter to + // List; this can never apply for a base specialization whose type argument is + // not itself a List<>. Under the universal-applicability rule the registration + // is rejected at metadata-resolution time regardless of which base specialization + // is actually constructed. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>>( + new OpenGenericDerived_Wrapped { Data = ["a", "b"] })); } [JsonDerivedType(typeof(OpenGenericDerived_Wrapped<>), "derived")] @@ -2890,19 +2890,23 @@ public class OpenGenericBase_GroundMismatch; public class OpenGenericDerived_GroundMismatch : OpenGenericBase_GroundMismatch; [Fact] - public async Task OpenGenericDerivedType_PartiallyConcrete_Works() - { - // Derived : Base registered on Base: - // position 0 (T) unifies with string, position 1 (concrete int) matches. - // Expected: closed derived is OpenGenericDerived_PartiallyConcrete, and - // round-trip serialization emits and reads the $type discriminator. - OpenGenericBase_PartiallyConcrete value = new OpenGenericDerived_PartiallyConcrete { Extra = "hello" }; - string json = await Serializer.SerializeWrapper(value); - JsonTestHelper.AssertJsonEqual("""{"$type":"derived","Extra":"hello","Value1":null,"Value2":0}""", json); + public async Task OpenGenericDerivedType_PartiallyConcrete_ThrowsForNonUniversalPinning() + { + // Derived : Base pins position 1 of the base to a concrete type. Even + // though the registration could "work" for Base closures, it does not + // apply to arbitrary closures of Base. Under the universal rule we reject + // the registration regardless of which closure is currently being constructed, + // so registrations cannot silently work for one specialization and break for + // another. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>( + new OpenGenericDerived_PartiallyConcrete { Extra = "hello" })); - var result = await Serializer.DeserializeWrapper>(json); - var derived = Assert.IsType>(result); - Assert.Equal("hello", derived.Extra); + // The exact same registration is rejected uniformly regardless of which closure + // happens to be constructed first; a Base closure throws the same way. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>( + new OpenGenericBase_PartiallyConcrete())); } [JsonDerivedType(typeof(OpenGenericDerived_PartiallyConcrete<>), "derived")] @@ -2960,25 +2964,18 @@ public class OpenGenericBase_Programmatic public class OpenGenericDerived_Programmatic : OpenGenericBase_Programmatic; [Fact] - public async Task OpenGenericDerivedType_MixedWithRegularDerivedType_Works() - { - // Validates that both regular and open generic derived types coexist. - OpenGenericBase_Mixed openValue = new OpenGenericDerived_Mixed { Value = 1 }; - OpenGenericBase_Mixed regularValue = new RegularDerived_Mixed { Value = 2, Extra = "extra" }; - - string openJson = await Serializer.SerializeWrapper(openValue); - string regularJson = await Serializer.SerializeWrapper(regularValue); - - JsonTestHelper.AssertJsonEqual("""{"$type":"open","Value":1}""", openJson); - JsonTestHelper.AssertJsonEqual("""{"$type":"regular","Value":2,"Extra":"extra"}""", regularJson); - - var openResult = await Serializer.DeserializeWrapper>(openJson); - var regularResult = await Serializer.DeserializeWrapper>(regularJson); - - Assert.IsType>(openResult); - Assert.IsType(regularResult); - Assert.Equal(1, openResult.Value); - Assert.Equal("extra", ((RegularDerived_Mixed)regularResult).Extra); + public async Task OpenGenericDerivedType_MixedWithClosedDerivedOnGenericBase_Throws() + { + // The closed RegularDerived_Mixed : OpenGenericBase_Mixed registration pins + // the base to a specific specialization. Although it could conceivably apply only + // to closures that pick T = int, the shared attribute attached to the open base + // definition is necessarily inherited by every closure -- so a registration that + // only applies to one specialization is rejected at metadata-resolution time. + // The open OpenGenericDerived_Mixed : OpenGenericBase_Mixed registration is + // universal, but the rejection of the closed sibling throws before it surfaces. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>( + new OpenGenericDerived_Mixed { Value = 1 })); } [JsonDerivedType(typeof(OpenGenericDerived_Mixed<>), "open")] @@ -3024,16 +3021,15 @@ public class OpenGenericImpl_InterfaceHierarchy : IOpenGenericDerived_Interfa } [Fact] - public async Task OpenGenericDerivedType_InterfaceBaseWithWrappedTypeArg_Works() + public async Task OpenGenericDerivedType_InterfaceBaseWithWrappedTypeArg_ThrowsForNonUniversalPinning() { - // Impl implements IBase> registered on IBase> unifies to Impl. - IOpenGenericBase_InterfaceWrapped> value = new OpenGenericImpl_InterfaceWrapped { Data = ["a", "b"] }; - string json = await Serializer.SerializeWrapper(value); - JsonTestHelper.AssertJsonEqual("""{"$type":"impl","Data":["a","b"]}""", json); - - var result = await Serializer.DeserializeWrapper>>(json); - Assert.IsType>(result); - Assert.Equal(new[] { "a", "b" }, ((OpenGenericImpl_InterfaceWrapped)result).Data); + // Impl implements IBase>: the interface ancestor pins the base's only + // type parameter to a List<>. Even though IBase> could be served by + // Impl, the registration only applies to closures whose argument is itself + // a List<>, so it is rejected uniformly. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>>( + new OpenGenericImpl_InterfaceWrapped { Data = ["a", "b"] })); } [JsonDerivedType(typeof(OpenGenericImpl_InterfaceWrapped<>), "impl")] @@ -3048,16 +3044,15 @@ public class OpenGenericImpl_InterfaceWrapped : IOpenGenericBase_InterfaceWra } [Fact] - public async Task OpenGenericDerivedType_ArrayTypeArg_Works() + public async Task OpenGenericDerivedType_ArrayTypeArg_ThrowsForNonUniversalPinning() { - // Derived : Base registered on Base unifies to Derived. - OpenGenericBase_ArrayArg value = new OpenGenericDerived_ArrayArg { Values = [1, 2, 3] }; - string json = await Serializer.SerializeWrapper(value); - JsonTestHelper.AssertJsonEqual("""{"$type":"derived","Values":[1,2,3]}""", json); - - var result = await Serializer.DeserializeWrapper>(json); - Assert.IsType>(result); - Assert.Equal(new[] { 1, 2, 3 }, ((OpenGenericDerived_ArrayArg)result).Values); + // Derived : Base pins the base's only type parameter to an array shape. + // The registration cannot apply to closures whose argument is not itself an array, + // so it is rejected at metadata-resolution time regardless of which closure is + // currently being constructed. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>( + new OpenGenericDerived_ArrayArg { Values = [1, 2, 3] })); } [JsonDerivedType(typeof(OpenGenericDerived_ArrayArg<>), "derived")] @@ -3093,16 +3088,15 @@ public class OpenGenericDerived_Reordered : OpenGenericBase_Reordered : Base registered on Base unifies to Derived. - OpenGenericBase_Partial value = new OpenGenericDerived_Partial { Value = "hello" }; - string json = await Serializer.SerializeWrapper(value); - JsonTestHelper.AssertJsonEqual("""{"$type":"derived","Value":"hello"}""", json); - - var result = await Serializer.DeserializeWrapper>(json); - Assert.IsType>(result); - Assert.Equal("hello", ((OpenGenericDerived_Partial)result).Value); + // Companion of OpenGenericDerivedType_PartiallyConcrete_ThrowsForNonUniversalPinning + // on a base whose second parameter is unused -- proves that the rejection is purely + // structural at registration time and does not depend on whether the pinned base + // parameter is referenced by the closure's property bag. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>( + new OpenGenericDerived_Partial { Value = "hello" })); } [JsonDerivedType(typeof(OpenGenericDerived_Partial<>), "derived")] @@ -3114,15 +3108,15 @@ public class OpenGenericDerived_Partial : OpenGenericBase_Partial } [Fact] - public async Task OpenGenericDerivedType_KeyValuePairArg_Works() + public async Task OpenGenericDerivedType_KeyValuePairArg_ThrowsForNonUniversalPinning() { - // Derived : Base> registered on Base> unifies to Derived. - OpenGenericBase_KvpArg> value = new OpenGenericDerived_KvpArg { Pair = new KeyValuePair("k", 99) }; - string json = await Serializer.SerializeWrapper(value); - JsonTestHelper.AssertJsonEqual("""{"$type":"derived","Pair":{"Key":"k","Value":99}}""", json); - - var result = await Serializer.DeserializeWrapper>>(json); - Assert.IsType>(result); + // Derived : Base> pins the base's only type parameter + // to a KeyValuePair shape AND pins the Key half of that pair to string. + // The registration cannot apply universally, so it is rejected at metadata- + // resolution time. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>>( + new OpenGenericDerived_KvpArg { Pair = new KeyValuePair("k", 99) })); } [JsonDerivedType(typeof(OpenGenericDerived_KvpArg<>), "derived")] @@ -3134,15 +3128,15 @@ public class OpenGenericDerived_KvpArg : OpenGenericBase_KvpArg : Base>, Leaf : Mid; registered on Base> unifies to Leaf. - OpenGenericBase_MultiLevel> value = new OpenGenericLeaf_MultiLevel { Items = [10, 20] }; - string json = await Serializer.SerializeWrapper(value); - JsonTestHelper.AssertJsonEqual("""{"$type":"leaf","Items":[10,20]}""", json); - - var result = await Serializer.DeserializeWrapper>>(json); - Assert.IsType>(result); + // The Leaf -> Mid -> Base> chain is universal in the Leaf->Mid hop + // but non-universal in the Mid->Base hop (Mid pins Base's parameter to List). + // Universality is required end-to-end, so the registration is rejected even though + // the leaf's own immediate base is universal. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>>( + new OpenGenericLeaf_MultiLevel { Items = [10, 20] })); } [JsonDerivedType(typeof(OpenGenericLeaf_MultiLevel<>), "leaf")] @@ -3156,14 +3150,14 @@ public class OpenGenericLeaf_MultiLevel : OpenGenericMid_MultiLevel } [Fact] - public async Task OpenGenericDerivedType_TupleSyntax_Works() + public async Task OpenGenericDerivedType_TupleSyntax_ThrowsForNonUniversalPinning() { - // Derived : Base<(T1, T2)> registered on Base<(int, string)> unifies to Derived. - OpenGenericBase_Tuple<(int, string)> value = new OpenGenericDerived_Tuple { Pair = (5, "x") }; - string json = await Serializer.SerializeWrapper(value); - - var result = await Serializer.DeserializeWrapper>(json); - Assert.IsType>(result); + // Derived : Base<(T1, T2)> pins the base's only type parameter to a + // ValueTuple<,> shape (`(T1, T2)` is sugar for `ValueTuple`). The + // registration is non-universal and rejected at metadata-resolution time. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>( + new OpenGenericDerived_Tuple { Pair = (5, "x") })); } [JsonDerivedType(typeof(OpenGenericDerived_Tuple<,>), "derived")] @@ -3203,12 +3197,19 @@ public class OpenGenericBase_Unbound; public class OpenGenericDerived_Unbound : OpenGenericBase_Unbound; [Fact] - public async Task OpenGenericDerivedType_ConstraintViolation_ThrowsInvalidOperationException() + public async Task OpenGenericDerivedType_ConstraintNarrowing_ThrowsForAllSpecializations() { - // Derived : Base where T : struct, registered on Base. - // Constraint fails → InvalidOperationException. - var value = new OpenGenericBase_StructConstraint(); - await Assert.ThrowsAsync(() => Serializer.SerializeWrapper(value)); + // The derived adds a `where T : struct` constraint that the base does not have. + // This is rejected uniformly: even though the constraint happens to be satisfied + // for some closures (e.g. Base), the registration is non-universal and + // would mysteriously stop applying on closures the constraint excludes. Reject + // at metadata-resolution time. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>( + new OpenGenericBase_StructConstraint())); + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>( + new OpenGenericBase_StructConstraint())); } [JsonDerivedType(typeof(OpenGenericDerived_StructConstraint<>), "derived")] @@ -3236,14 +3237,16 @@ public class OpenGenericBase_NullableArg public class OpenGenericDerived_NullableArg : OpenGenericBase_NullableArg; [Fact] - public async Task OpenGenericDerivedType_DuplicateClosedAndOpenRegistration_ThrowsInvalidOperationException() + public async Task OpenGenericDerivedType_DuplicateClosedAndOpenRegistration_ThrowsForClosedDerivedOnGenericBase() { - // Base has BOTH a closed-form Derived registration AND an open-form - // Derived<> registration. The open form closes to Derived, producing a - // duplicate derived-type registration. The existing dup-detection in - // PolymorphicTypeResolver must surface this as InvalidOperationException. - OpenGenericBase_DuplicateDerivedRegistrations value = new OpenGenericDerived_DuplicateDerivedRegistrations(); - await Assert.ThrowsAsync(() => Serializer.SerializeWrapper(value)); + // The closed Derived registration on the open generic base is rejected + // outright under the universal-registration rule (a closed derived registered on + // a generic base only applies to one specialization, which the shared attribute + // declaration cannot express). The throw masks any downstream duplicate-id check + // that would otherwise have surfaced. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>( + new OpenGenericDerived_DuplicateDerivedRegistrations())); } [JsonDerivedType(typeof(OpenGenericDerived_DuplicateDerivedRegistrations), "closed")] @@ -3337,24 +3340,21 @@ public class OpenGenericImpl_Diamond : IOpenGenericDerived_Diamond } [Fact] - public async Task OpenGenericDerivedType_MultipleInterfaceConstructions_NonAmbiguousResolution_Works() - { - // Impl reaches IBase<> twice: once via its own type-parameterized interface - // (IBase) and once via inheritance from the non-generic IntBase (IBase). - // When the closed base is IBase, only the IBase ancestor unifies - // (T=string); the IBase ancestor is incompatible. Resolution must succeed - // and produce Impl. (The both-legs-match scenario is the ambiguous one, - // covered separately.) Indirecting the IBase leg through a non-generic base - // class avoids C# CS0695 -- a class cannot directly declare two constructions of - // the same generic interface that could unify under any substitution. - IOpenGenericBase_MultiCtor value = new OpenGenericImpl_MultiCtor { Item = "hello" }; - string json = await Serializer.SerializeWrapper(value); - - JsonTestHelper.AssertJsonEqual("""{"$type":"impl","Item":"hello"}""", json); - - var result = await Serializer.DeserializeWrapper>(json); - var impl = Assert.IsType>(result); - Assert.Equal("hello", impl.Item); + public async Task OpenGenericDerivedType_MultipleInterfaceConstructions_NonUniversalPinning_Throws() + { + // Impl reaches IBase<> via two ancestors: directly (IBase, universal) and + // transitively through OpenGenericImpl_MultiCtor_IntBase : IBase (pins to + // int). Universal applicability requires every matching ancestor to agree on a + // canonical substitution mapping each derived parameter to a base parameter; the + // IBase ancestor pins the base parameter to a concrete type, so the + // registration is rejected uniformly even for closures where the IBase leg + // alone would have been a clean match. Indirecting the IBase leg through a + // non-generic base class avoids C# CS0695 -- a class cannot directly declare two + // constructions of the same generic interface that could unify under any + // substitution. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>( + new OpenGenericImpl_MultiCtor { Item = "hello" })); } [JsonDerivedType(typeof(OpenGenericImpl_MultiCtor<>), "impl")] @@ -3367,12 +3367,19 @@ public class OpenGenericImpl_MultiCtor : OpenGenericImpl_MultiCtor_IntBase, I public T? Item { get; set; } } - // ---- Specialization-specific filtering scenarios (PR #127318 follow-up) ---- + // ---- Universal-applicability tests (PR #129294, B-strict rule) ---- + // + // Under the universal-applicability rule a derived registration on a generic base must + // apply to EVERY closed specialization of that base. Registrations that only apply to + // some specializations -- closed derived types pinning a specific spec, open derived + // types that pin a base parameter to a concrete type, or open derived types that + // narrow the base's constraints -- are rejected at metadata-resolution time so that + // serialization cannot silently work for one closure and break for another. - // Scenario 1: Closed derived types registered on an OPEN generic base where each - // derived only matches a specific base specialization (Cat:Animal, - // Dog:Animal). Resolver must drop the derived that does not match the - // current closed base specialization rather than throw. + // Scenario 1: Closed derived types on an OPEN generic base. The same [JsonDerivedType] + // attribute lives on the open base definition and is shared across every closed + // specialization, so a closed derived (which necessarily pins a single specialization) + // can never apply universally. Rejected. [JsonDerivedType(typeof(SpecAnimal_Cat), "cat")] [JsonDerivedType(typeof(SpecAnimal_Dog), "dog")] @@ -3384,56 +3391,22 @@ public class SpecAnimal public sealed class SpecAnimal_Cat : SpecAnimal { public int Lives { get; set; } } public sealed class SpecAnimal_Dog : SpecAnimal { public string? Breed { get; set; } } - [Fact] - public async Task ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnSpecAnimalOfInt() - { - // For SpecAnimal, only SpecAnimal_Cat applies (SpecAnimal_Dog : SpecAnimal - // does not match SpecAnimal and must be filtered silently). - SpecAnimal cat = new SpecAnimal_Cat { Name = "Felix", Lives = 9 }; - string json = await Serializer.SerializeWrapper(cat); - JsonTestHelper.AssertJsonEqual("""{"$type":"cat","Lives":9,"Name":"Felix"}""", json); - - var roundTrippedCat = await Serializer.DeserializeWrapper>(json); - var typedCat = Assert.IsType(roundTrippedCat); - Assert.Equal(9, typedCat.Lives); - Assert.Equal("Felix", typedCat.Name); - } - - [Fact] - public async Task ClosedDerivedTypes_MismatchedBaseSpecialization_AreFilteredOnSpecAnimalOfString() - { - // For SpecAnimal, only SpecAnimal_Dog applies (SpecAnimal_Cat : SpecAnimal - // does not match SpecAnimal and must be filtered silently). - SpecAnimal dog = new SpecAnimal_Dog { Name = "Rex", Breed = "Husky" }; - string json = await Serializer.SerializeWrapper(dog); - JsonTestHelper.AssertJsonEqual("""{"$type":"dog","Breed":"Husky","Name":"Rex"}""", json); - - var roundTrippedDog = await Serializer.DeserializeWrapper>(json); - var typedDog = Assert.IsType(roundTrippedDog); - Assert.Equal("Husky", typedDog.Breed); - Assert.Equal("Rex", typedDog.Name); - } - - [Fact] - public async Task ClosedDerivedTypes_AllFilteredForSpecialization_SerializesAsBase() - { - // For SpecAnimal, NEITHER Cat nor Dog matches. After filtering, no - // derived types remain -- the type should silently serialize as a plain - // (non-polymorphic) base, with no $type discriminator and no throw. - SpecAnimal animal = new() { Name = "Cookie" }; - string json = await Serializer.SerializeWrapper(animal); - JsonTestHelper.AssertJsonEqual("""{"Name":"Cookie"}""", json); - - var roundTripped = await Serializer.DeserializeWrapper>(json); - Assert.Equal("Cookie", roundTripped.Name); + [Theory] + [InlineData(typeof(SpecAnimal))] + [InlineData(typeof(SpecAnimal))] + [InlineData(typeof(SpecAnimal))] + public async Task ClosedDerivedOnGenericBase_ThrowsForEverySpecialization(Type closedBaseType) + { + // Whichever closure of SpecAnimal the user constructs, resolution fails + // because the closed derived registrations are non-universal. There is no + // "happy" closure that suppresses the rejection. + object baseValue = Activator.CreateInstance(closedBaseType)!; + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(baseValue, closedBaseType)); } - // Variant of SpecAnimal where the user explicitly declared [JsonPolymorphic]. - // Even if all closed-derived registrations are filtered out for a given base - // specialization, the explicit opt-in must be preserved -- the runtime should - // surface the "no derived types specified" error rather than silently demote - // to non-polymorphic. This guards the polymorphicAttribute-is-null branch in - // PopulatePolymorphismMetadata against future regression. + // Scenario 2: Closed derived on a generic base, with [JsonPolymorphic] also declared + // (no behavior difference under universal applicability -- the rejection is the same). [JsonPolymorphic] [JsonDerivedType(typeof(SpecAnimal_Cat), "cat")] @@ -3444,18 +3417,21 @@ public class SpecAnimalExplicit } [Fact] - public async Task ClosedDerivedTypes_AllFilteredButPolymorphicAttributeExplicit_Throws() + public async Task ClosedDerivedOnGenericBase_WithExplicitPolymorphicAttribute_Throws() { - // For SpecAnimalExplicit, both closed derived attributes are filtered. - // Because [JsonPolymorphic] is declared, the user's intent to participate in - // polymorphism is preserved and the empty-derived-set error surfaces. + // Companion to ClosedDerivedOnGenericBase_ThrowsForEverySpecialization. The + // explicit [JsonPolymorphic] attribute does not change the rejection -- whether + // the user opted in via attribute or got an implicit options shape from + // [JsonDerivedType] alone, the same universal-applicability rule applies. SpecAnimalExplicit animal = new() { Name = "Cookie" }; await Assert.ThrowsAsync( () => Serializer.SerializeWrapper(animal)); } - // Scenario 2: Open derived with a constraint that fails for the current base - // specialization (Derived : Base where T : IEnumerable, on Base). + // Scenario 3: Open derived with a narrower constraint than the base. The derived + // happens to satisfy the constraint for some closures (e.g. List) but not + // others (e.g. string). Rejected uniformly so it can't silently start failing on a + // new closure. [JsonDerivedType(typeof(ConstrainedDerived<>), "derived")] public class ConstrainedBase @@ -3468,42 +3444,29 @@ public class ConstrainedDerived : ConstrainedBase where T : IEnumerable satisfies IEnumerable, so ConstrainedDerived> - // is a valid closure for ConstrainedBase>. - var derived = new ConstrainedDerived> - { - Value = new List { 1, 2, 3 }, - Extra = 42, - }; - ConstrainedBase> value = derived; - string json = await Serializer.SerializeWrapper(value); - JsonTestHelper.AssertJsonEqual("""{"$type":"derived","Extra":42,"Value":[1,2,3]}""", json); - } - - [Fact] - public async Task OpenDerivedWithConstraint_ThrowsWhenConstraintFails() - { - // string does NOT satisfy IEnumerable, so the open ConstrainedDerived - // registration cannot be closed against ConstrainedBase. The resolver - // surfaces this as InvalidOperationException at first-use of the closed base. - ConstrainedBase baseValue = new() { Value = "hello" }; + [Theory] + [InlineData(typeof(ConstrainedBase))] + [InlineData(typeof(ConstrainedBase>))] + public async Task OpenDerivedWithNarrowerConstraint_ThrowsForAllSpecializations(Type closedBaseType) + { + // Both "string" (constraint fails) and "List" (constraint holds) closures + // throw the same way -- universal applicability requires the constraint to hold + // for EVERY closure, not just the one in front of us. + object baseValue = Activator.CreateInstance(closedBaseType)!; InvalidOperationException ex = await Assert.ThrowsAsync( - () => Serializer.SerializeWrapper(baseValue)); + () => Serializer.SerializeWrapper(baseValue, closedBaseType)); - // The message should identify both the registration and the failure reason so the - // user can diagnose the registration without diving into source. + // The message must identify both the registration and the base so users can + // diagnose the registration without diving into source. Assert.Contains("ConstrainedDerived", ex.Message); Assert.Contains("ConstrainedBase", ex.Message); } - // Scenario 3: Open derived definitions with EXTRA unbound type parameters - // (Cat registered on Animal). The extra-parameter variant is not - // closeable against any specialization of the base and must surface a hard - // diagnostic. The well-formed variant (Cat) is verified separately on its - // own base type so the failing registration doesn't poison shared metadata. + // Scenario 4: Open derived definitions with EXTRA unbound type parameters + // (Cat registered on Animal). The extra-parameter variant is not closeable + // against any specialization of the base and surfaces a hard diagnostic. The + // well-formed variant (Cat) is verified separately on its own base type so the + // failing registration doesn't poison shared metadata. [JsonDerivedType(typeof(ExtraParam_Cat<>), "cat")] public class ExtraParamAnimal @@ -3531,7 +3494,8 @@ public class ExtraParam_Cat : ExtraParamAnimalWithBadRegistration public async Task OpenDerivedWithoutExtraParameter_Resolves() { // Sanity-check companion to the throw test below: ExtraParam_Cat with no - // extra unbound parameter closes cleanly against ExtraParamAnimal. + // extra unbound parameter is universal -- it closes cleanly against any + // specialization of ExtraParamAnimal. ExtraParamAnimal value = new ExtraParam_Cat { Name = "Felix", Tag = 7 }; string json = await Serializer.SerializeWrapper(value); JsonTestHelper.AssertJsonEqual("""{"$type":"cat","Name":"Felix","Tag":7}""", json); @@ -3547,8 +3511,8 @@ public async Task OpenDerivedWithExtraUnboundParameter_ThrowsInvalidOperationExc { // ExtraParam_Cat has an unbound T2 that the single-parameter base // ExtraParamAnimalWithBadRegistration cannot pin down. The derived - // definition is structurally malformed for this base hierarchy regardless - // of which closed base is supplied, so the resolver surfaces a hard error. + // definition is structurally malformed for this base hierarchy regardless of + // which closed base is supplied. ExtraParamAnimalWithBadRegistration value = new(); InvalidOperationException ex = await Assert.ThrowsAsync( () => Serializer.SerializeWrapper(value)); @@ -3557,12 +3521,9 @@ public async Task OpenDerivedWithExtraUnboundParameter_ThrowsInvalidOperationExc Assert.Contains("ExtraParamAnimalWithBadRegistration", ex.Message); } - // Mixed-scenario coverage: a single open base def carrying a mix of closed and - // well-formed open registrations. Closed registrations whose base spec does not - // match the current specialization must be silently filtered; well-formed open - // registrations must still resolve. (Registrations that would throw for the - // current specialization -- extra unbound params, failing constraints -- are - // covered above on dedicated bases so they don't poison the whole hierarchy.) + // Scenario 5: Mixed open/closed registrations on the same open base. Closed + // registrations always pin a specialization, so they reject the whole metadata + // resolution -- the well-formed open arm cannot save the registration. [JsonDerivedType(typeof(MixedClosedInt), "closed-int")] [JsonDerivedType(typeof(MixedClosedString), "closed-string")] @@ -3577,45 +3538,419 @@ public sealed class MixedClosedString : MixedBase { public string? B { g public class MixedOpenOk : MixedBase { public T? C { get; set; } } + [Theory] + [InlineData(typeof(MixedBase))] + [InlineData(typeof(MixedBase))] + [InlineData(typeof(MixedBase))] + public async Task MixedRegistrations_ClosedSiblingPoisonsTheWholeBase(Type closedBaseType) + { + // The presence of closed derived registrations (MixedClosedInt, MixedClosedString) + // on the generic base poisons the entire base metadata regardless of which closure + // is constructed and regardless of the presence of a well-formed open arm. + object value = closedBaseType == typeof(MixedBase) ? new MixedOpenOk() + : closedBaseType == typeof(MixedBase) ? new MixedOpenOk() + : (object)new MixedOpenOk(); + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(value, closedBaseType)); + } + + // ---- Pattern catalog (B-strict additions) ---- + // + // Comprehensive coverage of the universal-applicability rule across pinning, + // collapse, reorder, multi-ancestor, constraint subsumption, and edge-case patterns. + + // ---- Pattern A: parameter collapse ---- + + [JsonDerivedType(typeof(Catalog_ParamCollapse_Derived<>), "d")] + public class Catalog_ParamCollapse_Base + { + public T1? V1 { get; set; } + public T2? V2 { get; set; } + } + + public class Catalog_ParamCollapse_Derived : Catalog_ParamCollapse_Base; + [Fact] - public async Task MixedRegistrations_OnlyApplicableSurviveOnIntSpecialization() + public async Task Catalog_ParameterCollapse_ThrowsForNonUniversalPinning() { - // For MixedBase: closed-int matches, closed-string filtered, open-ok applies. - MixedBase closedInt = new MixedClosedInt { A = 1, Value = 10 }; - JsonTestHelper.AssertJsonEqual( - """{"$type":"closed-int","A":1,"Value":10}""", - await Serializer.SerializeWrapper(closedInt)); + // Derived : Base "collapses" both base parameters to the same derived + // parameter. For arbitrary Base closures where T1 != T2 the derived + // cannot apply, so the registration is rejected uniformly. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(new Catalog_ParamCollapse_Base())); + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(new Catalog_ParamCollapse_Base())); + } - MixedBase openOk = new MixedOpenOk { C = 2, Value = 20 }; - JsonTestHelper.AssertJsonEqual( - """{"$type":"open-ok","C":2,"Value":20}""", - await Serializer.SerializeWrapper(openOk)); + // ---- Pattern B: parameter reorder ---- + + [JsonDerivedType(typeof(Catalog_Reorder_Derived<,>), "d")] + public class Catalog_Reorder_Base + { + public T1? V1 { get; set; } + public T2? V2 { get; set; } + } + + public class Catalog_Reorder_Derived : Catalog_Reorder_Base + { + public U1? Left { get; set; } + public U2? Right { get; set; } } [Fact] - public async Task MixedRegistrations_OnlyApplicableSurviveOnStringSpecialization() + public async Task Catalog_ParameterReorder_IsUniversal() { - // For MixedBase: closed-int filtered, closed-string matches, open-ok applies. - MixedBase closedString = new MixedClosedString { B = "b", Value = "v" }; - JsonTestHelper.AssertJsonEqual( - """{"$type":"closed-string","B":"b","Value":"v"}""", - await Serializer.SerializeWrapper(closedString)); + // Derived : Base maps each base parameter to a distinct derived + // parameter -- the substitution is bijective and applies to every closure. + Catalog_Reorder_Base value = new Catalog_Reorder_Derived + { + Left = "L", + Right = 7, + }; + string json = await Serializer.SerializeWrapper(value); + JsonTestHelper.AssertJsonEqual("""{"$type":"d","Left":"L","Right":7,"V1":0,"V2":null}""", json); - MixedBase openOk = new MixedOpenOk { C = "c", Value = "v" }; - JsonTestHelper.AssertJsonEqual( - """{"$type":"open-ok","C":"c","Value":"v"}""", - await Serializer.SerializeWrapper(openOk)); + var roundTripped = await Serializer.DeserializeWrapper>(json); + Assert.IsType>(roundTripped); + } + + // ---- Pattern C: constraint subsumption matrix ---- + + // C1: derived adds `where T : struct` where base has no constraint -- rejected. + [JsonDerivedType(typeof(Catalog_C1_Derived<>), "d")] + public class Catalog_C1_Base; + public class Catalog_C1_Derived : Catalog_C1_Base where T : struct; + + // C2: derived adds `where T : class` where base has no constraint -- rejected. + [JsonDerivedType(typeof(Catalog_C2_Derived<>), "d")] + public class Catalog_C2_Base; + public class Catalog_C2_Derived : Catalog_C2_Base where T : class; + + // C3: derived adds `where T : new()` where base has no constraint -- rejected. + [JsonDerivedType(typeof(Catalog_C3_Derived<>), "d")] + public class Catalog_C3_Base; + public class Catalog_C3_Derived : Catalog_C3_Base where T : new(); + + // C4: derived adds `where T : IDisposable` where base has no constraint -- rejected. + [JsonDerivedType(typeof(Catalog_C4_Derived<>), "d")] + public class Catalog_C4_Base; + public class Catalog_C4_Derived : Catalog_C4_Base where T : IDisposable; + + // C5: derived's constraint is identical to base's constraint -- accepted. (Under C# + // language rules, derived must impose at-least-as-strict constraints as base, so + // "exactly matching" is the only configuration that is BOTH compilable and + // universal. Tighter constraints on derived produce non-universality.) + [JsonDerivedType(typeof(Catalog_C5_Derived<>), "d")] + public class Catalog_C5_Base where T : class + { + public T? Value { get; set; } + } + public class Catalog_C5_Derived : Catalog_C5_Base where T : class + { + public T? Extra { get; set; } + } + + // C8: derived's interface constraint references its own derived param substituted + // to the base param -- accepted as long as the base has the equivalent constraint. + [JsonDerivedType(typeof(Catalog_C8_Derived<>), "d")] + public class Catalog_C8_Base where T : IComparable; + public class Catalog_C8_Derived : Catalog_C8_Base where T : IComparable; + + [Theory] + [InlineData(typeof(Catalog_C1_Base))] // derived adds struct constraint + [InlineData(typeof(Catalog_C1_Base))] + [InlineData(typeof(Catalog_C2_Base))] // derived adds class constraint + [InlineData(typeof(Catalog_C2_Base))] + [InlineData(typeof(Catalog_C3_Base))] // derived adds new() constraint + [InlineData(typeof(Catalog_C4_Base))] // derived adds IDisposable constraint + public async Task Catalog_ConstraintNarrowing_ThrowsForAllSpecializations(Type closedBaseType) + { + object value = Activator.CreateInstance(closedBaseType)!; + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(value, closedBaseType)); } [Fact] - public async Task MixedRegistrations_ClosedBothFilteredOnUnrelatedSpecialization() + public async Task Catalog_ConstraintIdentical_IsUniversal() { - // For MixedBase: both closed registrations are filtered, open-ok still - // applies. Polymorphism remains active with a reduced derived-type set. - MixedBase openOk = new MixedOpenOk { C = true, Value = false }; - JsonTestHelper.AssertJsonEqual( - """{"$type":"open-ok","C":true,"Value":false}""", - await Serializer.SerializeWrapper(openOk)); + // C5: derived's `class` constraint matches base's `class` constraint exactly. + Catalog_C5_Base value = new Catalog_C5_Derived { Value = "v", Extra = "e" }; + string json = await Serializer.SerializeWrapper(value); + JsonTestHelper.AssertJsonEqual("""{"$type":"d","Extra":"e","Value":"v"}""", json); + } + + [Fact] + public async Task Catalog_SelfReferentialInterfaceConstraint_IsUniversal() + { + // C8: derived's `IComparable` constraint, after substituting T with the base's + // T, exactly matches the base's `IComparable` constraint. + Catalog_C8_Base value = new Catalog_C8_Derived(); + string json = await Serializer.SerializeWrapper(value); + Assert.Contains("\"$type\":\"d\"", json); + } + + // ---- Pattern D: multi-ancestor interface implementations ---- + + // D1: Impl reaches IBase<> via two ancestors -- directly as IBase and + // transitively through Helper : IBase. Each ancestor produces a substitution + // that leaves the other ancestor's parameter free, so neither is universal in + // isolation and they disagree -- rejected. + + [JsonDerivedType(typeof(Catalog_D1_Impl<,>), "d")] + public interface ICatalog_D1_Base; + + public class Catalog_D1_Helper : ICatalog_D1_Base; + public class Catalog_D1_Impl : Catalog_D1_Helper, ICatalog_D1_Base; + + [Fact] + public async Task Catalog_MultipleInterfaceAncestors_EachLeavesOtherParamUnbound_Throws() + { + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>(new Catalog_D1_Impl())); + } + + // D2: Impl reaches IBase<,> via two ancestors with reordered substitutions + // (direct: T1=U1,T2=U2; via Helper : IBase: T1=U2,T2=U1). The two + // ancestors produce complete but disagreeing substitutions -- rejected. + + [JsonDerivedType(typeof(Catalog_D2_Impl<,>), "d")] + public interface ICatalog_D2_Base; + + public class Catalog_D2_Helper : ICatalog_D2_Base; + public class Catalog_D2_Impl : Catalog_D2_Helper, ICatalog_D2_Base; + + [Fact] + public async Task Catalog_MultipleInterfaceAncestors_AmbiguousSubstitution_Throws() + { + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>(new Catalog_D2_Impl())); + } + + // D3: Impl reaches IBase<> via two ancestors -- directly as IBase (universal) + // and transitively through HelperList : IBase> (pins to List<>). The + // pinning ancestor breaks universality even though the direct ancestor is fine. + + [JsonDerivedType(typeof(Catalog_D3_Impl<>), "d")] + public interface ICatalog_D3_Base; + + public class Catalog_D3_HelperList : ICatalog_D3_Base>; + public class Catalog_D3_Impl : Catalog_D3_HelperList, ICatalog_D3_Base; + + [Fact] + public async Task Catalog_MultipleInterfaceAncestors_OneLegPinsConstructedShape_Throws() + { + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>(new Catalog_D3_Impl())); + } + + // D4: D : IBase, IBase -- two identical ancestors. Universal because the + // canonical substitutions agree on (U -> T). + + [JsonDerivedType(typeof(Catalog_D4_Impl<>), "d")] + public interface ICatalog_D4_BaseA; + + [JsonDerivedType(typeof(Catalog_D4_Impl<>), "d")] + public interface ICatalog_D4_BaseB; + + public class Catalog_D4_Impl : ICatalog_D4_BaseA, ICatalog_D4_BaseB; + + [Fact] + public async Task Catalog_MultipleDistinctInterfaceBases_UniversalImpl_Works() + { + ICatalog_D4_BaseA viaA = new Catalog_D4_Impl(); + ICatalog_D4_BaseB viaB = (Catalog_D4_Impl)viaA; + + string jsonA = await Serializer.SerializeWrapper(viaA); + string jsonB = await Serializer.SerializeWrapper(viaB); + Assert.Contains("\"$type\":\"d\"", jsonA); + Assert.Contains("\"$type\":\"d\"", jsonB); + } + + // ---- Pattern E: nested enclosing types ---- + + // E1: Outer + Outer.Inner : Outer -- the inner's enclosing T is implicitly + // the outer's T. Universal. + + [JsonDerivedType(typeof(Catalog_E1_Outer<>.Inner), "inner")] + public class Catalog_E1_Outer + { + public T? Value { get; set; } + + public class Inner : Catalog_E1_Outer; + } + + [Fact] + public async Task Catalog_NestedTypeUnderGenericEnclosing_IsUniversal() + { + Catalog_E1_Outer value = new Catalog_E1_Outer.Inner { Value = 7 }; + string json = await Serializer.SerializeWrapper(value); + Assert.Contains("\"$type\":\"inner\"", json); + } + + // E2: Outer.Inner : SomeBase -- enclosing type is closed but a different + // type parameter U is bound through SomeBase. Universal w.r.t. SomeBase<>. + + [JsonDerivedType(typeof(Catalog_E2_Outer.Inner<>), "inner")] + public class Catalog_E2_SomeBase + { + public T? Value { get; set; } + } + + public class Catalog_E2_Outer + { + public class Inner : Catalog_E2_SomeBase; + } + + [Fact] + public async Task Catalog_NestedDerivedUnderNonGenericEnclosing_IsUniversal() + { + Catalog_E2_SomeBase value = new Catalog_E2_Outer.Inner { Value = 9 }; + string json = await Serializer.SerializeWrapper(value); + Assert.Contains("\"$type\":\"inner\"", json); + } + + // ---- Pattern F: transitive inheritance through generic mid ---- + + // F1: Leaf : Mid : Base -- universal at every hop. Accepted. + + [JsonDerivedType(typeof(Catalog_F1_Leaf<>), "leaf")] + public class Catalog_F1_Base; + public class Catalog_F1_Mid : Catalog_F1_Base; + public class Catalog_F1_Leaf : Catalog_F1_Mid + { + public T? Value { get; set; } + } + + [Fact] + public async Task Catalog_TransitiveInheritance_UniversalAtEveryHop_Works() + { + Catalog_F1_Base value = new Catalog_F1_Leaf { Value = 11 }; + string json = await Serializer.SerializeWrapper(value); + Assert.Contains("\"$type\":\"leaf\"", json); + } + + // F2: Leaf : Mid : Base where Mid : Base -- Leaf goes through a + // non-generic Mid that pins Base. Leaf doesn't bind T from Base at all, + // so T is unbound w.r.t. the base. Rejected (UnboundParameter). + + [JsonDerivedType(typeof(Catalog_F2_Leaf<>), "leaf")] + public class Catalog_F2_Base; + public class Catalog_F2_Mid : Catalog_F2_Base; + public class Catalog_F2_Leaf : Catalog_F2_Mid; + + [Fact] + public async Task Catalog_TransitiveThroughNonGenericMid_LeafParamUnboundFromBase_Throws() + { + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>(new Catalog_F2_Leaf())); + } + + // ---- Pattern G: array element pinning vs whole-arg pinning ---- + + // G: D : Base pins T to an array shape -- rejected (same as Wrapped earlier + // but kept for catalog completeness). + + [JsonDerivedType(typeof(Catalog_G_Derived<>), "d")] + public class Catalog_G_Base; + public class Catalog_G_Derived : Catalog_G_Base; + + [Fact] + public async Task Catalog_ArrayShapePinning_Throws() + { + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper>(new Catalog_G_Derived())); + } + + // ---- Pattern H: user's original PR-thread example ---- + // + // The motivating example from the inline-review thread: a two-parameter base with a + // derived that pins only one of the base's parameters to a concrete type. Rejected + // uniformly regardless of which closure of the base is constructed. + + [JsonDerivedType(typeof(Catalog_H_Derived<>), "d")] + public class Catalog_H_Base; + public class Catalog_H_Derived : Catalog_H_Base; + + [Theory] + [InlineData(typeof(Catalog_H_Base))] + [InlineData(typeof(Catalog_H_Base))] + public async Task Catalog_PartialPinning_TwoParameterBase_Throws(Type closedBaseType) + { + // Even on Base (where the pinning happens to be consistent), the + // registration is rejected -- universality requires the substitution to be valid + // for every closure, not just one. + object value = Activator.CreateInstance(closedBaseType)!; + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(value, closedBaseType)); + } + + // ---- Pattern I: open derived registered on non-generic base ---- + + [JsonDerivedType(typeof(Catalog_I_OpenDerived<>), "d")] + public class Catalog_I_NonGenericBase; + public class Catalog_I_OpenDerived : Catalog_I_NonGenericBase; + + [Fact] + public async Task Catalog_OpenDerivedOnNonGenericBase_Throws() + { + // No closure of an open derived can be assignable to a non-generic base, so the + // registration is rejected at metadata-resolution time with NotAssignable. + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(new Catalog_I_NonGenericBase())); + } + + // ---- Pattern J: open derived structurally unrelated to base ---- + + [JsonDerivedType(typeof(Catalog_J_Unrelated<>), "d")] + public class Catalog_J_Base; + public class Catalog_J_Unrelated; // No inheritance relationship. + + [Fact] + public async Task Catalog_OpenDerivedNotAssignableToBase_Throws() + { + await Assert.ThrowsAsync( + () => Serializer.SerializeWrapper(new Catalog_J_Base())); + } + + // ---- Pattern K: programmatic API parity ---- + + public class Catalog_K_Base + { + public T? Value { get; set; } + } + + public class Catalog_K_NonUniversal_Derived : Catalog_K_Base; + + [Fact] + public async Task Catalog_ProgrammaticApi_NonUniversalRegistration_Throws() + { + // The same B-strict rejection applies whether the registration originates from + // [JsonDerivedType] attribute or from a programmatic resolver modifier. + var options = new JsonSerializerOptions + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + typeInfo => + { + if (typeInfo.Type == typeof(Catalog_K_Base)) + { + typeInfo.PolymorphismOptions = new JsonPolymorphismOptions + { + DerivedTypes = + { + new JsonDerivedType(typeof(Catalog_K_NonUniversal_Derived<>), "d"), + }, + }; + } + }, + }, + }, + }; + + Catalog_K_Base value = new(); + Assert.Throws(() => JsonSerializer.Serialize(value, options)); } #endregion @@ -3762,19 +4097,17 @@ public class NestedOuter { public class NestedBox { public TInne public class NestedDerivedEnclosingMismatch : NestedBase.NestedBox>; [Fact] - public async Task NestedGeneric_TypeParameterInEnclosing_Resolves() + public async Task NestedGeneric_TypeParameterInEnclosing_ThrowsForNonUniversalPinning() { // Pattern: NestedDerivedParamInEnclosing : NestedBaseB.NestedBoxB>. - // Target: NestedBaseB.NestedBoxB>. - // T appears only in the ENCLOSING type's argument list. Reflection has always - // resolved this correctly because GetGenericArguments() flattens. Pre-B2-fix - // source-gen would have false-rejected because TryUnifyWith only walked leaf - // TypeArguments (T was never bound). + // The derived pins the leaf NestedBoxB's TInner to a concrete int. This makes + // the registration non-universal -- it only applies to closures of NestedBaseB + // whose argument is shaped as ...NestedBoxB, not e.g. ...NestedBoxB. + // Under the universal-applicability rule the registration is rejected uniformly. NestedBaseB.NestedBoxB> value = new NestedDerivedParamInEnclosing { Item = new NestedOuterB.NestedBoxB { Inner = 42 } }; - string json = await Serializer.SerializeWrapper(value); - JsonTestHelper.AssertJsonEqual("""{"$type":"nestedB","Item":{"Inner":42}}""", json); + await Assert.ThrowsAsync(() => Serializer.SerializeWrapper(value)); } [JsonDerivedType(typeof(NestedDerivedParamInEnclosing<>), "nestedB")] @@ -3788,15 +4121,15 @@ public class NestedDerivedParamInEnclosing : NestedBaseB.Nest // changed to use Compilation.HasImplicitConversion for the same parity. [Fact] - public async Task Variance_CovariantInterfaceConstraintSatisfied_Resolves() + public async Task Variance_CovariantInterfaceConstraintSatisfied_ThrowsForConstraintNarrowing() { - // Constraint: where T : IEnumerable. Closing T to List satisfies - // the constraint ONLY via IEnumerable covariance (IEnumerable is - // assignable to IEnumerable only by virtue of 'out T'). Reflection - // resolves successfully; serialization emits the discriminator. + // ConstraintImpl : ConstraintBase where T : IEnumerable. + // The derived narrows the base's constraint (none) to require IEnumerable. + // Even though specific closures (e.g. List via variance) would satisfy the + // constraint, the registration is rejected under the universal-applicability rule + // because it does not apply to every specialization of the base. ConstraintBase> value = new ConstraintImpl> { Items = new List { "hello" } }; - string json = await Serializer.SerializeWrapper(value); - JsonTestHelper.AssertJsonEqual("""{"$type":"impl","Items":["hello"]}""", json); + await Assert.ThrowsAsync(() => Serializer.SerializeWrapper(value)); } [JsonDerivedType(typeof(ConstraintImpl<>), "impl")] From 1ab6387090f8a0d8b4c935a61fed24d4c9b60d99 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 15:44:12 +0300 Subject: [PATCH 04/20] Dedupe SYSLIB1229 source-gen diagnostic across base specializations 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, Base, Base) 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> --- .../gen/JsonSourceGenerator.Parser.cs | 46 +++++++++++++++++-- .../JsonSourceGeneratorDiagnosticsTests.cs | 29 ++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 04c2303f6091c3..382fb1da173b78 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -57,8 +57,29 @@ private sealed class Parser private readonly Queue _typesToGenerate = new(); #pragma warning disable RS1024 // Compare symbols correctly https://github.com/dotnet/roslyn-analyzers/issues/5804 private readonly Dictionary _generatedTypes = new(SymbolEqualityComparer.Default); + + // Tracks (open base definition, derived type definition) pairs for which an open-generic + // polymorphism diagnostic has already been emitted. Whether a derived registration is + // universally applicable to a generic base is a property of the open forms alone, so the + // diagnostic must fire at most once per (open base, derived) pair regardless of how many + // closed specializations of the base appear in [JsonSerializable] attributes. + private readonly HashSet<(ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition)> _diagnosedOpenDerivedRegistrations = + new(s_baseDerivedDefinitionPairComparer); #pragma warning restore + private static readonly IEqualityComparer<(ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition)> s_baseDerivedDefinitionPairComparer = + new BaseDerivedDefinitionPairComparer(); + + private sealed class BaseDerivedDefinitionPairComparer : IEqualityComparer<(ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition)> + { + public bool Equals((ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition) x, (ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition) y) => + SymbolEqualityComparer.Default.Equals(x.BaseDefinition, y.BaseDefinition) && + SymbolEqualityComparer.Default.Equals(x.DerivedDefinition, y.DerivedDefinition); + + public int GetHashCode((ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition) obj) => + unchecked(SymbolEqualityComparer.Default.GetHashCode(obj.BaseDefinition) * 397 ^ SymbolEqualityComparer.Default.GetHashCode(obj.DerivedDefinition)); + } + public List Diagnostics { get; } = new(); private Location? _contextClassLocation; @@ -75,6 +96,25 @@ public void ReportDiagnostic(DiagnosticDescriptor descriptor, Location? location Diagnostics.Add(Diagnostic.Create(descriptor, location, messageArgs)); } + /// + /// Emits a + /// diagnostic at most once per (open base definition, open derived definition) pair across + /// the lifetime of this instance (i.e., per ). + /// + /// + /// Whether a derived registration applies universally to a generic base is a property of + /// the open forms alone; emitting it once per closed specialization referenced via + /// would spam the user with the same warning for + /// every closure of the same base. + /// + private void ReportOpenGenericDerivedTypeDiagnostic(ITypeSymbol baseType, ITypeSymbol derivedType, Location? location, string? failureReason) + { + if (_diagnosedOpenDerivedRegistrations.Add((baseType.OriginalDefinition, derivedType.OriginalDefinition))) + { + ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, location, derivedType.ToDisplayString(), baseType.ToDisplayString(), failureReason); + } + } + public Parser(KnownTypeSymbols knownSymbols) { _knownSymbols = knownSymbols; @@ -946,7 +986,7 @@ private void ProcessTypeCustomAttributes( { // Open derived registered against a non-generic base: no // closure of the derived can ever be assignable to the base. - ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, attributeData.GetLocation(), derivedType.ToDisplayString(), typeToGenerate.Type.ToDisplayString(), SR.Polymorphism_OpenGeneric_Reason_NotAssignable); + ReportOpenGenericDerivedTypeDiagnostic(typeToGenerate.Type, derivedType, attributeData.GetLocation(), SR.Polymorphism_OpenGeneric_Reason_NotAssignable); continue; } @@ -954,7 +994,7 @@ private void ProcessTypeCustomAttributes( unboundDerived, constructedBase, out INamedTypeSymbol? resolvedType, out string? failureReason)) { - ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, attributeData.GetLocation(), derivedType.ToDisplayString(), typeToGenerate.Type.ToDisplayString(), failureReason); + ReportOpenGenericDerivedTypeDiagnostic(typeToGenerate.Type, derivedType, attributeData.GetLocation(), failureReason); continue; } @@ -971,7 +1011,7 @@ private void ProcessTypeCustomAttributes( // // Closed derived types on a NON-generic base continue to flow through // to the normal PolymorphicTypeResolver assignability check below. - ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, attributeData.GetLocation(), derivedType.ToDisplayString(), typeToGenerate.Type.ToDisplayString(), SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase); + ReportOpenGenericDerivedTypeDiagnostic(typeToGenerate.Type, derivedType, attributeData.GetLocation(), SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase); continue; } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs index 3c9dffabd8c9fa..f081bdf225562b 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs @@ -2030,5 +2030,34 @@ public class ConstraintImpl : ConstraintBase where T : IEnumerable Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } + + [Fact] + public void OpenGenericDerivedType_DiagnosticEmittedOncePerOpenBase_EvenWithMultipleClosures() + { + string source = """ + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(MyBase))] + [JsonSerializable(typeof(MyBase))] + [JsonSerializable(typeof(MyBase))] + internal partial class JsonContext : JsonSerializerContext { } + + [JsonDerivedType(typeof(MyDerived<>), "d")] + public class MyBase { } + + // Pins T to a specific specialization (string) of the base — non-universal, + // so SYSLIB1229 fires. It should fire exactly once for the (MyBase<>, MyDerived<>) + // pair, not once per closure of MyBase<>. + public class MyDerived : MyBase { } + } + """; + + Compilation compilation = CompilationHelper.CreateCompilation(source); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + } } } From 1f200128c8f5d2c855c79e22917558a26534fd5b Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 17:46:46 +0300 Subject: [PATCH 05/20] Replace constraint subsumption with exact-match equivalence 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> --- .../gen/Helpers/RoslynExtensions.cs | 90 +++++++--------- .../gen/JsonSourceGenerator.Parser.cs | 12 ++- .../gen/Resources/Strings.resx | 4 +- .../gen/Resources/xlf/Strings.cs.xlf | 8 +- .../gen/Resources/xlf/Strings.de.xlf | 8 +- .../gen/Resources/xlf/Strings.es.xlf | 8 +- .../gen/Resources/xlf/Strings.fr.xlf | 8 +- .../gen/Resources/xlf/Strings.it.xlf | 8 +- .../gen/Resources/xlf/Strings.ja.xlf | 8 +- .../gen/Resources/xlf/Strings.ko.xlf | 8 +- .../gen/Resources/xlf/Strings.pl.xlf | 8 +- .../gen/Resources/xlf/Strings.pt-BR.xlf | 8 +- .../gen/Resources/xlf/Strings.ru.xlf | 8 +- .../gen/Resources/xlf/Strings.tr.xlf | 8 +- .../gen/Resources/xlf/Strings.zh-Hans.xlf | 8 +- .../gen/Resources/xlf/Strings.zh-Hant.xlf | 8 +- .../src/Resources/Strings.resx | 4 +- .../src/System/ReflectionExtensions.cs | 100 +++++------------- .../DefaultJsonTypeInfoResolver.Helpers.cs | 12 ++- .../JsonSourceGeneratorDiagnosticsTests.cs | 20 ++-- .../PolymorphicTests.CustomTypeHierarchies.cs | 16 +-- 21 files changed, 151 insertions(+), 211 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs b/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs index b44e33ccf544a7..7aa8b6d30e2186 100644 --- a/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs +++ b/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs @@ -525,91 +525,73 @@ public static bool TryValidateGenericConstraints( } /// - /// Returns if every constraint declared on - /// is implied by (i.e. weaker-than-or-equal-to) some - /// constraint declared on , after applying + /// Returns if 's declared constraints + /// match 's declared constraints exactly, after applying /// to type-constraint references. Used by the /// "universal derived registration" check to verify that a derived type's parameter /// constraints will be satisfied for every valid specialization of the base. /// - /// Special-constraint subsumption rules: - /// * where T : class requires base parameter to also have class. - /// * where T : struct requires base parameter to have struct OR unmanaged. - /// * where T : unmanaged requires base parameter to have unmanaged. - /// * where T : new() requires base parameter to have new(), struct, or unmanaged. - /// * notnull is intentionally ignored (not runtime-enforced; matches ). + /// "Exact match" is used in place of one-sided constraint subsumption because in the + /// universal-applicability regime the two are equivalent (C# already forces a derived + /// parameter that's identified with a base parameter to declare at least the base's + /// constraints), and an equality check is simpler, easier to reason about, and + /// forward-compatible with new C# constraint kinds. + /// + /// + /// Special constraint flags (class, struct, unmanaged, + /// new()) must match exactly. + /// The set of type constraints must match exactly after substitution + /// (order-independent). + /// The compile-time-only notnull constraint is intentionally ignored + /// (it is not surfaced via reflection and is not runtime-enforced). + /// /// /// IMPORTANT: This implementation MIRRORS the reflection-side - /// System.Text.Json.Reflection.ReflectionExtensions.AreConstraintsImpliedBy. - /// Any change to subsumption rules MUST be applied on both sides. + /// System.Text.Json.Reflection.ReflectionExtensions.AreConstraintsEquivalent. + /// Any change to the equivalence rules MUST be applied on both sides. /// - public static bool AreConstraintsImpliedBy( + public static bool AreConstraintsEquivalent( this Compilation compilation, ITypeParameterSymbol derivedParam, ITypeParameterSymbol baseParam, IReadOnlyDictionary substitution) { - bool baseGivesStruct = baseParam.HasValueTypeConstraint || baseParam.HasUnmanagedTypeConstraint; - - if (derivedParam.HasReferenceTypeConstraint && !baseParam.HasReferenceTypeConstraint) + if (derivedParam.HasReferenceTypeConstraint != baseParam.HasReferenceTypeConstraint || + derivedParam.HasValueTypeConstraint != baseParam.HasValueTypeConstraint || + derivedParam.HasUnmanagedTypeConstraint != baseParam.HasUnmanagedTypeConstraint || + derivedParam.HasConstructorConstraint != baseParam.HasConstructorConstraint) { return false; } - if (derivedParam.HasValueTypeConstraint && !baseGivesStruct) + ImmutableArray derivedConstraints = derivedParam.ConstraintTypes; + ImmutableArray baseConstraints = baseParam.ConstraintTypes; + if (derivedConstraints.Length != baseConstraints.Length) { return false; } - if (derivedParam.HasUnmanagedTypeConstraint && !baseParam.HasUnmanagedTypeConstraint) + if (derivedConstraints.Length == 0) { - return false; - } - - if (derivedParam.HasConstructorConstraint && !(baseParam.HasConstructorConstraint || baseGivesStruct)) - { - return false; + return true; } - foreach (ITypeSymbol derivedConstraint in derivedParam.ConstraintTypes) + // Compare type-constraint sets order-independently. Substitute each derived + // constraint into base-parameter terms via the canonical mapping, then check + // that the resulting multiset matches the base's constraint set. Length parity + // was already established above, so removing each substituted derived constraint + // from a fresh copy of the base set proves equality iff every Remove succeeds. + var baseSet = new HashSet(baseConstraints, SymbolEqualityComparer.Default); + foreach (ITypeSymbol derivedConstraint in derivedConstraints) { ITypeSymbol substituted = compilation.SubstituteTypeParameters(derivedConstraint, substitution); - if (!IsBaseConstraintSetImplying(compilation, baseParam, substituted, baseGivesStruct)) + if (!baseSet.Remove(substituted)) { return false; } } return true; - - static bool IsBaseConstraintSetImplying( - Compilation compilation, - ITypeParameterSymbol baseParam, - ITypeSymbol target, - bool baseGivesStruct) - { - // System.ValueType is implied by the `struct`/`unmanaged` special constraint. - if (baseGivesStruct && target.SpecialType == SpecialType.System_ValueType) - { - return true; - } - - // Object is satisfied by every type parameter trivially. - if (target.SpecialType == SpecialType.System_Object) - { - return true; - } - - foreach (ITypeSymbol baseConstraint in baseParam.ConstraintTypes) - { - if (compilation.HasImplicitConversion(baseConstraint, target)) - { - return true; - } - } - - return false; - } } /// diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 382fb1da173b78..c45bb2000764ed 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -1212,17 +1212,19 @@ private bool TryResolveOpenGenericDerivedType( Debug.Assert(canonical is not null); - // Constraint subsumption: every derived parameter's constraints must be implied - // by the constraints on the mapped base parameter so that any valid closure of - // the base also yields a valid closure of the derived. + // Constraint equivalence: every derived parameter's constraints must exactly + // match the constraints on the mapped base parameter (after substitution) so + // that any valid closure of the base also yields a valid closure of the + // derived. See ReflectionExtensions.AreConstraintsEquivalent for the rationale + // behind exact match (vs one-sided subsumption). foreach (ITypeParameterSymbol derivedParam in requiredDerivedParams) { var mappedBaseParam = (ITypeParameterSymbol)canonical[derivedParam]; - if (!_knownSymbols.Compilation.AreConstraintsImpliedBy(derivedParam, mappedBaseParam, canonical)) + if (!_knownSymbols.Compilation.AreConstraintsEquivalent(derivedParam, mappedBaseParam, canonical)) { failureReason = string.Format( CultureInfo.InvariantCulture, - SR.Polymorphism_OpenGeneric_Reason_ConstraintNarrowing, + SR.Polymorphism_OpenGeneric_Reason_ConstraintMismatch, derivedParam.Name, mappedBaseParam.Name); return false; diff --git a/src/libraries/System.Text.Json/gen/Resources/Strings.resx b/src/libraries/System.Text.Json/gen/Resources/Strings.resx index 05b99748386664..9f2f7e29a98fb8 100644 --- a/src/libraries/System.Text.Json/gen/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/gen/Resources/Strings.resx @@ -240,8 +240,8 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' the derived type matches the base type through multiple ancestors diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf index b877faf9c6a368..6fdb0fd1461c75 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf index 0ec2d6d3a17a88..092fe6f4769ef0 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf index 9a299189efb265..a998133c2dd9bf 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf index c3e61e1b0f376a..195390c991758b 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf index e50916e15f071a..bf92a0c1d0f5c2 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf index 95501f17661736..a122ba750663a3 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf index f37abb5f69c0b4..3f11c92e193dde 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf index 51d42b169fad90..e6e314b6e91b55 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf index 541f5c03baf907..e283e207b44bd0 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf index beb8b65dbf1d58..6d31f28bb50943 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf index 9ff0d36395f5e1..0624ea3855719f 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf index 33478b6a4c4f50..3a590745ccb5cd 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf index d92afc7c65e45e..9c48d153efda59 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf @@ -1,4 +1,4 @@ - + @@ -147,9 +147,9 @@ a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx index d411670b4abedf..a2594e0d673b23 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -689,8 +689,8 @@ a type argument violates a generic constraint on the derived type - - the derived type's type parameter '{0}' declares constraints that are stricter than the constraints on the corresponding base type parameter '{1}' + + the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base diff --git a/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs b/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs index d3482dcbc52ce8..f592ad3847c6b9 100644 --- a/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs +++ b/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs @@ -321,26 +321,26 @@ public static bool TryUnifyWith(this Type pattern, Type target, IDictionary - /// Returns if the constraints on - /// (after substituting via ) are implied by the - /// constraints on . Used by the polymorphism resolver to - /// validate that every closure of the base type that respects the base's constraints - /// would also be a valid closure of the open derived type. + /// Returns if 's declared constraints + /// match 's declared constraints exactly, after applying + /// to type-constraint references. Used by the polymorphism + /// resolver to validate that every closure of the base type that respects the base's + /// constraints would also be a valid closure of the open derived type. /// /// IMPORTANT: This implementation MIRRORS - /// System.Text.Json.SourceGeneration.RoslynExtensions.AreConstraintsImpliedBy. - /// Any change to subsumption semantics must be applied on both sides. + /// System.Text.Json.SourceGeneration.RoslynExtensions.AreConstraintsEquivalent. + /// Any change to the equivalence rules must be applied on both sides. /// /// Known intentional asymmetry with the source-gen mirror: the C# unmanaged /// constraint is encoded as a modreq and is not surfaced through the standard - /// reflection API, so the reflection check cannot enforce that a derived - /// unmanaged constraint is matched by a base unmanaged constraint. The - /// polymorphism resolver falls back on to surface - /// any remaining constraint violations at closure time. + /// reflection API, so this check cannot enforce that a derived unmanaged + /// constraint is matched by a base unmanaged constraint. The polymorphism + /// resolver falls back on to surface any + /// remaining constraint violations at closure time. /// [RequiresUnreferencedCode("Reflects over derived and base generic parameter constraint types.")] [RequiresDynamicCode("Substitutes type parameters in constraint types.")] - public static bool AreConstraintsImpliedBy( + public static bool AreConstraintsEquivalent( Type derivedParam, Type baseParam, IReadOnlyDictionary substitution) @@ -348,80 +348,34 @@ public static bool AreConstraintsImpliedBy( Debug.Assert(derivedParam.IsGenericParameter); Debug.Assert(baseParam.IsGenericParameter); - GenericParameterAttributes derivedFlags = derivedParam.GenericParameterAttributes & GenericParameterAttributes.SpecialConstraintMask; - GenericParameterAttributes baseFlags = baseParam.GenericParameterAttributes & GenericParameterAttributes.SpecialConstraintMask; - - bool baseHasReferenceType = (baseFlags & GenericParameterAttributes.ReferenceTypeConstraint) != 0; - bool baseHasValueType = (baseFlags & GenericParameterAttributes.NotNullableValueTypeConstraint) != 0; - bool baseHasDefaultCtor = (baseFlags & GenericParameterAttributes.DefaultConstructorConstraint) != 0; - - if ((derivedFlags & GenericParameterAttributes.ReferenceTypeConstraint) != 0 && !baseHasReferenceType) + const GenericParameterAttributes Mask = GenericParameterAttributes.SpecialConstraintMask; + if ((derivedParam.GenericParameterAttributes & Mask) != (baseParam.GenericParameterAttributes & Mask)) { return false; } - if ((derivedFlags & GenericParameterAttributes.NotNullableValueTypeConstraint) != 0 && !baseHasValueType) + Type[] derivedConstraints = derivedParam.GetGenericParameterConstraints(); + Type[] baseConstraints = baseParam.GetGenericParameterConstraints(); + if (derivedConstraints.Length != baseConstraints.Length) { return false; } - // 'struct' implies 'new()'. - if ((derivedFlags & GenericParameterAttributes.DefaultConstructorConstraint) != 0 && !baseHasDefaultCtor && !baseHasValueType) + if (derivedConstraints.Length == 0) { - return false; + return true; } - Type[] baseConstraintTypes = baseParam.GetGenericParameterConstraints(); - foreach (Type derivedConstraint in derivedParam.GetGenericParameterConstraints()) + // Compare type-constraint sets order-independently. Substitute each derived + // constraint into base-parameter terms via the canonical mapping, then check + // that the resulting multiset matches the base's constraint set. Length parity + // was already established above, so removing each substituted derived constraint + // from a fresh copy of the base set proves equality iff every Remove succeeds. + var baseSet = new HashSet(baseConstraints); + foreach (Type derivedConstraint in derivedConstraints) { Type substituted = SubstituteTypeParameters(derivedConstraint, substitution); - - // System.Object as a constraint is trivially implied by any reference whatsoever. - if (substituted == typeof(object)) - { - continue; - } - - // System.ValueType is implied when the base has the 'struct' constraint. - if (substituted == typeof(ValueType) && baseHasValueType) - { - continue; - } - - if (substituted.IsGenericParameter) - { - // The derived constraint substituted to (another) base parameter. That - // parameter must literally appear in the base's own type constraints for - // the substitution to be guaranteed valid for every closure. - bool implied = false; - foreach (Type bc in baseConstraintTypes) - { - if (bc == substituted) - { - implied = true; - break; - } - } - - if (!implied) - { - return false; - } - - continue; - } - - bool match = false; - foreach (Type baseConstraint in baseConstraintTypes) - { - if (substituted.IsAssignableFrom(baseConstraint)) - { - match = true; - break; - } - } - - if (!match) + if (!baseSet.Remove(substituted)) { return false; } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs index 4f0b305755bc72..c079fb60601b0c 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs @@ -292,15 +292,17 @@ private static bool TryResolveOpenGenericDerivedType( Debug.Assert(canonical is not null); - // Constraint subsumption: every derived parameter's constraints must be implied - // by the constraints on the mapped base parameter so that any valid closure of - // the base also yields a valid closure of the derived. + // Constraint equivalence: every derived parameter's constraints must exactly + // match the constraints on the mapped base parameter (after substitution) so + // that any valid closure of the base also yields a valid closure of the derived. + // See ReflectionExtensions.AreConstraintsEquivalent for the rationale behind + // exact match (vs one-sided subsumption). foreach (Type derivedParam in requiredParams) { Type mappedBaseParam = canonical[derivedParam]; - if (!ReflectionExtensions.AreConstraintsImpliedBy(derivedParam, mappedBaseParam, canonical)) + if (!ReflectionExtensions.AreConstraintsEquivalent(derivedParam, mappedBaseParam, canonical)) { - failureReason = SR.Format(SR.Polymorphism_OpenGeneric_Reason_ConstraintNarrowing, + failureReason = SR.Format(SR.Polymorphism_OpenGeneric_Reason_ConstraintMismatch, derivedParam.Name, mappedBaseParam.Name); return false; } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs index f081bdf225562b..723b26ff7ba0e8 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs @@ -1598,11 +1598,11 @@ public class MyDerived : MyBase where T : struct } [Fact] - public void OpenGenericDerivedType_ReferenceTypeConstraintNarrowing_WarnsWithSYSLIB1229() + public void OpenGenericDerivedType_ReferenceTypeConstraintMismatch_WarnsWithSYSLIB1229() { // Derived : Base where T : class. Base has no constraint, so the - // derived's constraint is stricter than the base's. Rejected under the - // universal-applicability rule. + // derived's constraints don't match the base's exactly. Rejected under + // the universal-applicability rule. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1632,11 +1632,11 @@ public class MyDerived : MyBase where T : class } [Fact] - public void OpenGenericDerivedType_InterfaceConstraintNarrowing_WarnsWithSYSLIB1229() + public void OpenGenericDerivedType_InterfaceConstraintMismatch_WarnsWithSYSLIB1229() { // Derived : Base where T : IComparable. Base has no constraint, so - // the derived's constraint is stricter than the base's. Rejected under the - // universal-applicability rule. + // the derived's constraints don't match the base's exactly. Rejected under + // the universal-applicability rule. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -2001,12 +2001,12 @@ public class NestedDerivedParamInEnclosing : NestedBaseB.Nest } [Fact] - public void OpenGenericDerivedType_CovariantInterfaceConstraintNarrowing_WarnsWithSYSLIB1229() + public void OpenGenericDerivedType_CovariantInterfaceConstraintMismatch_WarnsWithSYSLIB1229() { // ConstraintImpl : ConstraintBase where T : IEnumerable. - // The derived narrows the constraint (base has none). Even though specific - // closures (e.g. List via variance) would satisfy the constraint, - // the registration is rejected under the universal-applicability rule. + // The derived has constraints the base doesn't. Even though specific closures + // (e.g. List via variance) would satisfy the constraint, the + // registration is rejected under the universal-applicability rule. string source = """ using System.Collections.Generic; using System.Text.Json.Serialization; diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs index 33e442e0c9dd80..b015baa8d9a38c 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PolymorphicTests.CustomTypeHierarchies.cs @@ -3197,7 +3197,7 @@ public class OpenGenericBase_Unbound; public class OpenGenericDerived_Unbound : OpenGenericBase_Unbound; [Fact] - public async Task OpenGenericDerivedType_ConstraintNarrowing_ThrowsForAllSpecializations() + public async Task OpenGenericDerivedType_ConstraintMismatch_ThrowsForAllSpecializations() { // The derived adds a `where T : struct` constraint that the base does not have. // This is rejected uniformly: even though the constraint happens to be satisfied @@ -3614,7 +3614,7 @@ public async Task Catalog_ParameterReorder_IsUniversal() Assert.IsType>(roundTripped); } - // ---- Pattern C: constraint subsumption matrix ---- + // ---- Pattern C: constraint equivalence matrix ---- // C1: derived adds `where T : struct` where base has no constraint -- rejected. [JsonDerivedType(typeof(Catalog_C1_Derived<>), "d")] @@ -3663,7 +3663,7 @@ public class Catalog_C8_Derived : Catalog_C8_Base where T : IComparable [InlineData(typeof(Catalog_C2_Base))] [InlineData(typeof(Catalog_C3_Base))] // derived adds new() constraint [InlineData(typeof(Catalog_C4_Base))] // derived adds IDisposable constraint - public async Task Catalog_ConstraintNarrowing_ThrowsForAllSpecializations(Type closedBaseType) + public async Task Catalog_ConstraintMismatch_ThrowsForAllSpecializations(Type closedBaseType) { object value = Activator.CreateInstance(closedBaseType)!; await Assert.ThrowsAsync( @@ -4121,13 +4121,13 @@ public class NestedDerivedParamInEnclosing : NestedBaseB.Nest // changed to use Compilation.HasImplicitConversion for the same parity. [Fact] - public async Task Variance_CovariantInterfaceConstraintSatisfied_ThrowsForConstraintNarrowing() + public async Task Variance_CovariantInterfaceConstraintSatisfied_ThrowsForConstraintMismatch() { // ConstraintImpl : ConstraintBase where T : IEnumerable. - // The derived narrows the base's constraint (none) to require IEnumerable. - // Even though specific closures (e.g. List via variance) would satisfy the - // constraint, the registration is rejected under the universal-applicability rule - // because it does not apply to every specialization of the base. + // The derived has constraints the base doesn't (IEnumerable). Even + // though specific closures (e.g. List via variance) would satisfy the + // constraint, the registration is rejected under the universal-applicability + // rule because it does not apply to every specialization of the base. ConstraintBase> value = new ConstraintImpl> { Items = new List { "hello" } }; await Assert.ThrowsAsync(() => Serializer.SerializeWrapper(value)); } From 1f9794d682328c10e60e1190d24368ff3f2e7847 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 18:13:08 +0300 Subject: [PATCH 06/20] Extract tuple-comparer combinator into RoslynExtensions 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(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 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> --- .../gen/Helpers/RoslynExtensions.cs | 36 +++++++++++++++++++ .../gen/JsonSourceGenerator.Parser.cs | 18 +++------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs b/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs index 7aa8b6d30e2186..f5c7f4cbbd6aeb 100644 --- a/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs +++ b/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs @@ -857,5 +857,41 @@ private static bool HasCodeAnalysisAttribute(this ISymbol symbol, string attribu attr.AttributeClass?.Name == attributeName && attr.AttributeClass.ContainingNamespace.ToDisplayString() == "System.Diagnostics.CodeAnalysis"); } + + /// + /// Returns an for value tuples of two elements that + /// delegates equality and hashing of each tuple component to the corresponding element + /// comparer. Useful when neither component has a usable default comparer (e.g. Roslyn + /// symbol types, where must be used). + /// + public static IEqualityComparer<(T1, T2)> CreateTupleComparer( + IEqualityComparer firstComparer, + IEqualityComparer secondComparer) + { + return new TupleComparer(firstComparer, secondComparer); + } + + private sealed class TupleComparer : IEqualityComparer<(T1, T2)> + { + private readonly IEqualityComparer _firstComparer; + private readonly IEqualityComparer _secondComparer; + + public TupleComparer(IEqualityComparer firstComparer, IEqualityComparer secondComparer) + { + _firstComparer = firstComparer; + _secondComparer = secondComparer; + } + + public bool Equals((T1, T2) x, (T1, T2) y) => + _firstComparer.Equals(x.Item1, y.Item1) && + _secondComparer.Equals(x.Item2, y.Item2); + + public int GetHashCode((T1, T2) obj) + { + int h1 = obj.Item1 is null ? 0 : _firstComparer.GetHashCode(obj.Item1); + int h2 = obj.Item2 is null ? 0 : _secondComparer.GetHashCode(obj.Item2); + return unchecked(h1 * 397 ^ h2); + } + } } } diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index c45bb2000764ed..f3306a369d76a9 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -63,23 +63,13 @@ private sealed class Parser // universally applicable to a generic base is a property of the open forms alone, so the // diagnostic must fire at most once per (open base, derived) pair regardless of how many // closed specializations of the base appear in [JsonSerializable] attributes. - private readonly HashSet<(ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition)> _diagnosedOpenDerivedRegistrations = + private static readonly IEqualityComparer<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> s_baseDerivedDefinitionPairComparer = + RoslynExtensions.CreateTupleComparer(SymbolEqualityComparer.Default, SymbolEqualityComparer.Default); + + private readonly HashSet<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> _diagnosedOpenDerivedRegistrations = new(s_baseDerivedDefinitionPairComparer); #pragma warning restore - private static readonly IEqualityComparer<(ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition)> s_baseDerivedDefinitionPairComparer = - new BaseDerivedDefinitionPairComparer(); - - private sealed class BaseDerivedDefinitionPairComparer : IEqualityComparer<(ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition)> - { - public bool Equals((ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition) x, (ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition) y) => - SymbolEqualityComparer.Default.Equals(x.BaseDefinition, y.BaseDefinition) && - SymbolEqualityComparer.Default.Equals(x.DerivedDefinition, y.DerivedDefinition); - - public int GetHashCode((ITypeSymbol BaseDefinition, ITypeSymbol DerivedDefinition) obj) => - unchecked(SymbolEqualityComparer.Default.GetHashCode(obj.BaseDefinition) * 397 ^ SymbolEqualityComparer.Default.GetHashCode(obj.DerivedDefinition)); - } - public List Diagnostics { get; } = new(); private Location? _contextClassLocation; From d09e5d3f7419ccd97e0c51210397107aff4ce570 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 18:27:23 +0300 Subject: [PATCH 07/20] Rename comparer to s_typePairComparer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Text.Json/gen/JsonSourceGenerator.Parser.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index f3306a369d76a9..8e044fa46542cb 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -63,11 +63,11 @@ private sealed class Parser // universally applicable to a generic base is a property of the open forms alone, so the // diagnostic must fire at most once per (open base, derived) pair regardless of how many // closed specializations of the base appear in [JsonSerializable] attributes. - private static readonly IEqualityComparer<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> s_baseDerivedDefinitionPairComparer = + private static readonly IEqualityComparer<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> s_typePairComparer = RoslynExtensions.CreateTupleComparer(SymbolEqualityComparer.Default, SymbolEqualityComparer.Default); private readonly HashSet<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> _diagnosedOpenDerivedRegistrations = - new(s_baseDerivedDefinitionPairComparer); + new(s_typePairComparer); #pragma warning restore public List Diagnostics { get; } = new(); From 7e0ae81f94a343dfe46ece4d2e44848c245d73b0 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 18:43:48 +0300 Subject: [PATCH 08/20] Convert ReportOpenGenericDerivedTypeDiagnostic to a local function 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> --- .../gen/JsonSourceGenerator.Parser.cs | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 8e044fa46542cb..4fcf0cb75fa3f8 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -86,25 +86,6 @@ public void ReportDiagnostic(DiagnosticDescriptor descriptor, Location? location Diagnostics.Add(Diagnostic.Create(descriptor, location, messageArgs)); } - /// - /// Emits a - /// diagnostic at most once per (open base definition, open derived definition) pair across - /// the lifetime of this instance (i.e., per ). - /// - /// - /// Whether a derived registration applies universally to a generic base is a property of - /// the open forms alone; emitting it once per closed specialization referenced via - /// would spam the user with the same warning for - /// every closure of the same base. - /// - private void ReportOpenGenericDerivedTypeDiagnostic(ITypeSymbol baseType, ITypeSymbol derivedType, Location? location, string? failureReason) - { - if (_diagnosedOpenDerivedRegistrations.Add((baseType.OriginalDefinition, derivedType.OriginalDefinition))) - { - ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, location, derivedType.ToDisplayString(), baseType.ToDisplayString(), failureReason); - } - } - public Parser(KnownTypeSymbols knownSymbols) { _knownSymbols = knownSymbols; @@ -1086,6 +1067,19 @@ namedUnionType is not null && { EnqueueUnionCaseTypes(typeToGenerate, hasUnionTypeClassifierSpecified); } + + // Emits a SYSLIB1229 diagnostic at most once per (open base definition, open derived definition) + // pair across the lifetime of this Parser instance (i.e., per JsonSerializerContext). + // Whether a derived registration applies universally to a generic base is a property of the + // open forms alone; emitting it once per closed specialization referenced via + // JsonSerializableAttribute would spam the user with the same warning for every closure of the same base. + void ReportOpenGenericDerivedTypeDiagnostic(ITypeSymbol baseType, ITypeSymbol derivedType, Location? location, string? failureReason) + { + if (_diagnosedOpenDerivedRegistrations.Add((baseType.OriginalDefinition, derivedType.OriginalDefinition))) + { + ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, location, derivedType.ToDisplayString(), baseType.ToDisplayString(), failureReason); + } + } } /// From 6cfcb337a5dbad17d21d7865b26827c37afcef14 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 18:49:50 +0300 Subject: [PATCH 09/20] Lazily allocate dedup set behind a property 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> --- .../System.Text.Json/gen/JsonSourceGenerator.Parser.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 4fcf0cb75fa3f8..a7cbf45c1c662b 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -57,18 +57,19 @@ private sealed class Parser private readonly Queue _typesToGenerate = new(); #pragma warning disable RS1024 // Compare symbols correctly https://github.com/dotnet/roslyn-analyzers/issues/5804 private readonly Dictionary _generatedTypes = new(SymbolEqualityComparer.Default); +#pragma warning restore // Tracks (open base definition, derived type definition) pairs for which an open-generic // polymorphism diagnostic has already been emitted. Whether a derived registration is // universally applicable to a generic base is a property of the open forms alone, so the // diagnostic must fire at most once per (open base, derived) pair regardless of how many // closed specializations of the base appear in [JsonSerializable] attributes. + // Lazily allocated since the diagnostic is rarely emitted. private static readonly IEqualityComparer<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> s_typePairComparer = RoslynExtensions.CreateTupleComparer(SymbolEqualityComparer.Default, SymbolEqualityComparer.Default); - private readonly HashSet<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> _diagnosedOpenDerivedRegistrations = - new(s_typePairComparer); -#pragma warning restore + private HashSet<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> DiagnosedOpenDerivedRegistrations => + field ??= new(s_typePairComparer); public List Diagnostics { get; } = new(); private Location? _contextClassLocation; @@ -1075,7 +1076,7 @@ namedUnionType is not null && // JsonSerializableAttribute would spam the user with the same warning for every closure of the same base. void ReportOpenGenericDerivedTypeDiagnostic(ITypeSymbol baseType, ITypeSymbol derivedType, Location? location, string? failureReason) { - if (_diagnosedOpenDerivedRegistrations.Add((baseType.OriginalDefinition, derivedType.OriginalDefinition))) + if (DiagnosedOpenDerivedRegistrations.Add((baseType.OriginalDefinition, derivedType.OriginalDefinition))) { ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, location, derivedType.ToDisplayString(), baseType.ToDisplayString(), failureReason); } From c2aa07d463345e495b41b7707abe0b8fe4a3bd9d Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 18:53:42 +0300 Subject: [PATCH 10/20] Remove redundant comment on s_typePairComparer Definition is self-explanatory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Text.Json/gen/JsonSourceGenerator.Parser.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index a7cbf45c1c662b..6670880d98f661 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -59,12 +59,6 @@ private sealed class Parser private readonly Dictionary _generatedTypes = new(SymbolEqualityComparer.Default); #pragma warning restore - // Tracks (open base definition, derived type definition) pairs for which an open-generic - // polymorphism diagnostic has already been emitted. Whether a derived registration is - // universally applicable to a generic base is a property of the open forms alone, so the - // diagnostic must fire at most once per (open base, derived) pair regardless of how many - // closed specializations of the base appear in [JsonSerializable] attributes. - // Lazily allocated since the diagnostic is rarely emitted. private static readonly IEqualityComparer<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> s_typePairComparer = RoslynExtensions.CreateTupleComparer(SymbolEqualityComparer.Default, SymbolEqualityComparer.Default); From ae3ab09f710961f2b411d8cb811e667269ef0920 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 19:15:29 +0300 Subject: [PATCH 11/20] Group s_typePairComparer with other Parser statics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Text.Json/gen/JsonSourceGenerator.Parser.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 6670880d98f661..455e2af9b0d8a0 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -33,6 +33,10 @@ private sealed class Parser private static readonly SymbolDisplayFormat s_fullyQualifiedWithConstraints = SymbolDisplayFormat.FullyQualifiedFormat.AddGenericsOptions( SymbolDisplayGenericsOptions.IncludeTypeConstraints); + + private static readonly IEqualityComparer<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> s_typePairComparer = + RoslynExtensions.CreateTupleComparer(SymbolEqualityComparer.Default, SymbolEqualityComparer.Default); + private const string JsonExtensionDataAttributeFullName = "System.Text.Json.Serialization.JsonExtensionDataAttribute"; private const string JsonIgnoreAttributeFullName = "System.Text.Json.Serialization.JsonIgnoreAttribute"; private const string JsonIgnoreConditionFullName = "System.Text.Json.Serialization.JsonIgnoreCondition"; @@ -59,9 +63,6 @@ private sealed class Parser private readonly Dictionary _generatedTypes = new(SymbolEqualityComparer.Default); #pragma warning restore - private static readonly IEqualityComparer<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> s_typePairComparer = - RoslynExtensions.CreateTupleComparer(SymbolEqualityComparer.Default, SymbolEqualityComparer.Default); - private HashSet<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> DiagnosedOpenDerivedRegistrations => field ??= new(s_typePairComparer); From 45e3ff8b72a80c1231ba55c10a5f075d66ac3d66 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 19:18:47 +0300 Subject: [PATCH 12/20] Collapse DiagnosedOpenDerivedRegistrations property to one line Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Text.Json/gen/JsonSourceGenerator.Parser.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 455e2af9b0d8a0..486070823255f5 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -63,8 +63,7 @@ private sealed class Parser private readonly Dictionary _generatedTypes = new(SymbolEqualityComparer.Default); #pragma warning restore - private HashSet<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> DiagnosedOpenDerivedRegistrations => - field ??= new(s_typePairComparer); + private HashSet<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> DiagnosedOpenDerivedRegistrations => field ??= new(s_typePairComparer); public List Diagnostics { get; } = new(); private Location? _contextClassLocation; From 2bfc38444beec49148b778f262772c108a37de61 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 19:30:56 +0300 Subject: [PATCH 13/20] Integrate non-generic-base guard into TryResolveOpenGenericDerivedType 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> --- .../gen/JsonSourceGenerator.Parser.cs | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 486070823255f5..659f9b20619b37 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -948,16 +948,8 @@ private void ProcessTypeCustomAttributes( if (derivedType is INamedTypeSymbol { IsUnboundGenericType: true } unboundDerived) { - if (typeToGenerate.Type is not INamedTypeSymbol { IsGenericType: true } constructedBase) - { - // Open derived registered against a non-generic base: no - // closure of the derived can ever be assignable to the base. - ReportOpenGenericDerivedTypeDiagnostic(typeToGenerate.Type, derivedType, attributeData.GetLocation(), SR.Polymorphism_OpenGeneric_Reason_NotAssignable); - continue; - } - if (!TryResolveOpenGenericDerivedType( - unboundDerived, constructedBase, + unboundDerived, typeToGenerate.Type, out INamedTypeSymbol? resolvedType, out string? failureReason)) { ReportOpenGenericDerivedTypeDiagnostic(typeToGenerate.Type, derivedType, attributeData.GetLocation(), failureReason); @@ -1079,9 +1071,10 @@ void ReportOpenGenericDerivedTypeDiagnostic(ITypeSymbol baseType, ITypeSymbol de /// /// Source-gen-side resolver: validates that applies - /// universally to every specialization of 's open - /// definition, and (when it does) produces the closed derived type for the specific - /// closure . + /// universally to every specialization of 's open definition, + /// and (when it does) produces the closed derived type for the specific closure + /// . When is not a generic + /// , the registration is rejected as unassignable. /// /// "Universal" means: there is a single canonical substitution mapping each derived /// type parameter to a base type parameter that simultaneously satisfies every @@ -1106,13 +1099,21 @@ void ReportOpenGenericDerivedTypeDiagnostic(ITypeSymbol baseType, ITypeSymbol de /// private bool TryResolveOpenGenericDerivedType( INamedTypeSymbol unboundDerived, - INamedTypeSymbol constructedBase, + ITypeSymbol baseType, out INamedTypeSymbol? resolvedType, out string? failureReason) { resolvedType = null; failureReason = null; + if (baseType is not INamedTypeSymbol { IsGenericType: true } constructedBase) + { + // Open derived registered against a non-generic base: no closure of the + // derived can ever be assignable to the base. + failureReason = SR.Polymorphism_OpenGeneric_Reason_NotAssignable; + return false; + } + INamedTypeSymbol derivedDefinition = unboundDerived.OriginalDefinition; INamedTypeSymbol baseDefinition = constructedBase.OriginalDefinition; From 1f227eb30cd01541a8d5d2205e167e76591c25f5 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 20:00:51 +0300 Subject: [PATCH 14/20] Fold closed-derived-on-generic-base check into the resolver 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> --- .../gen/JsonSourceGenerator.Parser.cs | 109 ++++++++++-------- 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 659f9b20619b37..eb704ba620bfd9 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -946,33 +946,16 @@ private void ProcessTypeCustomAttributes( Debug.Assert(attributeData.ConstructorArguments.Length > 0); var derivedType = (ITypeSymbol)attributeData.ConstructorArguments[0].Value!; - if (derivedType is INamedTypeSymbol { IsUnboundGenericType: true } unboundDerived) + if (!TryResolveDerivedType( + derivedType, typeToGenerate.Type, + out ITypeSymbol? resolvedDerivedType, out string? failureReason)) { - if (!TryResolveOpenGenericDerivedType( - unboundDerived, typeToGenerate.Type, - out INamedTypeSymbol? resolvedType, out string? failureReason)) - { - ReportOpenGenericDerivedTypeDiagnostic(typeToGenerate.Type, derivedType, attributeData.GetLocation(), failureReason); - continue; - } - - derivedType = resolvedType!; - } - else if (typeToGenerate.Type is INamedTypeSymbol { IsGenericType: true }) - { - // Closed (non-open) derived type registered against a generic base. - // The same JsonDerivedType attribute lives on the open base definition - // and is shared across every closed specialization; a closed derived - // type necessarily pins a specific specialization of the base. Reject - // at metadata-resolution time so this cannot silently work for one - // specialization and break for another. - // - // Closed derived types on a NON-generic base continue to flow through - // to the normal PolymorphicTypeResolver assignability check below. - ReportOpenGenericDerivedTypeDiagnostic(typeToGenerate.Type, derivedType, attributeData.GetLocation(), SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase); + ReportOpenGenericDerivedTypeDiagnostic(typeToGenerate.Type, derivedType, attributeData.GetLocation(), failureReason); continue; } + derivedType = resolvedDerivedType!; + TypeRef derivedTypeRef = EnqueueType(derivedType, typeToGenerate.Mode); object? typeDiscriminator = null; @@ -1070,42 +1053,68 @@ void ReportOpenGenericDerivedTypeDiagnostic(ITypeSymbol baseType, ITypeSymbol de } /// - /// Source-gen-side resolver: validates that applies - /// universally to every specialization of 's open definition, - /// and (when it does) produces the closed derived type for the specific closure - /// . When is not a generic - /// , the registration is rejected as unassignable. + /// Source-gen-side resolver for a [JsonDerivedType] registration against + /// . Handles four cases: + /// + /// Open derived + generic base: validates universal applicability and + /// produces the closed derived type for the current closure. + /// Open derived + non-generic base: rejected as unassignable. + /// Closed derived + generic base: rejected. The attribute is shared across + /// every closure of the base definition; a closed derived necessarily pins one + /// specialization and would silently work for that closure and break for the + /// others. + /// Closed derived + non-generic base: passes through unchanged; the normal + /// PolymorphicTypeResolver assignability check applies downstream. + /// /// - /// "Universal" means: there is a single canonical substitution mapping each derived - /// type parameter to a base type parameter that simultaneously satisfies every - /// matching ancestor of the derived type, with every derived constraint implied by - /// (i.e. weaker-than-or-equal-to) the constraints on the corresponding base parameter. - /// Registrations that pin a particular specialization (e.g. Derived<T> : Base<T, int>) - /// 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 generated. + /// "Universal" (open + generic case) means: there is a single canonical substitution + /// mapping each derived type parameter to a base type parameter that simultaneously + /// satisfies every matching ancestor of the derived type, with every derived + /// constraint implied by (i.e. weaker-than-or-equal-to) the constraints on the + /// corresponding base parameter. Registrations that pin a particular specialization + /// (e.g. Derived<T> : Base<T, int>) 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 + /// generated. /// - /// Returns true with the closed derived type. Returns false with a - /// localized suitable for inclusion in - /// SYSLIB1229. + /// Returns true with set (either the + /// closed derived type from unification, or as-is + /// for the pass-through case). Returns false with a localized + /// suitable for inclusion in SYSLIB1229. /// - /// IMPORTANT: This implementation MIRRORS the reflection resolver + /// IMPORTANT: The open-derived unification path (per-ancestor unification, canonical- + /// substitution consistency check, constraint-equivalence rules) MIRRORS the + /// reflection resolver /// DefaultJsonTypeInfoResolver.Helpers.TryResolveOpenGenericDerivedType in - /// src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs. - /// Both implementations -- the per-ancestor unification, the canonical-substitution - /// consistency check, and the constraint-subsumption rules -- must be kept in - /// lockstep so that source-gen and reflection produce the same closed type for the - /// same registration. + /// src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs + /// and must be kept in lockstep so that source-gen and reflection produce the same + /// closed type for the same registration. The reflection-side wrapper splits the + /// four-case dispatch differently (it pre-splits the base into definition + args and + /// inlines the closed-derived / non-generic-base checks at the caller for caching); + /// the unification core is what's mirrored, not the dispatch shape. /// - private bool TryResolveOpenGenericDerivedType( - INamedTypeSymbol unboundDerived, + private bool TryResolveDerivedType( + ITypeSymbol declaredDerived, ITypeSymbol baseType, - out INamedTypeSymbol? resolvedType, + out ITypeSymbol? resolvedDerivedType, out string? failureReason) { - resolvedType = null; + resolvedDerivedType = declaredDerived; failureReason = null; + if (declaredDerived is not INamedTypeSymbol { IsUnboundGenericType: true } unboundDerived) + { + if (baseType is INamedTypeSymbol { IsGenericType: true }) + { + failureReason = SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase; + return false; + } + + return true; + } + + resolvedDerivedType = null; + if (baseType is not INamedTypeSymbol { IsGenericType: true } constructedBase) { // Open derived registered against a non-generic base: no closure of the @@ -1228,7 +1237,7 @@ private bool TryResolveOpenGenericDerivedType( closedArgs[i] = constructedBaseAllArgs[baseParamPosition[mappedBaseParam]]; } - resolvedType = derivedDefinition.ConstructWithEnclosingTypeArguments(closedArgs); + resolvedDerivedType = derivedDefinition.ConstructWithEnclosingTypeArguments(closedArgs); return true; static bool SubstitutionsEqual( From d989e82de48bb7c973e2f4cc38c1272405b48dc7 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 20:15:10 +0300 Subject: [PATCH 15/20] Inline ReportOpenGenericDerivedTypeDiagnostic at its sole call site 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> --- .../gen/JsonSourceGenerator.Parser.cs | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index eb704ba620bfd9..5b2c063f72a1c4 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -63,6 +63,11 @@ private sealed class Parser private readonly Dictionary _generatedTypes = new(SymbolEqualityComparer.Default); #pragma warning restore + // 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 universally 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. private HashSet<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> DiagnosedOpenDerivedRegistrations => field ??= new(s_typePairComparer); public List Diagnostics { get; } = new(); @@ -950,7 +955,10 @@ private void ProcessTypeCustomAttributes( derivedType, typeToGenerate.Type, out ITypeSymbol? resolvedDerivedType, out string? failureReason)) { - ReportOpenGenericDerivedTypeDiagnostic(typeToGenerate.Type, derivedType, attributeData.GetLocation(), failureReason); + if (DiagnosedOpenDerivedRegistrations.Add((typeToGenerate.Type.OriginalDefinition, derivedType.OriginalDefinition))) + { + ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, attributeData.GetLocation(), derivedType.ToDisplayString(), typeToGenerate.Type.ToDisplayString(), failureReason); + } continue; } @@ -1037,19 +1045,6 @@ namedUnionType is not null && { EnqueueUnionCaseTypes(typeToGenerate, hasUnionTypeClassifierSpecified); } - - // Emits a SYSLIB1229 diagnostic at most once per (open base definition, open derived definition) - // pair across the lifetime of this Parser instance (i.e., per JsonSerializerContext). - // Whether a derived registration applies universally to a generic base is a property of the - // open forms alone; emitting it once per closed specialization referenced via - // JsonSerializableAttribute would spam the user with the same warning for every closure of the same base. - void ReportOpenGenericDerivedTypeDiagnostic(ITypeSymbol baseType, ITypeSymbol derivedType, Location? location, string? failureReason) - { - if (DiagnosedOpenDerivedRegistrations.Add((baseType.OriginalDefinition, derivedType.OriginalDefinition))) - { - ReportDiagnostic(DiagnosticDescriptors.OpenGenericDerivedTypeCouldNotBeResolved, location, derivedType.ToDisplayString(), baseType.ToDisplayString(), failureReason); - } - } } /// From 3d0059e0afdd4fa9f43249c2fca8210a0cfab790 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 20:32:32 +0300 Subject: [PATCH 16/20] Comment closed-derived-on-generic-base early-out with example Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Text.Json/gen/JsonSourceGenerator.Parser.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 5b2c063f72a1c4..c41c24e4ef6d83 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -1101,6 +1101,13 @@ private bool TryResolveDerivedType( { if (baseType is INamedTypeSymbol { IsGenericType: true }) { + // Closed derived registered against a generic base, e.g. + // [JsonDerivedType(typeof(Cat))] class Animal; class Cat : Animal; + // 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) and would silently apply for that closure + // and break for every other (Animal, Animal, ...). Reject at + // metadata-resolution time. failureReason = SR.Polymorphism_OpenGeneric_Reason_ClosedDerivedOnGenericBase; return false; } From b3a0af57f21abd12a86ba608cf641849590b8ed2 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 20:39:58 +0300 Subject: [PATCH 17/20] Comment the closed-derived/non-generic-base pass-through arm Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Text.Json/gen/JsonSourceGenerator.Parser.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index c41c24e4ef6d83..f5b3bdfb18d4ef 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -1112,6 +1112,16 @@ private bool TryResolveDerivedType( return false; } + // Closed derived registered against a non-generic base. This covers the common + // sensible case (e.g. [JsonDerivedType(typeof(Cat))] class Animal; class Cat : + // Animal;) AND outright invalid pairs (e.g. [JsonDerivedType(typeof(int))] class + // Foo;) where declaredDerived isn't even assignable to baseType. The source + // generator does not check assignability itself; it passes declaredDerived + // through unchanged so the runtime PolymorphicTypeResolver constructor can + // apply its IsAssignableFrom check (PolymorphicTypeResolver.cs -> + // IsSupportedDerivedType -> ThrowInvalidOperationException_DerivedTypeNotSupported) + // and reject any non-assignable registration the same way it does for the + // reflection-built equivalent. return true; } From aa3b6369195e31e2d6187cf384641fbe41fe5a4d Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 20:47:56 +0300 Subject: [PATCH 18/20] Extract open-generic-derived unification core to RoslynExtensions 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> --- .../gen/Helpers/RoslynExtensions.cs | 199 +++++++++++++++++ .../gen/JsonSourceGenerator.Parser.cs | 205 ++---------------- .../DefaultJsonTypeInfoResolver.Helpers.cs | 4 +- 3 files changed, 220 insertions(+), 188 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs b/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs index f5c7f4cbbd6aeb..aebe5dd36a3fda 100644 --- a/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs +++ b/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs @@ -5,6 +5,7 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -664,6 +665,204 @@ public static INamedTypeSymbol ConstructWithEnclosingTypeArguments(this INamedTy public static bool IsGenericTypeDefinition(this ITypeSymbol type) => type is INamedTypeSymbol { IsGenericType: true } namedType && SymbolEqualityComparer.Default.Equals(namedType, namedType.ConstructedFrom); + /// + /// Validates that applies universally to every + /// specialization of the open generic base type whose definition matches + /// constructedBase.OriginalDefinition, and (when it does) produces the closed + /// derived type for the closure identified by . + /// + /// "Universal" means: there is a single canonical substitution mapping each derived + /// type parameter to a base type parameter that simultaneously satisfies every + /// matching ancestor of the derived type, with every derived constraint exactly + /// matching the constraints on the corresponding base parameter. Registrations that + /// pin a particular specialization (e.g. Derived<T> : Base<T, int>) + /// 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 generated. + /// + /// Returns with set to + /// the closed derived type, or with a localized + /// drawn from SR.Polymorphism_OpenGeneric_* + /// (suitable for inclusion in the SYSLIB1229 message). + /// + /// IMPORTANT: This implementation MIRRORS the reflection-side resolver + /// DefaultJsonTypeInfoResolver.Helpers.TryResolveOpenGenericDerivedType in + /// src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs. + /// Both implementations -- the per-ancestor unification, the canonical-substitution + /// consistency check, and the constraint-equivalence rules -- must be kept in lockstep + /// so source-gen and reflection produce the same closed type for the same registration. + /// + public static bool TryResolveOpenGenericDerivedType( + this Compilation compilation, + INamedTypeSymbol unboundDerived, + INamedTypeSymbol constructedBase, + out INamedTypeSymbol? resolvedDerivedType, + out string? failureReason) + { + Debug.Assert(unboundDerived.IsUnboundGenericType); + Debug.Assert(constructedBase.IsGenericType); + + resolvedDerivedType = null; + failureReason = null; + + INamedTypeSymbol derivedDefinition = unboundDerived.OriginalDefinition; + INamedTypeSymbol baseDefinition = constructedBase.OriginalDefinition; + + // Every ancestor of the derived type definition whose original definition matches + // the base type definition. For classes there is at most one such ancestor; for + // interfaces a derived type can implement the same interface definition multiple + // times with different type arguments. + List matchingBases = derivedDefinition + .GetCompatibleGenericBaseTypes(baseDefinition) + .ToList(); + + if (matchingBases.Count == 0) + { + failureReason = SR.Polymorphism_OpenGeneric_Reason_NotAssignable; + return false; + } + + // The complete set of derived parameters that must be bound (enclosing + leaf). + List requiredDerivedParams = derivedDefinition.GetAllTypeParameters(); + List baseParams = baseDefinition.GetAllTypeParameters(); + var baseParamSet = new HashSet(baseParams, SymbolEqualityComparer.Default); + + // Per-ancestor independent substitutions; the universal answer must be a single + // canonical substitution agreed upon by every ancestor. + Dictionary? canonical = null; + + foreach (INamedTypeSymbol ancestor in matchingBases) + { + var substitution = new Dictionary( + requiredDerivedParams.Count, SymbolEqualityComparer.Default); + + if (!ancestor.TryUnifyWith(baseDefinition, substitution)) + { + // Some position pins a concrete type (e.g. Base) or a constructed + // pattern (e.g. Base>) that cannot match a free base parameter. + failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; + return false; + } + + foreach (ITypeParameterSymbol p in requiredDerivedParams) + { + if (!substitution.TryGetValue(p, out ITypeSymbol? mapped)) + { + // E.g. D : IBase -- U2 is not bound by this ancestor. + failureReason = string.Format( + CultureInfo.InvariantCulture, + SR.Polymorphism_OpenGeneric_Reason_UnboundParameter, + p.Name); + return false; + } + + if (mapped is not ITypeParameterSymbol mappedBaseParam || !baseParamSet.Contains(mappedBaseParam)) + { + // Substitution value isn't one of the base's own type parameters -- + // happens when a derived ancestor binds a parameter to something + // structural (which TryUnifyWith would have rejected) or pinned. + // Treated as non-universal. + failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; + return false; + } + } + + if (canonical is null) + { + canonical = substitution; + } + else if (!SubstitutionsEqual(canonical, substitution)) + { + // Two ancestors agree on independent bindings but produce different + // (derived -> base) mappings, e.g. D : IBase, IBase. + // There is no single canonical answer for an arbitrary base closure. + failureReason = SR.Polymorphism_OpenGeneric_Reason_AmbiguousMatch; + return false; + } + } + + Debug.Assert(canonical is not null); + + // Constraint equivalence: every derived parameter's constraints must exactly + // match the constraints on the mapped base parameter (after substitution) so + // that any valid closure of the base also yields a valid closure of the + // derived. See ReflectionExtensions.AreConstraintsEquivalent for the rationale + // behind exact match (vs one-sided subsumption). + foreach (ITypeParameterSymbol derivedParam in requiredDerivedParams) + { + var mappedBaseParam = (ITypeParameterSymbol)canonical[derivedParam]; + if (!compilation.AreConstraintsEquivalent(derivedParam, mappedBaseParam, canonical)) + { + failureReason = string.Format( + CultureInfo.InvariantCulture, + SR.Polymorphism_OpenGeneric_Reason_ConstraintMismatch, + derivedParam.Name, + mappedBaseParam.Name); + return false; + } + } + + // Closure construction: substitute the canonical mapping then specialize each + // base parameter to the actual closed-base type argument. + var baseParamPosition = new Dictionary(SymbolEqualityComparer.Default); + for (int i = 0; i < baseParams.Count; i++) + { + baseParamPosition[baseParams[i]] = i; + } + + List constructedBaseAllArgs = GetAllTypeArguments(constructedBase); + + var closedArgs = new ITypeSymbol[requiredDerivedParams.Count]; + for (int i = 0; i < requiredDerivedParams.Count; i++) + { + var mappedBaseParam = (ITypeParameterSymbol)canonical[requiredDerivedParams[i]]; + closedArgs[i] = constructedBaseAllArgs[baseParamPosition[mappedBaseParam]]; + } + + resolvedDerivedType = derivedDefinition.ConstructWithEnclosingTypeArguments(closedArgs); + return true; + + static bool SubstitutionsEqual( + Dictionary a, + Dictionary b) + { + if (a.Count != b.Count) + { + return false; + } + + foreach (KeyValuePair kvp in a) + { + if (!b.TryGetValue(kvp.Key, out ITypeSymbol? otherValue) || + !SymbolEqualityComparer.Default.Equals(kvp.Value, otherValue)) + { + return false; + } + } + + return true; + } + + static List GetAllTypeArguments(INamedTypeSymbol type) + { + var result = new List(); + Append(type.ContainingType, result); + result.AddRange(type.TypeArguments); + return result; + + static void Append(INamedTypeSymbol? enclosing, List list) + { + if (enclosing is null) + { + return; + } + + Append(enclosing.ContainingType, list); + list.AddRange(enclosing.TypeArguments); + } + } + } + public static bool IsNumberType(this ITypeSymbol type) { return type.SpecialType is diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index f5b3bdfb18d4ef..6a6c93c26bdd1f 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -5,7 +5,6 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using System.Threading; @@ -1049,44 +1048,25 @@ namedUnionType is not null && /// /// Source-gen-side resolver for a [JsonDerivedType] registration against - /// . Handles four cases: + /// . Dispatches on the four open/closed × generic/non-generic + /// shapes: /// - /// Open derived + generic base: validates universal applicability and - /// produces the closed derived type for the current closure. + /// Open derived + generic base: delegates to + /// for + /// universal-applicability validation and closure construction. /// Open derived + non-generic base: rejected as unassignable. - /// Closed derived + generic base: rejected. The attribute is shared across - /// every closure of the base definition; a closed derived necessarily pins one - /// specialization and would silently work for that closure and break for the - /// others. - /// Closed derived + non-generic base: passes through unchanged; the normal - /// PolymorphicTypeResolver assignability check applies downstream. + /// Closed derived + generic base: rejected. The attribute lives on the open + /// base definition and is shared across every closure; a closed derived necessarily + /// pins one specialization and would silently work for that closure and break for + /// the others. + /// Closed derived + non-generic base: passes through unchanged; the runtime + /// PolymorphicTypeResolver assignability check applies downstream. /// /// - /// "Universal" (open + generic case) means: there is a single canonical substitution - /// mapping each derived type parameter to a base type parameter that simultaneously - /// satisfies every matching ancestor of the derived type, with every derived - /// constraint implied by (i.e. weaker-than-or-equal-to) the constraints on the - /// corresponding base parameter. Registrations that pin a particular specialization - /// (e.g. Derived<T> : Base<T, int>) 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 - /// generated. - /// - /// Returns true with set (either the - /// closed derived type from unification, or as-is - /// for the pass-through case). Returns false with a localized + /// Returns with set + /// (either the closed derived type from unification, or + /// as-is for the pass-through case). Returns with a localized /// suitable for inclusion in SYSLIB1229. - /// - /// IMPORTANT: The open-derived unification path (per-ancestor unification, canonical- - /// substitution consistency check, constraint-equivalence rules) MIRRORS the - /// reflection resolver - /// DefaultJsonTypeInfoResolver.Helpers.TryResolveOpenGenericDerivedType in - /// src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs - /// and must be kept in lockstep so that source-gen and reflection produce the same - /// closed type for the same registration. The reflection-side wrapper splits the - /// four-case dispatch differently (it pre-splits the base into definition + args and - /// inlines the closed-derived / non-generic-base checks at the caller for caching); - /// the unification core is what's mirrored, not the dispatch shape. /// private bool TryResolveDerivedType( ITypeSymbol declaredDerived, @@ -1135,162 +1115,15 @@ private bool TryResolveDerivedType( return false; } - INamedTypeSymbol derivedDefinition = unboundDerived.OriginalDefinition; - INamedTypeSymbol baseDefinition = constructedBase.OriginalDefinition; - - // Every ancestor of the derived type definition whose original definition matches - // the base type definition. For classes there is at most one such ancestor; for - // interfaces a derived type can implement the same interface definition multiple - // times with different type arguments. - List matchingBases = derivedDefinition - .GetCompatibleGenericBaseTypes(baseDefinition) - .ToList(); - - if (matchingBases.Count == 0) - { - failureReason = SR.Polymorphism_OpenGeneric_Reason_NotAssignable; - return false; - } - - // The complete set of derived parameters that must be bound (enclosing + leaf). - List requiredDerivedParams = derivedDefinition.GetAllTypeParameters(); - List baseParams = baseDefinition.GetAllTypeParameters(); - var baseParamSet = new HashSet(baseParams, SymbolEqualityComparer.Default); - - // Per-ancestor independent substitutions; the universal answer must be a single - // canonical substitution agreed upon by every ancestor. - Dictionary? canonical = null; - - foreach (INamedTypeSymbol ancestor in matchingBases) - { - var substitution = new Dictionary( - requiredDerivedParams.Count, SymbolEqualityComparer.Default); - - if (!ancestor.TryUnifyWith(baseDefinition, substitution)) - { - // Some position pins a concrete type (e.g. Base) or a constructed - // pattern (e.g. Base>) that cannot match a free base parameter. - failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; - return false; - } - - foreach (ITypeParameterSymbol p in requiredDerivedParams) - { - if (!substitution.TryGetValue(p, out ITypeSymbol? mapped)) - { - // E.g. D : IBase -- U2 is not bound by this ancestor. - failureReason = string.Format( - CultureInfo.InvariantCulture, - SR.Polymorphism_OpenGeneric_Reason_UnboundParameter, - p.Name); - return false; - } - - if (mapped is not ITypeParameterSymbol mappedBaseParam || !baseParamSet.Contains(mappedBaseParam)) - { - // Substitution value isn't one of the base's own type parameters -- - // happens when a derived ancestor binds a parameter to something - // structural (which TryUnifyWith would have rejected) or pinned. - // Treated as non-universal. - failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; - return false; - } - } - - if (canonical is null) - { - canonical = substitution; - } - else if (!SubstitutionsEqual(canonical, substitution)) - { - // Two ancestors agree on independent bindings but produce different - // (derived -> base) mappings, e.g. D : IBase, IBase. - // There is no single canonical answer for an arbitrary base closure. - failureReason = SR.Polymorphism_OpenGeneric_Reason_AmbiguousMatch; - return false; - } - } - - Debug.Assert(canonical is not null); - - // Constraint equivalence: every derived parameter's constraints must exactly - // match the constraints on the mapped base parameter (after substitution) so - // that any valid closure of the base also yields a valid closure of the - // derived. See ReflectionExtensions.AreConstraintsEquivalent for the rationale - // behind exact match (vs one-sided subsumption). - foreach (ITypeParameterSymbol derivedParam in requiredDerivedParams) + if (_knownSymbols.Compilation.TryResolveOpenGenericDerivedType( + unboundDerived, constructedBase, + out INamedTypeSymbol? closedDerivedType, out failureReason)) { - var mappedBaseParam = (ITypeParameterSymbol)canonical[derivedParam]; - if (!_knownSymbols.Compilation.AreConstraintsEquivalent(derivedParam, mappedBaseParam, canonical)) - { - failureReason = string.Format( - CultureInfo.InvariantCulture, - SR.Polymorphism_OpenGeneric_Reason_ConstraintMismatch, - derivedParam.Name, - mappedBaseParam.Name); - return false; - } - } - - // Closure construction: substitute the canonical mapping then specialize each - // base parameter to the actual closed-base type argument. - var baseParamPosition = new Dictionary(SymbolEqualityComparer.Default); - for (int i = 0; i < baseParams.Count; i++) - { - baseParamPosition[baseParams[i]] = i; - } - - List constructedBaseAllArgs = GetAllTypeArguments(constructedBase); - - var closedArgs = new ITypeSymbol[requiredDerivedParams.Count]; - for (int i = 0; i < requiredDerivedParams.Count; i++) - { - var mappedBaseParam = (ITypeParameterSymbol)canonical[requiredDerivedParams[i]]; - closedArgs[i] = constructedBaseAllArgs[baseParamPosition[mappedBaseParam]]; - } - - resolvedDerivedType = derivedDefinition.ConstructWithEnclosingTypeArguments(closedArgs); - return true; - - static bool SubstitutionsEqual( - Dictionary a, - Dictionary b) - { - if (a.Count != b.Count) - { - return false; - } - - foreach (KeyValuePair kvp in a) - { - if (!b.TryGetValue(kvp.Key, out ITypeSymbol? otherValue) || - !SymbolEqualityComparer.Default.Equals(kvp.Value, otherValue)) - { - return false; - } - } - + resolvedDerivedType = closedDerivedType; return true; } - static List GetAllTypeArguments(INamedTypeSymbol type) - { - var result = new List(); - Append(type.ContainingType, result); - result.AddRange(type.TypeArguments); - return result; - - static void Append(INamedTypeSymbol? enclosing, List list) - { - if (enclosing is null) - { - return; - } - - Append(enclosing.ContainingType, list); - list.AddRange(enclosing.TypeArguments); - } - } + return false; } /// diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs index c079fb60601b0c..fcbfb4ebe36370 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs @@ -194,8 +194,8 @@ private static void ResolveOpenGenericDerivedTypes(Type baseType, IListJsonSourceGenerator.Parser.TryResolveOpenGenericDerivedType in - /// gen/JsonSourceGenerator.Parser.cs. Both implementations -- the per-ancestor + /// RoslynExtensions.TryResolveOpenGenericDerivedType in + /// gen/Helpers/RoslynExtensions.cs. Both implementations -- the per-ancestor /// unification, the canonical-substitution consistency check, and the /// constraint-subsumption rules -- must be kept in lockstep so that reflection and /// source-gen produce the same closed type for the same registration. From ea5cd85fdc559ded3a2c3e2986c1f58fd6266551 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 22:46:09 +0300 Subject: [PATCH 19/20] Rename Universal terminology to Uniform and split NonUniformUnification 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> --- .../gen/Helpers/RoslynExtensions.cs | 57 +++++---- .../gen/Resources/Strings.resx | 9 +- .../gen/Resources/xlf/Strings.cs.xlf | 15 ++- .../gen/Resources/xlf/Strings.de.xlf | 15 ++- .../gen/Resources/xlf/Strings.es.xlf | 15 ++- .../gen/Resources/xlf/Strings.fr.xlf | 15 ++- .../gen/Resources/xlf/Strings.it.xlf | 15 ++- .../gen/Resources/xlf/Strings.ja.xlf | 15 ++- .../gen/Resources/xlf/Strings.ko.xlf | 15 ++- .../gen/Resources/xlf/Strings.pl.xlf | 15 ++- .../gen/Resources/xlf/Strings.pt-BR.xlf | 15 ++- .../gen/Resources/xlf/Strings.ru.xlf | 15 ++- .../gen/Resources/xlf/Strings.tr.xlf | 15 ++- .../gen/Resources/xlf/Strings.zh-Hans.xlf | 15 ++- .../gen/Resources/xlf/Strings.zh-Hant.xlf | 15 ++- .../src/Resources/Strings.resx | 9 +- .../src/System/ReflectionExtensions.cs | 10 ++ .../DefaultJsonTypeInfoResolver.Helpers.cs | 40 ++++--- .../Serialization/PolymorphismTests.cs | 20 ++-- .../PolymorphicTests.CustomTypeHierarchies.cs | 112 +++++++++--------- 20 files changed, 281 insertions(+), 171 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs b/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs index aebe5dd36a3fda..79373d7c397214 100644 --- a/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs +++ b/src/libraries/System.Text.Json/gen/Helpers/RoslynExtensions.cs @@ -529,11 +529,11 @@ public static bool TryValidateGenericConstraints( /// Returns if 's declared constraints /// match 's declared constraints exactly, after applying /// to type-constraint references. Used by the - /// "universal derived registration" check to verify that a derived type's parameter + /// "uniform derived registration" check to verify that a derived type's parameter /// constraints will be satisfied for every valid specialization of the base. /// /// "Exact match" is used in place of one-sided constraint subsumption because in the - /// universal-applicability regime the two are equivalent (C# already forces a derived + /// uniform-applicability regime the two are equivalent (C# already forces a derived /// parameter that's identified with a base parameter to declare at least the base's /// constraints), and an equality check is simpler, easier to reason about, and /// forward-compatible with new C# constraint kinds. @@ -542,9 +542,15 @@ public static bool TryValidateGenericConstraints( /// Special constraint flags (class, struct, unmanaged, /// new()) must match exactly. /// The set of type constraints must match exactly after substitution - /// (order-independent). + /// (order-independent). For F-bounded constraints, the substitution is + /// applied to nested type-parameter positions as well, so a derived + /// where T : IComparable<T> matches a base + /// where U : IComparable<U> only after the substitution + /// { T -> U } has been applied to both occurrences. /// The compile-time-only notnull constraint is intentionally ignored - /// (it is not surfaced via reflection and is not runtime-enforced). + /// (it is not surfaced via reflection and is not runtime-enforced). As a + /// consequence, two registrations whose constraints differ only in the + /// presence of notnull are accepted as equivalent. /// /// /// IMPORTANT: This implementation MIRRORS the reflection-side @@ -666,19 +672,24 @@ public static bool IsGenericTypeDefinition(this ITypeSymbol type) => type is INamedTypeSymbol { IsGenericType: true } namedType && SymbolEqualityComparer.Default.Equals(namedType, namedType.ConstructedFrom); /// - /// Validates that applies universally to every + /// Validates that applies uniformly to every /// specialization of the open generic base type whose definition matches /// constructedBase.OriginalDefinition, and (when it does) produces the closed /// derived type for the closure identified by . /// - /// "Universal" means: there is a single canonical substitution mapping each derived + /// "Uniform" means: there is a single canonical substitution mapping each derived /// type parameter to a base type parameter that simultaneously satisfies every /// matching ancestor of the derived type, with every derived constraint exactly - /// matching the constraints on the corresponding base parameter. Registrations that - /// pin a particular specialization (e.g. Derived<T> : Base<T, int>) - /// 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 generated. + /// matching the constraints on the corresponding base parameter. 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. + /// Registrations that pin a particular specialization + /// (e.g. Derived<T> : Base<T, int>) 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 + /// generated. /// /// Returns with set to /// the closed derived type, or with a localized @@ -727,7 +738,7 @@ public static bool TryResolveOpenGenericDerivedType( List baseParams = baseDefinition.GetAllTypeParameters(); var baseParamSet = new HashSet(baseParams, SymbolEqualityComparer.Default); - // Per-ancestor independent substitutions; the universal answer must be a single + // Per-ancestor independent substitutions; the uniform answer must be a single // canonical substitution agreed upon by every ancestor. Dictionary? canonical = null; @@ -738,9 +749,10 @@ public static bool TryResolveOpenGenericDerivedType( if (!ancestor.TryUnifyWith(baseDefinition, substitution)) { - // Some position pins a concrete type (e.g. Base) or a constructed - // pattern (e.g. Base>) that cannot match a free base parameter. - failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; + // No unifier exists. Some position pins a concrete type (e.g. Base) + // or a constructed pattern (e.g. Base>) that cannot match the base + // type parameter at that position (the rigid target). + failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniformPinning; return false; } @@ -758,11 +770,16 @@ public static bool TryResolveOpenGenericDerivedType( if (mapped is not ITypeParameterSymbol mappedBaseParam || !baseParamSet.Contains(mappedBaseParam)) { - // Substitution value isn't one of the base's own type parameters -- - // happens when a derived ancestor binds a parameter to something - // structural (which TryUnifyWith would have rejected) or pinned. - // Treated as non-universal. - failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; + // Defensive: a unifier exists but it would map a derived parameter to + // something other than one of the base's own type parameters (i.e. the + // result isn't a pure renaming). With the rigid-target unification used + // here this is essentially unreachable -- TryUnifyWith binds derived + // parameters only against the base definition's own type arguments, + // which are all base parameters -- but we report it separately from + // NonUniformPinning so any future relaxation of TryUnifyWith (e.g. + // binding into nested constructed targets) surfaces with a precise + // diagnostic instead of getting silently lumped under pinning. + failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniformUnification; return false; } } diff --git a/src/libraries/System.Text.Json/gen/Resources/Strings.resx b/src/libraries/System.Text.Json/gen/Resources/Strings.resx index 9f2f7e29a98fb8..b62d90eecbacef 100644 --- a/src/libraries/System.Text.Json/gen/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/gen/Resources/Strings.resx @@ -231,8 +231,11 @@ the base type's arguments do not match the derived type's base specification - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments the type parameter '{0}' of the derived type is not bound by the base type's arguments @@ -247,6 +250,6 @@ the derived type matches the base type through multiple ancestors - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf index 6fdb0fd1461c75..481dd32a5314a6 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.cs.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf index 092fe6f4769ef0..52278a1b8050e9 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.de.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf index a998133c2dd9bf..cced94257f4d07 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.es.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf index 195390c991758b..d70082d3166db8 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.fr.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf index bf92a0c1d0f5c2..60876e9d9a7a10 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.it.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf index a122ba750663a3..d6fdda07369cd3 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ja.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf index 3f11c92e193dde..036ed21fd120b9 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ko.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf index e6e314b6e91b55..8bd68fb8a7d254 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pl.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf index e283e207b44bd0..fbfcd81775c05c 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.pt-BR.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf index 6d31f28bb50943..203be7f140154a 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.ru.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf index 0624ea3855719f..80937e2d60ae0f 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.tr.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf index 3a590745ccb5cd..7d09aaf5061cdd 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hans.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf index 9c48d153efda59..c60e49b4022d3d 100644 --- a/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf +++ b/src/libraries/System.Text.Json/gen/Resources/xlf/Strings.zh-Hant.xlf @@ -143,8 +143,8 @@ - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base @@ -157,9 +157,14 @@ the constraint on type parameter '{0}' is not satisfied by '{1}' - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx index a2594e0d673b23..f0c701d7af1b78 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -677,8 +677,11 @@ the base type's arguments do not match the derived type's base specification - - the derived type does not apply universally to every specialization of the base type because it pins one or more base type arguments + + the unification of the derived type with the base type binds a derived type parameter to a target other than a base type parameter + + + the derived type does not apply uniformly to every specialization of the base type because it pins one or more base type arguments the type parameter '{0}' of the derived type is not bound by the base type's arguments @@ -693,7 +696,7 @@ the derived type's type parameter '{0}' declares constraints that differ from the constraints on the corresponding base type parameter '{1}' - a closed derived type cannot serve a generic base type universally; the registration pins a specific specialization of the base + a closed derived type cannot serve a generic base type uniformly; the registration pins a specific specialization of the base The polymorphic type '{0}' has already specified a type discriminator '{1}'. diff --git a/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs b/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs index f592ad3847c6b9..0e099065e5f5e4 100644 --- a/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs +++ b/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs @@ -327,6 +327,16 @@ public static bool TryUnifyWith(this Type pattern, Type target, IDictionarywhere T : IComparable<T> matches a base + /// where U : IComparable<U> only after the substitution + /// { T -> U } has been applied to both occurrences. + /// + /// The compile-time-only notnull constraint is not surfaced through the + /// standard reflection API, so it is invisible here. As a consequence, two + /// registrations whose constraints differ only in the presence of notnull + /// are accepted as equivalent. + /// /// IMPORTANT: This implementation MIRRORS /// System.Text.Json.SourceGeneration.RoslynExtensions.AreConstraintsEquivalent. /// Any change to the equivalence rules must be applied on both sides. diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs index fcbfb4ebe36370..97cefc14f0f01a 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs @@ -179,19 +179,24 @@ private static void ResolveOpenGenericDerivedTypes(Type baseType, IList /// Reflection-side resolver: validates that applies - /// universally to every specialization of the open generic + /// uniformly to every specialization of the open generic /// , and (when it does) produces the closed /// derived type for the specific closure identified by /// . /// - /// "Universal" means: there is a single canonical substitution mapping each derived + /// "Uniform" means: there is a single canonical substitution mapping each derived /// type parameter to a base type parameter that simultaneously satisfies every /// matching ancestor of the derived type, with every derived constraint implied by /// (i.e. weaker-than-or-equal-to) the constraints on the corresponding base parameter. - /// Registrations that pin a particular specialization (e.g. Derived<T> : Base<T, int>) - /// 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. + /// 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. + /// Registrations that pin a particular specialization + /// (e.g. Derived<T> : Base<T, int>) 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. /// /// IMPORTANT: This implementation MIRRORS the source-gen resolver /// RoslynExtensions.TryResolveOpenGenericDerivedType in @@ -242,7 +247,7 @@ private static bool TryResolveOpenGenericDerivedType( Type[] baseParams = baseTypeDefinition.GetGenericArguments(); var baseParamSet = new HashSet(baseParams); - // Per-ancestor independent substitutions; the universal answer must be a single + // Per-ancestor independent substitutions; the uniform answer must be a single // canonical substitution agreed upon by every ancestor. Dictionary? canonical = null; @@ -251,9 +256,10 @@ private static bool TryResolveOpenGenericDerivedType( var substitution = new Dictionary(requiredParams.Length); if (!ancestor.TryUnifyWith(baseTypeDefinition, substitution)) { - // Some position pins a concrete type (e.g. Base) or a constructed - // pattern (e.g. Base>) that cannot match a free base parameter. - failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; + // No unifier exists. Some position pins a concrete type (e.g. Base) + // or a constructed pattern (e.g. Base>) that cannot match the base + // type parameter at that position (the rigid target). + failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniformPinning; return false; } @@ -268,10 +274,16 @@ private static bool TryResolveOpenGenericDerivedType( if (!mapped.IsGenericParameter || !baseParamSet.Contains(mapped)) { - // Substitution value isn't one of the base's own type parameters -- - // happens when a derived ancestor binds a parameter to a non-parameter - // structural target. Treated as non-universal. - failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniversalPinning; + // Defensive: a unifier exists but it would map a derived parameter to + // something other than one of the base's own type parameters (i.e. the + // result isn't a pure renaming). With the rigid-target unification used + // here this is essentially unreachable -- TryUnifyWith binds derived + // parameters only against the base definition's own type arguments, + // which are all base parameters -- but we report it separately from + // NonUniformPinning so any future relaxation of TryUnifyWith surfaces + // with a precise diagnostic instead of getting silently lumped under + // pinning. + failureReason = SR.Polymorphism_OpenGeneric_Reason_NonUniformUnification; return false; } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs index a4fabb19318280..198b2ec5e9b3b0 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PolymorphismTests.cs @@ -75,10 +75,10 @@ public static void CollectionPolymorphismOptions_AreGenerated() } [Fact] - public static void OpenGenericDerivedType_NonUniversalPinning_IsDroppedFromMetadata() + public static void OpenGenericDerivedType_NonUniformPinning_IsDroppedFromMetadata() { // SourceGenOpenGenericDerived : SourceGenOpenGenericBase pins T2 to - // int -- non-universal w.r.t. SourceGenOpenGenericBase. Source-gen + // int -- non-uniform w.r.t. SourceGenOpenGenericBase. Source-gen // emits SYSLIB1229 (suppressed on the fixture decoration) and drops the // registration from the generated metadata; the closed base has no // PolymorphismOptions and serializes plainly. @@ -93,9 +93,9 @@ public static void OpenGenericDerivedType_NonUniversalPinning_IsDroppedFromMetad [Fact] public static void ClosedDerivedOnGenericBase_IsDroppedOnIntSpec() { - // Under the universal-applicability rule, closed-derived registrations on an + // Under the uniform-applicability rule, closed-derived registrations on an // OPEN generic base (here SourceGenSpecAnimal_Cat : SourceGenSpecAnimal - // and Dog : SourceGenSpecAnimal) are non-universal -- they can never + // and Dog : SourceGenSpecAnimal) are non-uniform -- they can never // apply to every closure of the open base. Source-gen drops them from emitted // metadata (with SYSLIB1229 suppressed on the fixture) so the closed base has // no PolymorphismOptions and serializes plainly. @@ -139,7 +139,7 @@ public static void OpenDerivedWithNarrowerConstraint_IsDroppedOnUnsatisfyingSpec { // SourceGenConstrainedDerived where T : struct, registered on // SourceGenConstrainedBase. The derived's struct constraint is narrower - // than the base's (none), so the registration is non-universal under B-strict. + // than the base's (none), so the registration is non-uniform under B-strict. // Source-gen emits SYSLIB1229 (suppressed on the fixture) and drops the // registration; every closure of the base has null PolymorphismOptions. JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenConstrainedBaseString; @@ -154,7 +154,7 @@ public static void OpenDerivedWithNarrowerConstraint_IsDroppedOnSatisfyingSpec() { // Same fixture as above, applied to a closure (int) where the derived's // struct constraint happens to hold. Under per-closure filtering this would - // have been accepted; under B-strict universal applicability it is rejected + // have been accepted; under B-strict uniform applicability it is rejected // uniformly so that introducing a new closure (e.g. ) cannot break a // working serialization site. JsonTypeInfo typeInfo = PolymorphismTestsContext.Default.SourceGenConstrainedBaseInt; @@ -167,7 +167,7 @@ public static void OpenDerivedWithNarrowerConstraint_IsDroppedOnSatisfyingSpec() [Fact] public static void OpenDerivedWithExtraUnboundParameter_BadArmIsDroppedFromEmittedMetadata() { - // Two registrations on the same base: SourceGenExtraParam_Cat is universal + // Two registrations on the same base: SourceGenExtraParam_Cat is uniform // and resolves to SourceGenExtraParam_Cat; SourceGenExtraParam_Cat // has an extra unbound T2 that the single-parameter base cannot pin down, so // source-gen emits SYSLIB1229 (suppressed below) and drops it from the @@ -204,7 +204,7 @@ internal sealed partial class PolymorphismTestsContext : JsonSerializerContext } // SourceGenOpenGenericDerived : SourceGenOpenGenericBase pins the second - // base parameter to a concrete type -- non-universal under B-strict. Source-gen emits + // base parameter to a concrete type -- non-uniform under B-strict. Source-gen emits // SYSLIB1229 at compile time; suppress it here so we can verify the runtime drops the // registration and the closed base serializes plainly. #pragma warning disable SYSLIB1229 @@ -221,9 +221,9 @@ public sealed class SourceGenOpenGenericDerived : SourceGenOpenGenericBase public class OpenGenericDerived_ComplexArg : OpenGenericBase_ComplexArg; [Fact] - public async Task OpenGenericDerivedType_WrappedTypeArg_ThrowsForNonUniversalPinning() + public async Task OpenGenericDerivedType_WrappedTypeArg_ThrowsForNonUniformPinning() { // Derived : Base> structurally pins the base's only type parameter to // List; this can never apply for a base specialization whose type argument is - // not itself a List<>. Under the universal-applicability rule the registration + // not itself a List<>. Under the uniform-applicability rule the registration // is rejected at metadata-resolution time regardless of which base specialization // is actually constructed. await Assert.ThrowsAsync( @@ -2890,11 +2890,11 @@ public class OpenGenericBase_GroundMismatch; public class OpenGenericDerived_GroundMismatch : OpenGenericBase_GroundMismatch; [Fact] - public async Task OpenGenericDerivedType_PartiallyConcrete_ThrowsForNonUniversalPinning() + public async Task OpenGenericDerivedType_PartiallyConcrete_ThrowsForNonUniformPinning() { // Derived : Base pins position 1 of the base to a concrete type. Even // though the registration could "work" for Base closures, it does not - // apply to arbitrary closures of Base. Under the universal rule we reject + // apply to arbitrary closures of Base. Under the uniform rule we reject // the registration regardless of which closure is currently being constructed, // so registrations cannot silently work for one specialization and break for // another. @@ -2972,7 +2972,7 @@ public async Task OpenGenericDerivedType_MixedWithClosedDerivedOnGenericBase_Thr // definition is necessarily inherited by every closure -- so a registration that // only applies to one specialization is rejected at metadata-resolution time. // The open OpenGenericDerived_Mixed : OpenGenericBase_Mixed registration is - // universal, but the rejection of the closed sibling throws before it surfaces. + // uniform, but the rejection of the closed sibling throws before it surfaces. await Assert.ThrowsAsync( () => Serializer.SerializeWrapper>( new OpenGenericDerived_Mixed { Value = 1 })); @@ -3021,7 +3021,7 @@ public class OpenGenericImpl_InterfaceHierarchy : IOpenGenericDerived_Interfa } [Fact] - public async Task OpenGenericDerivedType_InterfaceBaseWithWrappedTypeArg_ThrowsForNonUniversalPinning() + public async Task OpenGenericDerivedType_InterfaceBaseWithWrappedTypeArg_ThrowsForNonUniformPinning() { // Impl implements IBase>: the interface ancestor pins the base's only // type parameter to a List<>. Even though IBase> could be served by @@ -3044,7 +3044,7 @@ public class OpenGenericImpl_InterfaceWrapped : IOpenGenericBase_InterfaceWra } [Fact] - public async Task OpenGenericDerivedType_ArrayTypeArg_ThrowsForNonUniversalPinning() + public async Task OpenGenericDerivedType_ArrayTypeArg_ThrowsForNonUniformPinning() { // Derived : Base pins the base's only type parameter to an array shape. // The registration cannot apply to closures whose argument is not itself an array, @@ -3088,9 +3088,9 @@ public class OpenGenericDerived_Reordered : OpenGenericBase_Reordered : OpenGenericBase_Partial } [Fact] - public async Task OpenGenericDerivedType_KeyValuePairArg_ThrowsForNonUniversalPinning() + public async Task OpenGenericDerivedType_KeyValuePairArg_ThrowsForNonUniformPinning() { // Derived : Base> pins the base's only type parameter // to a KeyValuePair shape AND pins the Key half of that pair to string. - // The registration cannot apply universally, so it is rejected at metadata- + // The registration cannot apply uniformly, so it is rejected at metadata- // resolution time. await Assert.ThrowsAsync( () => Serializer.SerializeWrapper>>( @@ -3128,12 +3128,12 @@ public class OpenGenericDerived_KvpArg : OpenGenericBase_KvpArg -> Mid -> Base> chain is universal in the Leaf->Mid hop - // but non-universal in the Mid->Base hop (Mid pins Base's parameter to List). - // Universality is required end-to-end, so the registration is rejected even though - // the leaf's own immediate base is universal. + // The Leaf -> Mid -> Base> chain is uniform in the Leaf->Mid hop + // but non-uniform in the Mid->Base hop (Mid pins Base's parameter to List). + // Uniformity is required end-to-end, so the registration is rejected even though + // the leaf's own immediate base is uniform. await Assert.ThrowsAsync( () => Serializer.SerializeWrapper>>( new OpenGenericLeaf_MultiLevel { Items = [10, 20] })); @@ -3150,11 +3150,11 @@ public class OpenGenericLeaf_MultiLevel : OpenGenericMid_MultiLevel } [Fact] - public async Task OpenGenericDerivedType_TupleSyntax_ThrowsForNonUniversalPinning() + public async Task OpenGenericDerivedType_TupleSyntax_ThrowsForNonUniformPinning() { // Derived : Base<(T1, T2)> pins the base's only type parameter to a // ValueTuple<,> shape (`(T1, T2)` is sugar for `ValueTuple`). The - // registration is non-universal and rejected at metadata-resolution time. + // registration is non-uniform and rejected at metadata-resolution time. await Assert.ThrowsAsync( () => Serializer.SerializeWrapper>( new OpenGenericDerived_Tuple { Pair = (5, "x") })); @@ -3201,7 +3201,7 @@ public async Task OpenGenericDerivedType_ConstraintMismatch_ThrowsForAllSpeciali { // The derived adds a `where T : struct` constraint that the base does not have. // This is rejected uniformly: even though the constraint happens to be satisfied - // for some closures (e.g. Base), the registration is non-universal and + // for some closures (e.g. Base), the registration is non-uniform and // would mysteriously stop applying on closures the constraint excludes. Reject // at metadata-resolution time. await Assert.ThrowsAsync( @@ -3240,7 +3240,7 @@ public class OpenGenericDerived_NullableArg : OpenGenericBase_NullableArg; public async Task OpenGenericDerivedType_DuplicateClosedAndOpenRegistration_ThrowsForClosedDerivedOnGenericBase() { // The closed Derived registration on the open generic base is rejected - // outright under the universal-registration rule (a closed derived registered on + // outright under the uniform-registration rule (a closed derived registered on // a generic base only applies to one specialization, which the shared attribute // declaration cannot express). The throw masks any downstream duplicate-id check // that would otherwise have surfaced. @@ -3340,11 +3340,11 @@ public class OpenGenericImpl_Diamond : IOpenGenericDerived_Diamond } [Fact] - public async Task OpenGenericDerivedType_MultipleInterfaceConstructions_NonUniversalPinning_Throws() + public async Task OpenGenericDerivedType_MultipleInterfaceConstructions_NonUniformPinning_Throws() { - // Impl reaches IBase<> via two ancestors: directly (IBase, universal) and + // Impl reaches IBase<> via two ancestors: directly (IBase, uniform) and // transitively through OpenGenericImpl_MultiCtor_IntBase : IBase (pins to - // int). Universal applicability requires every matching ancestor to agree on a + // int). Uniform applicability requires every matching ancestor to agree on a // canonical substitution mapping each derived parameter to a base parameter; the // IBase ancestor pins the base parameter to a concrete type, so the // registration is rejected uniformly even for closures where the IBase leg @@ -3367,9 +3367,9 @@ public class OpenGenericImpl_MultiCtor : OpenGenericImpl_MultiCtor_IntBase, I public T? Item { get; set; } } - // ---- Universal-applicability tests (PR #129294, B-strict rule) ---- + // ---- Uniform-applicability tests (PR #129294, B-strict rule) ---- // - // Under the universal-applicability rule a derived registration on a generic base must + // Under the uniform-applicability rule a derived registration on a generic base must // apply to EVERY closed specialization of that base. Registrations that only apply to // some specializations -- closed derived types pinning a specific spec, open derived // types that pin a base parameter to a concrete type, or open derived types that @@ -3379,7 +3379,7 @@ public class OpenGenericImpl_MultiCtor : OpenGenericImpl_MultiCtor_IntBase, I // Scenario 1: Closed derived types on an OPEN generic base. The same [JsonDerivedType] // attribute lives on the open base definition and is shared across every closed // specialization, so a closed derived (which necessarily pins a single specialization) - // can never apply universally. Rejected. + // can never apply uniformly. Rejected. [JsonDerivedType(typeof(SpecAnimal_Cat), "cat")] [JsonDerivedType(typeof(SpecAnimal_Dog), "dog")] @@ -3398,7 +3398,7 @@ public sealed class SpecAnimal_Dog : SpecAnimal { public string? Breed { public async Task ClosedDerivedOnGenericBase_ThrowsForEverySpecialization(Type closedBaseType) { // Whichever closure of SpecAnimal the user constructs, resolution fails - // because the closed derived registrations are non-universal. There is no + // because the closed derived registrations are non-uniform. There is no // "happy" closure that suppresses the rejection. object baseValue = Activator.CreateInstance(closedBaseType)!; await Assert.ThrowsAsync( @@ -3406,7 +3406,7 @@ await Assert.ThrowsAsync( } // Scenario 2: Closed derived on a generic base, with [JsonPolymorphic] also declared - // (no behavior difference under universal applicability -- the rejection is the same). + // (no behavior difference under uniform applicability -- the rejection is the same). [JsonPolymorphic] [JsonDerivedType(typeof(SpecAnimal_Cat), "cat")] @@ -3422,7 +3422,7 @@ public async Task ClosedDerivedOnGenericBase_WithExplicitPolymorphicAttribute_Th // Companion to ClosedDerivedOnGenericBase_ThrowsForEverySpecialization. The // explicit [JsonPolymorphic] attribute does not change the rejection -- whether // the user opted in via attribute or got an implicit options shape from - // [JsonDerivedType] alone, the same universal-applicability rule applies. + // [JsonDerivedType] alone, the same uniform-applicability rule applies. SpecAnimalExplicit animal = new() { Name = "Cookie" }; await Assert.ThrowsAsync( () => Serializer.SerializeWrapper(animal)); @@ -3450,7 +3450,7 @@ public class ConstrainedDerived : ConstrainedBase where T : IEnumerable" (constraint holds) closures - // throw the same way -- universal applicability requires the constraint to hold + // throw the same way -- uniform applicability requires the constraint to hold // for EVERY closure, not just the one in front of us. object baseValue = Activator.CreateInstance(closedBaseType)!; InvalidOperationException ex = await Assert.ThrowsAsync( @@ -3494,7 +3494,7 @@ public class ExtraParam_Cat : ExtraParamAnimalWithBadRegistration public async Task OpenDerivedWithoutExtraParameter_Resolves() { // Sanity-check companion to the throw test below: ExtraParam_Cat with no - // extra unbound parameter is universal -- it closes cleanly against any + // extra unbound parameter is uniform -- it closes cleanly against any // specialization of ExtraParamAnimal. ExtraParamAnimal value = new ExtraParam_Cat { Name = "Felix", Tag = 7 }; string json = await Serializer.SerializeWrapper(value); @@ -3556,7 +3556,7 @@ await Assert.ThrowsAsync( // ---- Pattern catalog (B-strict additions) ---- // - // Comprehensive coverage of the universal-applicability rule across pinning, + // Comprehensive coverage of the uniform-applicability rule across pinning, // collapse, reorder, multi-ancestor, constraint subsumption, and edge-case patterns. // ---- Pattern A: parameter collapse ---- @@ -3571,7 +3571,7 @@ public class Catalog_ParamCollapse_Base public class Catalog_ParamCollapse_Derived : Catalog_ParamCollapse_Base; [Fact] - public async Task Catalog_ParameterCollapse_ThrowsForNonUniversalPinning() + public async Task Catalog_ParameterCollapse_ThrowsForNonUniformPinning() { // Derived : Base "collapses" both base parameters to the same derived // parameter. For arbitrary Base closures where T1 != T2 the derived @@ -3598,7 +3598,7 @@ public class Catalog_Reorder_Derived : Catalog_Reorder_Base } [Fact] - public async Task Catalog_ParameterReorder_IsUniversal() + public async Task Catalog_ParameterReorder_IsUniform() { // Derived : Base maps each base parameter to a distinct derived // parameter -- the substitution is bijective and applies to every closure. @@ -3639,7 +3639,7 @@ public class Catalog_C4_Derived : Catalog_C4_Base where T : IDisposable; // C5: derived's constraint is identical to base's constraint -- accepted. (Under C# // language rules, derived must impose at-least-as-strict constraints as base, so // "exactly matching" is the only configuration that is BOTH compilable and - // universal. Tighter constraints on derived produce non-universality.) + // uniform. Tighter constraints on derived produce non-uniformity.) [JsonDerivedType(typeof(Catalog_C5_Derived<>), "d")] public class Catalog_C5_Base where T : class { @@ -3671,7 +3671,7 @@ await Assert.ThrowsAsync( } [Fact] - public async Task Catalog_ConstraintIdentical_IsUniversal() + public async Task Catalog_ConstraintIdentical_IsUniform() { // C5: derived's `class` constraint matches base's `class` constraint exactly. Catalog_C5_Base value = new Catalog_C5_Derived { Value = "v", Extra = "e" }; @@ -3680,7 +3680,7 @@ public async Task Catalog_ConstraintIdentical_IsUniversal() } [Fact] - public async Task Catalog_SelfReferentialInterfaceConstraint_IsUniversal() + public async Task Catalog_SelfReferentialInterfaceConstraint_IsUniform() { // C8: derived's `IComparable` constraint, after substituting T with the base's // T, exactly matches the base's `IComparable` constraint. @@ -3693,7 +3693,7 @@ public async Task Catalog_SelfReferentialInterfaceConstraint_IsUniversal() // D1: Impl reaches IBase<> via two ancestors -- directly as IBase and // transitively through Helper : IBase. Each ancestor produces a substitution - // that leaves the other ancestor's parameter free, so neither is universal in + // that leaves the other ancestor's parameter free, so neither is uniform in // isolation and they disagree -- rejected. [JsonDerivedType(typeof(Catalog_D1_Impl<,>), "d")] @@ -3726,9 +3726,9 @@ await Assert.ThrowsAsync( () => Serializer.SerializeWrapper>(new Catalog_D2_Impl())); } - // D3: Impl reaches IBase<> via two ancestors -- directly as IBase (universal) + // D3: Impl reaches IBase<> via two ancestors -- directly as IBase (uniform) // and transitively through HelperList : IBase> (pins to List<>). The - // pinning ancestor breaks universality even though the direct ancestor is fine. + // pinning ancestor breaks uniformity even though the direct ancestor is fine. [JsonDerivedType(typeof(Catalog_D3_Impl<>), "d")] public interface ICatalog_D3_Base; @@ -3743,7 +3743,7 @@ await Assert.ThrowsAsync( () => Serializer.SerializeWrapper>(new Catalog_D3_Impl())); } - // D4: D : IBase, IBase -- two identical ancestors. Universal because the + // D4: D : IBase, IBase -- two identical ancestors. Uniform because the // canonical substitutions agree on (U -> T). [JsonDerivedType(typeof(Catalog_D4_Impl<>), "d")] @@ -3755,7 +3755,7 @@ public interface ICatalog_D4_BaseB; public class Catalog_D4_Impl : ICatalog_D4_BaseA, ICatalog_D4_BaseB; [Fact] - public async Task Catalog_MultipleDistinctInterfaceBases_UniversalImpl_Works() + public async Task Catalog_MultipleDistinctInterfaceBases_UniformImpl_Works() { ICatalog_D4_BaseA viaA = new Catalog_D4_Impl(); ICatalog_D4_BaseB viaB = (Catalog_D4_Impl)viaA; @@ -3769,7 +3769,7 @@ public async Task Catalog_MultipleDistinctInterfaceBases_UniversalImpl_Works() // ---- Pattern E: nested enclosing types ---- // E1: Outer + Outer.Inner : Outer -- the inner's enclosing T is implicitly - // the outer's T. Universal. + // the outer's T. Uniform. [JsonDerivedType(typeof(Catalog_E1_Outer<>.Inner), "inner")] public class Catalog_E1_Outer @@ -3780,7 +3780,7 @@ public class Inner : Catalog_E1_Outer; } [Fact] - public async Task Catalog_NestedTypeUnderGenericEnclosing_IsUniversal() + public async Task Catalog_NestedTypeUnderGenericEnclosing_IsUniform() { Catalog_E1_Outer value = new Catalog_E1_Outer.Inner { Value = 7 }; string json = await Serializer.SerializeWrapper(value); @@ -3788,7 +3788,7 @@ public async Task Catalog_NestedTypeUnderGenericEnclosing_IsUniversal() } // E2: Outer.Inner : SomeBase -- enclosing type is closed but a different - // type parameter U is bound through SomeBase. Universal w.r.t. SomeBase<>. + // type parameter U is bound through SomeBase. Uniform w.r.t. SomeBase<>. [JsonDerivedType(typeof(Catalog_E2_Outer.Inner<>), "inner")] public class Catalog_E2_SomeBase @@ -3802,7 +3802,7 @@ public class Inner : Catalog_E2_SomeBase; } [Fact] - public async Task Catalog_NestedDerivedUnderNonGenericEnclosing_IsUniversal() + public async Task Catalog_NestedDerivedUnderNonGenericEnclosing_IsUniform() { Catalog_E2_SomeBase value = new Catalog_E2_Outer.Inner { Value = 9 }; string json = await Serializer.SerializeWrapper(value); @@ -3811,7 +3811,7 @@ public async Task Catalog_NestedDerivedUnderNonGenericEnclosing_IsUniversal() // ---- Pattern F: transitive inheritance through generic mid ---- - // F1: Leaf : Mid : Base -- universal at every hop. Accepted. + // F1: Leaf : Mid : Base -- uniform at every hop. Accepted. [JsonDerivedType(typeof(Catalog_F1_Leaf<>), "leaf")] public class Catalog_F1_Base; @@ -3822,7 +3822,7 @@ public class Catalog_F1_Leaf : Catalog_F1_Mid } [Fact] - public async Task Catalog_TransitiveInheritance_UniversalAtEveryHop_Works() + public async Task Catalog_TransitiveInheritance_UniformAtEveryHop_Works() { Catalog_F1_Base value = new Catalog_F1_Leaf { Value = 11 }; string json = await Serializer.SerializeWrapper(value); @@ -3877,7 +3877,7 @@ public class Catalog_H_Derived : Catalog_H_Base; public async Task Catalog_PartialPinning_TwoParameterBase_Throws(Type closedBaseType) { // Even on Base (where the pinning happens to be consistent), the - // registration is rejected -- universality requires the substitution to be valid + // registration is rejected -- uniformity requires the substitution to be valid // for every closure, not just one. object value = Activator.CreateInstance(closedBaseType)!; await Assert.ThrowsAsync( @@ -3919,10 +3919,10 @@ public class Catalog_K_Base public T? Value { get; set; } } - public class Catalog_K_NonUniversal_Derived : Catalog_K_Base; + public class Catalog_K_NonUniform_Derived : Catalog_K_Base; [Fact] - public async Task Catalog_ProgrammaticApi_NonUniversalRegistration_Throws() + public async Task Catalog_ProgrammaticApi_NonUniformRegistration_Throws() { // The same B-strict rejection applies whether the registration originates from // [JsonDerivedType] attribute or from a programmatic resolver modifier. @@ -3940,7 +3940,7 @@ public async Task Catalog_ProgrammaticApi_NonUniversalRegistration_Throws() { DerivedTypes = { - new JsonDerivedType(typeof(Catalog_K_NonUniversal_Derived<>), "d"), + new JsonDerivedType(typeof(Catalog_K_NonUniform_Derived<>), "d"), }, }; } @@ -4097,13 +4097,13 @@ public class NestedOuter { public class NestedBox { public TInne public class NestedDerivedEnclosingMismatch : NestedBase.NestedBox>; [Fact] - public async Task NestedGeneric_TypeParameterInEnclosing_ThrowsForNonUniversalPinning() + public async Task NestedGeneric_TypeParameterInEnclosing_ThrowsForNonUniformPinning() { // Pattern: NestedDerivedParamInEnclosing : NestedBaseB.NestedBoxB>. // The derived pins the leaf NestedBoxB's TInner to a concrete int. This makes - // the registration non-universal -- it only applies to closures of NestedBaseB + // the registration non-uniform -- it only applies to closures of NestedBaseB // whose argument is shaped as ...NestedBoxB, not e.g. ...NestedBoxB. - // Under the universal-applicability rule the registration is rejected uniformly. + // Under the uniform-applicability rule the registration is rejected. NestedBaseB.NestedBoxB> value = new NestedDerivedParamInEnclosing { Item = new NestedOuterB.NestedBoxB { Inner = 42 } }; @@ -4126,7 +4126,7 @@ public async Task Variance_CovariantInterfaceConstraintSatisfied_ThrowsForConstr // ConstraintImpl : ConstraintBase where T : IEnumerable. // The derived has constraints the base doesn't (IEnumerable). Even // though specific closures (e.g. List via variance) would satisfy the - // constraint, the registration is rejected under the universal-applicability + // constraint, the registration is rejected under the uniform-applicability // rule because it does not apply to every specialization of the base. ConstraintBase> value = new ConstraintImpl> { Items = new List { "hello" } }; await Assert.ThrowsAsync(() => Serializer.SerializeWrapper(value)); From dff0e434a9d81d946ad2ba03d87d8510e4b6941f Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 12 Jun 2026 22:46:30 +0300 Subject: [PATCH 20/20] Add post-condition assignability check to TryResolveDerivedType 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> --- .../gen/JsonSourceGenerator.Parser.cs | 87 ++++---- .../JsonCreationHandlingTests.Object.cs | 4 +- .../JsonSourceGeneratorDiagnosticsTests.cs | 201 ++++++++++++++++-- 3 files changed, 241 insertions(+), 51 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 6a6c93c26bdd1f..83218d3dd994b3 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -64,7 +64,7 @@ private sealed class Parser // 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 universally to a generic base is a property of the open forms alone; without + // 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. private HashSet<(ISymbol BaseDefinition, ISymbol DerivedDefinition)> DiagnosedOpenDerivedRegistrations => field ??= new(s_typePairComparer); @@ -1053,20 +1053,21 @@ namedUnionType is not null && /// /// Open derived + generic base: delegates to /// for - /// universal-applicability validation and closure construction. + /// uniform-applicability validation and closure construction. /// Open derived + non-generic base: rejected as unassignable. /// Closed derived + generic base: rejected. The attribute lives on the open /// base definition and is shared across every closure; a closed derived necessarily /// pins one specialization and would silently work for that closure and break for /// the others. - /// Closed derived + non-generic base: passes through unchanged; the runtime - /// PolymorphicTypeResolver assignability check applies downstream. + /// Closed derived + non-generic base: the registration is accepted as-is, + /// subject to the post-condition assignability check below. /// /// /// Returns with set /// (either the closed derived type from unification, or - /// as-is for the pass-through case). Returns with a localized - /// suitable for inclusion in SYSLIB1229. + /// as-is for the pass-through case) only if the resolved type is also a real subtype + /// of ; otherwise returns with a + /// localized suitable for inclusion in SYSLIB1229. /// private bool TryResolveDerivedType( ITypeSymbol declaredDerived, @@ -1074,10 +1075,29 @@ private bool TryResolveDerivedType( out ITypeSymbol? resolvedDerivedType, out string? failureReason) { - resolvedDerivedType = declaredDerived; + resolvedDerivedType = null; failureReason = null; - if (declaredDerived is not INamedTypeSymbol { IsUnboundGenericType: true } unboundDerived) + if (declaredDerived is INamedTypeSymbol { IsUnboundGenericType: true } unboundDerived) + { + if (baseType is not INamedTypeSymbol { IsGenericType: true } constructedBase) + { + // Open derived registered against a non-generic base: no closure of the + // derived can ever be assignable to the base. + failureReason = SR.Polymorphism_OpenGeneric_Reason_NotAssignable; + return false; + } + + if (!_knownSymbols.Compilation.TryResolveOpenGenericDerivedType( + unboundDerived, constructedBase, + out INamedTypeSymbol? closedDerivedType, out failureReason)) + { + return false; + } + + resolvedDerivedType = closedDerivedType; + } + else { if (baseType is INamedTypeSymbol { IsGenericType: true }) { @@ -1092,38 +1112,33 @@ private bool TryResolveDerivedType( return false; } - // Closed derived registered against a non-generic base. This covers the common - // sensible case (e.g. [JsonDerivedType(typeof(Cat))] class Animal; class Cat : - // Animal;) AND outright invalid pairs (e.g. [JsonDerivedType(typeof(int))] class - // Foo;) where declaredDerived isn't even assignable to baseType. The source - // generator does not check assignability itself; it passes declaredDerived - // through unchanged so the runtime PolymorphicTypeResolver constructor can - // apply its IsAssignableFrom check (PolymorphicTypeResolver.cs -> - // IsSupportedDerivedType -> ThrowInvalidOperationException_DerivedTypeNotSupported) - // and reject any non-assignable registration the same way it does for the - // reflection-built equivalent. - return true; - } - - resolvedDerivedType = null; - - if (baseType is not INamedTypeSymbol { IsGenericType: true } constructedBase) - { - // Open derived registered against a non-generic base: no closure of the - // derived can ever be assignable to the base. + // Closed derived registered against a non-generic base. The common sensible + // case (e.g. [JsonDerivedType(typeof(Cat))] class Animal; class Cat : Animal;) + // is accepted unchanged here; outright invalid pairs (e.g. + // [JsonDerivedType(typeof(int))] class Foo;) are caught by the post-condition + // assignability check below. + resolvedDerivedType = declaredDerived; + } + + // Post-condition: the resolved derived type must be a real subtype of the + // declared base type. For the open-derived / generic-base case this holds by + // construction (closure construction substitutes derived parameters with the + // matching base type arguments, so the matching ancestor of resolvedDerivedType + // is exactly baseType); the check is defensive and never expected to fail. For + // the closed-derived / non-generic-base case this is the only compile-time + // validation that the registration is well-formed and lifts a runtime-only + // failure (PolymorphicTypeResolver -> IsSupportedDerivedType -> + // ThrowInvalidOperationException_DerivedTypeNotSupported, surfaced at first + // serialization) to a SYSLIB1229 diagnostic at metadata-resolution time. + Debug.Assert(resolvedDerivedType is not null); + if (!baseType.IsAssignableFrom(resolvedDerivedType)) + { + resolvedDerivedType = null; failureReason = SR.Polymorphism_OpenGeneric_Reason_NotAssignable; return false; } - if (_knownSymbols.Compilation.TryResolveOpenGenericDerivedType( - unboundDerived, constructedBase, - out INamedTypeSymbol? closedDerivedType, out failureReason)) - { - resolvedDerivedType = closedDerivedType; - return true; - } - - return false; + return true; } /// diff --git a/src/libraries/System.Text.Json/tests/Common/JsonCreationHandlingTests.Object.cs b/src/libraries/System.Text.Json/tests/Common/JsonCreationHandlingTests.Object.cs index bd91642bbe7f70..ace332ee1710ca 100644 --- a/src/libraries/System.Text.Json/tests/Common/JsonCreationHandlingTests.Object.cs +++ b/src/libraries/System.Text.Json/tests/Common/JsonCreationHandlingTests.Object.cs @@ -548,8 +548,8 @@ await Assert.ThrowsAsync( async () => await Serializer.DeserializeWrapper(json, options)); } - [JsonDerivedType(typeof(BaseClassWithPolymorphism), "base")] - [JsonDerivedType(typeof(DerivedClass_DerivingFrom_BaseClassWithPolymorphism), "derived")] + [JsonDerivedType(typeof(BaseClassRecursive), "base")] + [JsonDerivedType(typeof(DerivedClass_DerivingFrom_BaseClassRecursive), "derived")] public class BaseClassRecursive { [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs index 723b26ff7ba0e8..81002eb8ff11d3 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs @@ -1268,8 +1268,8 @@ public void OpenGenericDerivedType_PartiallyConcrete_WarnsWithSYSLIB1229() { // Derived : Base registered on Base: // the derived pins position 1 (T2) to a concrete int, which makes the - // registration non-universal w.r.t. the open Base. Under the - // universal-applicability rule the source generator emits SYSLIB1229 + // registration non-uniform w.r.t. the open Base. Under the + // uniform-applicability rule the source generator emits SYSLIB1229 // regardless of which closure was registered. string source = """ using System.Text.Json.Serialization; @@ -1335,6 +1335,181 @@ public class MyDerived : MyBase Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); } + [Fact] + public void ClosedDerivedType_NotAssignableToNonGenericBase_WarnsWithSYSLIB1229() + { + // A closed derived type that has no inheritance relationship to a non-generic + // base used to flow through the source generator unchanged and only fail at + // first serialization, when PolymorphicTypeResolver applies its + // IsAssignableFrom check and throws InvalidOperationException. The + // post-condition assignability check in TryResolveDerivedType lifts that + // failure to a compile-time SYSLIB1229 so misregistrations are surfaced when + // the metadata is generated. + string source = """ + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(MyBase))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonDerivedType(typeof(Unrelated), "unrelated")] + public class MyBase + { + public int Value { get; set; } + } + + public class Unrelated + { + public string? Name { get; set; } + } + } + """; + + Compilation compilation = CompilationHelper.CreateCompilation(source); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + string message = diagnostic.GetMessage(); + Assert.Contains("Unrelated", message); + Assert.Contains("MyBase", message); + Assert.Contains("not assignable", message); + } + + [Fact] + public void ClosedDerivedType_AssignableToNonGenericBase_CompilesSuccessfully() + { + // The standard happy path for the closed-derived / non-generic-base arm: + // confirm the new post-condition assignability check does not regress valid + // registrations. + string source = """ + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(Animal))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonDerivedType(typeof(Cat), "cat")] + [JsonDerivedType(typeof(Dog), "dog")] + public class Animal + { + public string? Name { get; set; } + } + + public class Cat : Animal { public int Lives { get; set; } } + public class Dog : Animal { public bool Bark { get; set; } } + } + """; + + Compilation compilation = CompilationHelper.CreateCompilation(source); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + + Assert.Empty(result.Diagnostics.Where(d => d.Id == "SYSLIB1229")); + } + + [Fact] + public void ClosedDerivedType_NonGenericInterfaceBase_AssignableViaInterface_CompilesSuccessfully() + { + // Variant of the happy path where the non-generic base is an interface and + // the derived type satisfies the base via an interface implementation. The + // IsAssignableFrom helper walks AllInterfaces in addition to the base-type + // chain, so this should compile without diagnostics. + string source = """ + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(IShape))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonDerivedType(typeof(Circle), "circle")] + public interface IShape + { + } + + public class Circle : IShape + { + public double Radius { get; set; } + } + } + """; + + Compilation compilation = CompilationHelper.CreateCompilation(source); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + + Assert.Empty(result.Diagnostics.Where(d => d.Id == "SYSLIB1229")); + } + + [Fact] + public void ClosedDerivedType_NotAssignableToInterfaceBase_WarnsWithSYSLIB1229() + { + // Closed derived that does not implement the declared interface base. + string source = """ + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(IShape))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonDerivedType(typeof(NotAShape), "no")] + public interface IShape + { + } + + public class NotAShape + { + } + } + """; + + Compilation compilation = CompilationHelper.CreateCompilation(source); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Contains("not assignable", diagnostic.GetMessage()); + } + + [Fact] + public void ClosedDerivedType_ValueTypeRegisteredOnReferenceTypeBase_WarnsWithSYSLIB1229() + { + // Value type with no inheritance relationship to a class base. + string source = """ + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(MyBase))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonDerivedType(typeof(int), "i")] + public class MyBase + { + } + } + """; + + Compilation compilation = CompilationHelper.CreateCompilation(source); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1229"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Contains("not assignable", diagnostic.GetMessage()); + } + [Fact] public void OpenGenericDerivedType_SYSLIB1229_IsPragmaSuppressible() { @@ -1376,7 +1551,7 @@ public void OpenGenericDerivedType_WrappedArgWithMatchingBase_WarnsWithSYSLIB122 // Derived : Base> registered on Base>: // the derived pins the leaf shape to List<>. The registration does not apply // to closures of Base whose T is not a List<>, so it is rejected under the - // universal-applicability rule. + // uniform-applicability rule. string source = """ #nullable enable using System.Collections.Generic; @@ -1443,7 +1618,7 @@ public void OpenGenericDerivedType_PartialConcretization_WarnsWithSYSLIB1229() { // Derived : Base registered on Base: // the derived pins T2 to int. The registration is rejected under the - // universal-applicability rule regardless of which closure was registered. + // uniform-applicability rule regardless of which closure was registered. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1477,7 +1652,7 @@ public void OpenGenericDerivedType_ArrayTypeArg_WarnsWithSYSLIB1229() { // Derived : Base registered on Base: pins the leaf shape to // an array. The registration does not apply to closures of Base whose T - // is not an array, so it is rejected under the universal-applicability rule. + // is not an array, so it is rejected under the uniform-applicability rule. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1602,7 +1777,7 @@ public void OpenGenericDerivedType_ReferenceTypeConstraintMismatch_WarnsWithSYSL { // Derived : Base where T : class. Base has no constraint, so the // derived's constraints don't match the base's exactly. Rejected under - // the universal-applicability rule. + // the uniform-applicability rule. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1636,7 +1811,7 @@ public void OpenGenericDerivedType_InterfaceConstraintMismatch_WarnsWithSYSLIB12 { // Derived : Base where T : IComparable. Base has no constraint, so // the derived's constraints don't match the base's exactly. Rejected under - // the universal-applicability rule. + // the uniform-applicability rule. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1670,7 +1845,7 @@ public void OpenGenericDerivedType_ArrayInsideGenericConstraint_WarnsWithSYSLIB1 { // Derived : Base where T : IEnumerable. Base has no // constraint on T1, so the derived's constraint is stricter. Rejected under - // the universal-applicability rule. + // the uniform-applicability rule. string source = """ #nullable enable using System.Collections.Generic; @@ -1706,7 +1881,7 @@ public void OpenGenericDerivedType_NestedInGenericOuter_WarnsWithSYSLIB1229() // Outer.Middle.Leaf : Base<(T, U)>. The derived pins the closed base's // single type argument to a tuple shape, so it does not apply to closures of // Base whose T is not a (_, _) tuple. Rejected under the - // universal-applicability rule. + // uniform-applicability rule. string source = """ #nullable enable using System.Text.Json.Serialization; @@ -1813,7 +1988,7 @@ public void OpenGenericDerivedType_MultipleInterfaceConstructions_DisagreeingSub // Impl reaches IBase<> via two ancestors: directly as IBase and via // IntBase : IBase. The two ancestors produce disagreeing substitutions // for the base's single type parameter (one binds it to T, the other to int). - // Rejected under the universal-applicability rule. + // Rejected under the uniform-applicability rule. string source = """ using System.Text.Json.Serialization; @@ -1971,7 +2146,7 @@ public void OpenGenericDerivedType_NestedGenericTypeParameterInEnclosing_WarnsWi // The derived pins the closed base's single type argument to an // NestedOuterB<>.NestedBoxB shape. It does not apply to closures of // NestedBaseB whose T is not of that shape, so it is rejected under the - // universal-applicability rule. + // uniform-applicability rule. string source = """ using System.Text.Json.Serialization; @@ -2006,7 +2181,7 @@ public void OpenGenericDerivedType_CovariantInterfaceConstraintMismatch_WarnsWit // ConstraintImpl : ConstraintBase where T : IEnumerable. // The derived has constraints the base doesn't. Even though specific closures // (e.g. List via variance) would satisfy the constraint, the - // registration is rejected under the universal-applicability rule. + // registration is rejected under the uniform-applicability rule. string source = """ using System.Collections.Generic; using System.Text.Json.Serialization; @@ -2047,7 +2222,7 @@ internal partial class JsonContext : JsonSerializerContext { } [JsonDerivedType(typeof(MyDerived<>), "d")] public class MyBase { } - // Pins T to a specific specialization (string) of the base — non-universal, + // Pins T to a specific specialization (string) of the base — non-uniform, // so SYSLIB1229 fires. It should fire exactly once for the (MyBase<>, MyDerived<>) // pair, not once per closure of MyBase<>. public class MyDerived : MyBase { }