From fd4cedc33af8de88cb81d79f52622702eea9d459 Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 13 Feb 2026 07:09:56 +0100 Subject: [PATCH 1/8] Fix ValidationsGenerator silently skipping ITypeParameterSymbol parameters When endpoint handlers use generic extension methods (e.g., MapCommand()), the resolved method has ITypeParameterSymbol parameters. These have DeclaredAccessibility == NotApplicable, which fails the accessibility check in TryExtractValidatableType, causing the type to be silently skipped without any diagnostic. Handle ITypeParameterSymbol before the accessibility check by walking constraint types to discover validatable concrete types. Add the type parameter to visitedTypes before iterating constraints to prevent stack overflow with circular constraints. Add ContainsTypeParameter guard in ExtractValidatableMembers to prevent invalid typeof() expressions in generated code for properties whose types contain unresolved type parameters (e.g., CRTP patterns). --- .../ValidationsGenerator.TypesParser.cs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs index 71466a8a547b..62e5959d633c 100644 --- a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs +++ b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs @@ -74,6 +74,25 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow return false; } + // Type parameters (e.g., TRequest from a generic MapCommand() extension) + // have DeclaredAccessibility == NotApplicable. The concrete type is only known at + // call sites, not inside the generic method body where the endpoint delegate is + // defined. Walk constraint types to discover any validatable types reachable + // through type constraints. + if (typeSymbol is ITypeParameterSymbol typeParam) + { + // Add to visitedTypes BEFORE iterating constraints to prevent + // infinite recursion through circular constraints such as + // where T : class, IEnumerable (SEC-001). + visitedTypes.Add(typeSymbol); + var foundValidatable = false; + foreach (var constraintType in typeParam.ConstraintTypes) + { + foundValidatable |= TryExtractValidatableType(constraintType, wellKnownTypes, ref validatableTypes, ref visitedTypes); + } + return foundValidatable; + } + // Skip types that are not accessible from generated code if (typeSymbol.DeclaredAccessibility is not Accessibility.Public) { @@ -195,6 +214,16 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb ref validatableTypes, ref visitedTypes); + // Skip properties whose type is a type parameter (e.g., TSelf + // from CRTP pattern RequestBase). The emitter would + // generate typeof(TSelf) which is not valid C# (CRASH-001). + // Concrete validatable types reachable through constraints are + // already discovered by TryExtractValidatableType above. + if (ContainsTypeParameter(correspondingProperty.Type)) + { + continue; + } + members.Add(new ValidatableProperty( ContainingType: correspondingProperty.ContainingType, Type: correspondingProperty.Type, @@ -252,6 +281,14 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb continue; } + // Skip properties whose type is a type parameter (e.g., TSelf + // from CRTP pattern RequestBase). The emitter would + // generate typeof(TSelf) which is not valid C# (CRASH-001). + if (ContainsTypeParameter(member.Type)) + { + continue; + } + members.Add(new ValidatableProperty( ContainingType: member.ContainingType, Type: member.Type, @@ -307,4 +344,40 @@ internal static bool HasIValidatableObjectInterface(ITypeSymbol typeSymbol, Well var validatableObjectSymbol = wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_IValidatableObject); return typeSymbol.ImplementsInterface(validatableObjectSymbol); } + + /// + /// Returns true if the given type symbol contains an unresolved type parameter + /// anywhere in its type tree. This catches not only bare T but also + /// constructed types like List<T>, T[], T?, and + /// Dictionary<string, T> — all of which would produce invalid + /// typeof(...) expressions in the emitted code. + /// + private static bool ContainsTypeParameter(ITypeSymbol type) + { + // Bare type parameter: T, TSelf, TSelf? + if (type is ITypeParameterSymbol) + { + return true; + } + + // Array: T[], T[,], List[] + if (type is IArrayTypeSymbol arrayType) + { + return ContainsTypeParameter(arrayType.ElementType); + } + + // Constructed generic: List, Dictionary, Nullable, Func + if (type is INamedTypeSymbol { IsGenericType: true } namedType) + { + foreach (var typeArg in namedType.TypeArguments) + { + if (ContainsTypeParameter(typeArg)) + { + return true; + } + } + } + + return false; + } } From e20d0e4a168f98fa6239117d0645f654f1a1e740 Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 13 Feb 2026 11:29:04 +0100 Subject: [PATCH 2/8] Add tests for CRTP generic base class validation Verifies that types using the Curiously Recurring Template Pattern (CommandBase where TSelf : CommandBase) are correctly discovered and validated through the inheritance hierarchy. Covers both class and record CRTP patterns with runtime endpoint verification for Required and Range validation attributes. Co-Authored-By: Claude Opus 4.6 --- .../ValidationsGenerator.TypeParameter.cs | 179 ++++++++++++++ ...lass#ValidatableInfoResolver.g.verified.cs | 234 ++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateTypesWithGenericBaseClass#ValidatableInfoResolver.g.verified.cs diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs new file mode 100644 index 000000000000..3e8bd893968e --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs @@ -0,0 +1,179 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Extensions.Validation.GeneratorTests; + +public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase +{ + [Fact] + public async Task CanValidateTypesWithGenericBaseClass() + { + var source = """ +using System; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Validation; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddValidation(); + +var app = builder.Build(); + +app.MapPost("/crtp-class", (CreateOrderCommand request) => Results.Ok("Passed"!)); +app.MapPost("/crtp-record", (CreateRecordCommand request) => Results.Ok("Passed"!)); + +app.Run(); + +public class CommandBase where TSelf : CommandBase +{ + [Required] + public string Name { get; set; } = "default"; +} + +public class CreateOrderCommand : CommandBase +{ + [Range(1, 1000)] + public int Quantity { get; set; } = 1; +} + +public record RecordCommandBase where TSelf : RecordCommandBase +{ + [Required] + public string Title { get; set; } = "default"; +} + +public record CreateRecordCommand : RecordCommandBase +{ + [Range(1, 100)] + public int Count { get; set; } = 1; +} +"""; + await Verify(source, out var compilation); + await VerifyEndpoint(compilation, "/crtp-class", async (endpoint, serviceProvider) => + { + await InvalidNameProducesError(endpoint); + await InvalidQuantityProducesError(endpoint); + await ValidInputProducesNoErrors(endpoint); + + async Task InvalidNameProducesError(Endpoint endpoint) + { + var payload = """ + { + "Name": "", + "Quantity": 5 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.Single()); + }); + } + + async Task InvalidQuantityProducesError(Endpoint endpoint) + { + var payload = """ + { + "Name": "valid", + "Quantity": 0 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("Quantity", kvp.Key); + Assert.Equal("The field Quantity must be between 1 and 1000.", kvp.Value.Single()); + }); + } + + async Task ValidInputProducesNoErrors(Endpoint endpoint) + { + var payload = """ + { + "Name": "valid", + "Quantity": 5 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + await endpoint.RequestDelegate(context); + + Assert.Equal(200, context.Response.StatusCode); + } + }); + + await VerifyEndpoint(compilation, "/crtp-record", async (endpoint, serviceProvider) => + { + await InvalidTitleProducesError(endpoint); + await InvalidCountProducesError(endpoint); + await ValidRecordInputProducesNoErrors(endpoint); + + async Task InvalidTitleProducesError(Endpoint endpoint) + { + var payload = """ + { + "Title": "", + "Count": 5 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("Title", kvp.Key); + Assert.Equal("The Title field is required.", kvp.Value.Single()); + }); + } + + async Task InvalidCountProducesError(Endpoint endpoint) + { + var payload = """ + { + "Title": "valid", + "Count": 0 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("Count", kvp.Key); + Assert.Equal("The field Count must be between 1 and 100.", kvp.Value.Single()); + }); + } + + async Task ValidRecordInputProducesNoErrors(Endpoint endpoint) + { + var payload = """ + { + "Title": "valid", + "Count": 5 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + await endpoint.RequestDelegate(context); + + Assert.Equal(200, context.Response.StatusCode); + } + }); + } +} diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateTypesWithGenericBaseClass#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateTypesWithGenericBaseClass#ValidatableInfoResolver.g.verified.cs new file mode 100644 index 000000000000..749259012cae --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateTypesWithGenericBaseClass#ValidatableInfoResolver.g.verified.cs @@ -0,0 +1,234 @@ +//HintName: ValidatableInfoResolver.g.cs +#nullable enable annotations +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +#nullable enable +#pragma warning disable ASP0029 + +namespace System.Runtime.CompilerServices +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : System.Attribute + { + public InterceptsLocationAttribute(int version, string data) + { + } + } +} + +namespace Microsoft.Extensions.Validation.Generated +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatablePropertyInfo : global::Microsoft.Extensions.Validation.ValidatablePropertyInfo + { + public GeneratedValidatablePropertyInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + global::System.Type propertyType, + string name, + string displayName) : base(containingType, propertyType, name, displayName) + { + ContainingType = containingType; + Name = name; + } + + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + internal global::System.Type ContainingType { get; } + internal string Name { get; } + + protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes() + => ValidationAttributeCache.GetPropertyValidationAttributes(ContainingType, Name); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatableTypeInfo : global::Microsoft.Extensions.Validation.ValidatableTypeInfo + { + public GeneratedValidatableTypeInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type, + ValidatablePropertyInfo[] members) : base(type, members) + { + Type = type; + } + + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + internal global::System.Type Type { get; } + + protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes() + => ValidationAttributeCache.GetTypeValidationAttributes(Type); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class GeneratedValidatableInfoResolver : global::Microsoft.Extensions.Validation.IValidatableInfoResolver + { + public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + if (type == typeof(global::CommandBase)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::CommandBase), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::CommandBase), + propertyType: typeof(string), + name: "Name", + displayName: "Name" + ), + ] + ); + return true; + } + if (type == typeof(global::CreateOrderCommand)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::CreateOrderCommand), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::CreateOrderCommand), + propertyType: typeof(int), + name: "Quantity", + displayName: "Quantity" + ), + ] + ); + return true; + } + if (type == typeof(global::RecordCommandBase)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::RecordCommandBase), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::RecordCommandBase), + propertyType: typeof(string), + name: "Title", + displayName: "Title" + ), + ] + ); + return true; + } + if (type == typeof(global::CreateRecordCommand)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::CreateRecordCommand), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::CreateRecordCommand), + propertyType: typeof(int), + name: "Count", + displayName: "Count" + ), + ] + ); + return true; + } + + return false; + } + + // No-ops, rely on runtime code for ParameterInfo-based resolution + public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + return false; + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class GeneratedServiceCollectionExtensions + { + [InterceptsLocation] + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action? configureOptions = null) + { + // Use non-extension method to avoid infinite recursion. + return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options => + { + options.Resolvers.Insert(0, new GeneratedValidatableInfoResolver()); + if (configureOptions is not null) + { + configureOptions(options); + } + }); + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class ValidationAttributeCache + { + private sealed record CacheKey( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + [property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type ContainingType, + string PropertyName); + private static readonly global::System.Collections.Concurrent.ConcurrentDictionary _propertyCache = new(); + private static readonly global::System.Lazy> _lazyTypeCache = new (() => new ()); + private static global::System.Collections.Concurrent.ConcurrentDictionary TypeCache => _lazyTypeCache.Value; + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetPropertyValidationAttributes( + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + string propertyName) + { + var key = new CacheKey(containingType, propertyName); + return _propertyCache.GetOrAdd(key, static k => + { + var results = new global::System.Collections.Generic.List(); + + // Get attributes from the property + var property = k.ContainingType.GetProperty(k.PropertyName); + if (property != null) + { + var propertyAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(property, inherit: true); + + results.AddRange(propertyAttributes); + } + + // Check constructors for parameters that match the property name + // to handle record scenarios + foreach (var constructor in k.ContainingType.GetConstructors()) + { + // Look for parameter with matching name (case insensitive) + var parameter = global::System.Linq.Enumerable.FirstOrDefault( + constructor.GetParameters(), + p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase)); + + if (parameter != null) + { + var paramAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(parameter, inherit: true); + + results.AddRange(paramAttributes); + + break; + } + } + + return results.ToArray(); + }); + } + + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetTypeValidationAttributes( + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type + ) + { + return TypeCache.GetOrAdd(type, static t => + { + var typeAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(t, inherit: true); + return global::System.Linq.Enumerable.ToArray(typeAttributes); + }); + } + } +} \ No newline at end of file From fdf8bf684d415d7ed9be25123400621f939354f2 Mon Sep 17 00:00:00 2001 From: ancplua Date: Wed, 13 May 2026 22:26:24 +0200 Subject: [PATCH 3/8] Fix CS1615 after rebase of main: drop obsolete `ref` keywords After main commit b58523d4c9 ("Cleanup ValidationsGenerator for better incrementality") landed on 2026-04-28, `TryExtractValidatableType` no longer declares its `HashSet` and `List` parameters as `ref`. The new `ITypeParameterSymbol` branch added by this PR was still passing them with `ref`, so once the branch was merged with main the build failed with CS1615 on Linux x64 and Source-Build. Pass the arguments without `ref` to match the post-cleanup signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs index e584e06e12da..f93d211cc6e1 100644 --- a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs +++ b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs @@ -89,7 +89,7 @@ internal static bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, W var foundValidatable = false; foreach (var constraintType in typeParam.ConstraintTypes) { - foundValidatable |= TryExtractValidatableType(constraintType, wellKnownTypes, ref validatableTypes, ref visitedTypes); + foundValidatable |= TryExtractValidatableType(constraintType, wellKnownTypes, validatableTypes, visitedTypes); } return foundValidatable; } From a58595c025fda3a61fea63fdf53e33be2081b995 Mon Sep 17 00:00:00 2001 From: ancplua Date: Wed, 13 May 2026 22:26:36 +0200 Subject: [PATCH 4/8] Add test that fails on main demonstrating open type parameter silent skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previously added `CanValidateTypesWithGenericBaseClass` test does not differentiate between fixed and unfixed code: it uses MapPost with a concrete `CreateOrderCommand` parameter, so the generator only ever sees closed generics and the `ITypeParameterSymbol` codepath is never reached. As @Youssef1313 noted in review, that test passes on plain main without these changes. This new test makes the bug observable. It defines a generic endpoint extension `MapValidated(this IEndpointRouteBuilder, string)` whose body calls `endpoints.MapPost(pattern, (T req) => ...)` — the inner MapPost is the call site the generator inspects, and the delegate parameter resolves to an *open* `ITypeParameterSymbol`. On main, the type parameter hits the `DeclaredAccessibility is not Accessibility.Public` guard in `TryExtractValidatableType` (`NotApplicable` for type parameters) and silently returns false. The concrete constraint type `UserRequest` is therefore never discovered and no `typeof(global::UserRequest)` check is emitted, even though it carries `[Required]` and `[Range]` attributes. With the fix the new `ITypeParameterSymbol` branch walks `typeParam.ConstraintTypes`, recurses into `UserRequest`, and emits the expected resolver entry. The committed snapshot is that expected output, so running the test against plain main fails with a clean snapshot diff that shows exactly the 21 missing lines for `UserRequest`. Runtime endpoint behavior is identical between fixed and unfixed code because the runtime falls back to reflection-based validation when the static resolver returns false — the snapshot is therefore the precise witness of the silent-skip bug. The runtime `VerifyEndpoint` assertions are kept as regression coverage for the validation pipeline end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ValidationsGenerator.TypeParameter.cs | 114 ++++++++++ ...aint#ValidatableInfoResolver.g.verified.cs | 195 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateOpenTypeParameterReachableThroughConstraint#ValidatableInfoResolver.g.verified.cs diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs index 3e8bd893968e..bc4ba58939f0 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs @@ -7,6 +7,120 @@ namespace Microsoft.Extensions.Validation.GeneratorTests; public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase { + // Repro for the silent-skip half of issue dotnet/aspnetcore#65418. When the + // route-handler delegate parameter resolves to an open ITypeParameterSymbol — + // as happens inside the body of a generic endpoint extension method like + // MapValidated(this IEndpointRouteBuilder, string) — TryExtractValidatableType + // on main hits the DeclaredAccessibility check (NotApplicable for type parameters) + // and silently returns false. The concrete constraint type is therefore never + // discovered and no typeof(...) check is emitted for it, even though it carries + // [Required] / [Range] attributes. With the fix the generator walks the type + // parameter's ConstraintTypes and discovers the concrete validatable type. + // + // The snapshot is the bug witness: on main the resolver body is empty (no + // typeof(global::UserRequest) check); with the fix the resolver contains the + // type and its members. + [Fact] + public async Task CanValidateOpenTypeParameterReachableThroughConstraint() + { + var source = """ +using System; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Validation; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddValidation(); + +var app = builder.Build(); + +app.MapValidated("/users"); + +app.Run(); + +public class UserRequest +{ + [Required] + public string Name { get; set; } = "default"; + + [Range(1, 120)] + public int Age { get; set; } = 25; +} + +public static class GenericEndpointExtensions +{ + public static RouteHandlerBuilder MapValidated(this IEndpointRouteBuilder endpoints, string pattern) + where T : UserRequest + => endpoints.MapPost(pattern, (T req) => Results.Ok()); +} +"""; + await Verify(source, out var compilation); + await VerifyEndpoint(compilation, "/users", async (endpoint, serviceProvider) => + { + await InvalidNameProducesError(endpoint); + await InvalidAgeProducesError(endpoint); + await ValidInputProducesNoErrors(endpoint); + + async Task InvalidNameProducesError(Endpoint endpoint) + { + var payload = """ + { + "Name": "", + "Age": 30 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.Single()); + }); + } + + async Task InvalidAgeProducesError(Endpoint endpoint) + { + var payload = """ + { + "Name": "Alice", + "Age": 0 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("Age", kvp.Key); + Assert.Equal("The field Age must be between 1 and 120.", kvp.Value.Single()); + }); + } + + async Task ValidInputProducesNoErrors(Endpoint endpoint) + { + var payload = """ + { + "Name": "Alice", + "Age": 30 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + await endpoint.RequestDelegate(context); + + Assert.Equal(200, context.Response.StatusCode); + } + }); + } + [Fact] public async Task CanValidateTypesWithGenericBaseClass() { diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateOpenTypeParameterReachableThroughConstraint#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateOpenTypeParameterReachableThroughConstraint#ValidatableInfoResolver.g.verified.cs new file mode 100644 index 000000000000..1a479efa416f --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateOpenTypeParameterReachableThroughConstraint#ValidatableInfoResolver.g.verified.cs @@ -0,0 +1,195 @@ +//HintName: ValidatableInfoResolver.g.cs +#nullable enable annotations +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +#nullable enable +#pragma warning disable ASP0029 + +namespace System.Runtime.CompilerServices +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : System.Attribute + { + public InterceptsLocationAttribute(int version, string data) + { + } + } +} + +namespace Microsoft.Extensions.Validation.Generated +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatablePropertyInfo : global::Microsoft.Extensions.Validation.ValidatablePropertyInfo + { + public GeneratedValidatablePropertyInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + global::System.Type propertyType, + string name, + string displayName) : base(containingType, propertyType, name, displayName) + { + ContainingType = containingType; + Name = name; + } + + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + internal global::System.Type ContainingType { get; } + internal string Name { get; } + + protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes() + => ValidationAttributeCache.GetPropertyValidationAttributes(ContainingType, Name); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatableTypeInfo : global::Microsoft.Extensions.Validation.ValidatableTypeInfo + { + public GeneratedValidatableTypeInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type, + ValidatablePropertyInfo[] members) : base(type, members) + { + Type = type; + } + + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + internal global::System.Type Type { get; } + + protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes() + => ValidationAttributeCache.GetTypeValidationAttributes(Type); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class GeneratedValidatableInfoResolver : global::Microsoft.Extensions.Validation.IValidatableInfoResolver + { + public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + if (type == typeof(global::UserRequest)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::UserRequest), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::UserRequest), + propertyType: typeof(string), + name: "Name", + displayName: "Name" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::UserRequest), + propertyType: typeof(int), + name: "Age", + displayName: "Age" + ), + ] + ); + return true; + } + + return false; + } + + // No-ops, rely on runtime code for ParameterInfo-based resolution + public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + return false; + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class GeneratedServiceCollectionExtensions + { + [InterceptsLocation] + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action? configureOptions = null) + { + // Use non-extension method to avoid infinite recursion. + return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options => + { + options.Resolvers.Insert(0, new GeneratedValidatableInfoResolver()); + if (configureOptions is not null) + { + configureOptions(options); + } + }); + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class ValidationAttributeCache + { + private sealed record CacheKey( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + [property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type ContainingType, + string PropertyName); + private static readonly global::System.Collections.Concurrent.ConcurrentDictionary _propertyCache = new(); + private static readonly global::System.Lazy> _lazyTypeCache = new (() => new ()); + private static global::System.Collections.Concurrent.ConcurrentDictionary TypeCache => _lazyTypeCache.Value; + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetPropertyValidationAttributes( + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + string propertyName) + { + var key = new CacheKey(containingType, propertyName); + return _propertyCache.GetOrAdd(key, static k => + { + var results = new global::System.Collections.Generic.List(); + + // Get attributes from the property + var property = k.ContainingType.GetProperty(k.PropertyName); + if (property != null) + { + var propertyAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(property, inherit: true); + + results.AddRange(propertyAttributes); + } + + // Check constructors for parameters that match the property name + // to handle record scenarios + foreach (var constructor in k.ContainingType.GetConstructors()) + { + // Look for parameter with matching name (case insensitive) + var parameter = global::System.Linq.Enumerable.FirstOrDefault( + constructor.GetParameters(), + p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase)); + + if (parameter != null) + { + var paramAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(parameter, inherit: true); + + results.AddRange(paramAttributes); + + break; + } + } + + return results.ToArray(); + }); + } + + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetTypeValidationAttributes( + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type + ) + { + return TypeCache.GetOrAdd(type, static t => + { + var typeAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(t, inherit: true); + return global::System.Linq.Enumerable.ToArray(typeAttributes); + }); + } + } +} \ No newline at end of file From 85e971f6b3543833c92a079144589b2edb623039 Mon Sep 17 00:00:00 2001 From: ancplua Date: Tue, 19 May 2026 19:33:08 -0500 Subject: [PATCH 5/8] Drop internal tag references from new TypeParameter handling comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The (SEC-001) and (CRASH-001) tags carried no meaning to upstream reviewers — no other comment in src/Validation references internal classification IDs. The surrounding prose already explains the "why" (circular constraint guard, typeof(TSelf) crash). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../gen/Parsers/ValidationsGenerator.TypesParser.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs index 6f58ea0929df..c232310dfaf1 100644 --- a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs +++ b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs @@ -84,7 +84,7 @@ internal static bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, W { // Add to visitedTypes BEFORE iterating constraints to prevent // infinite recursion through circular constraints such as - // where T : class, IEnumerable (SEC-001). + // where T : class, IEnumerable. visitedTypes.Add(typeSymbol); var foundValidatable = false; foreach (var constraintType in typeParam.ConstraintTypes) @@ -230,9 +230,9 @@ private static ImmutableArray ExtractValidatableMembers(ITy // Skip properties whose type is a type parameter (e.g., TSelf // from CRTP pattern RequestBase). The emitter would - // generate typeof(TSelf) which is not valid C# (CRASH-001). - // Concrete validatable types reachable through constraints are - // already discovered by TryExtractValidatableType above. + // generate typeof(TSelf) which is not valid C#. Concrete + // validatable types reachable through constraints are already + // discovered by TryExtractValidatableType above. if (ContainsTypeParameter(correspondingProperty.Type)) { continue; @@ -307,7 +307,7 @@ private static ImmutableArray ExtractValidatableMembers(ITy // Skip properties whose type is a type parameter (e.g., TSelf // from CRTP pattern RequestBase). The emitter would - // generate typeof(TSelf) which is not valid C# (CRASH-001). + // generate typeof(TSelf) which is not valid C#. if (ContainsTypeParameter(member.Type)) { continue; From 15566302aa3ad1f8f2f22d079536769439387d6e Mon Sep 17 00:00:00 2001 From: ancplua Date: Thu, 4 Jun 2026 09:46:21 +0200 Subject: [PATCH 6/8] Migrate Claude plugin settings to Codex --- .agents/plugins/README.md | 17 ++++++++++ .agents/plugins/marketplace.json | 53 ++++++++++++++++++++++++++++++ .codex/migrate-to-codex-report.txt | 11 +++++++ 3 files changed, 81 insertions(+) create mode 100644 .agents/plugins/README.md create mode 100644 .agents/plugins/marketplace.json create mode 100644 .codex/migrate-to-codex-report.txt diff --git a/.agents/plugins/README.md b/.agents/plugins/README.md new file mode 100644 index 000000000000..a4a1b332ef03 --- /dev/null +++ b/.agents/plugins/README.md @@ -0,0 +1,17 @@ +# Project Plugin Migration + +Source: `.claude/settings.json`. + +This marketplace exposes the Claude-enabled .NET plugins as Codex marketplace +entries for this repository: + +- `dotnet-dnceng` +- `dotnet` +- `dotnet-test` + +The entries resolve in Codex as `aspnetcore-dotnet-skills`, but direct plugin +installation currently fails with `missing plugin.json` because the upstream +.NET plugin folders use a Claude-style top-level `plugin.json` instead of a +Codex `.codex-plugin/plugin.json` manifest. Keep these entries for discovery; +install after the upstream plugin bundles add Codex manifests or after local +Codex wrappers are created for the bundles. diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 000000000000..8cc652bcc46b --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,53 @@ +{ + "name": "aspnetcore-dotnet-skills", + "interface": { + "displayName": "ASP.NET Core .NET Skills" + }, + "plugins": [ + { + "name": "dotnet-dnceng", + "source": { + "source": "git-subdir", + "url": "https://github.com/dotnet/arcade-skills.git", + "path": "./plugins/dotnet-dnceng", + "ref": "main" + }, + "description": "Skills for .NET engineering infrastructure: CI/CD analysis, build pipeline workflows", + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "dotnet", + "source": { + "source": "git-subdir", + "url": "https://github.com/dotnet/skills.git", + "path": "./plugins/dotnet", + "ref": "main" + }, + "description": "Collection of core .NET skills for handling common .NET coding tasks.", + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "dotnet-test", + "source": { + "source": "git-subdir", + "url": "https://github.com/dotnet/skills.git", + "path": "./plugins/dotnet-test", + "ref": "main" + }, + "description": "Skills for running, diagnosing, and migrating .NET tests: test execution, filtering, platform detection, and MSTest workflows.", + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.codex/migrate-to-codex-report.txt b/.codex/migrate-to-codex-report.txt new file mode 100644 index 000000000000..ebecba4a8f06 --- /dev/null +++ b/.codex/migrate-to-codex-report.txt @@ -0,0 +1,11 @@ +Migration inventory: + inactive: instruction files - none found + inactive: skills - none found + inactive: command sources - none found + inactive: subagents - none found +Migration surfaces: + inactive: AGENTS.md - No supported instruction file found. + inactive: skills - No skills found. + inactive: MCP config - No settings or MCP config found. + inactive: subagents - No subagents found. +Migration report: From 9be2650b1bcebed8c68880ae6e06a0b6075b3428 Mon Sep 17 00:00:00 2001 From: ancplua Date: Thu, 4 Jun 2026 13:28:57 +0200 Subject: [PATCH 7/8] Remove unrelated plugin/codex files accidentally committed The .agents/plugins/ and .codex/ files from 15566302aa belong to local tooling migration, not to this aspnetcore change. Drop them from the PR. --- .agents/plugins/README.md | 17 ---------- .agents/plugins/marketplace.json | 53 ------------------------------ .codex/migrate-to-codex-report.txt | 11 ------- 3 files changed, 81 deletions(-) delete mode 100644 .agents/plugins/README.md delete mode 100644 .agents/plugins/marketplace.json delete mode 100644 .codex/migrate-to-codex-report.txt diff --git a/.agents/plugins/README.md b/.agents/plugins/README.md deleted file mode 100644 index a4a1b332ef03..000000000000 --- a/.agents/plugins/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Project Plugin Migration - -Source: `.claude/settings.json`. - -This marketplace exposes the Claude-enabled .NET plugins as Codex marketplace -entries for this repository: - -- `dotnet-dnceng` -- `dotnet` -- `dotnet-test` - -The entries resolve in Codex as `aspnetcore-dotnet-skills`, but direct plugin -installation currently fails with `missing plugin.json` because the upstream -.NET plugin folders use a Claude-style top-level `plugin.json` instead of a -Codex `.codex-plugin/plugin.json` manifest. Keep these entries for discovery; -install after the upstream plugin bundles add Codex manifests or after local -Codex wrappers are created for the bundles. diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json deleted file mode 100644 index 8cc652bcc46b..000000000000 --- a/.agents/plugins/marketplace.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "aspnetcore-dotnet-skills", - "interface": { - "displayName": "ASP.NET Core .NET Skills" - }, - "plugins": [ - { - "name": "dotnet-dnceng", - "source": { - "source": "git-subdir", - "url": "https://github.com/dotnet/arcade-skills.git", - "path": "./plugins/dotnet-dnceng", - "ref": "main" - }, - "description": "Skills for .NET engineering infrastructure: CI/CD analysis, build pipeline workflows", - "policy": { - "installation": "INSTALLED_BY_DEFAULT", - "authentication": "ON_INSTALL" - }, - "category": "Productivity" - }, - { - "name": "dotnet", - "source": { - "source": "git-subdir", - "url": "https://github.com/dotnet/skills.git", - "path": "./plugins/dotnet", - "ref": "main" - }, - "description": "Collection of core .NET skills for handling common .NET coding tasks.", - "policy": { - "installation": "INSTALLED_BY_DEFAULT", - "authentication": "ON_INSTALL" - }, - "category": "Productivity" - }, - { - "name": "dotnet-test", - "source": { - "source": "git-subdir", - "url": "https://github.com/dotnet/skills.git", - "path": "./plugins/dotnet-test", - "ref": "main" - }, - "description": "Skills for running, diagnosing, and migrating .NET tests: test execution, filtering, platform detection, and MSTest workflows.", - "policy": { - "installation": "INSTALLED_BY_DEFAULT", - "authentication": "ON_INSTALL" - }, - "category": "Productivity" - } - ] -} diff --git a/.codex/migrate-to-codex-report.txt b/.codex/migrate-to-codex-report.txt deleted file mode 100644 index ebecba4a8f06..000000000000 --- a/.codex/migrate-to-codex-report.txt +++ /dev/null @@ -1,11 +0,0 @@ -Migration inventory: - inactive: instruction files - none found - inactive: skills - none found - inactive: command sources - none found - inactive: subagents - none found -Migration surfaces: - inactive: AGENTS.md - No supported instruction file found. - inactive: skills - No skills found. - inactive: MCP config - No settings or MCP config found. - inactive: subagents - No subagents found. -Migration report: From 333f00330ac86006905febd7e733634486a6ea88 Mon Sep 17 00:00:00 2001 From: ancplua Date: Thu, 4 Jun 2026 14:28:38 +0200 Subject: [PATCH 8/8] Add test covering the ContainsTypeParameter guard (typeof(TSelf) repro) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers @Youssef1313's review question on whether ContainsTypeParameter is covered, and provides the compilation-error repro asked for in the issue. A generic endpoint helper constrains its request type to a CRTP base (where T : Node) whose only validatable member is itself of the type parameter (TSelf Next / TSelf Link). Reached open through the constraint walk, the member's type is the unresolved parameter, so the guard drops it at both call sites — the regular-property site for the class and the record primary-constructor site for the record — leaving the base with no validatable members so it is never emitted as a ValidatableType. Without the guard the generator emits typeof(global::Node) / typeof(global::RecordNode) with T not in scope; the generated resolver fails to compile with CS0246. VerifyEndpoint re-emits source + generated code and asserts the emit succeeds, so this test is exactly that compile-error repro. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ValidationsGenerator.TypeParameter.cs | 80 +++++ ...aint#ValidatableInfoResolver.g.verified.cs | 297 ++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitTypeofForTypeParameterMembersReachedThroughConstraint#ValidatableInfoResolver.g.verified.cs diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs index bc4ba58939f0..fe383ad7caf6 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.TypeParameter.cs @@ -290,4 +290,84 @@ async Task ValidRecordInputProducesNoErrors(Endpoint endpoint) } }); } + + // Coverage for the ContainsTypeParameter guard — the typeof(TSelf) compile-error half of + // issue dotnet/aspnetcore#65418. A generic endpoint helper constrains its request type to a + // CRTP base (where T : Node) whose only validatable member is itself of the type + // parameter. Reached through the constraint walk as an open type, that member's type is the + // unresolved parameter, so ContainsTypeParameter is true and the member is dropped — at the + // regular-property guard for the class (Node.Next) and at the record primary-constructor + // guard for the record (RecordNode(TSelf Link)). With the member dropped, the base has no + // validatable members and is never emitted as a ValidatableType, so the snapshot resolver + // contains neither Node nor RecordNode. + // + // Without the guard the generator keeps the member and adds the open base with + // typeof(global::Node) — T is not in scope in the generated resolver — and the generated + // code fails to compile (CS0246). VerifyEndpoint re-emits source + generated code and asserts + // the emit succeeds, so this test is exactly that compile-error repro. + [Fact] + public async Task DoesNotEmitTypeofForTypeParameterMembersReachedThroughConstraint() + { + var source = """ +using System; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Validation; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddValidation(); + +var app = builder.Build(); + +app.MapValidatedNode("/nodes"); +app.MapValidatedRecord("/records"); + +app.Run(); + +public class Node where TSelf : Node +{ + [Required] + public TSelf Next { get; set; } = default!; +} + +public class ConcreteNode : Node +{ +} + +public record RecordNode(TSelf Link) where TSelf : RecordNode; + +public record ConcreteRecord() : RecordNode(default!); + +public static class GenericCrtpEndpointExtensions +{ + public static RouteHandlerBuilder MapValidatedNode(this IEndpointRouteBuilder endpoints, string pattern) + where T : Node + => endpoints.MapPost(pattern, (T req) => Results.Ok()); + + public static RouteHandlerBuilder MapValidatedRecord(this IEndpointRouteBuilder endpoints, string pattern) + where T : RecordNode + => endpoints.MapPost(pattern, (T req) => Results.Ok()); +} +"""; + await Verify(source, out var compilation); + + // Reaching either callback proves source + generated code compiled and the host started; + // without the guard the generated resolver references typeof(global::Node) and fails to + // compile at the VerifyEndpoint emit step. + await VerifyEndpoint(compilation, "/nodes", (endpoint, _) => + { + Assert.NotNull(endpoint); + return Task.CompletedTask; + }); + + await VerifyEndpoint(compilation, "/records", (endpoint, _) => + { + Assert.NotNull(endpoint); + return Task.CompletedTask; + }); + } } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitTypeofForTypeParameterMembersReachedThroughConstraint#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitTypeofForTypeParameterMembersReachedThroughConstraint#ValidatableInfoResolver.g.verified.cs new file mode 100644 index 000000000000..729bc96a5156 --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitTypeofForTypeParameterMembersReachedThroughConstraint#ValidatableInfoResolver.g.verified.cs @@ -0,0 +1,297 @@ +//HintName: ValidatableInfoResolver.g.cs +#nullable enable annotations +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +#nullable enable +#pragma warning disable ASP0029 + +namespace System.Runtime.CompilerServices +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : System.Attribute + { + public InterceptsLocationAttribute(int version, string data) + { + } + } +} + +namespace Microsoft.Extensions.Validation.Generated +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatablePropertyInfo : global::Microsoft.Extensions.Validation.ValidatablePropertyInfo + { + public GeneratedValidatablePropertyInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + global::System.Type propertyType, + string name, + global::Microsoft.Extensions.Validation.DisplayNameInfo? displayNameInfo = null) : base(containingType, propertyType, name, displayNameInfo) + { + ContainingType = containingType; + Name = name; + } + + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + internal global::System.Type ContainingType { get; } + internal string Name { get; } + + protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes() + => ValidationAttributeCache.GetPropertyValidationAttributes(ContainingType, Name); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatableTypeInfo : global::Microsoft.Extensions.Validation.ValidatableTypeInfo + { + public GeneratedValidatableTypeInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type, + ValidatablePropertyInfo[] members, + global::Microsoft.Extensions.Validation.DisplayNameInfo? displayNameInfo = null) : base(type, members, displayNameInfo) + { + Type = type; + } + + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + internal global::System.Type Type { get; } + + protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes() + => ValidationAttributeCache.GetTypeValidationAttributes(Type); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class GeneratedValidatableInfoResolver : global::Microsoft.Extensions.Validation.IValidatableInfoResolver + { + public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + + return false; + } + + // No-ops, rely on runtime code for ParameterInfo-based resolution + public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + return false; + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class GeneratedServiceCollectionExtensions + { + [InterceptsLocation] + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action? configureOptions = null) + { + // Use non-extension method to avoid infinite recursion. + return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options => + { + options.Resolvers.Insert(0, new GeneratedValidatableInfoResolver()); + if (configureOptions is not null) + { + configureOptions(options); + } + }); + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class ValidationAttributeCache + { + private sealed record CacheKey( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + [property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type ContainingType, + string PropertyName); + private static readonly global::System.Collections.Concurrent.ConcurrentDictionary _propertyCache = new(); + private static readonly global::System.Lazy> _lazyTypeCache = new (() => new ()); + private static global::System.Collections.Concurrent.ConcurrentDictionary TypeCache => _lazyTypeCache.Value; + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetPropertyValidationAttributes( + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + string propertyName) + { + var key = new CacheKey(containingType, propertyName); + return _propertyCache.GetOrAdd(key, static k => + { + var results = new global::System.Collections.Generic.List(); + + // Get attributes from the property + var property = k.ContainingType.GetProperty(k.PropertyName); + if (property != null) + { + var propertyAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(property, inherit: true); + + results.AddRange(propertyAttributes); + } + + // Check constructors for parameters that match the property name + // to handle record scenarios + foreach (var constructor in k.ContainingType.GetConstructors()) + { + // Look for parameter with matching name (case insensitive) + var parameter = global::System.Linq.Enumerable.FirstOrDefault( + constructor.GetParameters(), + p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase)); + + if (parameter != null) + { + var paramAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(parameter, inherit: true); + + results.AddRange(paramAttributes); + + break; + } + } + + return results.ToArray(); + }); + } + + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetTypeValidationAttributes( + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type + ) + { + return TypeCache.GetOrAdd(type, static t => + { + var typeAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(t, inherit: true); + return global::System.Linq.Enumerable.ToArray(typeAttributes); + }); + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class LiteralDisplayName : global::Microsoft.Extensions.Validation.DisplayNameInfo + { + private readonly string _literal; + + public LiteralDisplayName(string literal) + { + _literal = literal; + } + + public override string? GetDisplayName(global::Microsoft.Extensions.Validation.ValidateContext context, string memberName, global::System.Type? declaringType) + { + var localizer = context.ValidationOptions.Localizer; + if (localizer is null) + { + return _literal; + } + return localizer.ResolveDisplayName(new global::Microsoft.Extensions.Validation.DisplayNameLocalizationContext + { + DeclaringType = declaringType, + DisplayName = _literal, + MemberName = memberName, + }) ?? _literal; + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class PropertyResourceDisplayName : global::Microsoft.Extensions.Validation.DisplayNameInfo + { + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + private readonly global::System.Type _containingType; + private readonly string _propertyName; + + public PropertyResourceDisplayName( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + string propertyName) + { + _containingType = containingType; + _propertyName = propertyName; + } + + public override string? GetDisplayName(global::Microsoft.Extensions.Validation.ValidateContext context, string memberName, global::System.Type? declaringType) + => DisplayAttributeCache.GetPropertyDisplayAttribute(_containingType, _propertyName)?.GetName(); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class TypeResourceDisplayName : global::Microsoft.Extensions.Validation.DisplayNameInfo + { + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + private readonly global::System.Type _type; + + public TypeResourceDisplayName( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type) + { + _type = type; + } + + public override string? GetDisplayName(global::Microsoft.Extensions.Validation.ValidateContext context, string memberName, global::System.Type? declaringType) + => DisplayAttributeCache.GetTypeDisplayAttribute(_type)?.GetName(); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class DisplayAttributeCache + { + private sealed record CacheKey( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + [property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type ContainingType, + string PropertyName); + + private static readonly global::System.Collections.Concurrent.ConcurrentDictionary _propertyCache = new(); + private static readonly global::System.Collections.Concurrent.ConcurrentDictionary _typeCache = new(); + + public static global::System.ComponentModel.DataAnnotations.DisplayAttribute? GetPropertyDisplayAttribute( + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + string propertyName) + { + var key = new CacheKey(containingType, propertyName); + return _propertyCache.GetOrAdd(key, static k => + { + // Check primary-constructor parameters first to handle record scenarios where + // [Display(ResourceType = ..., Name = ...)] is on the parameter rather than the property. + foreach (var constructor in k.ContainingType.GetConstructors()) + { + var parameter = global::System.Linq.Enumerable.FirstOrDefault( + constructor.GetParameters(), + p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.Ordinal)); + + if (parameter != null) + { + var paramDisplayAttr = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttribute(parameter); + if (paramDisplayAttr is not null) + { + return paramDisplayAttr; + } + + break; + } + } + + var property = k.ContainingType.GetProperty(k.PropertyName); + return property is null + ? null + : global::System.Reflection.CustomAttributeExtensions + .GetCustomAttribute(property, inherit: true); + }); + } + + public static global::System.ComponentModel.DataAnnotations.DisplayAttribute? GetTypeDisplayAttribute( + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type) + { + return _typeCache.GetOrAdd(type, static t => + global::System.Reflection.CustomAttributeExtensions + .GetCustomAttribute(t, inherit: true)); + } + } +} \ No newline at end of file