From 43fd11a82e6041bd09cd19471d304080c7366343 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 9 Oct 2026 18:50:15 +0100 Subject: [PATCH 01/21] test: added nonsentiel 'default' tests --- .../ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs index 9c4672b..bd8b3ff 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs @@ -13,9 +13,11 @@ public async Task IsValid_GivenGuidEmpty_ReturnsFalse() // Act var isValid = rule.IsValid(Guid.Empty); + var isDefaultValid = rule.IsValid(default); // Assert await Assert.That(isValid).IsFalse(); + await Assert.That(isDefaultValid).IsFalse(); } [Test] @@ -40,10 +42,12 @@ public async Task IsValid_GivenDateTimeBounds_ReturnsFalse() // Act var minIsValid = rule.IsValid(DateTime.MinValue); var maxIsValid = rule.IsValid(DateTime.MaxValue); + var defaultIsValid = rule.IsValid(default); // Assert await Assert.That(minIsValid).IsFalse(); await Assert.That(maxIsValid).IsFalse(); + await Assert.That(defaultIsValid).IsFalse(); } [Test] @@ -68,10 +72,12 @@ public async Task IsValid_GivenDateTimeOffsetBounds_ReturnsFalse() // Act var minIsValid = rule.IsValid(DateTimeOffset.MinValue); var maxIsValid = rule.IsValid(DateTimeOffset.MaxValue); + var defaultIsValid = rule.IsValid(default); // Assert await Assert.That(minIsValid).IsFalse(); await Assert.That(maxIsValid).IsFalse(); + await Assert.That(defaultIsValid).IsFalse(); } [Test] @@ -96,10 +102,12 @@ public async Task IsValid_GivenDateOnlyBounds_ReturnsFalse() // Act var minIsValid = rule.IsValid(DateOnly.MinValue); var maxIsValid = rule.IsValid(DateOnly.MaxValue); + var defaultIsValid = rule.IsValid(default); // Assert await Assert.That(minIsValid).IsFalse(); await Assert.That(maxIsValid).IsFalse(); + await Assert.That(defaultIsValid).IsFalse(); } [Test] @@ -124,10 +132,12 @@ public async Task IsValid_GivenTimeOnlyBounds_ReturnsFalse() // Act var minIsValid = rule.IsValid(TimeOnly.MinValue); var maxIsValid = rule.IsValid(TimeOnly.MaxValue); + var defaultIsValid = rule.IsValid(default); // Assert await Assert.That(minIsValid).IsFalse(); await Assert.That(maxIsValid).IsFalse(); + await Assert.That(defaultIsValid).IsFalse(); } [Test] @@ -154,9 +164,11 @@ public async Task IsValid_GivenNullEmptyOrWhitespaceString_ReturnsFalse(string? // Act var isValid = rule.IsValid(value!); + var defaultIsValid = rule.IsValid(default!); // Assert await Assert.That(isValid).IsFalse(); + await Assert.That(defaultIsValid).IsFalse(); } [Test] From 7fe3f7a4125d7170143de02c13fbcb8352198a02 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 9 Oct 2026 23:26:01 +0100 Subject: [PATCH 02/21] fix: rules on properties (i.e. args) of records --- docs/wiki/Source-Generator-DataAnnotations.md | 16 + package.json | 2 +- .../Helpers/TypeHelpersExtensions.cs | 34 +- .../Helpers/CustomRuleResolver.cs | 18 +- .../Helpers/PropertyAttributeResolver.cs | 94 ++ .../Helpers/SourceGenLibrary.cs | 128 ++- src/src/SourceGenerators/ZodSchemaAnalyzer.cs | 20 +- ...odSchemaAnalyzerTests.PositionalRecords.cs | 112 +++ ...dSchemaGeneratorTests.PositionalRecords.cs | 852 ++++++++++++++++++ 9 files changed, 1214 insertions(+), 62 deletions(-) create mode 100644 src/src/SourceGenerators/Helpers/PropertyAttributeResolver.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.PositionalRecords.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index 18908c8..77563f4 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -27,6 +27,22 @@ The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attrib `[RequiredZod]` (the generated `RequiredRule` attribute) mirrors `[Required]`: it is emitted outside the generator's non-null guard, so an absent value is reported as `missing_field` even though every other rule bound to the member is skipped for `null`. Its `AllowEmptyStrings` property has the same name and meaning as `RequiredAttribute.AllowEmptyStrings`, and it additionally exposes `TrimWhitespace`, so it is a drop-in replacement for `[Required]`. +## Positional records + +A positional record declares its properties from the primary-constructor parameter list, and the C# compiler applies an attribute that is valid on both a parameter and a property to the parameter only. The generator therefore reads the attributes on the synthesized property *and* on the primary-constructor parameter, so a validation attribute written directly on a positional parameter is honoured: + +```csharp +[ZodSchema] +public sealed record RepositoryInventorySummary( + [NonSentinel] string Name, + [NonSentinel] string CanonicalReference, + [NullOrNonWhiteSpace] string? DefaultBranch, + [NonSentinel] DateTimeOffset LastObservedAt +); +``` + +`[property: NonSentinel]` (the explicit property target) is equally honoured; when both targets carry the same attribute type the property target wins and the rule runs once. Declared properties, classes, and record structs are unaffected. + ## Enum properties Enum properties are validated automatically — the generator rejects a value that is not a defined member of the enum type with `invalid_enum_value`. The check is emitted as `ZodSharp.Rules.EnumRule`. Members can be excluded with `[ZodIgnore]` (on the enum member, for every property of that type) or `[DeniedValues]` (on the property only). `[Flags]` enums and properties with an explicit `[AllowedValues]` allow-list are not auto-validated, and the whole feature is disabled with `[ZodSchema(ValidateEnumValues = false)]`. See [Source Generator](Source-Generator.md#automatic-enum-validation) for the full rules and examples. diff --git a/package.json b/package.json index b95f01f..8ed2867 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.2", + "version": "2.1.0", "private": true, "license": "MIT", "author": { diff --git a/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/Helpers/TypeHelpersExtensions.cs b/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/Helpers/TypeHelpersExtensions.cs index df2bbaa..3b98a46 100644 --- a/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/Helpers/TypeHelpersExtensions.cs +++ b/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/Helpers/TypeHelpersExtensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.ComponentModel; using System.Text; using Microsoft.CodeAnalysis; @@ -84,18 +85,35 @@ public static bool HasZodSchemaAttribute(ITypeSymbol typeSymbol) return namedType.GetAttributes().Any(a => TypeLibrary.ZodSharp.ZodSchemaAttribute.Equals(a.AttributeClass)); } - public static bool HasDataAnnotationAttribute(IPropertySymbol propertySymbol) + public static bool HasDataAnnotationAttribute(IPropertySymbol propertySymbol) => + HasDataAnnotationAttribute(propertySymbol.GetAttributes()); + + /// + /// Determines whether any of derives from the DataAnnotations + /// ValidationAttribute. The attributes are supplied by the caller rather than read from a symbol + /// so a positional record property can contribute the attributes of the primary-constructor parameter it + /// is synthesized from, which the compiler does not copy onto the property. + /// + /// The attributes to inspect. + /// when an attribute derives from ValidationAttribute. + public static bool HasDataAnnotationAttribute(ImmutableArray attributes) { - // Check if the property has the DataAnnotation attribute - return propertySymbol - .GetAttributes() - .Any(a => - a.AttributeClass is not null + // Check if any attribute derives from the DataAnnotation attribute + foreach (var attribute in attributes) + { + if ( + attribute.AttributeClass is not null && TypeHelpers.InheritsFrom( - a.AttributeClass, + attribute.AttributeClass, TypeLibrary.System.ComponentModel.DataAnnotations.ValidationAttribute ) - ); + ) + { + return true; + } + } + + return false; } #pragma warning disable format diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index 32d0340..f66aab4 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -45,11 +45,27 @@ public static EquatableArray Resolve( ISymbol symbol, ITypeSymbol ruleTargetType, ImmutableArray.Builder diagnostics + ) => Resolve(symbol.GetAttributes(), ruleTargetType, diagnostics); + + /// + /// Resolves the custom rules declared by . The attributes are supplied by the + /// caller rather than read from a symbol so a positional record property can contribute the attributes of + /// the primary-constructor parameter it is synthesized from, which the compiler does not copy onto the + /// property when the attribute is valid on both targets. + /// + /// The validation attributes to resolve. + /// The type the rules are applied to. + /// The diagnostics a failed resolution reports into. + /// The resolved rule descriptors. + public static EquatableArray Resolve( + ImmutableArray attributes, + ITypeSymbol ruleTargetType, + ImmutableArray.Builder diagnostics ) { ImmutableArray.Builder? builder = null; - foreach (var attribute in symbol.GetAttributes()) + foreach (var attribute in attributes) { if (attribute.AttributeClass is not INamedTypeSymbol attributeClass) continue; diff --git a/src/src/SourceGenerators/Helpers/PropertyAttributeResolver.cs b/src/src/SourceGenerators/Helpers/PropertyAttributeResolver.cs new file mode 100644 index 0000000..a8993e9 --- /dev/null +++ b/src/src/SourceGenerators/Helpers/PropertyAttributeResolver.cs @@ -0,0 +1,94 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ZodSharp.SourceGenerators.Helpers; + +/// +/// Resolves the validation attributes that apply to a property: the attributes on the property itself plus, +/// when the property is synthesized from a positional record parameter, the attributes on that parameter. +/// +/// +/// +/// The C# compiler applies an attribute that is valid on both a parameter and a property to the parameter +/// only, so an attribute written on a positional record parameter without a property: target is not +/// present on the synthesized property. Reading the property alone would silently drop the rule, so the +/// parameter's attributes are merged in. +/// +/// +/// A property attribute of the same type wins: it is the more specific target for property validation, and +/// honoring both would emit the rule twice. +/// +/// +static class PropertyAttributeResolver +{ + /// + /// Gets the validation attributes that apply to , in declaration order. + /// + /// The property the schema validates. + /// The compilation being generated, used to bind the declaring parameter. + /// The effective validation attributes. + public static ImmutableArray Resolve(IPropertySymbol property, Compilation compilation) + { + var attributes = property.GetAttributes(); + var parameter = GetPositionalRecordParameter(property, compilation); + if (parameter is null) + return attributes; + + var parameterAttributes = parameter.GetAttributes(); + if (parameterAttributes.IsEmpty) + return attributes; + + var merged = ImmutableArray.CreateBuilder(attributes.Length + parameterAttributes.Length); + merged.AddRange(attributes); + + foreach (var parameterAttribute in parameterAttributes) + { + var alreadyPresent = false; + foreach (var attribute in attributes) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, parameterAttribute.AttributeClass)) + { + alreadyPresent = true; + break; + } + } + + if (!alreadyPresent) + merged.Add(parameterAttribute); + } + + return merged.ToImmutable(); + } + + /// + /// Gets the primary-constructor parameter a positional record property is synthesized from. + /// + /// The property to inspect. + /// The compilation being generated, used to bind the declaring parameter. + /// The declaring parameter, or for a declared property. + /// + /// A positional record property's declaring syntax is the parameter itself; a declared property's is its + /// own declaration, so this returns for every non-positional property. + /// + static IParameterSymbol? GetPositionalRecordParameter(IPropertySymbol property, Compilation compilation) + { + foreach (var reference in property.DeclaringSyntaxReferences) + { + if (reference.GetSyntax() is not ParameterSyntax parameterSyntax) + continue; + + if ( + compilation.GetSemanticModel(parameterSyntax.SyntaxTree).GetDeclaredSymbol(parameterSyntax) + is not IParameterSymbol parameter + ) + { + continue; + } + + return parameter; + } + + return null; + } +} diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 36e873d..0f508c1 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -145,7 +145,8 @@ CancellationToken cancellationToken if (context.SemanticModel.GetDeclaredSymbol(context.TargetNode, cancellationToken) is not INamedTypeSymbol root) return default; - ExternalSchemaResolver externalSchemas = new(context.SemanticModel.Compilation); + var compilation = context.SemanticModel.Compilation; + ExternalSchemaResolver externalSchemas = new(compilation); var schemas = ImmutableArray.CreateBuilder(); HashSet seen = []; Queue<(INamedTypeSymbol Symbol, bool IsPrimary)> queue = new(); @@ -170,7 +171,7 @@ CancellationToken cancellationToken : $"{target.Name}Schema"; var schema = target with { Name = schemaName }; var targetCanBeNull = TypeHelpers.CanBeNull(symbol); - var properties = GetZodProperties(symbol, externalSchemas); + var properties = GetZodProperties(symbol, externalSchemas, compilation); properties = AddAutomaticScalarProperty(symbol, properties, externalSchemas); // Type-level rules ([ZodRule]-mapped attributes on the target itself) validate the whole value @@ -233,7 +234,10 @@ var property in symbol m.DeclaredAccessibility == Accessibility.Public && !m.IsStatic && !m.IsIndexer - && (TypeHelpers.HasDataAnnotationAttribute(m) || IsPropertyWithNestedSchema(m, externalSchemas)) + && ( + TypeHelpers.HasDataAnnotationAttribute(GetValidationAttributes(m, compilation)) + || IsPropertyWithNestedSchema(m, externalSchemas) + ) ) ) { @@ -308,7 +312,8 @@ ImmutableArray> sets static EquatableArray> GetZodProperties( INamedTypeSymbol symbol, - ExternalSchemaResolver externalSchemas + ExternalSchemaResolver externalSchemas, + Compilation compilation ) { var properties = symbol @@ -317,12 +322,22 @@ ExternalSchemaResolver externalSchemas .Where(property => property.DeclaredAccessibility == Accessibility.Public && !property.IsStatic && !property.IsIndexer ) - .Select(property => GetValidatablePropertyDescriptor(property, externalSchemas)) + .Select(property => GetValidatablePropertyDescriptor(property, externalSchemas, compilation)) .ToImmutableArray(); return new(properties); } + /// + /// Gets the validation attributes that apply to , merging in the attributes of + /// the primary-constructor parameter a positional record property is synthesized from. See + /// for why the parameter's attributes are not on the property. + /// + internal static ImmutableArray GetValidationAttributes( + IPropertySymbol property, + Compilation compilation + ) => PropertyAttributeResolver.Resolve(property, compilation); + /// /// Adds the schema property for an automatic scalar value object /// ([Scalar<TValue>] / [Scalar(typeof(TValue))]), whose underlying member is @@ -426,7 +441,8 @@ originalPropertyType is INamedTypeSymbol internal static GeneratorResult GetValidatablePropertyDescriptor( IPropertySymbol property, - ExternalSchemaResolver? externalSchemas = null + ExternalSchemaResolver? externalSchemas, + Compilation compilation ) { var declaredType = CreateTypeIdentity(property.Type); @@ -469,20 +485,27 @@ originalPropertyType is INamedTypeSymbol && TypeHelpers.CanBeNull(GetCollectionElementTypeSymbol(originalPropertyType) ?? originalPropertyType); var nestedSchemaType = GetNestedSchemaTypeIdentity(property, validationKind, externalSchemas); var lengthAccessor = ClassifyLengthAccessor(originalPropertyType); - var displayName = GetDisplayName(property); - - var displayAttribute = DisplayAttributeData.FromAttributeData(property); - var requiredAttribute = RequiredAttributeData.FromAttributeData(property); - var compareAttribute = CompareAttributeData.FromAttributeData(property); - var emailAddressAttribute = EmailAddressAttributeData.FromAttributeData(property); - var creditCardAttribute = CreditCardAttributeData.FromAttributeData(property); - var phoneAttribute = PhoneAttribute.FromAttributeData(property); - var urlAttribute = UrlAttribute.FromAttributeData(property); - var stringLengthAttribute = StringLengthAttribute.FromAttributeData(property); - var minLengthAttribute = MinLengthAttributeData.FromAttributeData(property); - var maxLengthAttribute = MaxLengthAttributeData.FromAttributeData(property); + var validationAttributes = GetValidationAttributes(property, compilation); + var displayName = GetDisplayName(validationAttributes, property.Name); + + var displayAttribute = DisplayAttributeData.FromAttributeData(validationAttributes); + var requiredAttribute = RequiredAttributeData.FromAttributeData(validationAttributes); + var compareAttribute = CompareAttributeData.FromAttributeData(validationAttributes); + var emailAddressAttribute = EmailAddressAttributeData.FromAttributeData(validationAttributes); + var creditCardAttribute = CreditCardAttributeData.FromAttributeData(validationAttributes); + var phoneAttribute = PhoneAttribute.FromAttributeData(validationAttributes); + var urlAttribute = UrlAttribute.FromAttributeData(validationAttributes); + var stringLengthAttribute = StringLengthAttribute.FromAttributeData(validationAttributes); + var minLengthAttribute = MinLengthAttributeData.FromAttributeData(validationAttributes); + var maxLengthAttribute = MaxLengthAttributeData.FromAttributeData(validationAttributes); var regularExpressionAttribute = GeneratorResult.Empty; - if (RegularExpressionAttributeData.TryFromAttributeData(property, out var regexData, out var attribute)) + if ( + RegularExpressionAttributeData.TryFromAttributeData( + validationAttributes, + out var regexData, + out var attribute + ) + ) { regularExpressionAttribute = attribute is not null && propertyType.SpecialType != SpecialType.System_String @@ -498,12 +521,13 @@ originalPropertyType is INamedTypeSymbol : GeneratorResult.Create(regexData); } - var base64StringAttribute = Base64StringAttributeData.FromAttributeData(property); - var deniedValuesAttribute = DeniedValuesAttributeData.FromAttributeData(property); - var allowedValuesAttribute = AllowedValuesAttributeData.FromAttributeData(property); + var base64StringAttribute = Base64StringAttributeData.FromAttributeData(validationAttributes); + var deniedValuesAttribute = DeniedValuesAttributeData.FromAttributeData(validationAttributes); + var allowedValuesAttribute = AllowedValuesAttributeData.FromAttributeData(validationAttributes); AddUnsupportedDataAnnotationsDiagnostics( - property, + validationAttributes, + property.Name, propertyType, originalPropertyType, urlAttribute, @@ -517,7 +541,7 @@ originalPropertyType is INamedTypeSymbol ); var lengthAttribute = GeneratorResult.Empty; - if (LengthAttributeData.TryFromAttributeData(property, out var lengthData, out attribute)) + if (LengthAttributeData.TryFromAttributeData(validationAttributes, out var lengthData, out attribute)) { lengthAttribute = BuildLengthAttributeResult( property, @@ -528,7 +552,7 @@ originalPropertyType is INamedTypeSymbol ); } - var rangeAttribute = RangeAttributeData.FromAttributeData(property.GetAttributes(), out attribute); + var rangeAttribute = RangeAttributeData.FromAttributeData(validationAttributes, out attribute); var rangeAttributeResult = TryBuildRangeBoundaryExpressions( originalPropertyType, rangeAttribute, @@ -552,9 +576,9 @@ rangeAttribute with ) ); - ValidateCompareProperty(property, compareAttribute, diagnostics); + ValidateCompareProperty(property, validationAttributes, compareAttribute, diagnostics); - ValidateErrorMessageResourceConfiguration(property, diagnostics); + ValidateErrorMessageResourceConfiguration(validationAttributes, property.Name, diagnostics); diagnostics.AddRange(regularExpressionAttribute.Diagnostics); diagnostics.AddRange(lengthAttribute.Diagnostics); @@ -566,7 +590,7 @@ rangeAttribute with var isFlagsEnum = isEnum && HasFlagsAttribute(enumType!); var ignoredEnumMembers = isEnum && !isFlagsEnum ? GetIgnoredEnumMembers(enumType!) : default; - var customRules = CustomRuleResolver.Resolve(property, originalPropertyType, diagnostics); + var customRules = CustomRuleResolver.Resolve(validationAttributes, originalPropertyType, diagnostics); var compareViaCompareTo = validationKind == PropertyValidationKind.Comparable @@ -826,18 +850,18 @@ static LengthAccessor ClassifyLengthAccessor(ITypeSymbol propertyType) return new(string.Empty, string.Empty, false); } - static string GetDisplayName(IPropertySymbol property) + static string GetDisplayName(ImmutableArray attributes, string propertyName) { - var display = DisplayAttributeData.FromAttributeData(property); - return display.Exists && !string.IsNullOrEmpty(display.Name) ? display.Name! : property.Name; + var display = DisplayAttributeData.FromAttributeData(attributes); + return display.Exists && !string.IsNullOrEmpty(display.Name) ? display.Name! : propertyName; } static Location GetAttributeLocation(AttributeData? attributeData) => attributeData?.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; - static AttributeData? FindAttribute(IPropertySymbol property, string metadataName) + static AttributeData? FindAttribute(ImmutableArray attributes, string metadataName) { - foreach (var attribute in property.GetAttributes()) + foreach (var attribute in attributes) { if (attribute.AttributeClass?.MetadataName == metadataName) return attribute; @@ -847,7 +871,7 @@ static Location GetAttributeLocation(AttributeData? attributeData) => } static void AddUnsupportedDataAnnotationsUsage( - IPropertySymbol property, + string propertyName, AttributeData? attribute, ImmutableArray.Builder diagnostics ) => @@ -856,18 +880,21 @@ ImmutableArray.Builder diagnostics DiagnosticLibrary.UnsupportedDataAnnotationsUsage, true, GetAttributeLocation(attribute), - property.Name + propertyName ) ); static void AddUnsupportedDataAnnotationsUsage( - IPropertySymbol property, + ImmutableArray attributes, + string propertyName, string attributeMetadataName, ImmutableArray.Builder diagnostics - ) => AddUnsupportedDataAnnotationsUsage(property, FindAttribute(property, attributeMetadataName), diagnostics); + ) => + AddUnsupportedDataAnnotationsUsage(propertyName, FindAttribute(attributes, attributeMetadataName), diagnostics); static void AddUnsupportedDataAnnotationsDiagnostics( - IPropertySymbol property, + ImmutableArray attributes, + string propertyName, TypeIdentity propertyType, ITypeSymbol originalPropertyType, UrlAttribute urlAttribute, @@ -883,15 +910,15 @@ ImmutableArray.Builder diagnostics var isString = propertyType.SpecialType == SpecialType.System_String; if (urlAttribute.Exists && !isString) - AddUnsupportedDataAnnotationsUsage(property, "UrlAttribute", diagnostics); + AddUnsupportedDataAnnotationsUsage(attributes, propertyName, "UrlAttribute", diagnostics); if (phoneAttribute.Exists && !isString) - AddUnsupportedDataAnnotationsUsage(property, "PhoneAttribute", diagnostics); + AddUnsupportedDataAnnotationsUsage(attributes, propertyName, "PhoneAttribute", diagnostics); if (creditCardAttribute.Exists && !isString) - AddUnsupportedDataAnnotationsUsage(property, "CreditCardAttribute", diagnostics); + AddUnsupportedDataAnnotationsUsage(attributes, propertyName, "CreditCardAttribute", diagnostics); if (base64StringAttribute.Exists && !isString) - AddUnsupportedDataAnnotationsUsage(property, "Base64StringAttribute", diagnostics); + AddUnsupportedDataAnnotationsUsage(attributes, propertyName, "Base64StringAttribute", diagnostics); if (emailAddressAttribute.Exists && !isString) - AddUnsupportedDataAnnotationsUsage(property, "EmailAddressAttribute", diagnostics); + AddUnsupportedDataAnnotationsUsage(attributes, propertyName, "EmailAddressAttribute", diagnostics); if ( (allowedValuesAttribute.Exists || deniedValuesAttribute.Exists) @@ -899,9 +926,10 @@ ImmutableArray.Builder diagnostics ) { var valuesAttribute = - FindAttribute(property, "AllowedValuesAttribute") ?? FindAttribute(property, "DeniedValuesAttribute"); + FindAttribute(attributes, "AllowedValuesAttribute") + ?? FindAttribute(attributes, "DeniedValuesAttribute"); if (valuesAttribute is not null) - AddUnsupportedDataAnnotationsUsage(property, valuesAttribute, diagnostics); + AddUnsupportedDataAnnotationsUsage(propertyName, valuesAttribute, diagnostics); } } @@ -948,6 +976,7 @@ ITypeSymbol originalPropertyType static void ValidateCompareProperty( IPropertySymbol property, + ImmutableArray attributes, CompareAttributeData compareAttribute, ImmutableArray.Builder diagnostics ) @@ -965,7 +994,7 @@ ImmutableArray.Builder diagnostics ReportableDiagnostic.Create( DiagnosticLibrary.ComparePropertyNotFound, true, - GetAttributeLocation(FindAttribute(property, "CompareAttribute")), + GetAttributeLocation(FindAttribute(attributes, "CompareAttribute")), property.Name, compareAttribute.OtherProperty ) @@ -998,11 +1027,12 @@ or SpecialType.System_Double } static void ValidateErrorMessageResourceConfiguration( - IPropertySymbol property, + ImmutableArray attributes, + string propertyName, ImmutableArray.Builder diagnostics ) { - foreach (var attribute in property.GetAttributes()) + foreach (var attribute in attributes) { if ( attribute.AttributeClass is null @@ -1036,7 +1066,7 @@ attribute.AttributeClass is null DiagnosticLibrary.InvalidDataAnnotationsErrorMessage, true, GetAttributeLocation(attribute), - property.Name + propertyName ) ); } diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index 9a0d3d2..b713fe9 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -177,17 +177,25 @@ ImmutableHashSet schemaReachableTypes foreach (var property in type.GetMembers().OfType()) { + // A positional record property inherits the attributes of the primary-constructor parameter it is + // synthesized from, so the analyzer resolves the same effective set the generator validates with. + var validationAttributes = SourceGenLibrary.GetValidationAttributes(property, context.Compilation); + if ( property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer - || !TypeHelpers.HasDataAnnotationAttribute(property) + || !TypeHelpers.HasDataAnnotationAttribute(validationAttributes) ) { continue; } - var propertyResult = SourceGenLibrary.GetValidatablePropertyDescriptor(property, externalSchemas); + var propertyResult = SourceGenLibrary.GetValidatablePropertyDescriptor( + property, + externalSchemas, + context.Compilation + ); foreach (var diagnosticInfo in propertyResult.Diagnostics) { var diagnostic = diagnosticInfo.ToDiagnostic(); @@ -393,7 +401,13 @@ ImmutableHashSet schemaReachableTypes if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) continue; - ReportRuleAttributes(context, property.GetAttributes(), type.Name); + // A positional record property inherits the attributes of the primary-constructor parameter it is + // synthesized from, so the same effective set the generator validates with is reported here. + ReportRuleAttributes( + context, + SourceGenLibrary.GetValidationAttributes(property, context.Compilation), + type.Name + ); } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.PositionalRecords.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.PositionalRecords.cs new file mode 100644 index 0000000..e38c109 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.PositionalRecords.cs @@ -0,0 +1,112 @@ +using ZodSharp.SourceGenerators.Helpers; + +namespace ZodSharp.SourceGenerators; + +/// +/// The analyzer resolves the same effective attribute set as the generator, so a validation attribute written +/// on a positional record parameter is reported from the parameter it was applied to. +/// +public partial class ZodSchemaAnalyzerTests +{ + [Test] + public async Task Analyze_GivenRuleAttributeOnPositionalRecordParameterWithoutSchema_ReportsZODSGEN033( + CancellationToken cancellationToken + ) + { + // Arrange - the attribute is applied to the parameter, not to the synthesized property, so the analyzer + // must read it from the parameter to keep reporting a rule that will not run. + const string source = """ + using System; + using ZodSharp.Rules; + + namespace Testing + { + public sealed record Summary([NonSentinel] DateTimeOffset LastObservedAt); + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.RuleAttributeWithoutSchema); + } + + [Test] + public async Task Analyze_GivenRuleAttributeOnPositionalRecordParameterWithSchema_ReportsNothing( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary([NonSentinel] DateTimeOffset LastObservedAt); + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.RuleAttributeWithoutSchema); + } + + [Test] + public async Task Analyze_GivenUnsupportedDataAnnotationOnPositionalRecordParameter_ReportsZODSGEN006( + CancellationToken cancellationToken + ) + { + // Arrange - an email rule cannot validate a DateTimeOffset, whichever target the attribute lands on. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Summary([EmailAddress] DateTimeOffset LastObservedAt); + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnsupportedDataAnnotationsUsage); + } + + [Test] + public async Task Analyze_GivenSupportedDataAnnotationOnPositionalRecordParameter_ReportsNothing( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Summary( + [Range(typeof(DateTimeOffset), "2020-01-01", "2030-12-31", ParseLimitsInInvariantCulture = true)] + DateTimeOffset LastObservedAt + ); + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnsupportedDataAnnotationsUsage); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs new file mode 100644 index 0000000..2b7ae97 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs @@ -0,0 +1,852 @@ +using System.Collections.Immutable; +using System.Reflection; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +/// +/// Validation attributes written on a positional record parameter are applied to the parameter, not to the +/// synthesized property, whenever the attribute is valid on both targets. The generator must therefore merge +/// the parameter's attributes into the property's, or every rule on a positional record is silently dropped. +/// +partial class ZodSchemaGeneratorTests +{ + /// + /// A [ZodSchema] positional record carrying a rule attribute on a + /// parameter: the case that reported NonSentinelRule<DateTimeOffset> failures as passing. + /// + const string NonSentinelDateTimeOffsetSource = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary( + [NonSentinel] + DateTimeOffset LastObservedAt + ); + } + """; + + [Test] + public async Task Generate_GivenNonSentinelOnPositionalDateTimeOffsetParameter_ClosesTheRuleWithThePropertyType( + CancellationToken cancellationToken + ) + { + // Act + var driverResult = await GenerateAsync(NonSentinelDateTimeOffsetSource, cancellationToken); + var generated = driverResult.GetSource("SummarySchema"); + + // Assert - the open generic is closed with the property's own type, not dropped. + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(null!, null!)" + ); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN006"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN030"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN031"); + } + + [Test] + public async Task Generate_GivenNonSentinelOnEverySentinelParameterType_ClosesTheRuleWithEachType( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Identifiers( + [NonSentinel] + Guid Id, + [NonSentinel] + DateTime When, + [NonSentinel] + DateTimeOffset LastObservedAt, + [NonSentinel] + DateOnly Day, + [NonSentinel] + TimeOnly Hour + ); + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("IdentifiersSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(null!, null!)" + ); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); + } + + [Test] + public async Task Generate_GivenNonSentinelOnPositionalRecordParameter_CarriesTheMessageOverride( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary( + [NonSentinel(Message = "Last observed must not be the default.")] + DateTimeOffset LastObservedAt + ); + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("SummarySchema"); + + // Assert - the named Message argument flows into the rule like it does for a declared property. + await Assert + .That(generated) + .ContainsGeneratedCode( + """new global::ZodSharp.Rules.NonSentinelRule("Last observed must not be the default.", null!)""" + ); + } + + [Test] + public async Task Generate_GivenBothTargetedAndPropertyTargetedNonSentinel_EmitsTheRuleOnce( + CancellationToken cancellationToken + ) + { + // Arrange - `[NonSentinel]` lands on the parameter and `[property: NonSentinel]` on the property, so the + // merged set must not apply the same attribute type twice. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary( + [NonSentinel] + [property: NonSentinel] + DateTimeOffset LastObservedAt + ); + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("SummarySchema")!; + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(null!, null!)" + ); + await Assert.That(CountOccurrences(generated, "NonSentinelRule")).IsEqualTo(1); + } + + [Test] + public async Task Generate_GivenDataAnnotationsOnPositionalRecordParameters_ResolvesThem( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Account( + [Display(Name = "Account name")] + [Required] + string Name, + [StringLength(10, MinimumLength = 3)] + string Handle, + [Range(typeof(DateTimeOffset), "2020-01-01", "2030-12-31", ParseLimitsInInvariantCulture = true)] + DateTimeOffset Window + ); + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("AccountSchema"); + + // Assert - the display name, the length bounds and the parsed DateTimeOffset bounds all come from the + // parameters, so the emitted validation is the one a declared property would get. + await Assert.That(generated).ContainsGeneratedCode("Required field 'Account name' is null"); + await Assert.That(generated).ContainsGeneratedCode("if (handleLength < 3)"); + await Assert.That(generated).ContainsGeneratedCode("if (handleLength > 10)"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "private static readonly global::System.DateTimeOffset RangeMinimum_Window = global::System.DateTimeOffset.Parse(\"2020-01-01\", global::System.Globalization.CultureInfo.InvariantCulture);" + ); + } + + /// + /// The acceptance path for the reported failure: a positional record parameter + /// set to default must be rejected by the generated validator. + /// + [Test] + public async Task GeneratedValidate_GivenDefaultDateTimeOffsetOnPositionalRecord_RejectsTheSummary( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileSummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.Summary")!; + var summary = Activator.CreateInstance(modelType, new object?[] { default(DateTimeOffset) })!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + var errors = GetErrors(result); + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo(Rules.NonSentinelRule.ErrorCode); + await Assert.That(errors[0].Path.Length).IsEqualTo(1); + await Assert.That(errors[0].Path[0]).IsEqualTo("LastObservedAt"); + } + + [Test] + public async Task GeneratedValidate_GivenDateTimeOffsetBoundsOnPositionalRecord_RejectsBothBounds( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileSummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.Summary")!; + + object?[][] bounds = + [ + [DateTimeOffset.MinValue], + [DateTimeOffset.MaxValue], + ]; + + foreach (var bound in bounds) + { + var summary = Activator.CreateInstance(modelType, bound)!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + } + } + + [Test] + public async Task GeneratedValidate_GivenSetDateTimeOffsetOnPositionalRecord_AcceptsTheSummary( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileSummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.Summary")!; + var summary = Activator.CreateInstance( + modelType, + new object?[] { new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero) } + )!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsTrue(); + } + + /// + /// A positional record shaped like the reported one: every member carries a rule, so only the member whose + /// value is a sentinel is rejected. + /// + const string InventorySummarySource = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + public enum Visibility + { + Public, + Private, + } + + [ZodSchema] + public sealed record InventorySummary( + [NonSentinel] + string Name, + [NonSentinel] + string CanonicalReference, + [NullOrNonWhiteSpace] + string? DefaultBranch, + Visibility Visibility, + bool IsArchived, + [NonSentinel] + DateTimeOffset LastObservedAt + ); + } + """; + + [Test] + public async Task GeneratedValidate_GivenFullPositionalRecord_RejectsOnlyTheSentinelMember( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileInventorySummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.InventorySummary")!; + var summary = Activator.CreateInstance( + modelType, + new object?[] + { + "changeops", + "purview-dev/changeops", + null, + Enum.ToObject(modelType.GetProperty("Visibility")!.PropertyType, 1), + false, + default(DateTimeOffset), + } + )!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + var errors = GetErrors(result); + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo(Rules.NonSentinelRule.ErrorCode); + await Assert.That(errors[0].Path[0]).IsEqualTo("LastObservedAt"); + } + + [Test] + public async Task GeneratedValidate_GivenFullPositionalRecord_WithValidValuesAcceptsIt( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileInventorySummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.InventorySummary")!; + var summary = Activator.CreateInstance( + modelType, + new object?[] + { + "changeops", + "purview-dev/changeops", + null, + Enum.ToObject(modelType.GetProperty("Visibility")!.PropertyType, 1), + false, + new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), + } + )!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsTrue(); + } + + [Test] + public async Task GeneratedValidate_GivenFullPositionalRecord_RejectsASentinelStringToo( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileInventorySummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.InventorySummary")!; + var summary = Activator.CreateInstance( + modelType, + new object?[] + { + string.Empty, + "purview-dev/changeops", + null, + Enum.ToObject(modelType.GetProperty("Visibility")!.PropertyType, 1), + false, + new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), + } + )!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + var errors = GetErrors(result); + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Path[0]).IsEqualTo("Name"); + } + + [Test] + public async Task GeneratedValidate_GivenNonSentinelOnPositionalStringParameter_RejectsAnEmptyValue( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Named([NonSentinel] string Name); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.NamedSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Named")!; + + var emptyResult = InvokeValidate( + validate, + Activator.CreateInstance(modelType, new object?[] { string.Empty })! + ); + var namedResult = InvokeValidate(validate, Activator.CreateInstance(modelType, new object?[] { "changeops" })!); + + // Assert + await Assert.That(IsSuccess(emptyResult)).IsFalse(); + await Assert.That(IsSuccess(namedResult)).IsTrue(); + } + + [Test] + public async Task GeneratedValidate_GivenNullOrNonWhiteSpaceOnPositionalParameter_AcceptsNullButNotWhitespace( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Branch([NullOrNonWhiteSpace] string? DefaultBranch); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.BranchSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Branch")!; + + var nullResult = InvokeValidate(validate, Activator.CreateInstance(modelType, new object?[] { null })!); + var whitespaceResult = InvokeValidate(validate, Activator.CreateInstance(modelType, new object?[] { " " })!); + + // Assert + await Assert.That(IsSuccess(nullResult)).IsTrue(); + await Assert.That(IsSuccess(whitespaceResult)).IsFalse(); + } + + [Test] + public async Task GeneratedValidate_GivenPropertyTargetedNonSentinel_ReportsASingleError( + CancellationToken cancellationToken + ) + { + // Arrange - the explicit `property:` target already puts the attribute on the property, so merging the + // parameter's copy must not add a second rule. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary( + [NonSentinel] + [property: NonSentinel] + DateTimeOffset LastObservedAt + ); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.SummarySchema", cancellationToken); + var modelType = assembly.GetType("Testing.Summary")!; + var summary = Activator.CreateInstance(modelType, new object?[] { default(DateTimeOffset) })!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(GetErrors(result)).HasSingleItem(); + } + + [Test] + public async Task GeneratedValidate_GivenRequiredOnPositionalRecordParameter_ReportsMissingField( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Account([Required] string Name); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.AccountSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Account")!; + var missing = Activator.CreateInstance(modelType, new object?[] { null })!; + + // Act + var result = InvokeValidate(validate, missing); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + await Assert + .That(GetErrors(result).Any(static error => error.Path.Contains("Name") && error.Code == "missing_field")) + .IsTrue(); + } + + [Test] + public async Task GeneratedValidate_GivenDisplayOnPositionalRecordParameter_ReportsTheDisplayName( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Account( + [Display(Name = "Account name")] + [Required] + string Name + ); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.AccountSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Account")!; + var missing = Activator.CreateInstance(modelType, new object?[] { null })!; + + // Act + var result = InvokeValidate(validate, missing); + + // Assert + await Assert.That(GetErrors(result)[0].Message).Contains("Account name"); + } + + [Test] + public async Task GeneratedValidate_GivenRangeOnPositionalDateTimeOffsetParameter_EnforcesTheBounds( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Window( + [Range(typeof(DateTimeOffset), "2020-01-01", "2030-12-31", ParseLimitsInInvariantCulture = true)] + DateTimeOffset When + ); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.WindowSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Window")!; + + var inRange = Activator.CreateInstance( + modelType, + new object?[] { new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero) } + )!; + var outOfRange = Activator.CreateInstance( + modelType, + new object?[] { new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero) } + )!; + + // Act + var inRangeResult = InvokeValidate(validate, inRange); + var outOfRangeResult = InvokeValidate(validate, outOfRange); + + // Assert + await Assert.That(IsSuccess(inRangeResult)).IsTrue(); + await Assert.That(IsSuccess(outOfRangeResult)).IsFalse(); + } + + [Test] + public async Task GeneratedValidate_GivenRecordStructWithPositionalParameters_ValidatesItsMembers( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public readonly record struct Observation( + [NonSentinel] + DateTimeOffset LastObservedAt, + [NonSentinel] + Guid Id + ); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.ObservationSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Observation")!; + + var invalid = Activator.CreateInstance(modelType, new object?[] { default(DateTimeOffset), Guid.Empty })!; + var valid = Activator.CreateInstance( + modelType, + new object?[] { new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), Guid.NewGuid() } + )!; + + // Act + var invalidResult = InvokeValidate(validate, invalid); + var validResult = InvokeValidate(validate, valid); + + // Assert + await Assert.That(IsSuccess(invalidResult)).IsFalse(); + await Assert.That(IsSuccess(validResult)).IsTrue(); + } + + [Test] + public async Task GeneratedValidate_GivenNonPositionalRecord_KeepsValidatingItsDeclaredProperties( + CancellationToken cancellationToken + ) + { + // Arrange - a record whose properties are declared in the body is unaffected by the merge. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary + { + [NonSentinel] + public DateTimeOffset LastObservedAt { get; init; } + } + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.SummarySchema", cancellationToken); + var modelType = assembly.GetType("Testing.Summary")!; + var summary = Activator.CreateInstance(modelType)!; + modelType.GetProperty("LastObservedAt")!.SetValue(summary, default(DateTimeOffset)); + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + } + + [Test] + public async Task GeneratedValidate_GivenNestedSchemaAsPositionalParameter_ReportsTheNestedErrors( + CancellationToken cancellationToken + ) + { + // Arrange - the nested schema is discovered through the positional parameter's data annotation. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed class Customer + { + [Required] + public string Name { get; set; } = string.Empty; + } + + [ZodSchema] + public sealed record Order([Required] Customer Customer); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.OrderSchema", cancellationToken); + var customerType = assembly.GetType("Testing.Customer")!; + var orderType = assembly.GetType("Testing.Order")!; + + var invalidCustomer = Activator.CreateInstance(customerType)!; + customerType.GetProperty("Name")!.SetValue(invalidCustomer, null); + + var validCustomer = Activator.CreateInstance(customerType)!; + customerType.GetProperty("Name")!.SetValue(validCustomer, "changeops"); + + // Act + var invalidResult = InvokeValidate( + validate, + Activator.CreateInstance(orderType, new object?[] { invalidCustomer })! + ); + var validResult = InvokeValidate( + validate, + Activator.CreateInstance(orderType, new object?[] { validCustomer })! + ); + + // Assert + await Assert.That(IsSuccess(invalidResult)).IsFalse(); + await Assert.That(IsSuccess(validResult)).IsTrue(); + } + + /// + /// A custom [ZodRule]-mapped attribute written on a positional record parameter, the shape a + /// project-local rule such as [NullOrValidString] takes. + /// + const string CustomRuleOnPositionalParameterSource = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule + { + public bool IsValid(in string value) => value.IndexOf(' ') < 0; + + public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; + } + + [ZodRule(typeof(NoWhitespaceRule), Code = "invalid_string", Origin = "string")] + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)] + public sealed class NoWhitespaceAttribute : ValidationAttribute + { + public string? Message { get; set; } + } + + [ZodSchema] + public sealed record User([NoWhitespace(Message = "No spaces allowed.")] string Name); + } + """; + + [Test] + public async Task Generate_GivenCustomRuleAttributeOnPositionalRecordParameter_EmitsTheRule( + CancellationToken cancellationToken + ) + { + // Act + var driverResult = await GenerateAsync(CustomRuleOnPositionalParameterSource, cancellationToken); + var generated = driverResult.GetSource("UserSchema"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("new global::Testing.NoWhitespaceRule("); + await Assert.That(generated).ContainsGeneratedCode("\"No spaces allowed.\""); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN030"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN031"); + } + + [Test] + public async Task GeneratedValidate_GivenCustomRuleAttributeOnPositionalRecordParameter_FailsValidationAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileAsync( + CustomRuleOnPositionalParameterSource, + "Testing.UserSchema", + cancellationToken + ); + var modelType = assembly.GetType("Testing.User")!; + var instance = Activator.CreateInstance(modelType, new object?[] { "John Doe" })!; + + // Act + var result = InvokeValidate(validate, instance); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + var errors = GetErrors(result); + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo("invalid_string"); + await Assert.That(errors[0].Origin).IsEqualTo("string"); + } + + async Task<(System.Reflection.Assembly Assembly, MethodInfo Validate)> CompileSummarySchema( + CancellationToken cancellationToken + ) => await CompileAsync(NonSentinelDateTimeOffsetSource, "Testing.SummarySchema", cancellationToken); + + async Task<(System.Reflection.Assembly Assembly, MethodInfo Validate)> CompileInventorySummarySchema( + CancellationToken cancellationToken + ) => await CompileAsync(InventorySummarySource, "Testing.InventorySummarySchema", cancellationToken); + + async Task<(System.Reflection.Assembly Assembly, MethodInfo Validate)> CompileAsync( + string source, + string schemaTypeName, + CancellationToken cancellationToken + ) + { + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var validate = assembly.GetType(schemaTypeName)!.GetMethod("Validate")!; + return (assembly, validate); + } + + static object InvokeValidate(MethodInfo validate, object value) => validate.Invoke(null, [value])!; + + static bool IsSuccess(object result) => (bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!; + + static ImmutableArray GetErrors(object result) => + (ImmutableArray)result.GetType().GetProperty("Errors")!.GetValue(result)!; + + static int CountOccurrences(string text, string needle) + { + var count = 0; + var index = 0; + + while ((index = text.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + + return count; + } +} From 5194d42e1fa2a23985d29ad7a5e7c19c3baecec9 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 10 Oct 2026 11:37:29 +0100 Subject: [PATCH 03/21] feat: scalar detected error messages now include the type name --- CHANGELOG.md | 10 ++ docs/wiki/Validation-Rules-Reference.md | 4 +- docs/wiki/Value-Objects-Integration.md | 14 +- .../Helpers/CustomRuleResolver.cs | 122 +++++++++++++-- .../Helpers/SourceGenLibrary.cs | 2 +- src/src/SourceGenerators/ZodSchemaAnalyzer.cs | 2 +- src/src/ZodSharp/Rules/NonSentinelRule.cs | 68 ++++++++- ...ZodSchemaGeneratorTests.AutomaticScalar.cs | 143 ++++++++++++++++++ .../Rules/NonSentinelRuleTests.cs | 29 +++- 9 files changed, 374 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c646a4..69c637b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,19 @@ been published to NuGet. keeps it, and `message`/`code`/`origin` remain properties. Attribute usages for required values become positional — `[MinValue(3)]`, `[Regex("^[a-z]+$")]`. Usages that previously set a required value by property name no longer compile. +- **`NonSentinelRule.MessageFormat` reworded and the `{0}` argument now describes the sentinel.** The format + is now `Value has a sentinel value, but got {0}`, and `{0}` is a description (`an empty GUID`, + `DateTime.MinValue`, `an empty string`, …) instead of the raw value. Code or tests that assert against the old + string must be updated. ### Added +- **A type-level rule on a `[Scalar]` type now names the scalar in its default message.** Because a scalar's + error reports an empty path, the generator replaces the leading subject word `Value` in the resolved rule's + `MessageFormat` with the scalar's name, so `[NonSentinel]` on `[Scalar] TenantId` reports + `TenantId has a sentinel value, but got an empty GUID`. A rule whose format does not contain `Value`, and any + explicit `message`/`ErrorMessage`, are left unchanged. + - **Native C# 15 union support.** `Z.NativeUnion` and `ZodTypedNativeUnion` return a native union on `net11.0`, giving allocation-free, exhaustively pattern-matchable options. `ZODSGEN041` suggests it where a typed union's option types are all reference types. diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index 849fbad..be13477 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -177,10 +177,10 @@ Behaviour notes: | Rule | Constructor | Fluent method | Code | Message format | |---|---|---|---|---| -| `NonSentinelRule` | `(string? message)` | `NonSentinel()` | `invalid_value` | `Value is a sentinel value, but got {0}` | +| `NonSentinelRule` | `(string? message)` | `NonSentinel()` | `invalid_value` | `Value has a sentinel value, but got {0}` | | `NullOrNonWhiteSpaceRule` | `(string? message, string? code)` | — | `invalid_string` | `Value must be null or non-whitespace, but got '{0}'` | -`NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to mean "no value": `Guid.Empty`; `DateTime`, `DateTimeOffset`, `DateOnly`, and `TimeOnly` `MinValue`/`MaxValue`; and `null`/empty/whitespace strings. Types without a known sentinel always pass, so the rule never rejects a type it does not understand. It detects sentinels with a `typeof(T)` dispatch and reinterprets the value in place, so no boxing occurs. +`NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to mean "no value": `Guid.Empty`; `DateTime`, `DateTimeOffset`, `DateOnly`, and `TimeOnly` `MinValue`/`MaxValue`; and `null`/empty/whitespace strings. Types without a known sentinel always pass, so the rule never rejects a type it does not understand. It detects sentinels with a `typeof(T)` dispatch and reinterprets the value in place, so no boxing occurs. The `{0}` argument is a **description** of the sentinel (`an empty GUID`, `DateTime.MinValue`, `an empty string`, …) rather than the raw value, and a type-level `[NonSentinel]` on a `[Scalar]` type replaces the message's `Value` subject with the scalar's name — see [Value Objects Integration](Value-Objects-Integration.md#message-overrides). `NullOrNonWhiteSpaceRule` is the null-tolerant counterpart: it accepts `null` but rejects an empty or whitespace-only string. It exists for nullable scalar value objects, which round-trip JSON `null`, so their schema must accept `null` while still rejecting whitespace — `[RequiredZod]` always rejects `null` and so cannot express it. Apply it to a nullable scalar type with `[NullOrNonWhiteSpace]`; a rule written against the underlying `string?` value is adapted to the value object automatically. diff --git a/docs/wiki/Value-Objects-Integration.md b/docs/wiki/Value-Objects-Integration.md index b02dd3b..1e9118d 100644 --- a/docs/wiki/Value-Objects-Integration.md +++ b/docs/wiki/Value-Objects-Integration.md @@ -177,6 +177,8 @@ The reported message is resolved in this order: `MessageFormat` is therefore the fallback, not the only message. +When the fallback is used by a **type-level** rule on a scalar, the generator rewords it: the leading subject word `Value` in `MessageFormat` is replaced with the scalar's name, so the error still identifies its owner even though it reports an empty path. A rule whose `MessageFormat` does not contain `Value` (for example `"Number must be even, but got {0}"`) is left unchanged, and an explicit `message`/`ErrorMessage` is never rewritten. This is why `NonSentinelRule` reports `TenantId has a sentinel value, but got an empty GUID` for `[NonSentinel]` on a `[Scalar] TenantId`, but `Value has a sentinel value, but got an empty GUID` for a member or the fluent API. + ### Rule against the value object Define one generic rule per underlying primitive, close it over the scalar, and implement `IZodRule` so each scalar keeps its own code: @@ -309,10 +311,16 @@ var adaptedError = TenantIdSchema.Validate(default).Errors[0]; await Assert.That(adaptedError.Code).IsEqualTo(NonSentinelRule.ErrorCode); // "invalid_value" await Assert.That(adaptedError.Message).IsEqualTo("TenantId must not be empty."); // attribute override -// With no Message override, the rule's MessageFormat is the message: +// With no Message override, the rule's MessageFormat is the message. When the rule runs at the type +// level of a scalar, the subject ("Value") is replaced with the scalar's name and the sentinel is +// described rather than printed raw: +await Assert.That(TenantIdSchema.Validate(default).Errors[0].Message) + .IsEqualTo("TenantId has a sentinel value, but got an empty GUID"); + +// Anywhere else (a member, or the fluent API), the generic subject is kept: await Assert.That( - string.Format(CultureInfo.CurrentCulture, NonSentinelRule.MessageFormat, Guid.Empty)) - .IsEqualTo("Value is a sentinel value, but got 00000000-0000-0000-0000-000000000000"); + string.Format(CultureInfo.CurrentCulture, NonSentinelRule.MessageFormat, "an empty GUID")) + .IsEqualTo("Value has a sentinel value, but got an empty GUID"); ``` ## Diagnostics and troubleshooting diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index f66aab4..802f357 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -32,20 +32,28 @@ static class CustomRuleResolver /// /// The symbol whose rule attributes are resolved. /// The type the rules are applied to. + /// + /// Whether the rules are applied to itself rather than to a member. A + /// type-level rule on a scalar reports an empty path, so its default message names the scalar. + /// /// The resolved rule descriptors. /// /// Used by the generator, which only needs the descriptors: the same resolution runs in /// ZodSchemaAnalyzer, which reports the diagnostics, so a rule that resolves to nothing is still /// visible in the build. /// - public static EquatableArray Resolve(ISymbol symbol, ITypeSymbol ruleTargetType) => - Resolve(symbol, ruleTargetType, ImmutableArray.CreateBuilder()); + public static EquatableArray Resolve( + ISymbol symbol, + ITypeSymbol ruleTargetType, + bool isTypeLevel = false + ) => Resolve(symbol, ruleTargetType, ImmutableArray.CreateBuilder(), isTypeLevel); public static EquatableArray Resolve( ISymbol symbol, ITypeSymbol ruleTargetType, - ImmutableArray.Builder diagnostics - ) => Resolve(symbol.GetAttributes(), ruleTargetType, diagnostics); + ImmutableArray.Builder diagnostics, + bool isTypeLevel = false + ) => Resolve(symbol.GetAttributes(), ruleTargetType, diagnostics, isTypeLevel); /// /// Resolves the custom rules declared by . The attributes are supplied by the @@ -56,15 +64,28 @@ ImmutableArray.Builder diagnostics /// The validation attributes to resolve. /// The type the rules are applied to. /// The diagnostics a failed resolution reports into. + /// + /// Whether the rules are applied to itself rather than to a member. A + /// type-level rule on a scalar reports an empty path, so its default message names the scalar. + /// /// The resolved rule descriptors. public static EquatableArray Resolve( ImmutableArray attributes, ITypeSymbol ruleTargetType, - ImmutableArray.Builder diagnostics + ImmutableArray.Builder diagnostics, + bool isTypeLevel = false ) { ImmutableArray.Builder? builder = null; + // A rule applied at the type level of a scalar value object reports an empty path, so the generic + // subject word in its message ("Value") is replaced with the scalar's name. The substitution only + // applies when the attribute supplies no message of its own. + var scalarSubject = + isTypeLevel && ruleTargetType is INamedTypeSymbol owner && TryGetScalarAttribute(owner, out _) + ? owner.Name + : null; + foreach (var attribute in attributes) { if (attribute.AttributeClass is not INamedTypeSymbol attributeClass) @@ -118,7 +139,22 @@ out var scalarValue continue; } - if (!TryBuildArguments(attribute, ruleType, out var arguments, out var unmappedParameterName)) + // The subject-substituted default message, used only when the attribute supplies no message of its + // own. It is derived from the resolved rule's own MessageFormat so the wording stays the rule's. + var subjectMessage = + scalarSubject is not null && GetRuleMessageFormat(ruleType) is { } messageFormat + ? SubstituteSubject(messageFormat, scalarSubject) + : null; + + if ( + !TryBuildArguments( + attribute, + ruleType, + subjectMessage, + out var arguments, + out var unmappedParameterName + ) + ) { diagnostics.Add( ReportableDiagnostic.Create( @@ -1005,6 +1041,59 @@ or SpecialType.System_Object return null; } + /// + /// Reads a rule's public const string MessageFormat, walking base types so a rule that inherits + /// its error identity (for example from a shared abstract base) is still described. + /// + /// The resolved rule type. + /// The constant's value, or when no such constant exists. + static string? GetRuleMessageFormat(INamedTypeSymbol ruleType) + { + for (var current = (INamedTypeSymbol?)ruleType; current is not null; current = current.BaseType) + { + foreach (var member in current.GetMembers("MessageFormat")) + { + if (member is IFieldSymbol { IsConst: true, ConstantValue: string value }) + return value; + } + } + + return null; + } + + /// + /// Replaces the first whole-word subject token (Value) in a rule's message format with + /// , so a type-level rule on a scalar names the scalar instead of the generic + /// subject. A format that does not reference the subject token is returned unchanged. + /// + /// The rule's message format. + /// The name to substitute for the subject token. + /// The message format with the subject token replaced. + static string SubstituteSubject(string messageFormat, string subject) + { + const string token = "Value"; + + for (var index = 0; index <= messageFormat.Length - token.Length; index++) + { + if (string.CompareOrdinal(messageFormat, index, token, 0, token.Length) != 0) + continue; + + var afterIndex = index + token.Length; + var before = index == 0 ? '\0' : messageFormat[index - 1]; + var after = afterIndex >= messageFormat.Length ? '\0' : messageFormat[afterIndex]; + + // Only replace a whole word, so a format that mentions the subject inside another word is left alone. + if (IsWordCharacter(before) || IsWordCharacter(after)) + continue; + + return messageFormat.Substring(0, index) + subject + messageFormat.Substring(afterIndex); + } + + return messageFormat; + } + + static bool IsWordCharacter(char value) => char.IsLetterOrDigit(value) || value == '_'; + static bool TryGetRuleMapping(INamedTypeSymbol attributeClass, out RuleMapping mapping) { for (var current = (INamedTypeSymbol?)attributeClass; current is not null; current = current.BaseType) @@ -1085,6 +1174,10 @@ static bool ImplementsRuleFor(INamedTypeSymbol ruleType, ITypeSymbol propertyTyp /// /// The applied attribute. /// The resolved rule type. + /// + /// The subject-substituted default message for a type-level rule on a scalar, or . + /// It is used only when the attribute supplies no message of its own. + /// /// The argument expressions, in the selected constructor's parameter order. /// /// The parameter that could not be mapped when no overload could be satisfied. @@ -1093,6 +1186,7 @@ static bool ImplementsRuleFor(INamedTypeSymbol ruleType, ITypeSymbol propertyTyp static bool TryBuildArguments( AttributeData attribute, INamedTypeSymbol ruleType, + string? subjectMessage, out EquatableArray arguments, out string? unmappedParameterName ) @@ -1111,7 +1205,7 @@ out string? unmappedParameterName if (!ConsumesSuppliedArguments(attribute, candidate)) continue; - if (TryBuildArgumentsFor(attribute, candidate, out arguments, out _)) + if (TryBuildArgumentsFor(attribute, candidate, subjectMessage, out arguments, out _)) return true; } @@ -1119,12 +1213,12 @@ out string? unmappedParameterName // their parameters differently from the rule's. foreach (var candidate in candidates) { - if (TryBuildArgumentsFor(attribute, candidate, out arguments, out _)) + if (TryBuildArgumentsFor(attribute, candidate, subjectMessage, out arguments, out _)) return true; } // Report the failure against the widest overload for a useful message. - TryBuildArgumentsFor(attribute, candidates[0], out _, out unmappedParameterName); + TryBuildArgumentsFor(attribute, candidates[0], subjectMessage, out _, out unmappedParameterName); return false; } @@ -1188,6 +1282,7 @@ static bool IsIdentityOrValidationName(string name) => static bool TryBuildArgumentsFor( AttributeData attribute, IMethodSymbol constructor, + string? subjectMessage, out EquatableArray arguments, out string? unmappedParameterName ) @@ -1264,6 +1359,15 @@ out string? unmappedParameterName continue; } + // A type-level rule on a scalar reports an empty path, so its default message names the scalar + // instead of the generic subject. An explicit message (named/positional/ErrorMessage) has already + // been consumed above, so this only replaces the rule's own default. + if (subjectMessage is not null && IsMessageParameter(parameter)) + { + expressions.Add(subjectMessage.StringLiteral()); + continue; + } + if (parameter.HasExplicitDefaultValue) { if (!TryConvertValue(parameter.ExplicitDefaultValue, parameter.Type, out var defaultExpression)) diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 0f508c1..1825620 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -178,7 +178,7 @@ CancellationToken cancellationToken // rather than a property, which is what makes a scalar value object validatable as a unit. The // generator only needs the descriptors: ZodSchemaAnalyzer resolves the same attributes and // reports the diagnostics, so a dropped rule is visible in the build instead of failing silently. - var typeRules = CustomRuleResolver.Resolve(symbol, symbol); + var typeRules = CustomRuleResolver.Resolve(symbol, symbol, isTypeLevel: true); var accessibility = symbol.ContainingType is null ? symbol.DeclaredAccessibility == Accessibility.Public ? TypeDeclarationAccessibility.Public diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index b713fe9..b58cbe1 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -225,7 +225,7 @@ ImmutableHashSet schemaReachableTypes static void ReportTypeRuleDiagnostics(SymbolAnalysisContext context, INamedTypeSymbol type, Location typeLocation) { var diagnostics = ImmutableArray.CreateBuilder(); - _ = CustomRuleResolver.Resolve(type, type, diagnostics); + _ = CustomRuleResolver.Resolve(type, type, diagnostics, isTypeLevel: true); foreach (var diagnosticInfo in diagnostics) { diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs index 0440c74..1b4701e 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -32,8 +32,8 @@ namespace ZodSharp.Rules; /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Value is a sentinel value, but got {0}"; + /// Gets the message format; {0} is a description of the offending sentinel value. + public const string MessageFormat = "Value has a sentinel value, but got {0}"; readonly string _message; @@ -60,7 +60,8 @@ public NonSentinelRule(string? message = null, string? code = null) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + public string GetErrorMessage(in T value) => + RuleMessage.Format(_message ?? MessageFormat, SentinelValues.Describe(value)); /// Gets the error code reported when the rule fails. public string Code => field.Or(ErrorCode); @@ -119,4 +120,65 @@ public static bool IsSentinel(in T value) return false; } + + /// + /// Describes the sentinel represents, for use in an error message (for example + /// "an empty GUID"). A value that is not a known sentinel is described by its own string + /// representation. + /// + /// The value to describe. + /// A human-readable description of the value. + public static string Describe(in T value) + { + if (typeof(T) == typeof(Guid)) + return Unsafe.As(ref Unsafe.AsRef(in value)) == Guid.Empty ? "an empty GUID" : Raw(in value); + + if (typeof(T) == typeof(DateTime)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == DateTime.MinValue ? "DateTime.MinValue" + : typed == DateTime.MaxValue ? "DateTime.MaxValue" + : Raw(in value); + } + + if (typeof(T) == typeof(DateTimeOffset)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == DateTimeOffset.MinValue ? "DateTimeOffset.MinValue" + : typed == DateTimeOffset.MaxValue ? "DateTimeOffset.MaxValue" + : Raw(in value); + } + + if (typeof(T) == typeof(DateOnly)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == DateOnly.MinValue ? "DateOnly.MinValue" + : typed == DateOnly.MaxValue ? "DateOnly.MaxValue" + : Raw(in value); + } + + if (typeof(T) == typeof(TimeOnly)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == TimeOnly.MinValue ? "TimeOnly.MinValue" + : typed == TimeOnly.MaxValue ? "TimeOnly.MaxValue" + : Raw(in value); + } + + if (typeof(T) == typeof(string)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed switch + { + null => "null", + { Length: 0 } => "an empty string", + _ when string.IsNullOrWhiteSpace(typed) => "whitespace", + _ => typed, + }; + } + + return Raw(in value); + } + + static string Raw(in T value) => value?.ToString() ?? "null"; } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs index 161d174..b9e0ca8 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs @@ -415,4 +415,147 @@ public readonly partial record struct InstallationId await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); } + + [Test] + public async Task AutomaticScalar_GivenTypeLevelRuleWithoutMessage_NamesTheScalarInTheDefaultMessage( + CancellationToken cancellationToken + ) + { + // Arrange — the property is declared so the harness can compile the generated schema. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel] + [ZodSchema] + public readonly partial record struct TenantId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("TenantIdSchema"); + + // Assert — the rule's own default message is reworded to name the scalar, since the error has an + // empty path and nothing else identifies it. + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(\"TenantId has a sentinel value, but got {0}\", null!)" + ); + } + + [Test] + public async Task AutomaticScalar_GivenTypeLevelRuleWithoutMessage_ReportsTheScalarNameAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange — the manual `[Scalar]` form with a declared `Value` property, exactly as a consumer writes it. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel] + [ZodSchema] + public readonly partial record struct TenantId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.TenantId")!; + var validate = assembly.GetType("Testing.TenantIdSchema")!.GetMethod("Validate")!; + + var emptyResult = validate.Invoke(null, [Activator.CreateInstance(modelType)!])!; + + // Assert + await Assert.That((bool)emptyResult.GetType().GetProperty("IsSuccess")!.GetValue(emptyResult)!).IsFalse(); + var errors = (System.Collections.Immutable.ImmutableArray) + emptyResult.GetType().GetProperty("Errors")!.GetValue(emptyResult)!; + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Message).IsEqualTo("TenantId has a sentinel value, but got an empty GUID"); + await Assert.That(errors[0].Path.Length).IsEqualTo(0); + } + + [Test] + public async Task AutomaticScalar_GivenTypeLevelRuleWithMessage_KeepsTheSuppliedMessage( + CancellationToken cancellationToken + ) + { + // Arrange — an explicit message must never be rewritten. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel(Message = "TenantId must not be empty.")] + [ZodSchema] + public readonly partial record struct TenantId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("TenantIdSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(\"TenantId must not be empty.\", null!)" + ); + } + + [Test] + public async Task PropertyRule_GivenRuleWithoutMessage_KeepsTheGenericSubject(CancellationToken cancellationToken) + { + // Arrange — a rule on a member reports the member through the error path, so the rule's own default + // message is left untouched. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Tenant + { + [NonSentinel] + public Guid TenantId { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("TenantSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); + } } diff --git a/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs index bd8b3ff..0f0eb41 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs @@ -208,11 +208,38 @@ public async Task GetErrorMessage_GivenNoCustomMessage_FormatsDefaultMessage() var expected = string.Format( CultureInfo.CurrentCulture, CompositeFormat.Parse(NonSentinelRule.MessageFormat), - Guid.Empty + "an empty GUID" ); // Assert await Assert.That(message).IsEqualTo(expected); + await Assert.That(message).IsEqualTo("Value has a sentinel value, but got an empty GUID"); + } + + [Test] + public async Task GetErrorMessage_GivenEmptyString_DescribesTheSentinel() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var message = rule.GetErrorMessage(""); + + // Assert + await Assert.That(message).IsEqualTo("Value has a sentinel value, but got an empty string"); + } + + [Test] + public async Task GetErrorMessage_GivenDateTimeMinValue_DescribesTheSentinel() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var message = rule.GetErrorMessage(DateTime.MinValue); + + // Assert + await Assert.That(message).IsEqualTo("Value has a sentinel value, but got DateTime.MinValue"); } [Test] From d5226f1f68cfd96a714961f6a8297b1c3464d493 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 11 Oct 2026 01:56:31 +0100 Subject: [PATCH 04/21] refactor: true schema export for custom/ non-std zod rules --- .config/dotnet-tools.json | 2 +- CHANGELOG.md | 38 ++ docs/wiki/JsonSchema-Export.md | 96 +++- docs/wiki/Source-Generator.md | 12 +- .../JsonSchemaSerializerOptions.cs | 1 + .../AnalyzerReleases.Unshipped.md | 6 + .../Helpers/AttributeGenHelper.cs | 20 + .../Helpers/CustomRuleResolver.cs | 2 +- .../Helpers/DiagnosticLibrary.cs | 15 + .../Helpers/EnumJsonResolver.cs | 168 +++++++ .../Helpers/GenerationSettingsResolver.cs | 142 ++++++ .../Helpers/PropertyLibrary.cs | 6 + .../Helpers/SourceGenLibrary.cs | 204 ++++---- .../Models/SchemaGenerationModel.cs | 64 ++- .../Models/ZodSchemaAttributeData.cs | 3 +- src/src/SourceGenerators/ZodSchemaAnalyzer.cs | 57 +++ .../ZodSchemaGenerator.BuildSchema.cs | 21 + .../ZodSchemaGenerator.JsonSchema.cs | 459 ++++++++++++++++++ .../JsonSchema/JsonSchemaDefinition.cs | 23 + .../JsonSchema/JsonSchemaIntrospection.cs | 16 + .../ZodSharp/JsonSchema/JsonSchemaRules.cs | 161 ++++++ .../JsonSchema/ToJsonSchemaConverter.cs | 32 ++ src/src/ZodSharp/Rules/AllowedValuesRule.cs | 11 +- src/src/ZodSharp/Rules/DeniedValuesRule.cs | 11 +- src/src/ZodSharp/Rules/EmailRule.cs | 14 +- src/src/ZodSharp/Rules/EndsWithRule.cs | 16 +- src/src/ZodSharp/Rules/EnumRule.cs | 30 +- src/src/ZodSharp/Rules/EvenRule.cs | 10 +- src/src/ZodSharp/Rules/FiniteRule.cs | 12 +- .../ZodSharp/Rules/GreaterThanOrEqualRule.cs | 16 +- src/src/ZodSharp/Rules/GreaterThanRule.cs | 13 +- src/src/ZodSharp/Rules/GuidRule.cs | 14 +- src/src/ZodSharp/Rules/IncludesRule.cs | 16 +- src/src/ZodSharp/Rules/IntRule.cs | 10 +- src/src/ZodSharp/Rules/LessThanOrEqualRule.cs | 16 +- src/src/ZodSharp/Rules/LessThanRule.cs | 13 +- src/src/ZodSharp/Rules/MaxLengthRule.cs | 14 +- src/src/ZodSharp/Rules/MaxValueRule.cs | 13 +- src/src/ZodSharp/Rules/MinLengthRule.cs | 14 +- src/src/ZodSharp/Rules/MinValueRule.cs | 13 +- src/src/ZodSharp/Rules/MultipleOfRule.cs | 13 +- src/src/ZodSharp/Rules/NonSentinelRule.cs | 37 +- .../ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs | 18 +- src/src/ZodSharp/Rules/OddRule.cs | 10 +- src/src/ZodSharp/Rules/RegexRule.cs | 14 +- src/src/ZodSharp/Rules/RequiredRule.cs | 17 +- src/src/ZodSharp/Rules/SafeIntegerRule.cs | 12 +- src/src/ZodSharp/Rules/StartsWithRule.cs | 16 +- src/src/ZodSharp/Rules/UriRule.cs | 10 +- src/src/ZodSharp/Rules/UrlRule.cs | 10 +- src/src/ZodSharp/Rules/UuidRule.cs | 14 +- src/src/ZodSharp/Schemas/ZodEnum.cs | 4 +- src/src/ZodSharp/Schemas/ZodNativeEnum.cs | 21 +- .../Infra/ZodAnalyzerTestOptions.cs | 2 + ...ZodSchemaAnalyzerTests.JsonSchemaExport.cs | 90 ++++ .../ZodSchemaGeneratorTests.JsonSchema.cs | 443 +++++++++++++++++ ...dSchemaGeneratorTests.PositionalRecords.cs | 6 +- ...odSchemaGeneratorTests.ValidatorAdapter.cs | 35 ++ .../ToJsonSchemaRuleMappingTests.cs | 127 +++++ 59 files changed, 2577 insertions(+), 126 deletions(-) create mode 100644 src/src/SourceGenerators/Helpers/EnumJsonResolver.cs create mode 100644 src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs create mode 100644 src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs create mode 100644 src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.JsonSchemaExport.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs create mode 100644 src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index fb05763..ebf3a16 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,7 +10,7 @@ "rollForward": false }, "dotnet-inspect": { - "version": "0.25.0", + "version": "0.26.0", "commands": [ "dotnet-inspect" ], diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c637b..659daaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,26 @@ been published to NuGet. - **`ZODSGEN044`.** A `[ZodSchema]` type using an automatic `[Scalar]` form that cannot produce a usable schema (a suppressed `Validate` method, or an unrepresentable underlying type) is reported instead of leaving a dangling `{Type}Schema` reference. Recorded in `AnalyzerReleases.Unshipped.md`. +- **A `[ZodSchema]` schema now exports a complete JSON Schema.** The generated `{Type}Schema` class emits a + static `BuildJsonSchema()` method and the generated `{Type}SchemaValidator` implements the new + `ZodSharp.JsonSchema.IJsonSchemaProvider`, so `Z.ToJsonSchema(new {Type}SchemaValidator())` returns a real + definition instead of an empty one. The definition is built from the same metadata as `Validate`, so nested + objects, native enums, nullable/required fields, scalar value objects (`[Scalar]` export as their underlying + value), `[JsonPropertyName]`/camelCase property names, DataAnnotations and `[ZodRule]`-mapped rules are all + reflected. Previously only hand-built runtime schemas could be exported. +- **Rules describe their own JSON Schema constraint.** The new `ZodSharp.JsonSchema.IJsonSchemaRule` is + implemented by the built-in rules (length, bounds, `multipleOf`, integer/safe-integer, string formats, + `pattern`, sentinel, allowed/denied values, enum), and `ToJsonSchemaConverter` and the generated + `BuildJsonSchema()` apply them identically. A rule that cannot be represented — or a consumer rule that has + not opted in — is listed under the `x-zodsharp-unsupported` extension on the exported schema instead of + being dropped silently. +- `ZodNativeEnum` and `ZodEnum` now export as JSON Schema `enum` (via the new `IJsonSchemaEnumInfo` + seam), and `JsonSchemaDefinition` gained `not`, `contentEncoding` and `x-zodsharp-unsupported`. +- **JSON Schema generation can be opted out, per type or per assembly.** `[ZodSchema(GenerateJsonSchema = + false)]` omits the export members for one type, and the `ZodSharpGenerateJsonSchemas=false` MSBuild property + does so for the whole assembly — keeping validation-only types out of the cross-platform contract surface. + An exporting type that references a type whose export is disabled falls back to an empty schema and is + reported as the new **ZODSGEN045** warning instead of failing to compile or exporting silently. ### Changed — trimming and Native AOT @@ -137,6 +157,24 @@ been published to NuGet. ### Fixed +- **Exported enum schemas now match the System.Text.Json wire form.** The generated JSON Schema always + enumerated an enum's numeric values, so an enum serialized as a string (a per-type or per-property + `[JsonConverter(typeof(JsonStringEnumConverter))]`, or a global converter) exported a schema that rejected + the actual JSON. The generator now detects the string-enum converter, honours `[JsonStringEnumMemberName]`, + and supports the assembly-level `ZodSharpEnumSerialization=String` / `ZodSharpEnumNamingPolicy=CamelCase` + switches for a global converter and naming policy. +- **A `[Flags]` enum exported an `enum` list, rejecting valid combinations.** The generated validator never + validates a flags enum against its defined members (a combination is a valid value without being a defined + member), but the generated JSON Schema enumerated them, so a value such as `Read | Write` failed the + exported schema. A flags enum now exports as `type: integer`. +- **A `[ZodSchema]` type referenced as a nested property could lose its generated DI adapter.** A type is + reached twice by the generator — once from its own `[ZodSchema]` attribute (the primary schema) and once as + a nested property of another schema (a secondary copy). Deduplication kept whichever the attribute contexts + happened to produce first, so a secondary copy could displace the primary, dropping the primary-only members: + the `{Type}SchemaValidator` adapter and its `[assembly: ZodSchemaGenerated]` registration. Value-object + structs that were referenced by an earlier-declared type (for example a `[Scalar]` id nested in a record) + silently vanished from `IZodSchemaFactory` assembly scanning. Deduplication now always prefers the primary + schema. - **`[RequiredZod]` now rejects an absent value exactly like `[Required]`.** A custom rule bound to a reference-type member was emitted inside the generator's non-null guard, so a missing value never reached `RequiredRule` and options/schema validation passed. The rule now implements the new `IRequiredRule` diff --git a/docs/wiki/JsonSchema-Export.md b/docs/wiki/JsonSchema-Export.md index 4c62ee8..91f8a5c 100644 --- a/docs/wiki/JsonSchema-Export.md +++ b/docs/wiki/JsonSchema-Export.md @@ -43,12 +43,106 @@ var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJ ## Supported schema types -`ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, and `ZodLazy`. +`ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, and `ZodLazy`. - Objects emit `additionalProperties: false`. - Literals emit `const` (and `type`). +- Native enums emit `enum` with the defined members in their System.Text.Json wire form: the numeric values by default, or the member names when the enum is serialized as a string. A `[Flags]` enum emits `type: integer` instead, because a combination is valid without being a defined member. - Lazy/recursive schemas emit `$ref` entries under `$defs` (e.g. `#/$defs/__lazyN`). +Rules that describe their own JSON Schema constraint implement `IJsonSchemaRule` and are applied during +export (see [Validation rules and JSON Schema](#validation-rules-and-json-schema) below). + +## Export a generated `[ZodSchema]` + +A source-generated `[ZodSchema]` schema can be exported directly — there is no need to rebuild its rules by +hand with `Z.Object()`/`Z.String()`. The generated `{Type}Schema` class exposes a static `BuildJsonSchema()` +method, and the generated `{Type}SchemaValidator` (the DI adapter) implements `IJsonSchemaProvider`, so it can +be handed to `Z.ToJsonSchema`: + +```csharp +using ZodSharp; + +// Either call the generated schema directly… +var jsonSchema = RepositoryInventoryItemSchema.BuildJsonSchema(); + +// …or export the generated validator (the DI adapter) through Z.ToJsonSchema. +var exported = Z.ToJsonSchema(new RepositoryInventoryItemSchemaValidator()); +``` + +The definition is produced from the same metadata the generated validator uses, so the two cannot drift +apart: property names, nested objects, native enums, nullable fields, required fields, `[NonSentinel]`, +`[NullOrNonWhiteSpace]`, DataAnnotations, and `[ZodRule]`-mapped rules are all reflected. + +- **Property names** use the System.Text.Json wire form: an explicit `[JsonPropertyName]` wins, otherwise the + name is camelCased to match the ASP.NET Core / `JsonSerializerDefaults.Web` default. A non-camelCase global + naming policy configured at runtime is not visible to the generator, so a consumer using one must adjust the + exported names (or declare `[JsonPropertyName]`). +- **Required fields** are the non-nullable ones (and any `[Required]`); a nullable field is wrapped in + `anyOf: [ …, { "type": "null" } ]`. +- **Scalar value objects** (`[Scalar]`) export as their underlying value's schema — a `Guid` scalar is + a `uuid` string — not as an object wrapping a `Value` property. +- **Enums** follow System.Text.Json. They export as numbers by default; a per-type or per-property + `[JsonConverter(typeof(JsonStringEnumConverter))]` (or the assembly-level `ZodSharpEnumSerialization=String` + switch, matching a global converter) makes them strings, and `ZodSharpEnumNamingPolicy=CamelCase` matches a + `JsonNamingPolicy.CamelCase`. A member's `[JsonStringEnumMemberName]` name is used verbatim. A `[Flags]` + enum exports as `type: integer`. +- **Unsupported rules** are not dropped silently: a rule the exporter cannot represent is listed under the + `x-zodsharp-unsupported` extension so the exported schema is visibly incomplete. + +> `additionalProperties: false` is emitted for objects (matching the runtime exporter). System.Text.Json +> ignores unknown members by default, so a request with extra keys is accepted by the C# validator but +> rejected by the exported schema. + +### Opting a type out of schema generation + +Not every `[ZodSchema]` type belongs to the cross-platform contract surface — a validation-only domain or +options type does not need to be exported to TypeScript/JavaScript. Two switches disable the export members +(the static `BuildJsonSchema` and the `IJsonSchemaProvider` implementation) while keeping validation intact: + +```csharp +[ZodSchema(GenerateJsonSchema = false)] // this type only +public sealed record InternalThing; +``` + +```xml + + + false + +``` + +A type that exports JSON Schema must not reference a type whose export is disabled, because the nested +`BuildJsonSchema` does not exist. The generator falls back to an empty (any) schema for that property and the +analyzer reports **ZODSGEN045** naming the property and the disabled type, so the gap is surfaced at build +time rather than shipped silently. + +## Validation rules and JSON Schema + +Every rule that can describe its own JSON Schema constraint implements `ZodSharp.JsonSchema.IJsonSchemaRule`. +The exporter applies these rules through `JsonSchemaRules.Apply`, and the generated `BuildJsonSchema()` does +the same, so a rule maps to JSON Schema identically on both paths: + +| Rule | JSON Schema | +|---|---| +| `MinLengthRule` / `MaxLengthRule` | `minLength` / `maxLength` | +| `MinValueRule` / `MaxValueRule` | `minimum` / `maximum` | +| `GreaterThanRule` / `LessThanRule` | `exclusiveMinimum` / `exclusiveMaximum` | +| `MultipleOfRule` / `EvenRule` / `OddRule` | `multipleOf` (odd uses `not: { multipleOf: 2 }`) | +| `IntRule` / `SafeIntegerRule` | `type: integer` (safe integer also bounds to the `int` range) | +| `EmailRule` / `UrlRule` / `UriRule` / `UUIDRule` | `format: email` / `uri` / `uuid` | +| `RegexRule` / `GuidRule` | `pattern` | +| `StartsWithRule` / `EndsWithRule` / `IncludesRule` | anchored / escaped `pattern` | +| `NonSentinelRule` / `NullOrNonWhiteSpaceRule` | `minLength: 1` + `pattern: "\\S"` | +| `NonSentinelRule` | a `pattern` that rejects the all-zero UUID | +| `AllowedValuesRule` / `DeniedValuesRule` / `EnumRule` | `enum` / `not: { enum: … }` | +| `FiniteRule` / `RequiredRule` | no keyword (implied by `number` / the object's `required` list) | + +A rule that does not implement `IJsonSchemaRule` is reported through +`JsonSchemaDefinition.UnsupportedRules` (`x-zodsharp-unsupported`). Consumer rules opt in by implementing the +interface; for example a custom `NullOrValidStringRule` would add `IJsonSchemaRule` and set `minLength`/`pattern` +on the schema passed to `ApplyTo`. + ## Serialize the definition Pick the JSON serializer that matches the integration package you referenced: diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 042aa11..eaaa8ef 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -30,11 +30,15 @@ For a `[ZodSchema]` target type `{TypeName}`, the generator emits: | Artifact | Shape | |---|---| -| `{TypeName}Schema` | static partial class — the validator; access mirrors the target (public/internal/private for private nested types); the name is overridable with `SchemaName`; contains `Validate`, `Parse`, and (when composition is enabled) `ApplyAnd`, `ApplyOr`, `ApplyRefine` | -| `{TypeName}SchemaValidator` | `partial class {TypeName}SchemaValidator : IZodSchemaValidator<{TypeName}>` — DI-friendly adapter with `Validate` / `ValidateAsync`; emitted only for the primary schema, and named `{SchemaName}Validator` when `SchemaName` is set | +| `{TypeName}Schema` | static partial class — the validator; access mirrors the target (public/internal/private for private nested types); the name is overridable with `SchemaName`; contains `Validate`, `Parse`, `BuildJsonSchema`, and (when composition is enabled) `ApplyAnd`, `ApplyOr`, `ApplyRefine` | +| `{TypeName}SchemaValidator` | `partial class {TypeName}SchemaValidator : IZodSchemaValidator<{TypeName}>, IJsonSchemaProvider` — DI-friendly adapter with `Validate` / `ValidateAsync` / `BuildJsonSchema`; emitted only for the primary schema, and named `{SchemaName}Validator` when `SchemaName` is set | | `{TypeName}Validator` | `sealed partial class {TypeName}Validator : IValidateOptions<{TypeName}>` — emitted only when `IValidateOptions` support is enabled (and the target is a class) | | `[assembly: ZodSchemaGenerated(typeof({TypeName}))]` | registration marker consumed by `IZodSchemaFactory` assembly scanning; emitted only for primary, non-nested schemas | +`BuildJsonSchema` returns a `ZodSharp.JsonSchema.JsonSchemaDefinition` built from the same metadata as +`Validate`, so a generated `[ZodSchema]` schema can be exported through `Z.ToJsonSchema` without rebuilding its +rules by hand. See [JSON Schema Export](JsonSchema-Export.md#export-a-generated-zodschema). + ```csharp // Value-first composition methods (EnableComposition, default true): var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); @@ -56,6 +60,7 @@ All options are optional. | `GenerateIValidateOptions` | `false` | Force `IValidateOptions` generation. | | `SuppressIValidateOptions` | `false` | Opt out even when auto-detection would enable it. | | `ValidateEnumValues` | `true` | Set to `false` to skip the automatic enum validation for the type's enum properties. | +| `GenerateJsonSchema` | `true` | Set to `false` to omit the JSON Schema export members (`BuildJsonSchema` and the `IJsonSchemaProvider` on the adapter), keeping the type out of the cross-platform contract surface. A type referenced by an exported type is reported as `ZODSGEN045`. | > [!NOTE] > `Parse`, the value-first composition methods (`ApplyAnd`/`ApplyOr`/`ApplyRefine`), the `IZodSchemaValidator` adapter and the `IValidateOptions` validator all depend on `Validate`. Setting `GenerateValidateMethod = false` omits them together. @@ -147,6 +152,9 @@ MSBuild switches: | `DisableZodSharpSourceGenerator` | unset | disables the generator entirely when truthy | | `ZodSharpAutoGenerateOptionsValidators` | `true` | auto-detect `IValidateOptions` (only explicit `false` disables) | | `ZodSharpAutoGenerateOptionsValidatorSuffixes` | `Options;Settings` | semicolon/comma-separated suffix list | +| `ZodSharpGenerateJsonSchemas` | `true` | assembly-wide JSON Schema export switch; explicit `false` omits `BuildJsonSchema` and `IJsonSchemaProvider` for every type | +| `ZodSharpEnumSerialization` | `Number` | `String` makes enums export as their member names, matching a global `JsonStringEnumConverter` | +| `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | ## Automatic enum validation diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs index 261dcce..0791df4 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs @@ -15,6 +15,7 @@ sealed class JsonSchemaNamingStrategy : CamelCaseNamingStrategy ["Id"] = "$id", ["Ref"] = "$ref", ["Defs"] = "$defs", + ["UnsupportedRules"] = "x-zodsharp-unsupported", }; public override string GetPropertyName(string name, bool hasSpecifiedName) => diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index d50fc1a..32171d2 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -5,3 +5,9 @@ ; across when the next version ships. ; ; ZODSGEN002 and ZODSGEN022-ZODSGEN026 were retired during development and are never reused. + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +ZODSGEN045 | ZodSharp.SourceGenerator | Warning | A type that exports JSON Schema references a nested [ZodSchema] type whose export is disabled ([ZodSchema(GenerateJsonSchema = false)] or the assembly-level ZodSharpGenerateJsonSchemas=false switch), so the parent's exported schema falls back to an empty schema for that property diff --git a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs index 4d81e3b..67c7703 100644 --- a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs +++ b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs @@ -161,6 +161,26 @@ static SourceText ZodSchemaAttribute() Initializer = "true", } ); + + body.XmlSummary( + "Whether to generate the JSON Schema export members for this type: the static", + "BuildJsonSchema method and the IJsonSchemaProvider implementation on the", + "generated validator adapter. Set to false to keep a validation-only type out of the", + "cross-platform (TypeScript/JavaScript) contract surface. Default is true.", + "A type whose export is disabled cannot be referenced by an exported type; the generator", + "falls back to an empty schema and the analyzer reports ZODSGEN045." + ) + .Property( + new( + nameof(ZodSchemaAttributeData.GenerateJsonSchema), + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public + ) + { + IsInitOnly = true, + Initializer = "true", + } + ); } ); diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index 802f357..bbb9b0f 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -424,7 +424,7 @@ internal static bool TryGetScalarAttribute(INamedTypeSymbol type, out ScalarAttr /// The type to inspect. /// The scalar's underlying value type, when it is a scalar. /// when is a scalar value object. - static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueType) + internal static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueType) { valueType = null!; diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index a93d3d0..d383645 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -451,4 +451,19 @@ static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true ); + + /// + /// ZODSGEN045: a type that exports JSON Schema references a nested [ZodSchema] type whose export + /// is disabled ([ZodSchema(GenerateJsonSchema = false)], or the assembly-level + /// ZodSharpGenerateJsonSchemas=false switch). The nested type's schema cannot be referenced, so + /// the parent's exported schema falls back to an empty schema for that property. + /// + public static readonly DiagnosticDescriptor NestedSchemaExportDisabled = new( + id: "ZODSGEN045", + title: "Nested type has JSON Schema export disabled", + messageFormat: "Property '{0}' references type '{1}', which disables JSON Schema generation; the exported schema cannot describe it", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); } diff --git a/src/src/SourceGenerators/Helpers/EnumJsonResolver.cs b/src/src/SourceGenerators/Helpers/EnumJsonResolver.cs new file mode 100644 index 0000000..a3b13fc --- /dev/null +++ b/src/src/SourceGenerators/Helpers/EnumJsonResolver.cs @@ -0,0 +1,168 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using ZodSharp.SourceGenerators.Models; +using ZodSharp.SourceGenerators.Models.DataAttributes; + +namespace ZodSharp.SourceGenerators.Helpers; + +/// +/// Resolves how an enum is serialized (numbers or strings) and, for strings, the JSON name of each member. +/// The generator uses this to make the exported JSON Schema match the System.Text.Json wire form instead of +/// always assuming the numeric default. +/// +static class EnumJsonResolver +{ + /// + /// Determines whether a property's enum is serialized as a string, from a per-type or per-property + /// [JsonConverter(typeof(JsonStringEnumConverter))] (or its generic form). The assembly-level + /// convention is applied separately, because a global converter is not visible to the generator. + /// + /// The enum type. + /// The property that references it. + /// when the enum is serialized as a string. + public static bool IsSerializedAsString(INamedTypeSymbol enumType, IPropertySymbol property) => + HasStringEnumConverter(enumType) || HasStringEnumConverter(property); + + static bool HasStringEnumConverter(ISymbol symbol) + { + foreach (var attribute in symbol.GetAttributes()) + { + if ( + attribute.AttributeClass is not { Name: "JsonConverterAttribute" } attributeClass + || attributeClass.ContainingNamespace.ToDisplayString() != "System.Text.Json.Serialization" + || attribute.ConstructorArguments.Length != 1 + || attribute.ConstructorArguments[0].Value is not ITypeSymbol converterType + ) + { + continue; + } + + // Both JsonStringEnumConverter and JsonStringEnumConverter share this name (the generic + // form carries an arity but the same metadata name). + if ( + converterType.Name == "JsonStringEnumConverter" + && converterType.ContainingNamespace.ToDisplayString() == "System.Text.Json.Serialization" + ) + { + return true; + } + } + + return false; + } + + /// + /// Gets the JSON names of a non-flags enum's allowed members, in declaration order. A member marked + /// [ZodIgnore] or listed by the property's [DeniedValues] is excluded; the name is the + /// member's [JsonStringEnumMemberName] value when declared, otherwise the member name. + /// + /// The enum type. + /// The property's DataAnnotations. + /// The allowed members' JSON names. + public static EquatableArray GetJsonNames( + INamedTypeSymbol enumType, + ImmutableArray validationAttributes + ) + { + var denied = DeniedValuesAttributeData.FromAttributeData(validationAttributes); + var builder = ImmutableArray.CreateBuilder(); + + foreach (var member in enumType.GetMembers()) + { + if (member is not IFieldSymbol { HasConstantValue: true } field) + continue; + + if ( + field + .GetAttributes() + .Any(attribute => TypeLibrary.ZodSharp.ZodIgnoreAttribute.Equals(attribute.AttributeClass)) + ) + { + continue; + } + + if (denied.Exists && IsDenied(field, denied.Values)) + continue; + + builder.Add(GetJsonStringEnumMemberName(field) ?? field.Name); + } + + return new(builder.ToImmutable()); + } + + static bool IsDenied(IFieldSymbol field, EquatableArray deniedValues) + { + for (var i = 0; i < deniedValues.Count; i++) + { + var value = deniedValues[i]; + if (!value.IsNull && Equals(value.Value, field.ConstantValue)) + return true; + } + + return false; + } + + static string? GetJsonStringEnumMemberName(IFieldSymbol field) + { + foreach (var attribute in field.GetAttributes()) + { + if ( + attribute.AttributeClass is not { Name: "JsonStringEnumMemberNameAttribute" } attributeClass + || attributeClass.ContainingNamespace.ToDisplayString() != "System.Text.Json.Serialization" + || attribute.ConstructorArguments.Length != 1 + ) + { + continue; + } + + if (attribute.ConstructorArguments[0].Value is string { Length: > 0 } name) + return name; + } + + return null; + } + + /// + /// Applies the assembly-level enum naming policy to a member name, matching + /// System.Text.Json's JsonNamingPolicy.CamelCase. + /// + /// The member name. + /// The naming policy. + /// The transformed name. + public static string ApplyNamingPolicy(string name, EnumJsonNamingPolicy policy) => + policy switch + { + EnumJsonNamingPolicy.CamelCase => ToCamelCase(name), + _ => name, + }; + + /// + /// Mirrors System.Text.Json.JsonNamingPolicy.CamelCase: the leading run of uppercase characters is + /// lowercased, except the last one when it is followed by a lowercase character (so IOStream + /// becomes ioStream). + /// + static string ToCamelCase(string name) + { + if (name.Length == 0 || !char.IsUpper(name[0])) + return name; + + var chars = name.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + if (i == 1 && !char.IsUpper(chars[i])) + break; + + var hasNext = i + 1 < chars.Length; + if (i > 0 && hasNext && !char.IsUpper(chars[i + 1])) + { + if (chars[i + 1] == ' ') + chars[i] = char.ToLowerInvariant(chars[i]); + break; + } + + chars[i] = char.ToLowerInvariant(chars[i]); + } + + return new string(chars); + } +} diff --git a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs new file mode 100644 index 0000000..2f9047e --- /dev/null +++ b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs @@ -0,0 +1,142 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators.Helpers; + +/// +/// Reads the assembly-level MSBuild switches that shape generation (ZodSharpAutoGenerateOptionsValidators, +/// ZodSharpGenerateJsonSchemas, ZodSharpEnumSerialization, ZodSharpEnumNamingPolicy) and +/// applies them to the collected schema descriptors. +/// +static class GenerationSettingsResolver +{ + internal readonly record struct AutoDetectSettings( + bool AutoGenerate, + EquatableArray Suffixes, + bool GenerateJsonSchemas, + bool EnumAsString, + EnumJsonNamingPolicy EnumNamingPolicy + ); + + public static AutoDetectSettings Read(AnalyzerConfigOptionsProvider provider) + { + // Auto-detection is on by default; only an explicit "false" disables it. + var autoGenerate = + !provider.GlobalOptions.TryGetValue( + $"build_property:{PropertyLibrary.AutoGenerateIValidateOptionsProperty}", + out var autoGenerateValue + ) + || !bool.TryParse(autoGenerateValue, out var autoGenerateEnabled) + || autoGenerateEnabled; + + var suffixes = ParseAutoGenerateIValidateOptionsSuffixes( + provider.GlobalOptions.TryGetValue( + $"build_property:{PropertyLibrary.AutoGenerateIValidateOptionsSuffixesProperty}", + out var suffixValue + ) + ? suffixValue + : null + ); + + // JSON Schema export is on by default; the assembly-level switch disables it for every type. + var generateJsonSchemas = + !provider.GlobalOptions.TryGetValue( + $"build_property:{PropertyLibrary.GenerateJsonSchemasProperty}", + out var generateJsonSchemasValue + ) + || !bool.TryParse(generateJsonSchemasValue, out var generateJsonSchemasEnabled) + || generateJsonSchemasEnabled; + + // Enums serialise as numbers by default; the assembly-level switch matches a global + // JsonStringEnumConverter, and the naming policy matches its JsonNamingPolicy. + var enumAsString = ReadEnumSerializationAsString(provider); + var enumNamingPolicy = ReadEnumNamingPolicy(provider); + + return new(autoGenerate, suffixes, generateJsonSchemas, enumAsString, enumNamingPolicy); + } + + static bool ReadEnumSerializationAsString(AnalyzerConfigOptionsProvider provider) => + provider.GlobalOptions.TryGetValue($"build_property:{PropertyLibrary.EnumSerializationProperty}", out var value) + && string.Equals(value?.Trim(), "String", StringComparison.OrdinalIgnoreCase); + + static EnumJsonNamingPolicy ReadEnumNamingPolicy(AnalyzerConfigOptionsProvider provider) => + provider.GlobalOptions.TryGetValue($"build_property:{PropertyLibrary.EnumNamingPolicyProperty}", out var value) + && string.Equals(value?.Trim(), "CamelCase", StringComparison.OrdinalIgnoreCase) + ? EnumJsonNamingPolicy.CamelCase + : EnumJsonNamingPolicy.Unchanged; + + static EquatableArray ParseAutoGenerateIValidateOptionsSuffixes(string? value) + { + var raw = string.IsNullOrWhiteSpace(value) ? "Options;Settings" : value!; + var builder = ImmutableArray.CreateBuilder(); + foreach (var segment in raw.Split(';', ',')) + { + var suffix = segment.Trim(); + if (suffix.Length > 0) + builder.Add(suffix); + } + + return new(builder.ToImmutable()); + } + + public static SchemaGenerationModel Apply((SchemaGenerationModel Model, AutoDetectSettings Settings) input) + { + var (model, settings) = input; + + var builder = ImmutableArray.CreateBuilder>(model.ZodSchemas.Count); + foreach (var result in model.ZodSchemas) + { + if (!result.ShouldProcess) + { + builder.Add(result); + continue; + } + + var schema = result.Value; + var changed = false; + + // The assembly-level switch disables JSON Schema export for every type in the compilation. + if (!settings.GenerateJsonSchemas && schema.GenerateJsonSchema) + { + schema = schema with { GenerateJsonSchema = false }; + changed = true; + } + + // The assembly-level enum convention applies to every enum in the compilation (a global + // JsonStringEnumConverter / naming policy is not visible to the generator). + if (settings.EnumAsString != schema.EnumAsString) + { + schema = schema with { EnumAsString = settings.EnumAsString }; + changed = true; + } + + if (settings.EnumNamingPolicy != schema.EnumNamingPolicy) + { + schema = schema with { EnumNamingPolicy = settings.EnumNamingPolicy }; + changed = true; + } + + if ( + settings.AutoGenerate + && settings.Suffixes.Count > 0 + && schema.GenerateIValidateOptions is null + && schema.IsPrimary + && !schema.IsValueType + && settings.Suffixes.Any(suffix => schema.TargetType.Name.EndsWith(suffix, StringComparison.Ordinal)) + ) + { + schema = schema with { GenerateIValidateOptions = true }; + changed = true; + } + + builder.Add(changed ? GeneratorResult.Create(schema) : result); + } + + return model with + { + ZodSchemas = new(builder.ToImmutable()), + }; + } +} diff --git a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs index 05391cb..e5ca96b 100644 --- a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs @@ -9,4 +9,10 @@ static class PropertyLibrary public const string AutoGenerateIValidateOptionsProperty = "ZodSharpAutoGenerateOptionsValidators"; public const string AutoGenerateIValidateOptionsSuffixesProperty = "ZodSharpAutoGenerateOptionsValidatorSuffixes"; + + public const string GenerateJsonSchemasProperty = "ZodSharpGenerateJsonSchemas"; + + public const string EnumSerializationProperty = "ZodSharpEnumSerialization"; + + public const string EnumNamingPolicyProperty = "ZodSharpEnumNamingPolicy"; } diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 1825620..384ea13 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -2,12 +2,16 @@ using System.Globalization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; using ZodSharp.SourceGenerators.Models; using ZodSharp.SourceGenerators.Models.DataAttributes; namespace ZodSharp.SourceGenerators.Helpers; +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Maintainability", + "CA1506:Avoid excessive class coupling", + Justification = "Cohesive helper library for the ZodSchema generator: the schema model, the DataAnnotations descriptors and the rule resolver are all in scope by design. The incremental stages that only configure generation live in GenerationSettingsResolver, and the enum/JSON wire metadata in EnumJsonResolver." +)] static partial class SourceGenLibrary { const string IValidateOptionsMetadataName = "Microsoft.Extensions.Options.IValidateOptions`1"; @@ -43,7 +47,7 @@ IncrementalGeneratorInitializationContext context GetSchemasForGeneration(attributeContext, cancellationToken) ); var autoDetectSettings = context.AnalyzerConfigOptionsProvider.Select( - static (provider, _) => ReadAutoDetectSettings(provider) + static (provider, _) => GenerationSettingsResolver.Read(provider) ); return generationContext @@ -54,87 +58,7 @@ IncrementalGeneratorInitializationContext context "CollectZodSchemas" ) .Combine(autoDetectSettings) - .Select(static (pair, _) => ApplyIValidateOptionsAutoDetection(pair)); - } - - readonly record struct AutoDetectSettings(bool AutoGenerate, EquatableArray Suffixes); - - static AutoDetectSettings ReadAutoDetectSettings(AnalyzerConfigOptionsProvider provider) - { - // Auto-detection is on by default; only an explicit "false" disables it. - var autoGenerate = - !provider.GlobalOptions.TryGetValue( - $"build_property:{PropertyLibrary.AutoGenerateIValidateOptionsProperty}", - out var autoGenerateValue - ) - || !bool.TryParse(autoGenerateValue, out var autoGenerateEnabled) - || autoGenerateEnabled; - - var suffixes = ParseAutoGenerateIValidateOptionsSuffixes( - provider.GlobalOptions.TryGetValue( - $"build_property:{PropertyLibrary.AutoGenerateIValidateOptionsSuffixesProperty}", - out var suffixValue - ) - ? suffixValue - : null - ); - - return new(autoGenerate, suffixes); - } - - static EquatableArray ParseAutoGenerateIValidateOptionsSuffixes(string? value) - { - var raw = string.IsNullOrWhiteSpace(value) ? "Options;Settings" : value!; - var builder = ImmutableArray.CreateBuilder(); - foreach (var segment in raw.Split(';', ',')) - { - var suffix = segment.Trim(); - if (suffix.Length > 0) - builder.Add(suffix); - } - - return new(builder.ToImmutable()); - } - - static SchemaGenerationModel ApplyIValidateOptionsAutoDetection( - (SchemaGenerationModel Model, AutoDetectSettings Settings) input - ) - { - var (model, settings) = input; - if (!settings.AutoGenerate || settings.Suffixes.Count == 0) - return model; - - var builder = ImmutableArray.CreateBuilder>(model.ZodSchemas.Count); - foreach (var result in model.ZodSchemas) - { - if (!result.ShouldProcess) - { - builder.Add(result); - continue; - } - - var schema = result.Value; - if ( - schema.GenerateIValidateOptions is null - && schema.IsPrimary - && !schema.IsValueType - && settings.Suffixes.Any(suffix => schema.TargetType.Name.EndsWith(suffix, StringComparison.Ordinal)) - ) - { - builder.Add( - GeneratorResult.Create(schema with { GenerateIValidateOptions = true }) - ); - } - else - { - builder.Add(result); - } - } - - return model with - { - ZodSchemas = new(builder.ToImmutable()), - }; + .Select(static (pair, _) => GenerationSettingsResolver.Apply(pair)); } static GeneratorResult GetSchemasForGeneration( @@ -193,6 +117,17 @@ CancellationToken cancellationToken if (customValidation.Value.HasCustomValidation && refinementHook.Value.IsImplemented) customValidation = CustomValidationMethodData.None; var isValueType = symbol.TypeKind == TypeKind.Struct; + + // A Purview.ValueObjects scalar serialises as its underlying value, so its JSON Schema is the + // underlying type's schema (for example a Guid scalar is a UUID string), not an object wrapping a + // Value property. + TypeIdentity? scalarUnderlyingType = null; + var scalarUnderlyingCanBeNull = false; + if (CustomRuleResolver.TryGetScalarUnderlyingValue(symbol, out var scalarValueType)) + { + scalarUnderlyingType = CreateTypeIdentity(TypeHelpers.UnwrapNullableType(scalarValueType)); + scalarUnderlyingCanBeNull = TypeHelpers.CanBeNull(scalarValueType); + } bool? generateIValidateOptions = zodSchemaAttribute.GenerateIValidateOptions ? true : zodSchemaAttribute.SuppressIValidateOptions ? false @@ -204,6 +139,7 @@ CancellationToken cancellationToken var generateValidateMethod = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateValidateMethod; var generateParseMethod = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateParseMethod; var validateEnumValues = !zodSchemaAttribute.Exists || zodSchemaAttribute.ValidateEnumValues; + var generateJsonSchema = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateJsonSchema; schemas.Add( new( @@ -222,7 +158,12 @@ CancellationToken cancellationToken generateParseMethod, typeRules, validateEnumValues, - isPrimary + isPrimary, + scalarUnderlyingType, + scalarUnderlyingCanBeNull, + generateJsonSchema, + false, + default ) ); @@ -295,6 +236,25 @@ ImmutableArray> sets { var results = ImmutableArray.CreateBuilder>(); HashSet seen = []; + + // A type can be reached twice: once from its own [ZodSchema] attribute (primary) and once as a + // nested property of another schema (secondary). The attribute contexts are processed in an + // unspecified order, so keeping whichever arrives first lets a secondary copy displace the primary. + // That drops the primary-only members — the DI adapter ({Type}SchemaValidator) and its assembly + // registration — for any type that happens to be referenced by a type processed earlier. Collect + // the primaries first, then fill in the secondaries, so the primary always wins. + foreach (var set in sets) + { + if (!set.ShouldProcess) + continue; + + foreach (var schema in set.Value.Schemas) + { + if (schema.IsPrimary && seen.Add(schema.TargetType)) + results.Add(schema); + } + } + foreach (var set in sets) { if (!set.ShouldProcess) @@ -410,6 +370,10 @@ originalPropertyType is INamedTypeSymbol new(string.Empty, string.Empty, false), false, EmptyValidationAttributes, + new([]), + null, + true, + false, new([]) ); @@ -589,6 +553,11 @@ rangeAttribute with var isEnum = enumType is { TypeKind: TypeKind.Enum }; var isFlagsEnum = isEnum && HasFlagsAttribute(enumType!); var ignoredEnumMembers = isEnum && !isFlagsEnum ? GetIgnoredEnumMembers(enumType!) : default; + var enumSerializedAsString = isEnum && EnumJsonResolver.IsSerializedAsString(enumType!, property); + var enumJsonNames = + isEnum && !isFlagsEnum + ? EnumJsonResolver.GetJsonNames(enumType!, validationAttributes) + : new(ImmutableArray.Empty); var customRules = CustomRuleResolver.Resolve(validationAttributes, originalPropertyType, diagnostics); @@ -631,12 +600,77 @@ rangeAttribute with lengthAttribute, rangeAttributeResult ), - customRules + customRules, + GetJsonPropertyName(property), + NestedSchemaExportsJsonSchema(property, validationKind, externalSchemas), + enumSerializedAsString, + enumJsonNames ), diagnostics.ToImmutable() ); } + /// + /// Determines whether the nested complex type a property references exports JSON Schema. A nested type + /// can opt out with [ZodSchema(GenerateJsonSchema = false)], in which case the parent's schema + /// cannot reference it and falls back to an empty schema (reported as ZODSGEN045). + /// + /// The property to inspect. + /// The property's validation kind. + /// The resolver that decides schema ownership. + /// when the property has no nested schema, or the nested type exports one. + static bool NestedSchemaExportsJsonSchema( + IPropertySymbol property, + PropertyValidationKind validationKind, + ExternalSchemaResolver? externalSchemas + ) + { + if (validationKind is not (PropertyValidationKind.Complex or PropertyValidationKind.Collection)) + return true; + + var targetType = + validationKind == PropertyValidationKind.Collection + ? GetCollectionElementTypeSymbol(property.Type) + : TypeHelpers.UnwrapNullableType(property.Type); + + if (targetType is not INamedTypeSymbol namedType) + return true; + + // Only a type owned by this compilation can opt out; an external schema's export is that assembly's + // concern. + if (externalSchemas is not null && !externalSchemas.IsSchemaOwnedByThisCompilation(namedType)) + return true; + + var attributeData = ZodSchemaAttributeData.FromAttributeData(namedType, out _); + return !attributeData.Exists || attributeData.GenerateJsonSchema; + } + + /// + /// Reads an explicit [System.Text.Json.Serialization.JsonPropertyName] from a property, so the + /// exported JSON Schema names the property exactly as System.Text.Json writes it. + /// + /// The property to inspect. + /// The declared JSON name, or when none is declared. + static string? GetJsonPropertyName(IPropertySymbol property) + { + foreach (var attribute in property.GetAttributes()) + { + if ( + attribute.AttributeClass is not { Name: "JsonPropertyNameAttribute" } attributeClass + || attributeClass.ContainingNamespace.ToDisplayString() != "System.Text.Json.Serialization" + || attribute.ConstructorArguments.Length != 1 + ) + { + continue; + } + + if (attribute.ConstructorArguments[0].Value is string { Length: > 0 } name) + return name; + } + + return null; + } + /// /// Creates the TypeIdentity for a symbol, returning for symbols that /// cannot be represented (unresolved error types, type parameters, dynamic, pointers, ...). diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index be5e145..94f8633 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -28,6 +28,19 @@ enum PropertyValidationKind Unsupported, } +/// +/// How a string-serialized enum's member names are transformed, mirroring the +/// JsonNamingPolicy a JsonStringEnumConverter may be constructed with. +/// +enum EnumJsonNamingPolicy +{ + /// Member names are used as declared. + Unchanged, + + /// Member names are camelCased, matching JsonNamingPolicy.CamelCase. + CamelCase, +} + readonly record struct LengthAccessor(string LengthExpression, string Origin, bool IsSupported); /// This is the target of the attribute, the type where the attribute was defined. @@ -52,6 +65,28 @@ enum PropertyValidationKind /// [ZodSchema] attribute opts out. /// /// True if this is the primary schema for the target type, false if it is a secondary schema. +/// +/// When the target is a Purview.ValueObjects scalar, the underlying value type whose JSON wire form the +/// scalar serialises as (for example a Guid scalar serialises as a UUID string). Null otherwise. +/// +/// +/// Whether the scalar's underlying value may be null (a nullable reference or nullable value scalar). +/// +/// +/// Whether the generated schema exports JSON Schema (the BuildJsonSchema method and the +/// IJsonSchemaProvider implementation on the adapter). Controlled by +/// [ZodSchema(GenerateJsonSchema = false)] and the assembly-level ZodSharpGenerateJsonSchemas +/// switch. +/// +/// +/// Whether enums in this schema are serialized as strings. Set by the assembly-level +/// ZodSharpEnumSerialization=String switch (for a global JsonStringEnumConverter); a property +/// can also opt in with a per-type/property [JsonConverter(typeof(JsonStringEnumConverter))]. +/// +/// +/// How a string-serialized enum's member names are transformed, set by the assembly-level +/// ZodSharpEnumNamingPolicy switch. +/// readonly record struct ZodSchemaDescriptor( TypeIdentity TargetType, TypeIdentity SchemaType, @@ -68,7 +103,12 @@ readonly record struct ZodSchemaDescriptor( bool GenerateParseMethod, EquatableArray TypeRules, bool ValidateEnumValues, - bool IsPrimary + bool IsPrimary, + TypeIdentity? ScalarUnderlyingType, + bool ScalarUnderlyingCanBeNull, + bool GenerateJsonSchema, + bool EnumAsString, + EnumJsonNamingPolicy EnumNamingPolicy ); /// The declared type of the property, with a nullable wrapper removed. @@ -90,6 +130,22 @@ bool IsPrimary /// Indicates whether ranges compare through IComparable. /// The DataAnnotations attributes declared on the property. /// The rules bound to the property through [ZodRule]-mapped attributes. +/// +/// The explicit [JsonPropertyName] value, when one is declared. Used to name the property in the +/// exported JSON Schema so it matches the System.Text.Json wire form. +/// +/// +/// Whether the nested complex type's generated schema exports JSON Schema. When the +/// parent's schema cannot reference it and falls back to an empty schema (reported as ZODSGEN045). +/// +/// +/// Whether the property's enum is serialized as a string (a per-type or per-property +/// [JsonConverter(typeof(JsonStringEnumConverter))]). The assembly-level switch is applied separately. +/// +/// +/// For a non-flags enum, the JSON names of the allowed members, in declaration order: the member name, or its +/// [JsonStringEnumMemberName] value when declared. Used when the enum is serialized as a string. +/// readonly record struct ZodPropertyDescriptor( TypeIdentity PropertyType, string Name, @@ -106,7 +162,11 @@ readonly record struct ZodPropertyDescriptor( LengthAccessor LengthAccessor, bool CompareViaCompareTo, ValidationAttributes ValidationAttributes, - EquatableArray CustomRules + EquatableArray CustomRules, + string? JsonPropertyName, + bool NestedSchemaExportsJsonSchema, + bool EnumSerializedAsString, + EquatableArray EnumJsonNames ); [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs b/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs index 709b437..fd03140 100644 --- a/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs +++ b/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs @@ -9,5 +9,6 @@ readonly partial record struct ZodSchemaAttributeData( string? CustomValidationMethodName, [Property(DefaultValue = false)] bool GenerateIValidateOptions, [Property(DefaultValue = false)] bool SuppressIValidateOptions, - [Property(DefaultValue = true)] bool ValidateEnumValues + [Property(DefaultValue = true)] bool ValidateEnumValues, + [Property(DefaultValue = true)] bool GenerateJsonSchema ); diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index b58cbe1..c86df93 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -43,6 +43,7 @@ public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.RuleAttributeWithoutSchema, DiagnosticLibrary.NativeUnionRecommended, DiagnosticLibrary.AutomaticScalarSchemaUnavailable, + DiagnosticLibrary.NestedSchemaExportDisabled, ]; public override ImmutableArray SupportedDiagnostics => SupportedDiagnosticsList; @@ -124,6 +125,9 @@ ImmutableHashSet schemaReachableTypes // validations, so a rule that resolves to nothing is reported here rather than dropped silently. ReportTypeRuleDiagnostics(context, type, typeLocation); + if (zodSchemaData.GenerateJsonSchema && ReadGenerateJsonSchemas(context.Options)) + ReportNestedSchemaExportDisabled(context, type, externalSchemas, typeLocation); + if (!hasDataAnnotations) { context.ReportDiagnostic( @@ -318,6 +322,59 @@ static Location GetMemberLocation(ISymbol member) return Location.None; } + /// + /// Reads the assembly-level JSON Schema switch (ZodSharpGenerateJsonSchemas). On by default; only + /// an explicit false disables it. + /// + /// The analyzer options. + /// when JSON Schema export is enabled for the compilation. + static bool ReadGenerateJsonSchemas(AnalyzerOptions options) => + !options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue( + $"build_property:{PropertyLibrary.GenerateJsonSchemasProperty}", + out var value + ) + || !bool.TryParse(value, out var enabled) + || enabled; + + /// + /// Reports ZODSGEN045 for each property whose nested [ZodSchema] type disables JSON Schema + /// export, so the parent's exported schema cannot describe it (it falls back to an empty schema). + /// + /// The analysis context the diagnostics are reported to. + /// The exporting schema type. + /// The resolver that decides schema ownership. + /// The location to fall back to when a property carries none. + static void ReportNestedSchemaExportDisabled( + SymbolAnalysisContext context, + INamedTypeSymbol type, + ExternalSchemaResolver externalSchemas, + Location typeLocation + ) + { + foreach (var property in type.GetMembers().OfType()) + { + if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) + continue; + + if (!SourceGenLibrary.TryGetNestedSchemaType(property, externalSchemas, out var nested)) + continue; + + var nestedData = ZodSchemaAttributeData.FromAttributeData(nested, out _); + if (!nestedData.Exists || nestedData.GenerateJsonSchema) + continue; + + var location = GetMemberLocation(property); + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.NestedSchemaExportDisabled, + location == Location.None ? typeLocation : location, + property.Name, + nested.Name + ) + ); + } + } + /// /// Collects the types that will receive a generated schema: every [ZodSchema] type in this /// assembly plus, transitively, the complex property types the generator discovers and emits secondary diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 0793279..41e17bf 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -200,6 +200,9 @@ CancellationToken cancellationToken GeneratePathFields(writer, outputContext.ZodSchema, cancellationToken); GenerateStaticAttributeFields(writer, outputContext.ZodSchema, cancellationToken); + if (outputContext.ZodSchema.GenerateJsonSchema) + GenerateJsonSchemaMethod(writer, outputContext.ZodSchema, cancellationToken); + if (outputContext.ZodSchema.GenerateValidateMethod) { GenerateValidationHelpers(writer, cancellationToken); @@ -254,6 +257,7 @@ CancellationToken cancellationToken Accessibility = schema.TargetAccessibility, IsPartial = true, BaseType = baseType, + Interfaces = schema.GenerateJsonSchema ? [JsonSchemaProviderIdentity.AsTypeReference()] : [], }, body => { @@ -276,6 +280,23 @@ CancellationToken cancellationToken ); GenerateValidateAsyncMethod(body, schema, cancellationToken); + + if (!schema.GenerateJsonSchema) + return; + + body.XmlSummary( + "Builds the JSON Schema (Draft 2020-12) definition for the target type.", + "Delegates to the generated schema, so an attribute-generated validator can be exported." + ) + .XmlReturn("The JSON Schema definition.") + .Method( + new MethodDeclarationOptions( + "BuildJsonSchema", + JsonSchemaDefinitionIdentity.AsTypeReference(), + TypeDeclarationAccessibility.Public + ), + methodBody => methodBody.Return($"{schema.SchemaType.Name}.BuildJsonSchema()") + ); } ); } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs new file mode 100644 index 0000000..b3851bf --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs @@ -0,0 +1,459 @@ +using System.Globalization; +using Microsoft.CodeAnalysis; +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; +using ZodSharp.SourceGenerators.Models.DataAttributes; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGenerator +{ + const string JsonSchemaDefinitionTypeName = "global::ZodSharp.JsonSchema.JsonSchemaDefinition"; + const string JsonSchemaRulesTypeName = "global::ZodSharp.JsonSchema.JsonSchemaRules"; + + static readonly TypeIdentity JsonSchemaDefinitionIdentity = new("JsonSchemaDefinition", "ZodSharp.JsonSchema"); + static readonly TypeIdentity JsonSchemaProviderIdentity = new("IJsonSchemaProvider", "ZodSharp.JsonSchema"); + + /// + /// Emits the BuildJsonSchema method on the generated schema class: a complete JSON Schema + /// (Draft 2020-12) definition built from the same metadata the validator uses. This is the bridge that + /// lets an attribute-generated schema be exported through Z.ToJsonSchema without rebuilding its + /// rules by hand. + /// + /// The writer positioned inside the generated schema class. + /// The schema descriptor. + /// The cancellation token. + static void GenerateJsonSchemaMethod( + CodeWriter writer, + ZodSchemaDescriptor schema, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + writer + .XmlSummary( + $"Builds the JSON Schema (Draft 2020-12) definition for {CodeWriter.XmlSee(schema.TargetType.Name)}.", + "The definition is produced from the same metadata as Validate, so the two cannot drift apart." + ) + .XmlReturn("The JSON Schema definition (without document metadata such as $schema/$id)."); + + writer.Method( + new MethodDeclarationOptions( + "BuildJsonSchema", + JsonSchemaDefinitionIdentity.AsTypeReference(), + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + }, + body => + { + if (schema.ScalarUnderlyingType is { } underlying) + { + EmitScalarJsonSchema(body, schema, underlying); + return; + } + + body.Assignment( + "var", + "schema", + $"new {JsonSchemaDefinitionTypeName} {{ Type = \"object\", Properties = new global::System.Collections.Generic.Dictionary(global::System.StringComparer.Ordinal), Required = new global::System.Collections.Generic.List(), AdditionalProperties = false }}" + ); + body.NewLine(); + + foreach (var property in schema.Properties) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!property.ShouldProcess) + continue; + + EmitJsonSchemaProperty(body, property.Value, schema); + body.NewLine(); + } + + body.Return("schema"); + } + ); + + writer.NewLine(); + } + + /// + /// Emits the JSON Schema for a scalar value object: the underlying value's schema (a Guid scalar is a + /// UUID string), with the scalar's own rules applied and a null branch when the underlying value + /// is nullable. + /// + static void EmitScalarJsonSchema(CodeWriter body, ZodSchemaDescriptor schema, TypeIdentity underlying) + { + body.Assignment("var", "inner", BuildBaseSchemaExpression(underlying)); + + foreach (var rule in schema.TypeRules) + body.Line($"{JsonSchemaRulesTypeName}.Apply(inner, {BuildRuleInstanceExpression(rule)});"); + + foreach (var property in schema.Properties) + { + if (!property.ShouldProcess) + continue; + + EmitAttributeConstraints(body, property.Value, "inner"); + foreach (var rule in property.Value.CustomRules) + body.Line($"{JsonSchemaRulesTypeName}.Apply(inner, {BuildRuleInstanceExpression(rule)});"); + } + + body.Return( + schema.ScalarUnderlyingCanBeNull + ? $"new {JsonSchemaDefinitionTypeName} {{ AnyOf = new() {{ inner, new {JsonSchemaDefinitionTypeName} {{ Type = \"null\" }} }} }}" + : "inner" + ); + } + + static void EmitJsonSchemaProperty(CodeWriter body, ZodPropertyDescriptor property, ZodSchemaDescriptor schema) + { + var localName = CodeGenHelpers.GetLocalIdentifier(property.Name, "JsonSchema"); + var nameLiteral = GetJsonName(property).StringLiteral(); + var hasRequiredAttribute = + property.ValidationAttributes.Required.ShouldProcess && property.ValidationAttributes.Required.Value.Exists; + var isRequired = hasRequiredAttribute || !property.CanBeNull; + var isNullable = property.CanBeNull && !hasRequiredAttribute; + + body.Assignment("var", localName, BuildPropertySchemaExpression(property, schema)); + + EmitAttributeConstraints(body, property, localName); + + foreach (var rule in property.CustomRules) + body.Line($"{JsonSchemaRulesTypeName}.Apply({localName}, {BuildRuleInstanceExpression(rule)});"); + + if (isNullable) + { + body.Line( + $"schema.Properties[{nameLiteral}] = new {JsonSchemaDefinitionTypeName} {{ AnyOf = new() {{ {localName}, new {JsonSchemaDefinitionTypeName} {{ Type = \"null\" }} }} }};" + ); + } + else + { + body.Line($"schema.Properties[{nameLiteral}] = {localName};"); + } + + if (isRequired) + body.Line($"schema.Required.Add({nameLiteral});"); + } + + /// + /// Builds the C# expression for a property's base schema (its JSON type, nested schema, enum values, or + /// array items), before any rule or attribute constraint is applied. + /// + static string BuildPropertySchemaExpression(ZodPropertyDescriptor property, ZodSchemaDescriptor schema) => + property.ValidationKind switch + { + PropertyValidationKind.Complex when property.NestedSchemaType is { } nested => + property.NestedSchemaExportsJsonSchema + ? $"{nested.AsTypeReference().RenderFullName}.BuildJsonSchema()" + : $"new {JsonSchemaDefinitionTypeName}()", + PropertyValidationKind.Collection => BuildCollectionSchemaExpression(property), + // A [Flags] enum is not validated against its defined members (any combination is valid), so the + // schema must not enumerate them either; the wire form is an integer bitmask. + PropertyValidationKind.Enum when property.IsEnum && property.IsFlagsEnum => Schema("integer"), + PropertyValidationKind.Enum when property.IsEnum => BuildEnumSchemaExpression(property, schema), + _ => BuildBaseSchemaExpression(property.PropertyType), + }; + + static string BuildCollectionSchemaExpression(ZodPropertyDescriptor property) + { + var items = + property.NestedSchemaType is { } nested + ? ( + property.NestedSchemaExportsJsonSchema + ? $"{nested.AsTypeReference().RenderFullName}.BuildJsonSchema()" + : $"new {JsonSchemaDefinitionTypeName}()" + ) + : property.ElementType is { } element ? BuildBaseSchemaExpression(element) + : $"new {JsonSchemaDefinitionTypeName}()"; + + return $"new {JsonSchemaDefinitionTypeName} {{ Type = \"array\", Items = {items} }}"; + } + + static string BuildEnumSchemaExpression(ZodPropertyDescriptor property, ZodSchemaDescriptor schema) + { + // A string-serialized enum (a per-type/per-property JsonStringEnumConverter, or the assembly-level + // ZodSharpEnumSerialization=String switch) lists the member JSON names rather than their numeric + // values. A naming policy mirrors the converter's JsonNamingPolicy. + if (property.EnumSerializedAsString || schema.EnumAsString) + { + var names = property.EnumJsonNames.Select(name => + EnumJsonResolver.ApplyNamingPolicy(name, schema.EnumNamingPolicy).StringLiteral() + ); + return $"new {JsonSchemaDefinitionTypeName} {{ Enum = new() {{ {string.Join(", ", names)} }} }}"; + } + + var enumType = property.PropertyType.AsTypeReference().RenderFullName; + var members = + property.IgnoredEnumMembers.Count == 0 ? string.Empty : string.Join(", ", property.IgnoredEnumMembers); + return $"{JsonSchemaRulesTypeName}.EnumSchema<{enumType}>({members})"; + } + + /// + /// Maps a property type to the JSON Schema its System.Text.Json wire form implies. A scalar with no + /// JSON Schema representation (or a type the generator cannot classify) exports as the empty schema + /// (any) rather than being guessed. + /// + static string BuildBaseSchemaExpression(TypeIdentity type) + { + if (type.SpecialType is SpecialType.System_String or SpecialType.System_Char) + return Schema("string"); + + if (type.SpecialType == SpecialType.System_Boolean) + return Schema("boolean"); + + if (IsIntegerType(type.SpecialType)) + return Schema("integer"); + + if (IsFloatingType(type.SpecialType)) + return Schema("number"); + + if (type.SpecialType == SpecialType.System_Object) + return $"new {JsonSchemaDefinitionTypeName}()"; + + if (IsWellKnown(type, "System", "Guid")) + return Schema("string", "uuid"); + + if (type.SpecialType == SpecialType.System_DateTime || IsWellKnown(type, "System", "DateTimeOffset")) + return Schema("string", "date-time"); + + if (IsWellKnown(type, "System", "DateOnly")) + return Schema("string", "date"); + + if (IsWellKnown(type, "System", "TimeOnly")) + return Schema("string", "time"); + + if (IsWellKnown(type, "System", "TimeSpan")) + return Schema("string", "duration"); + + if (IsWellKnown(type, "System", "Version")) + return Schema("string"); + + return $"new {JsonSchemaDefinitionTypeName}()"; + } + + /// + /// The property name as System.Text.Json writes it: an explicit [JsonPropertyName] wins, otherwise + /// the camelCase form that matches the ASP.NET Core / JsonSerializerDefaults.Web default. + /// + /// + /// A non-camelCase global naming policy configured at runtime is not visible to the generator, so a + /// consumer using one must adjust the exported names (or declare [JsonPropertyName]). + /// + static string GetJsonName(ZodPropertyDescriptor property) => + property.JsonPropertyName + ?? ( + property.Name.Length == 0 || char.IsLower(property.Name[0]) + ? property.Name + : char.ToLowerInvariant(property.Name[0]) + property.Name.Substring(1) + ); + + static string Schema(string type) => $"new {JsonSchemaDefinitionTypeName} {{ Type = \"{type}\" }}"; + + static string Schema(string type, string format) => + $"new {JsonSchemaDefinitionTypeName} {{ Type = \"{type}\", Format = \"{format}\" }}"; + + static bool IsWellKnown(TypeIdentity type, string @namespace, string name) => + type.Name == name && type.Namespace == @namespace; + + static bool IsIntegerType(SpecialType specialType) => + specialType + is SpecialType.System_SByte + or SpecialType.System_Byte + or SpecialType.System_Int16 + or SpecialType.System_UInt16 + or SpecialType.System_Int32 + or SpecialType.System_UInt32 + or SpecialType.System_Int64 + or SpecialType.System_UInt64; + + static bool IsFloatingType(SpecialType specialType) => + specialType is SpecialType.System_Single or SpecialType.System_Double or SpecialType.System_Decimal; + + static string BuildRuleInstanceExpression(CustomRuleDescriptor rule) + { + // A scalar rule adapter wraps a rule written against the scalar's underlying value; the wrapped rule + // owns the JSON Schema meaning, so construct that one. The adapter itself has none. + var ruleType = (rule.AdaptedFrom ?? rule.RuleType).AsTypeReference().RenderFullName; + var arguments = rule.Arguments.Count == 0 ? string.Empty : $"({string.Join(", ", rule.Arguments)})"; + return $"new {ruleType}{arguments}"; + } + + static void EmitAttributeConstraints(CodeWriter body, ZodPropertyDescriptor property, string localName) + { + var attributes = property.ValidationAttributes; + EmitLengthConstraints(body, property, attributes, localName); + EmitFormatConstraints(body, attributes, localName); + EmitRangeConstraint(body, attributes.Range, localName); + EmitValueConstraints(body, attributes, localName); + EmitUnsupportedAttributeConstraints(body, attributes, localName); + } + + static void EmitLengthConstraints( + CodeWriter body, + ZodPropertyDescriptor property, + ValidationAttributes attributes, + string localName + ) + { + // [MinLength]/[MaxLength] apply to strings and collections; the JSON Schema keyword differs. + var isCollection = property.ValidationKind == PropertyValidationKind.Collection; + var minKeyword = isCollection ? "MinItems" : "MinLength"; + var maxKeyword = isCollection ? "MaxItems" : "MaxLength"; + + if (attributes.MinLength.ShouldProcess && attributes.MinLength.Value.Exists) + body.Line( + $"{localName}.{minKeyword} = {attributes.MinLength.Value.Length.ToString(CultureInfo.InvariantCulture)};" + ); + + if ( + attributes.MaxLength.ShouldProcess + && attributes.MaxLength.Value.Exists + && attributes.MaxLength.Value.Length >= 0 + ) + { + body.Line( + $"{localName}.{maxKeyword} = {attributes.MaxLength.Value.Length.ToString(CultureInfo.InvariantCulture)};" + ); + } + + if (attributes.StringLength.ShouldProcess && attributes.StringLength.Value.Exists) + { + if (attributes.StringLength.Value.MinimumLength > 0) + { + body.Line( + $"{localName}.MinLength = {attributes.StringLength.Value.MinimumLength.ToString(CultureInfo.InvariantCulture)};" + ); + } + + body.Line( + $"{localName}.MaxLength = {attributes.StringLength.Value.MaximumLength.ToString(CultureInfo.InvariantCulture)};" + ); + } + + if (attributes.Length.ShouldProcess && attributes.Length.Value.Exists) + { + var isString = property.PropertyType.SpecialType == SpecialType.System_String; + var minimum = attributes.Length.Value.MinimumLength.ToString(CultureInfo.InvariantCulture); + var maximum = attributes.Length.Value.MaximumLength.ToString(CultureInfo.InvariantCulture); + body.Line(isString ? $"{localName}.MinLength = {minimum};" : $"{localName}.MinItems = {minimum};"); + body.Line(isString ? $"{localName}.MaxLength = {maximum};" : $"{localName}.MaxItems = {maximum};"); + } + } + + static void EmitFormatConstraints(CodeWriter body, ValidationAttributes attributes, string localName) + { + if (attributes.EmailAddress.ShouldProcess && attributes.EmailAddress.Value.Exists) + body.Line($"{localName}.Format = \"email\";"); + + if (attributes.Url.ShouldProcess && attributes.Url.Value.Exists) + body.Line($"{localName}.Format = \"uri\";"); + + if ( + attributes.RegularExpression.ShouldProcess + && attributes.RegularExpression.Value.Exists + && attributes.RegularExpression.Value.Pattern is { Length: > 0 } pattern + ) + { + body.Line($"{localName}.Pattern = {pattern.StringLiteral()};"); + } + + if (attributes.Base64String.ShouldProcess && attributes.Base64String.Value.Exists) + body.Line($"{localName}.ContentEncoding = \"base64\";"); + } + + static void EmitValueConstraints(CodeWriter body, ValidationAttributes attributes, string localName) + { + if (attributes.AllowedValues.ShouldProcess && attributes.AllowedValues.Value.Exists) + { + var values = RenderJsonValues(attributes.AllowedValues.Value.Values); + if (values.Count > 0) + body.Line($"{localName}.Enum = new() {{ {string.Join(", ", values)} }};"); + } + + if (attributes.DeniedValues.ShouldProcess && attributes.DeniedValues.Value.Exists) + { + var values = RenderJsonValues(attributes.DeniedValues.Value.Values); + if (values.Count > 0) + { + body.Line( + $"{localName}.Not = new {JsonSchemaDefinitionTypeName} {{ Enum = new() {{ {string.Join(", ", values)} }} }};" + ); + } + } + } + + static void EmitUnsupportedAttributeConstraints(CodeWriter body, ValidationAttributes attributes, string localName) + { + if (attributes.CreditCard.ShouldProcess && attributes.CreditCard.Value.Exists) + body.Line($"{JsonSchemaRulesTypeName}.MarkUnsupported({localName}, \"CreditCardAttribute\");"); + + if (attributes.Phone.ShouldProcess && attributes.Phone.Value.Exists) + body.Line($"{JsonSchemaRulesTypeName}.MarkUnsupported({localName}, \"PhoneAttribute\");"); + } + + static void EmitRangeConstraint(CodeWriter body, GeneratorResult range, string localName) + { + if (!range.ShouldProcess || !range.Value.Exists) + return; + + var data = range.Value; + if (data.Minimum is int minimumInt && data.Maximum is int maximumInt) + { + body.Line($"{localName}.Minimum = {minimumInt.ToString(CultureInfo.InvariantCulture)};"); + body.Line($"{localName}.Maximum = {maximumInt.ToString(CultureInfo.InvariantCulture)};"); + return; + } + + if (data.Minimum is double minimumDouble && data.Maximum is double maximumDouble) + { + body.Line($"{localName}.Minimum = {minimumDouble.ToString("R", CultureInfo.InvariantCulture)};"); + body.Line($"{localName}.Maximum = {maximumDouble.ToString("R", CultureInfo.InvariantCulture)};"); + } + } + + static List RenderJsonValues(EquatableArray values) + { + List rendered = [with(values.Count)]; + for (var i = 0; i < values.Count; i++) + { + if (TryRenderJsonValue(values[i], out var expression)) + rendered.Add(expression); + } + + return rendered; + } + + static bool TryRenderJsonValue(TypedConstant constant, out string expression) + { + if (constant.IsNull) + { + expression = "null"; + return true; + } + + expression = constant.Value switch + { + string text => text.StringLiteral(), + char character => character.ToString().StringLiteral(), + bool boolean => boolean ? "true" : "false", + byte value => value.ToString(CultureInfo.InvariantCulture), + sbyte value => value.ToString(CultureInfo.InvariantCulture), + short value => value.ToString(CultureInfo.InvariantCulture), + ushort value => value.ToString(CultureInfo.InvariantCulture), + int value => value.ToString(CultureInfo.InvariantCulture), + uint value => value.ToString(CultureInfo.InvariantCulture), + long value => value.ToString(CultureInfo.InvariantCulture), + ulong value => value.ToString(CultureInfo.InvariantCulture), + float value => value.ToString("R", CultureInfo.InvariantCulture), + double value => value.ToString("R", CultureInfo.InvariantCulture), + decimal value => value.ToString(CultureInfo.InvariantCulture), + _ => string.Empty, + }; + + return expression.Length > 0; + } +} diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs index a88d5c0..c92451c 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs @@ -99,6 +99,14 @@ public class JsonSchemaDefinition /// additionalProperties - Schema for additional properties, or false to disallow public object? AdditionalProperties { get; set; } + // ========== Negation ========== + + /// not - The data must not match this schema (for example a sentinel value that is rejected). + public JsonSchemaDefinition? Not { get; set; } + + /// contentEncoding - The encoding of a string (for example base64). + public string? ContentEncoding { get; set; } + // ========== Composition ========== /// anyOf - Match any of the schemas @@ -143,4 +151,19 @@ public class JsonSchemaDefinition /// nullable - OpenAPI 3.0 nullable flag public bool? Nullable { get; set; } + + // ========== Extensions ========== + + /// + /// Names of validation rules that could not be represented as JSON Schema keywords. An exported schema + /// that lists a rule here is incomplete — a consumer cannot rely on it to reproduce that rule's + /// validation — so the omission is surfaced in the schema rather than dropped silently. + /// + /// + /// Emitted as the vendor extension x-zodsharp-unsupported. The name is declared explicitly so both + /// serializers write it verbatim (Newtonsoft.Json maps it through + /// ZodSharp.JsonSchema.NewtonsoftJson.JsonSchemaNamingStrategy). + /// + [JsonPropertyName("x-zodsharp-unsupported")] + public List? UnsupportedRules { get; set; } } diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs index 2e6cab9..ae0ad15 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs @@ -31,3 +31,19 @@ interface IJsonSchemaLiteralInfo /// object? LiteralValue { get; } } + +/// +/// A non-generic view of an enum schema, for the JSON Schema exporter. +/// +/// +/// ZodNativeEnum<TEnum> is generic, so the exporter cannot pattern-match it without naming +/// TEnum. The values are boxed and expressed in their JSON wire form (the numeric value of a native +/// enum, matching System.Text.Json's default enum handling). +/// +interface IJsonSchemaEnumInfo +{ + /// + /// Gets the allowed values, boxed so they are reachable without naming the enum type. + /// + IReadOnlyList EnumValues { get; } +} diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs new file mode 100644 index 0000000..4d94f47 --- /dev/null +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs @@ -0,0 +1,161 @@ +using System.Globalization; + +namespace ZodSharp.JsonSchema; + +/// +/// Implemented by a schema (or validator) that can describe its own JSON Schema, so +/// can export it. +/// +/// +/// The source generator emits this on every generated {Type}SchemaValidator. That is what lets an +/// attribute-generated [ZodSchema] schema be exported through Z.ToJsonSchema without rebuilding +/// its rules by hand with Z.Object()/Z.String(). +/// +public interface IJsonSchemaProvider +{ + /// Builds the JSON Schema definition for the schema's target type. + /// The JSON Schema definition, without document metadata such as $schema/$id. + JsonSchemaDefinition BuildJsonSchema(); +} + +/// +/// Implemented by a validation rule that can describe its own JSON Schema constraint, so the exporter +/// preserves it instead of dropping it. +/// +/// +/// A rule that cannot be represented (or that simply does not implement this interface) is reported through +/// ; it is never dropped silently. +/// +public interface IJsonSchemaRule +{ + /// Applies this rule's JSON Schema constraint(s) to . + /// The schema being built for the value the rule validates. + void ApplyTo(JsonSchemaDefinition schema); +} + +/// +/// Helpers for applying rules to a schema during export. +/// +public static class JsonSchemaRules +{ + /// + /// Applies to when it implements + /// ; otherwise records the rule as unsupported so the exported schema is + /// visibly incomplete rather than silently weaker. + /// + /// The schema the rule constrains. + /// The rule instance. + public static void Apply(JsonSchemaDefinition schema, object rule) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(rule); + + if (rule is IJsonSchemaRule mapped) + { + mapped.ApplyTo(schema); + return; + } + + MarkUnsupported(schema, rule.GetType().Name); + } + + /// + /// Records in . + /// + /// The schema that could not reproduce the rule. + /// The rule's type name. + public static void MarkUnsupported(JsonSchemaDefinition schema, string ruleName) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrEmpty(ruleName); + + schema.UnsupportedRules ??= []; + if (!schema.UnsupportedRules.Contains(ruleName, StringComparer.Ordinal)) + schema.UnsupportedRules.Add(ruleName); + } + + /// + /// Builds the JSON Schema for a native enum: the defined members, minus any + /// member, in their numeric wire form (System.Text.Json's default enum representation). + /// + /// The enum type. + /// Members excluded from the allowed set. + /// The enum schema. + public static JsonSchemaDefinition EnumSchema(params TEnum[] disallowed) + where TEnum : struct, Enum + { + var disallowedSet = disallowed is { Length: > 0 } ? new HashSet(disallowed) : null; + + return new JsonSchemaDefinition + { + Enum = + [ + .. Enum.GetValues() + .Where(value => disallowedSet is null || !disallowedSet.Contains(value)) + .Select(static value => + Convert.ChangeType(value, Enum.GetUnderlyingType(typeof(TEnum)), CultureInfo.InvariantCulture) + ), + ], + }; + } +} + +/// +/// Converts a rule's typed bound to the a JSON Schema numeric keyword expects. +/// +/// +/// Rules are generic over the value they validate, so the bound is only known at the call site. A bound that +/// is not numeric (for example a bound closed by ZodDate) is reported as +/// unsupported by the caller rather than silently mistranslated. +/// +static class JsonSchemaNumbers +{ + /// + /// Converts to a when it is a numeric type. + /// + /// The boxed bound. + /// The converted value when the method returns . + /// when the value is numeric. + internal static bool TryToDouble(object? value, out double number) + { + switch (value) + { + case byte b: + number = b; + return true; + case sbyte sb: + number = sb; + return true; + case short s: + number = s; + return true; + case ushort us: + number = us; + return true; + case int i: + number = i; + return true; + case uint ui: + number = ui; + return true; + case long l: + number = l; + return true; + case ulong ul: + number = ul; + return true; + case float f: + number = f; + return true; + case double d: + number = d; + return true; + case decimal m: + number = (double)m; + return true; + default: + number = 0; + return false; + } + } +} diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 605a3c3..55950d4 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -61,6 +61,12 @@ static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx) while (schema is IJsonSchemaInnerSchema wrapper) schema = wrapper.InnerSchema; + // A generated [ZodSchema] validator (or any schema) that describes its own JSON Schema takes over + // here. This is the bridge that makes an attribute-generated schema exportable without rebuilding + // its rules by hand with Z.Object()/Z.String(). + if (schema is IJsonSchemaProvider provider) + return provider.BuildJsonSchema(); + // Handle circular references if (ctx.Seen.Contains(schema)) { @@ -129,6 +135,7 @@ static JsonSchemaDefinition ConvertString(ZodString schema) break; default: + ApplyUnmappedRule(result, rule); break; } } @@ -178,6 +185,7 @@ static JsonSchemaDefinition ConvertNumber(ZodNumber schema) break; default: + ApplyUnmappedRule(result, rule); break; } } @@ -246,6 +254,13 @@ static JsonSchemaDefinition ConvertGeneric(object schema, ConversionContext ctx) var schemaType = schema.GetType(); var typeName = schemaType.Name; + // Handle enum schemas (ZodNativeEnum / ZodEnum) through their introspection seam rather than + // by type name: a generic enum cannot be pattern-matched without naming its type. + if (schema is IJsonSchemaEnumInfo enumInfo) + { + return new JsonSchemaDefinition { Enum = [.. enumInfo.EnumValues] }; + } + // Handle ZodArray. Matched through the internal IJsonSchemaArrayInfo seam rather than by type // name plus private-field reflection: the old lookups named fields and rule types that do not // exist, so element schemas and bounds never reached the exported schema. See IJsonSchemaArrayInfo. @@ -297,4 +312,21 @@ static JsonSchemaDefinition ConvertGeneric(object schema, ConversionContext ctx) // Default: empty schema (any) return new JsonSchemaDefinition(); } + + /// + /// Applies a rule the switch did not recognise. A rule that describes its own JSON Schema constraint + /// contributes it; anything else is recorded as unsupported so the omission is visible rather than + /// silently weakening the exported schema. + /// + static void ApplyUnmappedRule(JsonSchemaDefinition schema, object rule) + { + if (rule is IJsonSchemaRule mapped) + { + mapped.ApplyTo(schema); + return; + } + + if (rule is Core.IZodRule) + JsonSchemaRules.MarkUnsupported(schema, rule.GetType().Name); + } } diff --git a/src/src/ZodSharp/Rules/AllowedValuesRule.cs b/src/src/ZodSharp/Rules/AllowedValuesRule.cs index a8397c6..6d14d2a 100644 --- a/src/src/ZodSharp/Rules/AllowedValuesRule.cs +++ b/src/src/ZodSharp/Rules/AllowedValuesRule.cs @@ -12,7 +12,7 @@ namespace ZodSharp.Rules; /// System.ComponentModel.DataAnnotations. /// [Core.ZodRule] -public readonly record struct AllowedValuesRule : Core.IValidationRule, Core.IZodRule +public readonly record struct AllowedValuesRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; @@ -59,6 +59,15 @@ public AllowedValuesRule(T[]? values, string? message = null, string? code = nul /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies the JSON Schema enum constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (_values is { Length: > 0 }) + schema.Enum = [.. _values.Cast()]; + } + /// Gets the error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/DeniedValuesRule.cs b/src/src/ZodSharp/Rules/DeniedValuesRule.cs index 83b11c4..961ce1b 100644 --- a/src/src/ZodSharp/Rules/DeniedValuesRule.cs +++ b/src/src/ZodSharp/Rules/DeniedValuesRule.cs @@ -12,7 +12,7 @@ namespace ZodSharp.Rules; /// System.ComponentModel.DataAnnotations. /// [Core.ZodRule] -public readonly record struct DeniedValuesRule : Core.IValidationRule, Core.IZodRule +public readonly record struct DeniedValuesRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; @@ -59,6 +59,15 @@ public DeniedValuesRule(T[]? values, string? message = null, string? code = null /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies a JSON Schema not constraint against the denied values. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (_values is { Length: > 0 }) + schema.Not = new JsonSchema.JsonSchemaDefinition { Enum = [.. _values.Cast()] }; + } + /// Gets the error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/EmailRule.cs b/src/src/ZodSharp/Rules/EmailRule.cs index bab31b8..b5986af 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -7,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct EmailRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct EmailRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -61,6 +65,14 @@ public EmailRule(string? message = null, string? code = null) public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); + /// Applies the JSON Schema email string format. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "email"; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index d62d791..b3b0941 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace ZodSharp.Rules; /// @@ -5,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct EndsWithRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct EndsWithRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -66,6 +72,14 @@ public EndsWithRule( public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _suffix, value.ToString()); + /// Applies a JSON Schema anchored pattern for the required suffix. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Pattern = Regex.Escape(_suffix) + "$"; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/EnumRule.cs b/src/src/ZodSharp/Rules/EnumRule.cs index 633b166..912953d 100644 --- a/src/src/ZodSharp/Rules/EnumRule.cs +++ b/src/src/ZodSharp/Rules/EnumRule.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace ZodSharp.Rules; /// @@ -18,7 +20,7 @@ namespace ZodSharp.Rules; /// /// [Core.ZodRule] -public readonly record struct EnumRule : Core.IValidationRule, Core.IZodRule +public readonly record struct EnumRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where TEnum : struct, Enum { /// Gets the error code reported when the rule fails. @@ -73,6 +75,32 @@ public bool IsValid(in TEnum value) => public string GetErrorMessage(in TEnum value) => RuleMessage.Format(_message ?? MessageFormat, value, typeof(TEnum).Name); + /// + /// Applies the JSON Schema enum constraint listing the defined members that are not disallowed, + /// in their numeric wire form (System.Text.Json's default enum representation). + /// + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + + // A lambda inside a struct cannot capture instance members, so copy the field to a local first. + var disallowed = _disallowed; + schema.Enum = + [ + .. System + .Enum.GetValues() + .Where(value => disallowed is null || Array.IndexOf(disallowed, value) < 0) + .Select(static value => + Convert.ChangeType( + value, + System.Enum.GetUnderlyingType(typeof(TEnum)), + CultureInfo.InvariantCulture + ) + ), + ]; + } + /// Gets the error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/EvenRule.cs b/src/src/ZodSharp/Rules/EvenRule.cs index 0f7d264..bb3807c 100644 --- a/src/src/ZodSharp/Rules/EvenRule.cs +++ b/src/src/ZodSharp/Rules/EvenRule.cs @@ -8,7 +8,7 @@ namespace ZodSharp.Rules; /// /// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct EvenRule : Core.IValidationRule, Core.IZodRule +public readonly record struct EvenRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. @@ -44,6 +44,14 @@ public EvenRule(string? message = null, string? code = null) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies the JSON Schema multipleOf: 2 constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.MultipleOf = 2; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/FiniteRule.cs b/src/src/ZodSharp/Rules/FiniteRule.cs index f1a2246..4fcd560 100644 --- a/src/src/ZodSharp/Rules/FiniteRule.cs +++ b/src/src/ZodSharp/Rules/FiniteRule.cs @@ -8,7 +8,7 @@ namespace ZodSharp.Rules; /// /// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct FiniteRule : Core.IValidationRule, Core.IZodRule +public readonly record struct FiniteRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. @@ -44,6 +44,16 @@ public FiniteRule(string? message = null, string? code = null) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// + /// Applies nothing: JSON has no representation for infinity or NaN, so every JSON number is finite and + /// the constraint is implicit in the number type. + /// + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs index 34f516e..42d517a 100644 --- a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs @@ -6,7 +6,10 @@ namespace ZodSharp.Rules; /// /// The comparable type [Core.ZodRule] -public readonly record struct GreaterThanOrEqualRule : Core.IValidationRule, Core.IZodRule +public readonly record struct GreaterThanOrEqualRule + : Core.IValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -46,6 +49,17 @@ public GreaterThanOrEqualRule(T minValue, string? message = null, string? code = /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MinValue, value); + /// Applies the JSON Schema minimum constraint (inclusive). + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(MinValue, out var minimum)) + schema.Minimum = minimum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(GreaterThanOrEqualRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/GreaterThanRule.cs b/src/src/ZodSharp/Rules/GreaterThanRule.cs index 537e115..7d87ddb 100644 --- a/src/src/ZodSharp/Rules/GreaterThanRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// /// The numeric type [Core.ZodRule] -public readonly record struct GreaterThanRule : Core.IValidationRule, Core.IZodRule +public readonly record struct GreaterThanRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -47,6 +47,17 @@ public GreaterThanRule(T exclusiveMinimum, string? message = null, string? code public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _exclusiveMinimum, value); + /// Applies the JSON Schema exclusiveMinimum constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(_exclusiveMinimum, out var minimum)) + schema.ExclusiveMinimum = minimum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(GreaterThanRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/GuidRule.cs b/src/src/ZodSharp/Rules/GuidRule.cs index a3743fc..88600cf 100644 --- a/src/src/ZodSharp/Rules/GuidRule.cs +++ b/src/src/ZodSharp/Rules/GuidRule.cs @@ -6,7 +6,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct GuidRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct GuidRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -78,6 +82,14 @@ public bool IsValid(ReadOnlySpan value) public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); + /// Applies a JSON Schema pattern for an 8-4-4-4-12 hexadecimal identifier. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Pattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/IncludesRule.cs b/src/src/ZodSharp/Rules/IncludesRule.cs index aceb05b..f8a24c9 100644 --- a/src/src/ZodSharp/Rules/IncludesRule.cs +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace ZodSharp.Rules; /// @@ -5,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct IncludesRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct IncludesRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -58,6 +64,14 @@ public IncludesRule(string substring, string? message = null, string? code = nul public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _substring, value.ToString()); + /// Applies a JSON Schema pattern for the required substring. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Pattern = Regex.Escape(_substring); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/IntRule.cs b/src/src/ZodSharp/Rules/IntRule.cs index a31816c..b3907c1 100644 --- a/src/src/ZodSharp/Rules/IntRule.cs +++ b/src/src/ZodSharp/Rules/IntRule.cs @@ -8,7 +8,7 @@ namespace ZodSharp.Rules; /// /// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct IntRule : Core.IValidationRule, Core.IZodRule +public readonly record struct IntRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. @@ -36,6 +36,14 @@ public IntRule(string? message = null, string? code = null) /// public bool IsValid(in T value) => value % T.One == T.Zero; + /// Applies the JSON Schema integer type. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Type = "integer"; + } + /// /// Gets the error message for a failed validation. /// diff --git a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs index 29aaf32..2d7d5c5 100644 --- a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs @@ -6,7 +6,10 @@ namespace ZodSharp.Rules; /// /// The comparable type [Core.ZodRule] -public readonly record struct LessThanOrEqualRule : Core.IValidationRule, Core.IZodRule +public readonly record struct LessThanOrEqualRule + : Core.IValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -46,6 +49,17 @@ public LessThanOrEqualRule(T maxValue, string? message = null, string? code = nu /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MaxValue, value); + /// Applies the JSON Schema maximum constraint (inclusive). + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(MaxValue, out var maximum)) + schema.Maximum = maximum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(LessThanOrEqualRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/LessThanRule.cs b/src/src/ZodSharp/Rules/LessThanRule.cs index 6f03e0c..9bc348f 100644 --- a/src/src/ZodSharp/Rules/LessThanRule.cs +++ b/src/src/ZodSharp/Rules/LessThanRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// /// The numeric type [Core.ZodRule] -public readonly record struct LessThanRule : Core.IValidationRule, Core.IZodRule +public readonly record struct LessThanRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -47,6 +47,17 @@ public LessThanRule(T exclusiveMaximum, string? message = null, string? code = n public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _exclusiveMaximum, value); + /// Applies the JSON Schema exclusiveMaximum constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(_exclusiveMaximum, out var maximum)) + schema.ExclusiveMaximum = maximum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(LessThanRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index 3c71f43..49ab650 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -5,7 +5,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct MaxLengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct MaxLengthRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_big"; @@ -66,4 +70,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// Applies the JSON Schema maxLength constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.MaxLength = MaxLength; + } } diff --git a/src/src/ZodSharp/Rules/MaxValueRule.cs b/src/src/ZodSharp/Rules/MaxValueRule.cs index afab6a2..7a023fe 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// /// The numeric type [Core.ZodRule] -public readonly record struct MaxValueRule : Core.IValidationRule, Core.IZodRule +public readonly record struct MaxValueRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -46,6 +46,17 @@ public MaxValueRule(T maxValue, string? message = null, string? code = null) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MaxValue, value); + /// Applies the JSON Schema maximum constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(MaxValue, out var maximum)) + schema.Maximum = maximum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(MaxValueRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index 986d7a3..e770d38 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -5,7 +5,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct MinLengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct MinLengthRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_small"; @@ -66,4 +70,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// Applies the JSON Schema minLength constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.MinLength = MinLength; + } } diff --git a/src/src/ZodSharp/Rules/MinValueRule.cs b/src/src/ZodSharp/Rules/MinValueRule.cs index 34c755a..9b1a088 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// /// The numeric type [Core.ZodRule] -public readonly record struct MinValueRule : Core.IValidationRule, Core.IZodRule +public readonly record struct MinValueRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -47,6 +47,17 @@ public MinValueRule(T minValue, string? message = null, string? code = null) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MinValue, value); + /// Applies the JSON Schema minimum constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(MinValue, out var minimum)) + schema.Minimum = minimum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(MinValueRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index afca4a8..463ae81 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -8,7 +8,7 @@ namespace ZodSharp.Rules; /// /// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct MultipleOfRule : Core.IValidationRule, Core.IZodRule +public readonly record struct MultipleOfRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. @@ -71,6 +71,17 @@ public bool IsValid(in T value) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, Divisor, value); + /// Applies the JSON Schema multipleOf constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(Divisor, out var divisor)) + schema.MultipleOf = divisor; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(MultipleOfRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs index 1b4701e..1fedcfa 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -27,7 +27,7 @@ namespace ZodSharp.Rules; /// /// [Core.ZodRule] -public readonly record struct NonSentinelRule : Core.IValidationRule, Core.IZodRule +public readonly record struct NonSentinelRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; @@ -55,6 +55,41 @@ public NonSentinelRule(string? message = null, string? code = null) /// True if valid, false otherwise public bool IsValid(in T value) => !SentinelValues.IsSentinel(value); + /// + /// Applies the JSON Schema constraint that rejects the value's sentinel, where the wire form is known. + /// + /// The schema for the value being validated. + /// + /// A string sentinel (null/empty/whitespace) becomes minLength: 1 plus a non-whitespace pattern; a + /// GUID sentinel () becomes a not: { const: ... } against the all-zero + /// UUID. A date/time sentinel depends on the exact serialized form, which the exporter cannot know here, + /// so it is reported through instead of + /// being guessed or dropped. + /// + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + + if (typeof(T) == typeof(string)) + { + schema.MinLength = 1; + schema.Pattern = "\\S"; + return; + } + + if (typeof(T) == typeof(Guid)) + { + // Expressed as a pattern rather than `not: { const: ... }` so it survives converters that do not + // implement JSON Schema `not` (for example Zod's fromJSONSchema). The negative lookahead rejects + // the all-zero UUID while the body requires the canonical 8-4-4-4-12 form. + schema.Pattern = + "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + return; + } + + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(NonSentinelRule<>)); + } + /// /// Gets the error message for a failed validation. /// diff --git a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs index 9edb38f..32c5552 100644 --- a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs +++ b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs @@ -19,7 +19,10 @@ namespace ZodSharp.Rules; /// /// [Core.ZodRule] -public readonly record struct NullOrNonWhiteSpaceRule : Core.IValidationRule, Core.IZodRule +public readonly record struct NullOrNonWhiteSpaceRule + : Core.IValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -47,6 +50,19 @@ public NullOrNonWhiteSpaceRule(string? message = null, string? code = null) /// True if valid, false otherwise public bool IsValid(in string? value) => value is null || !string.IsNullOrWhiteSpace(value); + /// + /// Applies the JSON Schema constraint for a null-or-non-whitespace string: at least one non-whitespace + /// character when the value is present. Whether null is permitted is expressed by the property's + /// nullability, not by this rule. + /// + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.MinLength = 1; + schema.Pattern = "\\S"; + } + /// /// Gets the error message for a failed validation. /// diff --git a/src/src/ZodSharp/Rules/OddRule.cs b/src/src/ZodSharp/Rules/OddRule.cs index a5073bf..4ec4793 100644 --- a/src/src/ZodSharp/Rules/OddRule.cs +++ b/src/src/ZodSharp/Rules/OddRule.cs @@ -8,7 +8,7 @@ namespace ZodSharp.Rules; /// /// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct OddRule : Core.IValidationRule, Core.IZodRule +public readonly record struct OddRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. @@ -44,6 +44,14 @@ public OddRule(string? message = null, string? code = null) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies the JSON Schema constraint that rejects multiples of two. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Not = new JsonSchema.JsonSchemaDefinition { MultipleOf = 2 }; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index ca5bf3c..791f072 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -7,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct RegexRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct RegexRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -121,4 +125,12 @@ public bool IsValid(ReadOnlySpan value) string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// Applies the JSON Schema pattern constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Pattern = Pattern.ToString(); + } } diff --git a/src/src/ZodSharp/Rules/RequiredRule.cs b/src/src/ZodSharp/Rules/RequiredRule.cs index d8911d8..650bf69 100644 --- a/src/src/ZodSharp/Rules/RequiredRule.cs +++ b/src/src/ZodSharp/Rules/RequiredRule.cs @@ -26,7 +26,11 @@ namespace ZodSharp.Rules; /// /// [Core.ZodRule] -public readonly record struct RequiredRule : Core.IValidationRule, Core.IZodRule, Core.IRequiredRule +public readonly record struct RequiredRule + : Core.IValidationRule, + Core.IZodRule, + Core.IRequiredRule, + JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "missing_field"; @@ -88,6 +92,17 @@ public bool IsValid(in T value) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// + /// Applies nothing: presence is expressed by the object schema's required list, which the exporter + /// builds from the property's nullability (and any [Required] attribute), not by a per-property + /// keyword. + /// + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + } + /// Gets the error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/SafeIntegerRule.cs b/src/src/ZodSharp/Rules/SafeIntegerRule.cs index ef9a879..c06f92f 100644 --- a/src/src/ZodSharp/Rules/SafeIntegerRule.cs +++ b/src/src/ZodSharp/Rules/SafeIntegerRule.cs @@ -5,7 +5,7 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct SafeIntegerRule : Core.IValidationRule, Core.IZodRule +public readonly record struct SafeIntegerRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_big"; @@ -41,6 +41,16 @@ public bool IsValid(in double value) => /// The error message public string GetErrorMessage(in double value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies the JSON Schema bounds of a safe integer (an int-range whole number). + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Type = "integer"; + schema.Minimum = int.MinValue; + schema.Maximum = int.MaxValue; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index 1be1fef..f1c50b6 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace ZodSharp.Rules; /// @@ -5,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct StartsWithRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct StartsWithRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -66,6 +72,14 @@ public StartsWithRule( public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _prefix, value.ToString()); + /// Applies a JSON Schema anchored pattern for the required prefix. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Pattern = "^" + Regex.Escape(_prefix); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/UriRule.cs b/src/src/ZodSharp/Rules/UriRule.cs index e1a661d..246edfc 100644 --- a/src/src/ZodSharp/Rules/UriRule.cs +++ b/src/src/ZodSharp/Rules/UriRule.cs @@ -5,7 +5,7 @@ namespace ZodSharp.Rules; /// This will allocate by utilising /// [Core.ZodRule] -public readonly record struct UriRule : Core.IValidationRule, Core.IZodRule +public readonly record struct UriRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -44,6 +44,14 @@ public bool IsValid(in string value) => /// The error message public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, _uriKind, value); + /// Applies the JSON Schema uri string format. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "uri"; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/UrlRule.cs b/src/src/ZodSharp/Rules/UrlRule.cs index 4ca3a13..9848fee 100644 --- a/src/src/ZodSharp/Rules/UrlRule.cs +++ b/src/src/ZodSharp/Rules/UrlRule.cs @@ -12,7 +12,7 @@ namespace ZodSharp.Rules; Justification = "Name is real" )] [Core.ZodRule] -public readonly record struct UrlRule : Core.IValidationRule, Core.IZodRule +public readonly record struct UrlRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -61,6 +61,14 @@ public bool IsValid(in string value) => /// The error message public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies the JSON Schema uri string format. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "uri"; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index f901334..25b556c 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -5,7 +5,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct UUIDRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct UUIDRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -138,6 +142,14 @@ static bool HasValidStructure(ReadOnlySpan value) static bool IsValidVariant(char c) => c is '8' or '9' or 'a' or 'b' or 'A' or 'B'; + /// Applies the JSON Schema uuid string format. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "uuid"; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Schemas/ZodEnum.cs b/src/src/ZodSharp/Schemas/ZodEnum.cs index 8d1c408..743f3ea 100644 --- a/src/src/ZodSharp/Schemas/ZodEnum.cs +++ b/src/src/ZodSharp/Schemas/ZodEnum.cs @@ -12,7 +12,7 @@ namespace ZodSharp.Schemas; /// /// The allowed string values. [SuppressMessage("Naming", "CA1711", Justification = "Name mirrors Zod's z.enum API.")] -public class ZodEnum(HashSet allowedValues) : ZodType +public class ZodEnum(HashSet allowedValues) : ZodType, JsonSchema.IJsonSchemaEnumInfo { static readonly string[] EmptyPath = []; @@ -21,6 +21,8 @@ public class ZodEnum(HashSet allowedValues) : ZodType /// public new IReadOnlyCollection AllowedValues => allowedValues; + IReadOnlyList JsonSchema.IJsonSchemaEnumInfo.EnumValues => [.. allowedValues.Cast()]; + /// /// Validates that the string is one of the allowed values. /// diff --git a/src/src/ZodSharp/Schemas/ZodNativeEnum.cs b/src/src/ZodSharp/Schemas/ZodNativeEnum.cs index 5573e8f..74a3b22 100644 --- a/src/src/ZodSharp/Schemas/ZodNativeEnum.cs +++ b/src/src/ZodSharp/Schemas/ZodNativeEnum.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using ZodSharp.Core; namespace ZodSharp.Schemas; @@ -13,7 +14,7 @@ namespace ZodSharp.Schemas; /// Initializes a new instance of the class. /// [SuppressMessage("Naming", "CA1711", Justification = "Name mirrors Zod's z.nativeEnum API.")] -public class ZodNativeEnum() : ZodType +public class ZodNativeEnum() : ZodType, JsonSchema.IJsonSchemaEnumInfo where TEnum : struct, Enum { static readonly string[] EmptyPath = []; @@ -22,6 +23,24 @@ public class ZodNativeEnum() : ZodType // Enum.IsDefined reflection path. static readonly HashSet DefinedValues = [.. System.Enum.GetValues()]; + // The JSON Schema wire form of each member. System.Text.Json serialises a native enum as its numeric + // value by default, so the exported enum lists the underlying numbers rather than the member names. + static readonly List JsonEnumValues = + [ + .. System + .Enum.GetValues() + .Select(static value => + (object?) + Convert.ChangeType( + value, + System.Enum.GetUnderlyingType(typeof(TEnum)), + CultureInfo.InvariantCulture + ) + ), + ]; + + IReadOnlyList JsonSchema.IJsonSchemaEnumInfo.EnumValues => JsonEnumValues; + /// /// Validates that the value is a defined enum member. /// diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs b/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs index accc552..9a72769 100644 --- a/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs @@ -34,6 +34,8 @@ public sealed class ZodSchemaAttribute : System.Attribute public string? CustomValidationMethodName { get; init; } public bool GenerateIValidateOptions { get; init; } = false; public bool SuppressIValidateOptions { get; init; } = false; + public bool ValidateEnumValues { get; init; } = true; + public bool GenerateJsonSchema { get; init; } = true; } """, ]; diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.JsonSchemaExport.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.JsonSchemaExport.cs new file mode 100644 index 0000000..df84c33 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.JsonSchemaExport.cs @@ -0,0 +1,90 @@ +using ZodSharp.SourceGenerators.Helpers; + +namespace ZodSharp.SourceGenerators; + +public partial class ZodSchemaAnalyzerTests +{ + [Test] + public async Task JsonSchemaExport_GivenExportedTypeReferencingDisabledNestedType_ProducesZODSGEN045( + CancellationToken cancellationToken + ) + { + // Arrange — Wrapper exports JSON Schema but Inner opts out, so Wrapper's schema cannot describe it. + const string source = + @" +namespace Testing +{ + [ZodSchema(GenerateJsonSchema = false)] + public class Inner + { + public bool Flag { get; set; } + } + + [ZodSchema] + public class Wrapper + { + public Inner Inner { get; set; } = new(); + } +}"; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.NestedSchemaExportDisabled); + } + + [Test] + public async Task JsonSchemaExport_GivenNestedTypeThatExports_ProducesNoZODSGEN045( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +namespace Testing +{ + [ZodSchema] + public class Inner + { + public bool Flag { get; set; } + } + + [ZodSchema] + public class Wrapper + { + public Inner Inner { get; set; } = new(); + } +}"; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.NestedSchemaExportDisabled); + } + + [Test] + public async Task JsonSchemaExport_GivenOptedOutTypeThatIsNotReferenced_ProducesNoZODSGEN045( + CancellationToken cancellationToken + ) + { + // Arrange — opting a type out is not itself a diagnostic; only a reference from an exported type is. + const string source = + @" +namespace Testing +{ + [ZodSchema(GenerateJsonSchema = false)] + public class Standalone + { + public bool Flag { get; set; } + } +}"; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.NestedSchemaExportDisabled); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs new file mode 100644 index 0000000..73f59dd --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs @@ -0,0 +1,443 @@ +using System.Collections.Immutable; +using ZodSharp.JsonSchema; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + [Test] + public async Task JsonSchema_GivenZodSchema_EmitsBuildJsonSchemaMethod(CancellationToken cancellationToken) + { + // Arrange + const string source = + @" +namespace Testing +{ + [ZodSchema] + public class Widget + { + public string Name { get; set; } = """"; + } +}"; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("WidgetSchema"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("BuildJsonSchema"); + await Assert.That(generated).ContainsGeneratedCode("JsonSchemaDefinition"); + } + + [Test] + public async Task JsonSchema_GivenZodSchema_ValidatorAdapterImplementsTheProvider( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +namespace Testing +{ + [ZodSchema] + public class Widget + { + public string Name { get; set; } = """"; + } +}"; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("WidgetSchema"); + + // Assert — the adapter is what Z.ToJsonSchema receives, so it must forward to the schema. + await Assert.That(generated).ContainsGeneratedCode("IJsonSchemaProvider"); + await Assert.That(generated).ContainsGeneratedCode("WidgetSchema.BuildJsonSchema()"); + } + + [Test] + public async Task JsonSchema_GivenNonSentinelString_ExportsTheNonWhitespaceConstraint( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +using ZodSharp.Rules; + +namespace Testing +{ + [ZodSchema] + public class Widget + { + [NonSentinel] + public string Name { get; set; } = """"; + } +}"; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var schemaType = assembly.GetType("Testing.WidgetSchema")!; + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — a reference-type property is nullable, so the property schema is an anyOf with the inner + // shape first and a null branch second. + await Assert.That(definition.Type).IsEqualTo("object"); + await Assert.That(definition.Properties).IsNotNull(); + var nameSchema = definition.Properties!["name"].AnyOf![0]; + await Assert.That(nameSchema.Type).IsEqualTo("string"); + await Assert.That(nameSchema.MinLength).IsEqualTo(1); + await Assert.That(nameSchema.Pattern).IsEqualTo("\\S"); + } + + [Test] + public async Task JsonSchema_GivenNestedSchema_ExportsTheNestedDefinition(CancellationToken cancellationToken) + { // Arrange + const string source = + @" +namespace Testing +{ + [ZodSchema] + public class Wrapper + { + public Inner Inner { get; set; } = new(); + } + + [ZodSchema] + public class Inner + { + public bool Flag { get; set; } + } +}"; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var schemaType = assembly.GetType("Testing.WrapperSchema")!; + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert + var innerSchema = definition.Properties!["inner"].AnyOf![0]; + await Assert.That(innerSchema.Type).IsEqualTo("object"); + await Assert.That(innerSchema.Properties!["flag"].Type).IsEqualTo("boolean"); + } + + [Test] + public async Task JsonSchema_GivenGenerateJsonSchemaFalse_DoesNotEmitTheExportMembers( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +namespace Testing +{ + [ZodSchema(GenerateJsonSchema = false)] + public class Widget + { + public string Name { get; set; } = """"; + } +}"; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("WidgetSchema"); + + // Assert — the validation members are still generated; only the export members are omitted. + await Assert.That(generated).ContainsGeneratedCode("Validate"); + await Assert.That(generated).DoesNotContain("BuildJsonSchema"); + await Assert.That(generated).DoesNotContain("IJsonSchemaProvider"); + } + + [Test] + public async Task JsonSchema_GivenNestedExportDisabled_FallsBackToAnEmptySchema(CancellationToken cancellationToken) + { + // Arrange — Inner opts out of JSON Schema, so Wrapper's exported schema cannot reference it. + const string source = + @" +namespace Testing +{ + [ZodSchema(GenerateJsonSchema = false)] + public class Inner + { + public bool Flag { get; set; } + } + + [ZodSchema] + public class Wrapper + { + public Inner Inner { get; set; } = new(); + } +}"; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var definition = (JsonSchemaDefinition) + assembly.GetType("Testing.WrapperSchema")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — the property is an empty (any) schema rather than a reference to a missing method. + var innerSchema = definition.Properties!["inner"].AnyOf![0]; + await Assert.That(innerSchema.Type).IsNull(); + await Assert.That(innerSchema.Properties).IsNull(); + } + + [Test] + public async Task JsonSchema_GivenFlagsEnum_ExportsAnIntegerNotAnEnumeration(CancellationToken cancellationToken) + { + // Arrange — a [Flags] enum accepts combinations that are not defined members, so enumerating the + // members would reject valid values. + const string source = + @" +using System; + +namespace Testing +{ + [Flags] + public enum Permissions + { + None = 0, + Read = 1, + Write = 2, + } + + [ZodSchema] + public class Widget + { + public Permissions Permissions { get; set; } + } +}"; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var definition = (JsonSchemaDefinition) + assembly.GetType("Testing.WidgetSchema")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert + var permissions = definition.Properties!["permissions"]; + await Assert.That(permissions.Type).IsEqualTo("integer"); + await Assert.That(permissions.Enum).IsNull(); + } + + [Test] + public async Task JsonSchema_GivenNumericEnum_ExportsNumericValues(CancellationToken cancellationToken) + { + // Arrange — the System.Text.Json default serialises an enum as its numeric value. + const string source = EnumSchemaSource; + + // Act + var definition = await BuildEnumDefinitionAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum).IsNotNull(); + await Assert.That(status.Enum!).Contains(0); + await Assert.That(status.Enum!).Contains(1); + await Assert.That(status.Enum!).DoesNotContain("Active"); + } + + [Test] + public async Task JsonSchema_GivenStringEnumConverterOnTheEnum_ExportsMemberNames( + CancellationToken cancellationToken + ) + { + // Arrange — the enum declares its converter, so System.Text.Json writes the member names. + const string source = + @" +using System.Text.Json.Serialization; + +namespace Testing +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum Status { Active = 0, Inactive = 1 } + + [ZodSchema] + public class Widget + { + public Status Status { get; set; } + } +}"; + + // Act + var definition = await BuildEnumDefinitionAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum).IsNotNull(); + await Assert.That(status.Enum!).Contains("Active"); + await Assert.That(status.Enum!).Contains("Inactive"); + await Assert.That(status.Enum!).DoesNotContain(0); + } + + [Test] + public async Task JsonSchema_GivenStringEnumConverterOnTheProperty_ExportsMemberNames( + CancellationToken cancellationToken + ) + { + // Arrange — the converter is on the property rather than the enum. + const string source = + @" +using System.Text.Json.Serialization; + +namespace Testing +{ + public enum Status { Active = 0, Inactive = 1 } + + [ZodSchema] + public class Widget + { + [JsonConverter(typeof(JsonStringEnumConverter))] + public Status Status { get; set; } + } +}"; + + // Act + var definition = await BuildEnumDefinitionAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum!).Contains("Active"); + await Assert.That(status.Enum!).Contains("Inactive"); + } + + [Test] + public async Task JsonSchema_GivenJsonStringEnumMemberName_ExportsTheCustomName(CancellationToken cancellationToken) + { + // Arrange + const string source = + @" +using System.Text.Json.Serialization; + +namespace Testing +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum Status + { + [JsonStringEnumMemberName(""active"")] Active = 0, + [JsonStringEnumMemberName(""inactive"")] Inactive = 1, + } + + [ZodSchema] + public class Widget + { + public Status Status { get; set; } + } +}"; + + // Act + var definition = await BuildEnumDefinitionAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum!).Contains("active"); + await Assert.That(status.Enum!).Contains("inactive"); + await Assert.That(status.Enum!).DoesNotContain("Active"); + } + + [Test] + public async Task JsonSchema_GivenAssemblyEnumSerializationString_ExportsMemberNames( + CancellationToken cancellationToken + ) + { + // Arrange — the assembly-level switch matches a global JsonStringEnumConverter. + ZodSourceGeneratorTestOptions options = new() + { + CompileToAssembly = true, + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( + "build_property:ZodSharpEnumSerialization", + "String" + ), + }; + + // Act + var definition = await BuildEnumDefinitionAsync(EnumSchemaSource, options, cancellationToken); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum!).Contains("Active"); + await Assert.That(status.Enum!).Contains("Inactive"); + } + + [Test] + public async Task JsonSchema_GivenAssemblyEnumNamingPolicyCamelCase_ExportsCamelCaseNames( + CancellationToken cancellationToken + ) + { + // Arrange + ZodSourceGeneratorTestOptions options = new() + { + CompileToAssembly = true, + AnalyzerConfigOptions = ImmutableDictionary + .Empty.Add("build_property:ZodSharpEnumSerialization", "String") + .Add("build_property:ZodSharpEnumNamingPolicy", "CamelCase"), + }; + + // Act + var definition = await BuildEnumDefinitionAsync(EnumSchemaSource, options, cancellationToken); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum!).Contains("active"); + await Assert.That(status.Enum!).Contains("inactive"); + } + + const string EnumSchemaSource = + @" +namespace Testing +{ + public enum Status { Active = 0, Inactive = 1 } + + [ZodSchema] + public class Widget + { + public Status Status { get; set; } + } +}"; + + async Task BuildEnumDefinitionAsync( + string source, + ZodSourceGeneratorTestOptions options, + CancellationToken cancellationToken + ) + { + var driverResult = await GenerateAsync(source, options, cancellationToken); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + return (JsonSchemaDefinition) + assembly.GetType("Testing.WidgetSchema")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs index 2b7ae97..45813a1 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs @@ -163,13 +163,15 @@ DateTimeOffset LastObservedAt var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("SummarySchema")!; - // Assert + // Assert — the merged attribute set must not apply the same rule twice. The rule is constructed once + // in Validate and once in BuildJsonSchema (the exported JSON Schema), so a duplicate application would + // show up as four occurrences rather than two. await Assert .That(generated) .ContainsGeneratedCode( "new global::ZodSharp.Rules.NonSentinelRule(null!, null!)" ); - await Assert.That(CountOccurrences(generated, "NonSentinelRule")).IsEqualTo(1); + await Assert.That(CountOccurrences(generated, "NonSentinelRule")).IsEqualTo(2); } [Test] diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs index c93d7a4..a0744b5 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs @@ -28,6 +28,41 @@ public class Widget await Assert.That(generatedSource).ContainsGeneratedCode("return WidgetSchema.Validate(value);"); } + [Test] + public async Task Generate_GivenNestedTypeDeclaredAfterReferrer_KeepsTheNestedTypesOwnValidatorAdapter( + CancellationToken cancellationToken + ) + { + // Arrange — `Wrapper` is declared first, so its attribute context discovers `Inner` as a secondary + // (nested) schema before `Inner`'s own [ZodSchema] context is processed. Deduplication must still + // keep the primary copy, otherwise the nested type silently loses its DI adapter and registration. + const string source = + @" +namespace Testing +{ + [ZodSchema] + public class Wrapper + { + public Inner Inner { get; set; } = new(); + } + + [ZodSchema] + public partial record struct Inner + { + public string? Name { get; set; } + } +}"; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var schemaSource = driverResult.GetSource("InnerSchema"); + var registrationSource = driverResult.GetSource("InnerSchemaRegistration"); + + // Assert — the primary schema wins, so the adapter and its registration are emitted. + await Assert.That(schemaSource).ContainsGeneratedCode("class InnerSchemaValidator"); + await Assert.That(registrationSource).ContainsGeneratedCode("typeof(global::Testing.InnerSchemaValidator)"); + } + [Test] public async Task Generate_GivenZodSchema_EmitsModuleAttribute(CancellationToken cancellationToken) { diff --git a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs new file mode 100644 index 0000000..f06753f --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs @@ -0,0 +1,127 @@ +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace ZodSharp.JsonSchema; + +/// +/// Coverage for the rules the JSON Schema exporter now maps explicitly, and for the unsupported-rule +/// marker that replaces the silent empty fallback. +/// +public class ToJsonSchemaRuleMappingTests +{ + [Test] + public async Task ToJsonSchema_GivenNonSentinelString_ExportsNonWhitespaceConstraint( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().NonSentinel()); + + // Assert + await Assert.That(jsonSchema.Type).IsEqualTo("string"); + await Assert.That(jsonSchema.MinLength).IsEqualTo(1); + await Assert.That(jsonSchema.Pattern).IsEqualTo("\\S"); + } + + [Test] + public async Task ToJsonSchema_GivenNullOrNonWhiteSpace_ExportsNonWhitespaceConstraint( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().AddRule(new NullOrNonWhiteSpaceRule())); + + // Assert + await Assert.That(jsonSchema.MinLength).IsEqualTo(1); + await Assert.That(jsonSchema.Pattern).IsEqualTo("\\S"); + } + + [Test] + public async Task Apply_GivenNonSentinelGuid_ExportsUuidPatternThatRejectsTheEmptyGuid( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — a Guid scalar is validated through its underlying value, so the rule is applied to the + // string schema the generator builds rather than added to a ZodString. + JsonSchemaDefinition schema = new() { Type = "string", Format = "uuid" }; + + // Act + JsonSchemaRules.Apply(schema, new NonSentinelRule()); + + // Assert + await Assert.That(schema.Pattern).IsNotNull(); + await Assert.That(schema.Pattern!).Contains("00000000-0000-0000-0000-000000000000"); + } + + [Test] + public async Task ToJsonSchema_GivenNativeEnum_ExportsTheDefinedValues(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.Enum()); + + // Assert + await Assert.That(jsonSchema.Enum).IsNotNull(); + await Assert.That(jsonSchema.Enum!).Count().IsEqualTo(3); + await Assert.That(jsonSchema.Enum!).Contains(0); + await Assert.That(jsonSchema.Enum!).Contains(1); + await Assert.That(jsonSchema.Enum!).Contains(2); + } + + [Test] + public async Task Apply_GivenRuleWithoutJsonSchemaMapping_ReportsItAsUnsupported( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string" }; + + // Act + JsonSchemaRules.Apply(schema, new UnmappedRule()); + + // Assert + await Assert.That(schema.UnsupportedRules).IsNotNull(); + await Assert.That(schema.UnsupportedRules!).Contains(nameof(UnmappedRule)); + } + + [Test] + public async Task EnumSchema_GivenDisallowedMember_ExcludesIt(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var schema = JsonSchemaRules.EnumSchema(SampleStatus.Disabled); + + // Assert + await Assert.That(schema.Enum).IsNotNull(); + await Assert.That(schema.Enum!).DoesNotContain(2); + } +} + +enum SampleStatus +{ + Active = 0, + Inactive = 1, + Disabled = 2, +} + +/// A rule with no JSON Schema mapping, standing in for a consumer rule that has not opted in. +readonly record struct UnmappedRule : IValidationRule, IZodRule +{ + public bool IsValid(in string value) => true; + + public string GetErrorMessage(in string value) => "unmapped"; + + string? IZodRule.Code => null; + + string? IZodRule.Origin => null; +} From 70168927f8a21614bf018e2205f1e4e6b92623b5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 11 Oct 2026 13:31:01 +0100 Subject: [PATCH 05/21] refactor: removed old bridge for specific rule handling --- CHANGELOG.md | 8 ++ docs/wiki/JsonSchema-Export.md | 13 ++- .../Helpers/SourceGenLibrary.cs | 29 ++++++- .../Models/SchemaGenerationModel.cs | 8 +- .../ZodSchemaGenerator.BuildSchema.cs | 18 +++- .../ZodSchemaGenerator.JsonSchema.cs | 7 +- .../ZodSchemaGeneratorTests.JsonSchema.cs | 82 +++++++++++++++++-- 7 files changed, 150 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 659daaa..735d4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,14 @@ been published to NuGet. validates a flags enum against its defined members (a combination is a valid value without being a defined member), but the generated JSON Schema enumerated them, so a value such as `Read | Write` failed the exported schema. A flags enum now exports as `type: integer`. +- **Exported requiredness and null acceptance now follow the nullable reference-type annotation.** The + generator treated every reference type as nullable, so a non-nullable `string` with `[NonSentinel]` was + wrapped in `anyOf: [ …, { "type": "null" } ]`, omitted from `required`, and its rule was skipped for null + (null passed validation). A non-nullable reference type is now required, its rules run unconditionally and + reject null, and the generated validator reports a null value rather than skipping it — matching the + exported schema. A `string?` keeps its null branch, and a rule that rejects an absent value still makes the + property non-null; a rule that accepts null (such as the built-in `NullOrNonWhiteSpaceRule`) keeps the + property nullable. - **A `[ZodSchema]` type referenced as a nested property could lose its generated DI adapter.** A type is reached twice by the generator — once from its own `[ZodSchema]` attribute (the primary schema) and once as a nested property of another schema (a secondary copy). Deduplication kept whichever the attribute contexts diff --git a/docs/wiki/JsonSchema-Export.md b/docs/wiki/JsonSchema-Export.md index 91f8a5c..97cae33 100644 --- a/docs/wiki/JsonSchema-Export.md +++ b/docs/wiki/JsonSchema-Export.md @@ -78,8 +78,12 @@ apart: property names, nested objects, native enums, nullable fields, required f name is camelCased to match the ASP.NET Core / `JsonSerializerDefaults.Web` default. A non-camelCase global naming policy configured at runtime is not visible to the generator, so a consumer using one must adjust the exported names (or declare `[JsonPropertyName]`). -- **Required fields** are the non-nullable ones (and any `[Required]`); a nullable field is wrapped in - `anyOf: [ …, { "type": "null" } ]`. +- **Required fields** follow the nullable reference-type annotation: a non-nullable reference type (or + `Nullable`) is required, and a nullable one (`string?`) is wrapped in `anyOf: [ …, { "type": "null" } ]` + and is not required. `[Required]` also makes a field required. The generated validator agrees: a + non-nullable reference type rejects null and its rules run unconditionally, while a nullable one skips them + for null. A rule that rejects an absent value (for example `[RequiredZod]`) makes the field non-null even + when its annotation is nullable. - **Scalar value objects** (`[Scalar]`) export as their underlying value's schema — a `Guid` scalar is a `uuid` string — not as an object wrapping a `Value` property. - **Enums** follow System.Text.Json. They export as numbers by default; a per-type or per-property @@ -140,8 +144,9 @@ the same, so a rule maps to JSON Schema identically on both paths: A rule that does not implement `IJsonSchemaRule` is reported through `JsonSchemaDefinition.UnsupportedRules` (`x-zodsharp-unsupported`). Consumer rules opt in by implementing the -interface; for example a custom `NullOrValidStringRule` would add `IJsonSchemaRule` and set `minLength`/`pattern` -on the schema passed to `ApplyTo`. +interface; for example a custom null-or-non-whitespace rule would add `IJsonSchemaRule` and set +`minLength`/`pattern` on the schema passed to `ApplyTo`. The built-in `NullOrNonWhiteSpaceRule` already does +this, so a domain that needs that behaviour should use `[NullOrNonWhiteSpace]` rather than a bespoke rule. ## Serialize the definition diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 384ea13..4ae2bfd 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -126,7 +126,7 @@ CancellationToken cancellationToken if (CustomRuleResolver.TryGetScalarUnderlyingValue(symbol, out var scalarValueType)) { scalarUnderlyingType = CreateTypeIdentity(TypeHelpers.UnwrapNullableType(scalarValueType)); - scalarUnderlyingCanBeNull = TypeHelpers.CanBeNull(scalarValueType); + scalarUnderlyingCanBeNull = IsNullableProperty(scalarValueType); } bool? generateIValidateOptions = zodSchemaAttribute.GenerateIValidateOptions ? true @@ -359,6 +359,7 @@ originalPropertyType is INamedTypeSymbol propertyName, propertyName, TypeHelpers.CanBeNull(valueType), + IsNullableProperty(valueType), isNullableValueType, isEnum, isFlagsEnum, @@ -419,7 +420,15 @@ Compilation compilation var propertyType = declaredType.Value; var originalPropertyType = property.Type; + + // Two nullabilities are tracked. `CanBeNull` is whether the type can hold null at runtime (a + // reference type or Nullable), which governs whether [Required] is meaningful. `IsDeclaredNullable` + // follows the nullable reference-type annotation (a non-nullable `string` is required and its rules + // run unconditionally and reject null, while a `string?` stays nullable). A compilation with the + // nullable context disabled reports NullableAnnotation.None, where a reference type is treated as + // nullable. var propertyCanBeNull = TypeHelpers.CanBeNull(originalPropertyType); + var propertyDeclaredNullable = IsNullableProperty(originalPropertyType); var isNullableValueType = false; if ( originalPropertyType is INamedTypeSymbol @@ -572,6 +581,7 @@ rangeAttribute with property.Name, displayName, propertyCanBeNull, + propertyDeclaredNullable, isNullableValueType, isEnum, isFlagsEnum, @@ -671,6 +681,23 @@ static bool NestedSchemaExportsJsonSchema( return null; } + /// + /// Determines whether a property accepts null, from its nullable reference-type annotation or its + /// construction. A non-nullable reference type (for example string) is + /// non-nullable, so its rules run unconditionally and reject null; a string? accepts null. + /// + /// The declared property type. + /// when the property accepts null. + /// + /// A compilation with the nullable context disabled reports for + /// every reference type; there the annotation cannot distinguish nullable from non-nullable, so a + /// reference type is treated as nullable (the pre-annotation behaviour). + /// + static bool IsNullableProperty(ITypeSymbol typeSymbol) => + typeSymbol is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + || typeSymbol.NullableAnnotation == NullableAnnotation.Annotated + || (typeSymbol.NullableAnnotation == NullableAnnotation.None && typeSymbol.IsReferenceType); + /// /// Creates the TypeIdentity for a symbol, returning for symbols that /// cannot be represented (unresolved error types, type parameters, dynamic, pointers, ...). diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index 94f8633..8a784e6 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -114,7 +114,12 @@ EnumJsonNamingPolicy EnumNamingPolicy /// The declared type of the property, with a nullable wrapper removed. /// The name of the property. /// The name reported in error messages, honouring a [Display] attribute. -/// Indicates whether the property can be null. +/// Indicates whether the property type can hold null at runtime (a reference type or Nullable<T>). +/// +/// Indicates whether the property is declared nullable (the nullable reference-type annotation, or +/// Nullable<T>). This governs null acceptance in validation and the exported JSON Schema: a +/// non-nullable reference type is required and its rules run unconditionally and reject null. +/// /// Indicates whether the property type is a Nullable<T>. /// Indicates whether the property type is an enum. /// Indicates whether the enum type is declared with the [Flags] attribute. @@ -151,6 +156,7 @@ readonly record struct ZodPropertyDescriptor( string Name, string DisplayName, bool CanBeNull, + bool IsDeclaredNullable, bool IsNullableValueType, bool IsEnum, bool IsFlagsEnum, diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 41e17bf..4971ba2 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -735,10 +735,24 @@ CancellationToken cancellationToken block = writer.ElseScope(); GenerateValueSetValidations(writer, property, emitEnumRule); } + else if (!property.IsDeclaredNullable && property.CanBeNull) + { + // A non-nullable reference type rejects null — the exported JSON Schema marks it required — so + // the rules below run only for a present value. This keeps null acceptance consistent with the + // schema and makes length/count access null-safe. + writer.Rule( + propertyName, + $"value.{propertyName} == null", + "invalid_type", + $"Field '{displayName}' cannot be null" + ); + block = writer.ElseScope(); + GenerateValueSetValidations(writer, property, emitEnumRule); + } else { GenerateValueSetValidations(writer, property, emitEnumRule); - if (property.CanBeNull) + if (property.IsDeclaredNullable) block = writer.IfBlockScope($"value.{propertyName} != null"); } @@ -901,7 +915,7 @@ static void GenerateComplexTypeValidation(CodeWriter writer, ZodPropertyDescript var schemaType = property.NestedSchemaType.Value; writer.Assignment("var", propertyValueName, $"value.{propertyName}"); - if (property.CanBeNull) + if (property.IsDeclaredNullable) { using (writer.IfBlockScope($"{propertyValueName} is not null")) { diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs index b3851bf..55c0d9e 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs @@ -114,8 +114,11 @@ static void EmitJsonSchemaProperty(CodeWriter body, ZodPropertyDescriptor proper var nameLiteral = GetJsonName(property).StringLiteral(); var hasRequiredAttribute = property.ValidationAttributes.Required.ShouldProcess && property.ValidationAttributes.Required.Value.Exists; - var isRequired = hasRequiredAttribute || !property.CanBeNull; - var isNullable = property.CanBeNull && !hasRequiredAttribute; + // A rule that rejects an absent value ([RequiredZod]) makes the property effectively non-null even + // when its annotation is nullable, matching how the generated validator emits it outside the guard. + var hasRequiredRule = property.CustomRules.Any(rule => rule.IsRequired); + var isRequired = hasRequiredAttribute || !property.IsDeclaredNullable; + var isNullable = property.IsDeclaredNullable && !hasRequiredAttribute && !hasRequiredRule; body.Assignment("var", localName, BuildPropertySchemaExpression(property, schema)); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs index 73f59dd..9363c1e 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs @@ -86,14 +86,15 @@ public class Widget var schemaType = assembly.GetType("Testing.WidgetSchema")!; var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; - // Assert — a reference-type property is nullable, so the property schema is an anyOf with the inner - // shape first and a null branch second. + // Assert — a non-nullable string is required and its rules reject null, so the property schema is the + // string shape directly (no null branch). await Assert.That(definition.Type).IsEqualTo("object"); await Assert.That(definition.Properties).IsNotNull(); - var nameSchema = definition.Properties!["name"].AnyOf![0]; + var nameSchema = definition.Properties!["name"]; await Assert.That(nameSchema.Type).IsEqualTo("string"); await Assert.That(nameSchema.MinLength).IsEqualTo(1); await Assert.That(nameSchema.Pattern).IsEqualTo("\\S"); + await Assert.That(definition.Required!).Contains("name"); } [Test] @@ -127,7 +128,7 @@ public class Inner var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; // Assert - var innerSchema = definition.Properties!["inner"].AnyOf![0]; + var innerSchema = definition.Properties!["inner"]; await Assert.That(innerSchema.Type).IsEqualTo("object"); await Assert.That(innerSchema.Properties!["flag"].Type).IsEqualTo("boolean"); } @@ -191,7 +192,7 @@ public class Wrapper assembly.GetType("Testing.WrapperSchema")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; // Assert — the property is an empty (any) schema rather than a reference to a missing method. - var innerSchema = definition.Properties!["inner"].AnyOf![0]; + var innerSchema = definition.Properties!["inner"]; await Assert.That(innerSchema.Type).IsNull(); await Assert.That(innerSchema.Properties).IsNull(); } @@ -416,6 +417,77 @@ CancellationToken cancellationToken await Assert.That(status.Enum!).Contains("inactive"); } + [Test] + public async Task JsonSchema_GivenNonNullableAndNullableStrings_MatchesValidationRequiredness( + CancellationToken cancellationToken + ) + { + // Arrange — a non-nullable string is required and its rule rejects null; a nullable string keeps a + // null branch. + const string source = + @" +using ZodSharp.Rules; + +namespace Testing +{ + [ZodSchema] + public sealed class Item + { + [NonSentinel] + public string Name { get; set; } = """"; + + [NullOrNonWhiteSpace] + public string? Branch { get; set; } + } +}"; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var schemaType = assembly.GetType("Testing.ItemSchema")!; + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — the non-nullable string is required with no null branch. + await Assert.That(definition.Required!).Contains("name"); + var name = definition.Properties!["name"]; + await Assert.That(name.Type).IsEqualTo("string"); + await Assert.That(name.MinLength).IsEqualTo(1); + await Assert.That(name.Pattern).IsEqualTo("\\S"); + await Assert.That(name.AnyOf).IsNull(); + + // Assert — the nullable string keeps a null branch and is not required. + await Assert.That(definition.Required!).DoesNotContain("branch"); + var branch = definition.Properties!["branch"].AnyOf!; + await Assert.That(branch.Any(candidate => candidate.Type == "null")).IsTrue(); + var branchText = branch.Single(candidate => candidate.Type == "string"); + await Assert.That(branchText.MinLength).IsEqualTo(1); + await Assert.That(branchText.Pattern).IsEqualTo("\\S"); + + // Assert — runtime validation agrees with the schema for the same fixtures. + var itemType = assembly.GetType("Testing.Item")!; + var validate = schemaType.GetMethod("Validate")!; + + bool IsValid(string? name, string? branch) + { + var item = Activator.CreateInstance(itemType)!; + itemType.GetProperty("Name")!.SetValue(item, name); + itemType.GetProperty("Branch")!.SetValue(item, branch); + var result = validate.Invoke(null, [item])!; + return (bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!; + } + + await Assert.That(IsValid(null, null)).IsFalse(); + await Assert.That(IsValid("", null)).IsFalse(); + await Assert.That(IsValid(" ", null)).IsFalse(); + await Assert.That(IsValid("name", null)).IsTrue(); + await Assert.That(IsValid("name", " ")).IsFalse(); + await Assert.That(IsValid("name", "main")).IsTrue(); + } + const string EnumSchemaSource = @" namespace Testing From 1a98ed1b62cc8200fdc9b76ed2ee4fe21be66b97 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 12 Oct 2026 00:13:35 +0100 Subject: [PATCH 06/21] feat: added uuid versions + guid support --- CHANGELOG.md | 13 + Directory.Packages.props | 1 + docs/wiki/JsonSchema-Export.md | 22 +- docs/wiki/JsonSchema-Import.md | 5 +- docs/wiki/Source-Generator-DataAnnotations.md | 2 +- docs/wiki/Validation-Rules-Reference.md | 4 +- docs/wiki/Value-Objects-Integration.md | 17 ++ .../NewtonsoftJson/FromJsonSchemaParser.cs | 61 +++-- .../Helpers/SourceGenLibrary.cs | 4 +- .../Models/SchemaGenerationModel.cs | 3 +- .../ZodSchemaGenerator.JsonSchema.cs | 6 +- .../SystemTextJson/FromJsonSchemaParser.cs | 61 +++-- .../ZodSharp/JsonSchema/JsonSchemaRules.cs | 51 ++++ .../JsonSchema/ToJsonSchemaConverter.cs | 10 +- src/src/ZodSharp/Rules/EndsWithRule.cs | 2 +- src/src/ZodSharp/Rules/GuidRule.cs | 10 +- src/src/ZodSharp/Rules/IncludesRule.cs | 2 +- src/src/ZodSharp/Rules/NonSentinelRule.cs | 14 +- .../ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs | 2 +- src/src/ZodSharp/Rules/RegexRule.cs | 2 +- src/src/ZodSharp/Rules/StartsWithRule.cs | 2 +- src/src/ZodSharp/Rules/UuidRule.cs | 72 ++++- .../Json/Schema/FromJsonSchemaParserTests.cs | 27 ++ .../SourceGenerators.UnitTests.csproj | 3 + .../ZodSchemaGeneratorTests.JsonSchema.cs | 199 ++++++++++++++ ...aGeneratorTests.JsonSchemaCrossPlatform.cs | 256 ++++++++++++++++++ .../Json/Schema/FromJsonSchemaParserTests.cs | 27 ++ .../JsonSchema/ToJsonSchemaGuidTests.cs | 128 +++++++++ .../Rules/UuidRuleGuidTests.cs | 71 +++++ tests/ts/guid-uuid-parity.test.ts | 58 ++++ 30 files changed, 1059 insertions(+), 76 deletions(-) create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs create mode 100644 src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaGuidTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs create mode 100644 tests/ts/guid-uuid-parity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 735d4ec..b81555e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ been published to NuGet. ### Added +- **`UUIDRule` now validates a `Guid` as well as a `string`.** A `Guid` is a UUID, so its bytes are checked + directly; `[UUID]` / `[UUID(UuidVersion.Vx)]` can therefore constrain a `Guid` member or a `[Scalar]` + value object, and the constraint is exported as `format: "uuid"` plus a version-pinning `pattern`. + - **A type-level rule on a `[Scalar]` type now names the scalar in its default message.** Because a scalar's error reports an empty path, the generator replaces the leading subject word `Value` in the resolved rule's `MessageFormat` with the scalar's name, so `[NonSentinel]` on `[Scalar] TenantId` reports @@ -157,6 +161,15 @@ been published to NuGet. ### Fixed +- **Guid and UUID export semantics now match the runtime validator.** A C# `Guid` was exported as + `format: "uuid"`, so a consumer applied its RFC UUID validator (Zod's `z.uuid()`, which `z.fromJSONSchema` + also uses for `format: "guid"`) and rejected valid values that C# accepts — including every + `FastGuid.NewSqlServerGuid()`/`NewPostgreSqlGuid()` time-bound identifier, which is deliberately not an RFC + UUID. A `Guid` (and a `[Scalar]` value object) now exports as a canonical hexadecimal `pattern`; a + versioned `UUIDRule`/`[UUID(UuidVersion.Vx)]` keeps its version as an additional `pattern`; and two rules that + both constrain `pattern` (for example a Guid plus `[NonSentinel]`) are composed under `allOf` instead of one + silently overwriting the other. The JSON Schema importer now intersects every `allOf` branch (previously it + took only the first) so an exported schema round-trips. - **Exported enum schemas now match the System.Text.Json wire form.** The generated JSON Schema always enumerated an enum's numeric values, so an enum serialized as a string (a per-type or per-property `[JsonConverter(typeof(JsonStringEnumConverter))]`, or a global converter) exported a schema that rejected diff --git a/Directory.Packages.props b/Directory.Packages.props index 93b3457..9d643d2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,6 +38,7 @@ + ]`) export as their underlying value's schema — a `Guid` scalar is - a `uuid` string — not as an object wrapping a `Value` property. + a canonical 8-4-4-4-12 hexadecimal string (a `pattern`, not Zod's RFC `uuid` format, because C# `Guid` + accepts any 128-bit value) — not as an object wrapping a `Value` property. - **Enums** follow System.Text.Json. They export as numbers by default; a per-type or per-property `[JsonConverter(typeof(JsonStringEnumConverter))]` (or the assembly-level `ZodSharpEnumSerialization=String` switch, matching a global converter) makes them strings, and `ZodSharpEnumNamingPolicy=CamelCase` matches a @@ -134,14 +135,29 @@ the same, so a rule maps to JSON Schema identically on both paths: | `GreaterThanRule` / `LessThanRule` | `exclusiveMinimum` / `exclusiveMaximum` | | `MultipleOfRule` / `EvenRule` / `OddRule` | `multipleOf` (odd uses `not: { multipleOf: 2 }`) | | `IntRule` / `SafeIntegerRule` | `type: integer` (safe integer also bounds to the `int` range) | -| `EmailRule` / `UrlRule` / `UriRule` / `UUIDRule` | `format: email` / `uri` / `uuid` | -| `RegexRule` / `GuidRule` | `pattern` | +| `EmailRule` / `UrlRule` / `UriRule` | `format: email` / `uri` | +| `UUIDRule` (versionless) | `format: uuid` | +| `UUIDRule(UuidVersion.Vx)` | `format: uuid` plus a `pattern` that pins the version and variant | +| `GuidRule` | a `pattern` for a canonical 8-4-4-4-12 hexadecimal identifier (any version/variant) | +| `RegexRule` | `pattern` | | `StartsWithRule` / `EndsWithRule` / `IncludesRule` | anchored / escaped `pattern` | | `NonSentinelRule` / `NullOrNonWhiteSpaceRule` | `minLength: 1` + `pattern: "\\S"` | | `NonSentinelRule` | a `pattern` that rejects the all-zero UUID | | `AllowedValuesRule` / `DeniedValuesRule` / `EnumRule` | `enum` / `not: { enum: … }` | | `FiniteRule` / `RequiredRule` | no keyword (implied by `number` / the object's `required` list) | +A C# `Guid` is exported as a `pattern`, not `format: uuid`, because `Guid` accepts any 128-bit value while an +RFC UUID validator (Zod's `z.uuid()`, which `z.fromJSONSchema` applies to **both** `uuid` and `guid`) checks the +version and variant nibbles. A time-bound identifier generated by `FastGuid.NewSqlServerGuid()` / +`NewPostgreSqlGuid()` is deliberately not an RFC UUID and would be rejected by that validator. A Guid that must +be a specific RFC version opts in with `[UUID(UuidVersion.Vx)]` (which also validates a `Guid` or a +`[Scalar]`), and exports `format: uuid` plus a version-pinning `pattern`. + +JSON Schema has a single `pattern` keyword, so when more than one rule constrains the pattern (for example a +Guid plus `[NonSentinel]`, or a regex plus `[NonSentinel]`) the constraints are composed under `allOf` rather +than one overwriting the other. Each `allOf` branch repeats the schema's `type`, because Zod's `fromJSONSchema` +discards a branch without one. + A rule that does not implement `IJsonSchemaRule` is reported through `JsonSchemaDefinition.UnsupportedRules` (`x-zodsharp-unsupported`). Consumer rules opt in by implementing the interface; for example a custom null-or-non-whitespace rule would add `IJsonSchemaRule` and set diff --git a/docs/wiki/JsonSchema-Import.md b/docs/wiki/JsonSchema-Import.md index 3bd59a6..e33932d 100644 --- a/docs/wiki/JsonSchema-Import.md +++ b/docs/wiki/JsonSchema-Import.md @@ -37,8 +37,9 @@ var result = userSchema.Validate(userData); - `type` — `string` / `number` / `integer` / `boolean` / `null` / `object` / `array`. - `enum` → `ZodUnion` of literals (a single member becomes a literal); `const` → literal. -- `anyOf` / `oneOf` → `ZodUnion`; `allOf` → first schema. -- String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The `uuid`/`guid` format maps to the versionless `.UUID()`; JSON Schema has no versioned `uuid` format, so a versioned `.UUID(UuidVersion.V7)` schema exports back as plain `format: "uuid"`. +- `anyOf` / `oneOf` → `ZodUnion`; `allOf` → the intersection of every branch (and the base schema when the + schema also declares a `type`). A type-less branch is `any`, matching Zod's `fromJSONSchema`. +- String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The `uuid`/`guid` format maps to the versionless `.UUID()`; a versioned `.UUID(UuidVersion.V7)` schema exports `format: "uuid"` plus a version-pinning `pattern`, and the `allOf` composition preserves that pattern on import. Multiple `pattern` constraints are composed under `allOf`, and every branch is applied. - Numeric constraints — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`. - Objects — `required` and optional fields via `Z.Object().Field(...)`. - Arrays — `items`, `minItems`, `maxItems`. diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index 77563f4..c53a9c5 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -112,4 +112,4 @@ See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full The same pipeline honours custom rules exposed as validation attributes. Mark the attribute with `[ZodRule(typeof(MyRule))]` (or mark the rule itself with `[ZodRule]` to have the attribute generated), and properties annotated with it are validated through the rule. See [Custom Rules](Custom-Rules.md). -Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, `[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[NullOrNonWhiteSpace]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, and `[DeniedValuesZod]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file +Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, `[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[NullOrNonWhiteSpace]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, and `[DeniedValuesZod]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. `[UUID]` also applies to a `Guid` member or a `[Scalar]` type (a `Guid` is a UUID, so the rule validates its bytes), which is how a time-bound identifier that must be an RFC version is constrained. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index be13477..a094d32 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -129,7 +129,7 @@ Behaviour notes: - **`CreditCardRule`** mirrors `[CreditCard]`: Luhn check, ignoring spaces and hyphens. - **`Base64StringRule`** mirrors `[Base64String]`: `Convert.FromBase64String` must succeed. - **`Base64UrlRule`** accepts URL-safe base64 (`A-Z a-z 0-9 - _`) with no padding; a length of `4n+1` is rejected. -- **`UUIDRule`** accepts RFC 9562 versions 1-8 with a variant nibble of `8`-`9`/`a`-`b`, plus the nil and max UUIDs. The versioned constructor requires a specific version and rejects nil/max. +- **`UUIDRule`** accepts RFC 9562 versions 1-8 with a variant nibble of `8`-`9`/`a`-`b`, plus the nil and max UUIDs. The versioned constructor requires a specific version and rejects nil/max. It validates both a `string` and a `Guid` — a `Guid` is a UUID, so its bytes are inspected directly — which lets `[UUID(UuidVersion.Vx)]` constrain a `Guid` member or a `[Scalar]` value object. - **`ULIDRule`** requires 26 Crockford base32 characters, with the first character `0`-`7`. - **`JWTRule`** requires three non-empty base64url-encoded segments separated by periods. - **`IPAddressRule`** accepts IPv4 or IPv6 (`IPAddress.TryParse`); pass an `IPAddressRuleType` to require `IPv4`, `IPv6`, or `Any` (the default). @@ -140,7 +140,7 @@ Behaviour notes: - **`EmojiRule`** mirrors Zod's `emoji()`: every rune must be an `Extended_Pictographic` or `Emoji_Component` code point, and at least one must be an anchor (a pictograph, a regional indicator, or a keycap). The Unicode tables are generated from Unicode 16.0 `emoji-data.txt`, because .NET's regex engine does not expose the `Extended_Pictographic` property. - **`XidRule`** requires exactly 20 base32hex characters (`0-9`, `a-v`/`A-V`); **`KsuidRule`** requires exactly 27 base62 characters. - **`DurationRule`** accepts ISO 8601-1 durations; the 8601-2 extensions (negative durations and fractional/negative components) are rejected, matching Zod. -- **`GuidRule`** accepts any 8-4-4-4-12 hexadecimal identifier, including nil and max; `UUIDRule` additionally checks the RFC 9562 version and variant nibbles. +- **`GuidRule`** accepts any 8-4-4-4-12 hexadecimal identifier, including nil and max; `UUIDRule` additionally checks the RFC 9562 version and variant nibbles. A C# `Guid` is exported to JSON Schema as a canonical hexadecimal `pattern` (not `format: uuid`), because `Guid` accepts any 128-bit value; a `Guid` that must be an RFC version opts in with `[UUID(UuidVersion.Vx)]`. - **`CidrRule`** accepts an IPv4 or IPv6 block; pass a `CidrRuleType` to require `IPv4` or `IPv6` (`Any` is the default). ## Number rules diff --git a/docs/wiki/Value-Objects-Integration.md b/docs/wiki/Value-Objects-Integration.md index 1e9118d..57f12d7 100644 --- a/docs/wiki/Value-Objects-Integration.md +++ b/docs/wiki/Value-Objects-Integration.md @@ -292,6 +292,23 @@ The reported error keeps the **empty path**, and the error identity (`Code`/`Ori > [!NOTE] > If the type has no value-object contract (a plain class with a `Guid` property), the property-level form still works: map the attribute to an `IValidationRule` and put it on `Value`. See [Exposing a rule as a DataAnnotations attribute](Custom-Rules.md#exposing-a-rule-as-a-dataannotations-attribute) and [Generic rules](Custom-Rules.md#generic-rules). +> [!NOTE] +> A `Guid`-backed scalar is validated as a canonical Guid, and exports to JSON Schema as a `pattern`, not +> `format: uuid` — a `Guid` accepts any 128-bit value, and a time-bound identifier generated by +> `FastGuid.NewSqlServerGuid()`/`NewPostgreSqlGuid()` is not an RFC UUID. When an identifier *must* be a +> specific RFC version (for example a `Guid.CreateVersion7()` key), declare it with the UUID rule: +> +> ```csharp +> [Scalar] +> [ZodSchema] +> [UUID(UuidVersion.V7)] +> public readonly partial record struct AssetId { } +> ``` +> +> `UUIDRule` validates a `Guid`'s bytes directly, so the rule adapts onto the scalar; the export is +> `format: "uuid"` plus a version-pinning `pattern`. Generation strategy alone never implies a version +> constraint — it is only applied when the runtime validator applies it. + ## Testing Assert against the rule's constants instead of duplicating literals, and remember that an adapted scalar rule reads its identity from the wrapped rule: diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs index 1b368a2..c041bba 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs @@ -68,6 +68,42 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con ); } + // Convert the base schema (type and its keywords, enum, const, $ref) first, then layer the + // composition keywords on top. An explicit type makes the composition an intersection with the base; + // without one the composition stands alone. This mirrors Zod's fromJSONSchema, and is what lets an + // `allOf` of pattern branches (for example a Guid plus a UUID-version constraint) keep every + // constraint instead of taking only the first branch. + var baseSchema = ConvertBaseSchema(schema, ctx); + var hasExplicitType = schema.Type != null || schema.Enum is { Count: > 0 } || schema.Const != null; + + if (schema.AnyOf is { Count: > 0 } anyOf) + { + ZodUnion union = new(anyOf.Select(s => ConvertSchema(s, ctx)).ToArray()); + baseSchema = hasExplicitType ? Z.Intersection(baseSchema, union) : union; + } + + if (schema.OneOf is { Count: > 0 } oneOf) + { + // oneOf is semantically different (exactly one must match) but we approximate with union + ZodUnion union = new(oneOf.Select(s => ConvertSchema(s, ctx)).ToArray()); + baseSchema = hasExplicitType ? Z.Intersection(baseSchema, union) : union; + } + + if (schema.AllOf is { Count: > 0 } allOf) + { + var result = hasExplicitType ? baseSchema : ConvertSchema(allOf[0], ctx); + var start = hasExplicitType ? 0 : 1; + for (var i = start; i < allOf.Count; i++) + result = Z.Intersection(result, ConvertSchema(allOf[i], ctx)); + + baseSchema = result; + } + + return baseSchema; + } + + static IZodSchema ConvertBaseSchema(JsonSchemaDefinition schema, ConversionContext ctx) + { // Handle $ref if (schema.Ref != null) { @@ -93,27 +129,6 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con return CreateLiteralSchema(schema.Const); } - // Handle composition keywords - if (schema.AnyOf != null && schema.AnyOf.Count > 0) - { - var options = schema.AnyOf.Select(s => ConvertSchema(s, ctx)).ToArray(); - return new ZodUnion(options); - } - - if (schema.OneOf != null && schema.OneOf.Count > 0) - { - // oneOf is semantically different (exactly one must match) but we approximate with union - var options = schema.OneOf.Select(s => ConvertSchema(s, ctx)).ToArray(); - return new ZodUnion(options); - } - - if (schema.AllOf != null && schema.AllOf.Count > 0) - { - // allOf requires all to match - we can only fully support this for objects - // For now, just use the first schema - return ConvertSchema(schema.AllOf[0], ctx); - } - // Handle type if (schema.Type == null) { @@ -121,7 +136,7 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con return new AnySchema(); } - var result = schema.Type switch + return schema.Type switch { "string" => ConvertStringSchema(schema), "number" => ConvertNumberSchema(schema, isInteger: false), @@ -132,8 +147,6 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con "array" => ConvertArraySchema(schema, ctx), _ => new AnySchema(), }; - - return result; } [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 4ae2bfd..4cdf963 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -119,8 +119,8 @@ CancellationToken cancellationToken var isValueType = symbol.TypeKind == TypeKind.Struct; // A Purview.ValueObjects scalar serialises as its underlying value, so its JSON Schema is the - // underlying type's schema (for example a Guid scalar is a UUID string), not an object wrapping a - // Value property. + // underlying type's schema (for example a Guid scalar is a canonical Guid string, not Zod's + // RFC UUID format), not an object wrapping a Value property. TypeIdentity? scalarUnderlyingType = null; var scalarUnderlyingCanBeNull = false; if (CustomRuleResolver.TryGetScalarUnderlyingValue(symbol, out var scalarValueType)) diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index 8a784e6..35d00a7 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -67,7 +67,8 @@ enum EnumJsonNamingPolicy /// True if this is the primary schema for the target type, false if it is a secondary schema. /// /// When the target is a Purview.ValueObjects scalar, the underlying value type whose JSON wire form the -/// scalar serialises as (for example a Guid scalar serialises as a UUID string). Null otherwise. +/// scalar serialises as (for example a Guid scalar serialises as a canonical Guid string). Null +/// otherwise. /// /// /// Whether the scalar's underlying value may be null (a nullable reference or nullable value scalar). diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs index 55c0d9e..e2976f2 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs @@ -81,8 +81,8 @@ CancellationToken cancellationToken /// /// Emits the JSON Schema for a scalar value object: the underlying value's schema (a Guid scalar is a - /// UUID string), with the scalar's own rules applied and a null branch when the underlying value - /// is nullable. + /// canonical Guid string, not Zod's RFC UUID format), with the scalar's own rules applied and a + /// null branch when the underlying value is nullable. /// static void EmitScalarJsonSchema(CodeWriter body, ZodSchemaDescriptor schema, TypeIdentity underlying) { @@ -218,7 +218,7 @@ static string BuildBaseSchemaExpression(TypeIdentity type) return $"new {JsonSchemaDefinitionTypeName}()"; if (IsWellKnown(type, "System", "Guid")) - return Schema("string", "uuid"); + return $"new {JsonSchemaDefinitionTypeName} {{ Type = \"string\", Pattern = global::ZodSharp.Rules.GuidRule.JsonSchemaPattern }}"; if (type.SpecialType == SpecialType.System_DateTime || IsWellKnown(type, "System", "DateTimeOffset")) return Schema("string", "date-time"); diff --git a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs index 7c234ae..41de244 100644 --- a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs @@ -66,6 +66,42 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con ); } + // Convert the base schema (type and its keywords, enum, const, $ref) first, then layer the + // composition keywords on top. An explicit type makes the composition an intersection with the base; + // without one the composition stands alone. This mirrors Zod's fromJSONSchema, and is what lets an + // `allOf` of pattern branches (for example a Guid plus a UUID-version constraint) keep every + // constraint instead of taking only the first branch. + var baseSchema = ConvertBaseSchema(schema, ctx); + var hasExplicitType = schema.Type != null || schema.Enum is { Count: > 0 } || schema.Const != null; + + if (schema.AnyOf is { Count: > 0 } anyOf) + { + ZodUnion union = new(anyOf.Select(s => ConvertSchema(s, ctx)).ToArray()); + baseSchema = hasExplicitType ? Z.Intersection(baseSchema, union) : union; + } + + if (schema.OneOf is { Count: > 0 } oneOf) + { + // oneOf is semantically different (exactly one must match) but we approximate with union + ZodUnion union = new(oneOf.Select(s => ConvertSchema(s, ctx)).ToArray()); + baseSchema = hasExplicitType ? Z.Intersection(baseSchema, union) : union; + } + + if (schema.AllOf is { Count: > 0 } allOf) + { + var result = hasExplicitType ? baseSchema : ConvertSchema(allOf[0], ctx); + var start = hasExplicitType ? 0 : 1; + for (var i = start; i < allOf.Count; i++) + result = Z.Intersection(result, ConvertSchema(allOf[i], ctx)); + + baseSchema = result; + } + + return baseSchema; + } + + static IZodSchema ConvertBaseSchema(JsonSchemaDefinition schema, ConversionContext ctx) + { // Handle $ref if (schema.Ref != null) { @@ -91,27 +127,6 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con return CreateLiteralSchema(schema.Const); } - // Handle composition keywords - if (schema.AnyOf != null && schema.AnyOf.Count > 0) - { - var options = schema.AnyOf.Select(s => ConvertSchema(s, ctx)).ToArray(); - return new ZodUnion(options); - } - - if (schema.OneOf != null && schema.OneOf.Count > 0) - { - // oneOf is semantically different (exactly one must match) but we approximate with union - var options = schema.OneOf.Select(s => ConvertSchema(s, ctx)).ToArray(); - return new ZodUnion(options); - } - - if (schema.AllOf != null && schema.AllOf.Count > 0) - { - // allOf requires all to match - we can only fully support this for objects - // For now, just use the first schema - return ConvertSchema(schema.AllOf[0], ctx); - } - // Handle type if (schema.Type == null) { @@ -119,7 +134,7 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con return new AnySchema(); } - var result = schema.Type switch + return schema.Type switch { "string" => ConvertStringSchema(schema), "number" => ConvertNumberSchema(schema, isInteger: false), @@ -130,8 +145,6 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con "array" => ConvertArraySchema(schema, ctx), _ => new AnySchema(), }; - - return result; } [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs index 4d94f47..7d669bf 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs @@ -74,6 +74,57 @@ public static void MarkUnsupported(JsonSchemaDefinition schema, string ruleName) schema.UnsupportedRules.Add(ruleName); } + /// + /// Adds a pattern constraint to without discarding a pattern that is + /// already present. JSON Schema has a single pattern keyword, so two rules that both constrain + /// the string's shape would otherwise silently overwrite one another (last writer wins). + /// + /// The schema the pattern constrains. + /// The pattern to require in addition to any already present. + /// + /// The first pattern uses the pattern keyword, which keeps single-constraint exports unchanged. + /// A second, different pattern promotes the constraints to allOf, with each branch carrying the + /// schema's type: a consumer that discards a type-less branch (for example Zod's + /// fromJSONSchema, which maps one to z.any()) would otherwise drop the constraint. + /// + public static void ConstrainPattern(JsonSchemaDefinition schema, string pattern) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrEmpty(pattern); + + // Fast path: nothing constrains the pattern yet, so the single keyword is enough. + if (schema.Pattern is null && schema.AllOf is not { Count: > 0 }) + { + schema.Pattern = pattern; + return; + } + + if (string.Equals(schema.Pattern, pattern, StringComparison.Ordinal)) + return; + + if ( + schema.Pattern is null + && schema.AllOf is { } branches + && branches.Any(branch => string.Equals(branch.Pattern, pattern, StringComparison.Ordinal)) + ) + { + return; + } + + schema.AllOf ??= []; + + if (schema.Pattern is { Length: > 0 } current) + { + schema.AllOf.Insert(0, PatternBranch(schema.Type, current)); + schema.Pattern = null; + } + + schema.AllOf.Add(PatternBranch(schema.Type, pattern)); + } + + static JsonSchemaDefinition PatternBranch(string? type, string pattern) => + new() { Type = type ?? "string", Pattern = pattern }; + /// /// Builds the JSON Schema for a native enum: the defined members, minus any /// member, in their numeric wire form (System.Text.Json's default enum representation). diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 55950d4..c890b7c 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -126,12 +126,16 @@ static JsonSchemaDefinition ConvertString(ZodString schema) result.Format = "uri"; break; - case UUIDRule: - result.Format = "uuid"; + case UUIDRule uuid: + uuid.ApplyTo(result); + break; + + case GuidRule guid: + guid.ApplyTo(result); break; case RegexRule regex: - result.Pattern = regex.Pattern.ToString(); + JsonSchemaRules.ConstrainPattern(result, regex.Pattern.ToString()); break; default: diff --git a/src/src/ZodSharp/Rules/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index b3b0941..bf6ac59 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -77,7 +77,7 @@ public string GetErrorMessage(ReadOnlySpan value) => public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { ArgumentNullException.ThrowIfNull(schema); - schema.Pattern = Regex.Escape(_suffix) + "$"; + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, Regex.Escape(_suffix) + "$"); } /// Gets the Zod-compatible error code reported when the rule fails. diff --git a/src/src/ZodSharp/Rules/GuidRule.cs b/src/src/ZodSharp/Rules/GuidRule.cs index 88600cf..986deee 100644 --- a/src/src/ZodSharp/Rules/GuidRule.cs +++ b/src/src/ZodSharp/Rules/GuidRule.cs @@ -18,6 +18,14 @@ public readonly record struct GuidRule /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid GUID: {0}"; + /// + /// The JSON Schema pattern for a canonical 8-4-4-4-12 hexadecimal identifier (any version or + /// variant nibble). The source generator emits this for a , so the exported + /// constraint matches rather than Zod's RFC UUID validator. + /// + public const string JsonSchemaPattern = + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + readonly string _message; /// @@ -87,7 +95,7 @@ public string GetErrorMessage(ReadOnlySpan value) => public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { ArgumentNullException.ThrowIfNull(schema); - schema.Pattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, JsonSchemaPattern); } /// Gets the Zod-compatible error code reported when the rule fails. diff --git a/src/src/ZodSharp/Rules/IncludesRule.cs b/src/src/ZodSharp/Rules/IncludesRule.cs index f8a24c9..a523ab9 100644 --- a/src/src/ZodSharp/Rules/IncludesRule.cs +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -69,7 +69,7 @@ public string GetErrorMessage(ReadOnlySpan value) => public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { ArgumentNullException.ThrowIfNull(schema); - schema.Pattern = Regex.Escape(_substring); + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, Regex.Escape(_substring)); } /// Gets the Zod-compatible error code reported when the rule fails. diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs index 1fedcfa..3e923ea 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -35,6 +35,12 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is a description of the offending sentinel value. public const string MessageFormat = "Value has a sentinel value, but got {0}"; + /// + /// The JSON Schema pattern for a canonical Guid that is not the all-zero . + /// + public const string GuidJsonSchemaPattern = + "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + readonly string _message; /// @@ -73,7 +79,7 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) if (typeof(T) == typeof(string)) { schema.MinLength = 1; - schema.Pattern = "\\S"; + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, "\\S"); return; } @@ -81,9 +87,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { // Expressed as a pattern rather than `not: { const: ... }` so it survives converters that do not // implement JSON Schema `not` (for example Zod's fromJSONSchema). The negative lookahead rejects - // the all-zero UUID while the body requires the canonical 8-4-4-4-12 form. - schema.Pattern = - "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + // the all-zero UUID while the body requires the canonical 8-4-4-4-12 form. Composed through + // ConstrainPattern so a second pattern (for example a UUID version constraint) is not lost. + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, GuidJsonSchemaPattern); return; } diff --git a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs index 32c5552..dfc656e 100644 --- a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs +++ b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs @@ -60,7 +60,7 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { ArgumentNullException.ThrowIfNull(schema); schema.MinLength = 1; - schema.Pattern = "\\S"; + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, "\\S"); } /// diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index 791f072..a8048a1 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -131,6 +131,6 @@ public bool IsValid(ReadOnlySpan value) public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { ArgumentNullException.ThrowIfNull(schema); - schema.Pattern = Pattern.ToString(); + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, Pattern.ToString()); } } diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index f1c50b6..3824ef7 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -77,7 +77,7 @@ public string GetErrorMessage(ReadOnlySpan value) => public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { ArgumentNullException.ThrowIfNull(schema); - schema.Pattern = "^" + Regex.Escape(_prefix); + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, "^" + Regex.Escape(_prefix)); } /// Gets the Zod-compatible error code reported when the rule fails. diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index 25b556c..be72d44 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -7,6 +7,7 @@ namespace ZodSharp.Rules; [Core.ZodRule] public readonly record struct UUIDRule : Core.IValidationRule, + Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule @@ -82,6 +83,53 @@ public bool IsValid(ReadOnlySpan value) return IsValidVersionless(value); } + /// + /// Validates that the 's bytes form a UUID with the required version and a valid RFC + /// variant. This lets the same rule (and the generated [UUID] attribute) validate a + /// [Scalar<Guid>] value object or a plain member, not just a string. + /// + /// The value to validate. + /// True if valid, false otherwise. + public bool IsValid(in Guid value) + { + Span bytes = stackalloc byte[16]; + value.TryWriteBytes(bytes, bigEndian: true, out _); + + if (_version is UuidVersion version) + return (bytes[6] >> 4) == (int)version && IsValidVariantNibble(bytes[8]); + + // nil and max are allowed regardless of version/variant (Zod parity). + if (IsNilOrMax(bytes)) + return true; + + var versionNibble = bytes[6] >> 4; + return versionNibble is >= 1 and <= 8 && IsValidVariantNibble(bytes[8]); + } + + static bool IsValidVariantNibble(byte b) => (b >> 4) is 8 or 9 or 10 or 11; + + static bool IsNilOrMax(ReadOnlySpan bytes) + { + var first = bytes[0]; + if (first is not (0x00 or 0xFF)) + return false; + + foreach (var b in bytes) + { + if (b != first) + return false; + } + + return true; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in Guid value) => GetErrorMessageCore(value.ToString("D")); + /// /// Gets the error message for a failed validation. /// @@ -142,14 +190,36 @@ static bool HasValidStructure(ReadOnlySpan value) static bool IsValidVariant(char c) => c is '8' or '9' or 'a' or 'b' or 'A' or 'B'; - /// Applies the JSON Schema uuid string format. + /// + /// Applies the JSON Schema uuid string format, and a version-pinning pattern when the rule + /// requires a specific RFC 9562 version. + /// /// The schema for the value being validated. + /// + /// JSON Schema has no standard keyword for the RFC version nibble, and a consumer maps + /// format: "uuid" to a versionless RFC validator (for example Zod's fromJSONSchema). The + /// extra pattern carries the version and variant, so a versioned rule is not silently widened on export. + /// public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { ArgumentNullException.ThrowIfNull(schema); schema.Format = "uuid"; + + if (_version is UuidVersion version) + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, JsonSchemaVersionPattern(version)); } + /// + /// Builds the JSON Schema pattern that pins an RFC 9562 version and the RFC variant nibble. + /// + /// The required version (V1-V8). + /// An anchored pattern for a canonical UUID of the requested version. + /// Thrown when is . + public static string JsonSchemaVersionPattern(UuidVersion version) => + version == UuidVersion.None + ? throw new ArgumentOutOfRangeException(nameof(version), version, "UUID version must be between V1 and V8.") + : $"^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-{(int)version}[0-9a-fA-F]{{3}}-[89abAB][0-9a-fA-F]{{3}}-[0-9a-fA-F]{{12}}$"; + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs index e6ff327..634896f 100644 --- a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs +++ b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs @@ -1,5 +1,6 @@ using ZodSharp.JsonSchema; using ZodSharp.JsonSchema.NewtonsoftJson; +using ZodSharp.Rules; namespace ZodSharp.Json.Schema; @@ -135,4 +136,30 @@ public async Task JsonSchemaSerializerOptions_GivenRefAndDefs_UsesJsonSchemaKeyw await Assert.That(json).Contains("\"$ref\""); await Assert.That(json).Contains("\"$defs\""); } + + [Test] + public async Task Parse_GivenAllOfPatternBranches_EnforcesEveryBranch(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — the shape a Guid scalar with [NonSentinel] exports as: the canonical Guid pattern and the + // non-empty pattern as two allOf branches. + JsonSchemaDefinition definition = new() + { + Type = "string", + AllOf = + [ + new JsonSchemaDefinition { Type = "string", Pattern = GuidRule.JsonSchemaPattern }, + new JsonSchemaDefinition { Type = "string", Pattern = NonSentinelRule.GuidJsonSchemaPattern }, + ], + }; + + // Act + var schema = FromJsonSchemaParser.Parse(definition); + + // Assert — every branch is applied: the non-RFC Guid passes, the empty Guid and a malformed value fail. + await Assert.That(schema.Validate("9f41585f-31c5-fc4b-0d04-b1fe5b523139").IsSuccess).IsTrue(); + await Assert.That(schema.Validate("00000000-0000-0000-0000-000000000000").IsSuccess).IsFalse(); + await Assert.That(schema.Validate("not-a-guid").IsSuccess).IsFalse(); + } } diff --git a/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj b/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj index 977ffb1..9b4a2d4 100644 --- a/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj +++ b/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj @@ -6,6 +6,9 @@ + + diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs index 9363c1e..00eca7e 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using ZodSharp.JsonSchema; +using ZodSharp.Rules; using ZodSharp.SourceGenerators.Infra; namespace ZodSharp.SourceGenerators; @@ -488,6 +489,204 @@ bool IsValid(string? name, string? branch) await Assert.That(IsValid("name", "main")).IsTrue(); } + [Test] + public async Task JsonSchema_GivenGuidProperty_ExportsTheCanonicalPatternWithoutUuidFormat( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + + namespace Testing + { + [ZodSchema] + public class Widget + { + public Guid Id { get; set; } + } + } + """; + + // Act + var definition = await BuildDefinitionAsync(source, "WidgetSchema", cancellationToken); + + // Assert — a C# Guid accepts any 128-bit value, so it must not carry Zod's RFC UUID format. + var id = definition.Properties!["id"]; + await Assert.That(id.Type).IsEqualTo("string"); + await Assert.That(id.Format).IsNull(); + await Assert.That(id.Pattern).IsEqualTo(GuidRule.JsonSchemaPattern); + } + + [Test] + public async Task JsonSchema_GivenUuidAttributes_ExportsFormatAndVersionPattern(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public class Widget + { + [UUID] + public string Any { get; set; } = string.Empty; + + [UUID(UuidVersion.V4)] + public string V4 { get; set; } = string.Empty; + } + } + """; + + // Act + var definition = await BuildDefinitionAsync(source, "WidgetSchema", cancellationToken); + + // Assert — the versionless rule is the uuid format alone; the versioned rule pins the version with a + // pattern, since JSON Schema has no versioned uuid format. + var any = definition.Properties!["any"]; + await Assert.That(any.Format).IsEqualTo("uuid"); + await Assert.That(any.Pattern).IsNull(); + + var v4 = definition.Properties!["v4"]; + await Assert.That(v4.Format).IsEqualTo("uuid"); + await Assert.That(v4.Pattern).IsEqualTo(UUIDRule.JsonSchemaVersionPattern(UuidVersion.V4)); + } + + [Test] + public async Task JsonSchema_GivenGuidScalarWithNonSentinel_ComposesBothPatternsThroughAllOf( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [NonSentinel] + public partial record struct TenantId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + var definition = await BuildDefinitionAsync(source, "TenantIdSchema", cancellationToken); + + // Assert — the canonical Guid pattern and the non-empty pattern are both preserved. + await Assert.That(definition.Type).IsEqualTo("string"); + await Assert.That(definition.Format).IsNull(); + await Assert.That(definition.Pattern).IsNull(); + await Assert.That(definition.AllOf!).Count().IsEqualTo(2); + await Assert.That(definition.AllOf!.Select(branch => branch.Pattern)).Contains(GuidRule.JsonSchemaPattern); + await Assert + .That(definition.AllOf!.Select(branch => branch.Pattern)) + .Contains(NonSentinelRule.GuidJsonSchemaPattern); + } + + [Test] + public async Task JsonSchema_GivenGuidScalarWithUuidVersion_ExportsUuidFormatAndVersionPattern( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [UUID(UuidVersion.V7)] + public partial record struct AssetId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + var definition = await BuildDefinitionAsync(source, "AssetIdSchema", cancellationToken); + + // Assert — a Guid-backed scalar can require a UUID version; the base Guid pattern and the version + // pattern compose, so the version is not lost. + await Assert.That(definition.Type).IsEqualTo("string"); + await Assert.That(definition.Format).IsEqualTo("uuid"); + await Assert.That(definition.AllOf).IsNotNull(); + await Assert.That(definition.AllOf!.Select(branch => branch.Pattern)).Contains(GuidRule.JsonSchemaPattern); + await Assert + .That(definition.AllOf!.Select(branch => branch.Pattern)) + .Contains(UUIDRule.JsonSchemaVersionPattern(UuidVersion.V7)); + } + + [Test] + public async Task JsonSchema_GivenGuidScalarNestedInObjectAndArray_ExportsThePatternAtEachLevel( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [NonSentinel] + public partial record struct TenantId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + + [ZodSchema] + public class Holder + { + public Guid Plain { get; set; } + + public System.Collections.Generic.List PlainGuids { get; set; } = new(); + + public TenantId Tenant { get; set; } + + public System.Collections.Generic.List Tenants { get; set; } = new(); + } + } + """; + + // Act + var definition = await BuildDefinitionAsync(source, "HolderSchema", cancellationToken); + + // Assert — a plain Guid and a Guid-backed scalar keep their constraint at the property, element and + // nested-scalar level. + await Assert.That(definition.Properties!["plain"].Pattern).IsEqualTo(GuidRule.JsonSchemaPattern); + await Assert.That(definition.Properties!["plainGuids"].Items!.Pattern).IsEqualTo(GuidRule.JsonSchemaPattern); + await Assert.That(definition.Properties!["tenant"].AllOf!).Count().IsEqualTo(2); + await Assert.That(definition.Properties!["tenants"].Items!.AllOf!).Count().IsEqualTo(2); + } + + async Task BuildDefinitionAsync( + string source, + string schemaTypeName, + CancellationToken cancellationToken + ) + { + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + return (JsonSchemaDefinition) + assembly.GetType($"Testing.{schemaTypeName}")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + } + const string EnumSchemaSource = @" namespace Testing diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs new file mode 100644 index 0000000..6ed154e --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs @@ -0,0 +1,256 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using SecurityDriven; +using ZodSharp.JsonSchema; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +/// +/// Produces the shared Guid/UUID parity artifacts consumed by the TypeScript cross-platform test +/// (tests/ts/guid-uuid-parity.test.ts). Each artifact records a schema exported by the generator +/// (BuildJsonSchema) alongside payloads and the outcome of the generated C# validator, so the TS side +/// can prove that importing the exported schema with Zod's z.fromJSONSchema agrees with C#. +/// +/// +/// The producer lives here because the generator test harness can compile a [ZodSchema] model — and +/// the stand-in Purview.ValueObjects scalar contracts — without the repository referencing the +/// value-objects package. FastGuid is used for the time-bound identifier values the consumer generates; +/// those are deliberately not RFC UUIDs. +/// +public partial class ZodSchemaGeneratorTests +{ + const string ParityOutputRelativePath = "src/tests/cross-platform/guid-parity"; + + static readonly JsonSerializerOptions ParitySerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + const string NonRfcGuid = "9f41585f-31c5-fc4b-0d04-b1fe5b523139"; + const string PlainGuid = "1025f652-88f2-4b16-b4e1-a82d3b5f47ce"; + const string V4Uuid = "550e8400-e29b-41d4-a716-446655440000"; + const string V7Uuid = "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"; + const string NilUuid = "00000000-0000-0000-0000-000000000000"; + + /// + /// The models compiled for the parity run: a Guid holder (plain, optional, non-empty, list, scalar and + /// scalar list), a UUID holder (versionless, V4, V7), and a bare Guid-backed scalar. + /// + const string GuidParitySource = + "using System.Linq;\nusing System.Text.Json;\nusing System.Text.Json.Serialization;\n" + + AutomaticScalarPrelude + + """ + namespace Testing + { + /// Reads/writes a Guid-backed scalar as its canonical string wire form. + public sealed class GuidScalarJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) => + typeToConvert.GetInterfaces().Any(i => + i.IsGenericType + && i.GetGenericTypeDefinition() == typeof(Purview.ValueObjects.IScalarValueObject<,>) + && i.GetGenericArguments()[1] == typeof(Guid)); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => + (JsonConverter)Activator.CreateInstance( + typeof(GuidScalarJsonConverter<>).MakeGenericType(typeToConvert) + )!; + } + + public sealed class GuidScalarJsonConverter : JsonConverter + where TSelf : Purview.ValueObjects.IScalarValueObject + { + public override TSelf Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetGuid(); + var boxed = Activator.CreateInstance(typeToConvert)!; + typeToConvert.GetProperty("Value")!.SetValue(boxed, value); + return (TSelf)boxed; + } + + public override void Write(Utf8JsonWriter writer, TSelf value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); + } + + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [NonSentinel] + [JsonConverter(typeof(GuidScalarJsonConverterFactory))] + public partial record struct TenantId : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + + [ZodSchema] + public class GuidHolder + { + public Guid Plain { get; set; } + + public Guid? Optional { get; set; } + + [NonSentinel] + public Guid NonEmpty { get; set; } + + public System.Collections.Generic.List PlainGuids { get; set; } = new(); + + public TenantId Tenant { get; set; } + + public System.Collections.Generic.List Tenants { get; set; } = new(); + } + + [ZodSchema] + public class UuidHolder + { + [UUID] + public string Any { get; set; } = string.Empty; + + [UUID(UuidVersion.V4)] + public string V4 { get; set; } = string.Empty; + + [UUID(UuidVersion.V7)] + public string V7 { get; set; } = string.Empty; + } + } + """; + + [Test] + public async Task JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — real time-bound identifier values. FastGuid's database generators are not RFC UUIDs, so + // they must survive the exported schema. + var sqlServerGuid = FastGuid.NewSqlServerGuid(); + var postgreSqlGuid = FastGuid.NewPostgreSqlGuid(); + var v7Guid = FastGuid.UUID.CreateVersion7(); + + var driverResult = await GenerateAsync( + GuidParitySource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var outputDir = Path.Combine("..", "..", "..", "..", "..", "..", ParityOutputRelativePath); + + // Act & Assert + await WriteParityArtifactAsync( + assembly, + "GuidHolder", + [ + ("reproduction", GuidHolderPayload(NonRfcGuid, PlainGuid), true), + ("fastguid-sqlserver", GuidHolderPayload($"{sqlServerGuid:D}", $"{sqlServerGuid:D}"), true), + ("fastguid-postgresql", GuidHolderPayload($"{postgreSqlGuid:D}", $"{postgreSqlGuid:D}"), true), + ("fastguid-v7", GuidHolderPayload($"{v7Guid:D}", $"{v7Guid:D}"), true), + ("empty-plain", GuidHolderPayload(NilUuid, PlainGuid), true), + ("empty-nonsentinel", GuidHolderPayload(PlainGuid, NilUuid), false), + ("malformed", GuidHolderPayload("not-a-guid", PlainGuid), false), + ], + outputDir, + cancellationToken + ); + + await WriteParityArtifactAsync( + assembly, + "UuidHolder", + [ + ("versionless-accepts-v4", UuidHolderPayload(V4Uuid, V4Uuid, V7Uuid), true), + ("versionless-accepts-v7", UuidHolderPayload(V7Uuid, V4Uuid, V7Uuid), true), + ("non-rfc-rejected-by-versionless", UuidHolderPayload(NonRfcGuid, V4Uuid, V7Uuid), false), + ("v4-mismatch", UuidHolderPayload(V4Uuid, V7Uuid, V7Uuid), false), + ("v7-mismatch", UuidHolderPayload(V7Uuid, V4Uuid, V4Uuid), false), + ], + outputDir, + cancellationToken + ); + + await WriteParityArtifactAsync( + assembly, + "TenantId", + [ + ("reproduction", JsonString(NonRfcGuid), true), + ("fastguid", JsonString($"{sqlServerGuid:D}"), true), + ("empty", JsonString(NilUuid), false), + ("malformed", JsonString("not-a-guid"), false), + ], + outputDir, + cancellationToken + ); + } + + static string GuidHolderPayload(string plain, string nonEmpty) => + $$""" + { + "plain": "{{plain}}", + "nonEmpty": "{{nonEmpty}}", + "plainGuids": ["{{plain}}"], + "tenant": "{{PlainGuid}}", + "tenants": ["{{PlainGuid}}"] + } + """; + + static string UuidHolderPayload(string any, string v4, string v7) => + $$""" + { + "any": "{{any}}", + "v4": "{{v4}}", + "v7": "{{v7}}" + } + """; + + static string JsonString(string value) => $"\"{value}\""; + + static async Task WriteParityArtifactAsync( + Assembly assembly, + string typeName, + IReadOnlyList<(string Name, string Value, bool Valid)> cases, + string outputDir, + CancellationToken cancellationToken + ) + { + var schemaType = assembly.GetType($"Testing.{typeName}Schema")!; + var modelType = assembly.GetType($"Testing.{typeName}")!; + var schema = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + var validate = schemaType.GetMethod("Validate")!; + JsonSerializerOptions jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + List results = [with(cases.Count)]; + foreach (var (name, value, expected) in cases) + { + var actual = ComputeValidity(modelType, validate, value, jsonOptions); + await Assert.That(actual).IsEqualTo(expected).Because($"{typeName}/{name}"); + results.Add(new ParityCase(name, value, actual)); + } + + ParityArtifact artifact = new(typeName, schema, results); + var json = JsonSerializer.Serialize(artifact, ParitySerializerOptions); + + Directory.CreateDirectory(outputDir); + await File.WriteAllTextAsync(Path.Combine(outputDir, $"{typeName}.json"), json, cancellationToken); + } + + static bool ComputeValidity(Type modelType, MethodInfo validate, string value, JsonSerializerOptions jsonOptions) + { + try + { + var model = JsonSerializer.Deserialize(value, modelType, jsonOptions); + if (model is null) + return false; + + var result = validate.Invoke(null, [model])!; + return (bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!; + } + catch (JsonException) + { + // A payload the C# wire contract cannot even deserialize is rejected end to end. + return false; + } + } + + sealed record ParityCase(string Name, string Value, bool Valid); + + sealed record ParityArtifact(string Name, JsonSchemaDefinition Schema, List Cases); +} diff --git a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs index 6cd4467..622184d 100644 --- a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs +++ b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs @@ -1,5 +1,6 @@ using ZodSharp.JsonSchema; using ZodSharp.JsonSchema.SystemTextJson; +using ZodSharp.Rules; namespace ZodSharp.Json.Schema; @@ -148,4 +149,30 @@ public async Task JsonSchemaSerializerOptions_GivenRefAndDefs_UsesJsonSchemaKeyw await Assert.That(json).Contains("\"$ref\""); await Assert.That(json).Contains("\"$defs\""); } + + [Test] + public async Task Parse_GivenAllOfPatternBranches_EnforcesEveryBranch(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — the shape a Guid scalar with [NonSentinel] exports as: the canonical Guid pattern and the + // non-empty pattern as two allOf branches. + JsonSchemaDefinition definition = new() + { + Type = "string", + AllOf = + [ + new JsonSchemaDefinition { Type = "string", Pattern = GuidRule.JsonSchemaPattern }, + new JsonSchemaDefinition { Type = "string", Pattern = NonSentinelRule.GuidJsonSchemaPattern }, + ], + }; + + // Act + var schema = FromJsonSchemaParser.Parse(definition); + + // Assert — every branch is applied: the non-RFC Guid passes, the empty Guid and a malformed value fail. + await Assert.That(schema.Validate("9f41585f-31c5-fc4b-0d04-b1fe5b523139").IsSuccess).IsTrue(); + await Assert.That(schema.Validate("00000000-0000-0000-0000-000000000000").IsSuccess).IsFalse(); + await Assert.That(schema.Validate("not-a-guid").IsSuccess).IsFalse(); + } } diff --git a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaGuidTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaGuidTests.cs new file mode 100644 index 0000000..98b17a6 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaGuidTests.cs @@ -0,0 +1,128 @@ +using System.Text.RegularExpressions; +using ZodSharp.Rules; + +namespace ZodSharp.JsonSchema; + +/// +/// Coverage for Guid/UUID export semantics. A C# accepts any 128-bit value, so it must +/// not be exported as Zod's RFC UUID format; a UUID rule must keep its version; and two rules that both +/// constrain pattern must not overwrite one another. +/// +public class ToJsonSchemaGuidTests +{ + const string NonRfcGuid = "9f41585f-31c5-fc4b-0d04-b1fe5b523139"; + + [Test] + public async Task ToJsonSchema_GivenGuidString_ExportsCanonicalPatternWithoutUuidFormat( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().Guid()); + + // Assert — no `format`, so a consumer does not apply its RFC UUID validator. + await Assert.That(jsonSchema.Type).IsEqualTo("string"); + await Assert.That(jsonSchema.Format).IsNull(); + await Assert.That(jsonSchema.Pattern).IsEqualTo(GuidRule.JsonSchemaPattern); + } + + [Test] + public async Task ToJsonSchema_GivenGuidString_AcceptsNonRfcAndRejectsMalformed(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var pattern = Z.ToJsonSchema(Z.String().Guid()).Pattern!; + + // Assert — the reproduction value (version nibble `f`, variant nibble `0`) is a valid Guid. + await Assert.That(Regex.IsMatch(NonRfcGuid, pattern)).IsTrue(); + await Assert.That(Regex.IsMatch(Guid.Empty.ToString("D"), pattern)).IsTrue(); + await Assert.That(Regex.IsMatch("9f41585f-31c5-fc4b-0d04-b1fe5b52313", pattern)).IsFalse(); + await Assert.That(Regex.IsMatch("not-a-guid", pattern)).IsFalse(); + } + + [Test] + public async Task ToJsonSchema_GivenVersionlessUuid_ExportsUuidFormatOnly(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().UUID()); + + // Assert + await Assert.That(jsonSchema.Format).IsEqualTo("uuid"); + await Assert.That(jsonSchema.Pattern).IsNull(); + } + + [Test] + public async Task ToJsonSchema_GivenVersionedUuid_ExportsUuidFormatAndVersionPattern( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().UUID(UuidVersion.V4)); + + // Assert — the version is carried by the pattern, since JSON Schema has no versioned uuid format. + await Assert.That(jsonSchema.Format).IsEqualTo("uuid"); + await Assert.That(jsonSchema.Pattern).IsEqualTo(UUIDRule.JsonSchemaVersionPattern(UuidVersion.V4)); + await Assert.That(Regex.IsMatch("550e8400-e29b-41d4-a716-446655440000", jsonSchema.Pattern!)).IsTrue(); + await Assert.That(Regex.IsMatch("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c", jsonSchema.Pattern!)).IsFalse(); + } + + [Test] + public async Task ToJsonSchema_GivenTwoPatternRules_PreservesBothThroughAllOf(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act — StartsWith and EndsWith both constrain `pattern`; neither may overwrite the other. + var jsonSchema = Z.ToJsonSchema(Z.String().StartsWith("a").EndsWith("z")); + + // Assert + await Assert.That(jsonSchema.Pattern).IsNull(); + await Assert.That(jsonSchema.AllOf).IsNotNull(); + await Assert.That(jsonSchema.AllOf!).Count().IsEqualTo(2); + await Assert.That(jsonSchema.AllOf!.Select(branch => branch.Pattern)).Contains("^a"); + await Assert.That(jsonSchema.AllOf!.Select(branch => branch.Pattern)).Contains("z$"); + } + + [Test] + public async Task ConstrainPattern_GivenTwoPatterns_PromotesToTypedAllOfBranches( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string" }; + + // Act + JsonSchemaRules.ConstrainPattern(schema, "^a"); + JsonSchemaRules.ConstrainPattern(schema, "z$"); + + // Assert — each branch carries the type, because Zod's fromJSONSchema drops a type-less branch. + await Assert.That(schema.Pattern).IsNull(); + await Assert.That(schema.AllOf!).Count().IsEqualTo(2); + await Assert.That(schema.AllOf!.All(branch => branch.Type == "string")).IsTrue(); + } + + [Test] + public async Task ConstrainPattern_GivenTheSamePatternTwice_KeepsASingleKeyword(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string" }; + + // Act + JsonSchemaRules.ConstrainPattern(schema, "^a"); + JsonSchemaRules.ConstrainPattern(schema, "^a"); + + // Assert + await Assert.That(schema.Pattern).IsEqualTo("^a"); + await Assert.That(schema.AllOf).IsNull(); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs b/src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs new file mode 100644 index 0000000..836dca6 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs @@ -0,0 +1,71 @@ +namespace ZodSharp.Rules; + +/// +/// A is a UUID, so the UUID rule validates its bytes directly. This is what lets a +/// [Scalar<Guid>] or a plain member carry a UUID-version constraint through the +/// generated [UUID] attribute. +/// +public class UuidRuleGuidTests +{ + const string Nil = "00000000-0000-0000-0000-000000000000"; + const string Max = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + const string V4 = "550e8400-e29b-41d4-a716-446655440000"; + const string V7 = "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"; + + // Version nibble `f` and variant nibble `0`, so it is a valid Guid but not an RFC UUID. + const string NonRfc = "9f41585f-31c5-fc4b-0d04-b1fe5b523139"; + + [Test] + [Arguments(Nil, true)] + [Arguments(Max, true)] + [Arguments(V4, true)] + [Arguments(V7, true)] + [Arguments(NonRfc, false)] + public async Task Versionless_GivenGuid_MatchesTheRfcUuidPredicate(string value, bool expected) + { + // Arrange + UUIDRule rule = new(); + + // Act + var isValid = rule.IsValid(Guid.Parse(value)); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments(UuidVersion.V4, V4, true)] + [Arguments(UuidVersion.V4, V7, false)] + [Arguments(UuidVersion.V4, Nil, false)] + [Arguments(UuidVersion.V4, NonRfc, false)] + [Arguments(UuidVersion.V7, V7, true)] + [Arguments(UuidVersion.V7, V4, false)] + [Arguments(UuidVersion.V7, NonRfc, false)] + public async Task Versioned_GivenGuid_RequiresTheDeclaredVersion(UuidVersion version, string value, bool expected) + { + // Arrange + UUIDRule rule = new(version); + + // Act + var isValid = rule.IsValid(Guid.Parse(value)); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + public async Task Versioned_GivenGuid_UsesTheVersionedMessageFormat(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + UUIDRule rule = new(UuidVersion.V4); + + // Act + var message = rule.GetErrorMessage(Guid.Parse(V7)); + + // Assert + await Assert.That(message).Contains("v4"); + await Assert.That(message).Contains(V7); + } +} diff --git a/tests/ts/guid-uuid-parity.test.ts b/tests/ts/guid-uuid-parity.test.ts new file mode 100644 index 0000000..e60f32d --- /dev/null +++ b/tests/ts/guid-uuid-parity.test.ts @@ -0,0 +1,58 @@ +/** + * Guid/UUID cross-platform parity: C# (Purview.ZodSharp) -> Zod. + * + * The C# side (SourceGenerators.UnitTests) exports each generated schema with `BuildJsonSchema()` and writes + * a shared payload set with the outcome of the generated C# validator. This suite imports each exported + * schema with Zod's built-in `z.fromJSONSchema()` and checks it agrees with C# for the same payloads — no + * hand-written TypeScript equivalent schemas. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { z } from "zod"; + +const parityDir = resolve("src", "tests", "cross-platform", "guid-parity"); + +interface ParityCase { + name: string; + value: string; + valid: boolean; +} + +interface ParityArtifact { + name: string; + schema: unknown; + cases: ParityCase[]; +} + +let artifacts: ParityArtifact[] = []; +try { + artifacts = readdirSync(parityDir) + .filter((file) => file.endsWith(".json")) + .map((file) => JSON.parse(readFileSync(resolve(parityDir, file), "utf-8")) as ParityArtifact); +} catch { + // The directory does not exist until the C# tests run. Handled below. +} + +describe("Guid/UUID export matches C# validation under z.fromJSONSchema", () => { + if (artifacts.length === 0) { + // Do NOT pass silently: an empty directory means this suite verified nothing. + it("has C# parity artifacts to validate", () => { + expect.fail( + `No parity artifacts found in ${parityDir}. ` + + "Run the C# tests first (`just test`) so they write their output, then re-run `bun run test`.", + ); + }); + } + + for (const artifact of artifacts) { + const zodSchema = z.fromJSONSchema(artifact.schema as never); + + for (const testCase of artifact.cases) { + it(`${artifact.name} / ${testCase.name} — matches C#`, () => { + const result = zodSchema.safeParse(JSON.parse(testCase.value)); + expect(result.success, `${artifact.name}/${testCase.name} (${testCase.value})`).toBe(testCase.valid); + }); + } + } +}); From 38e5e2fef91bfc77d9dd007effaecf6d346a2509 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 12 Oct 2026 12:07:28 +0100 Subject: [PATCH 07/21] chore: bumped build-sdk --- global.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/global.json b/global.json index bb960c5..9375bf3 100644 --- a/global.json +++ b/global.json @@ -5,9 +5,9 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.4" + "Purview.BuildSdk": "1.0.5" }, "test": { "runner": "Microsoft.Testing.Platform" } -} +} \ No newline at end of file From cfc0e378bf5aa46e542bd58ab4e540a55cd891f5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 12 Oct 2026 15:03:26 +0100 Subject: [PATCH 08/21] test: dispose source generator results that emit an assembly DriverRunResult is IDisposable: for a CompileToAssembly run, Dispose unloads the collectible AssemblyLoadContext holding the emitted assembly and releases the MetadataLoadContext. None of the 75 .Compile() call sites honoured that contract, so every emitted assembly stayed loaded for the lifetime of the test run. Adds `using` to the 71 direct call sites. The remaining four call BuildEnumDefinitionAsync, which returns a non-disposable JsonSchemaDefinition -- the leak was inside the helper, so the result is disposed there instead, after the schema has been built. This is a correctness fix against the documented contract, not a memory fix: peak working set across the four TFM processes is unchanged (measured 15.5 GB vs 14.0 GB baseline, within run-to-run noise). The dominant cost is per-test MetadataReference re-creation in the Testing harness, which is being addressed upstream in Purview.SourceGeneratorFramework. --- .../ZodSchemaGeneratorTests.AttributeGen.cs | 2 +- ...ZodSchemaGeneratorTests.AutomaticScalar.cs | 4 ++-- ...emaGeneratorTests.BuiltInRuleAttributes.cs | 14 ++++++------ ...odSchemaGeneratorTests.ComparableRanges.cs | 2 +- .../ZodSchemaGeneratorTests.ComplexTypes.cs | 12 +++++----- ...SchemaGeneratorTests.CompositionMethods.cs | 4 ++-- .../ZodSchemaGeneratorTests.CustomRules.cs | 2 +- ...aGeneratorTests.CustomValidationRuntime.cs | 8 +++---- ...ZodSchemaGeneratorTests.DataAnnotations.cs | 22 +++++++++---------- .../ZodSchemaGeneratorTests.EnumValidation.cs | 14 ++++++------ .../ZodSchemaGeneratorTests.ErrorMessage.cs | 4 ++-- .../ZodSchemaGeneratorTests.GenericRules.cs | 8 +++---- .../ZodSchemaGeneratorTests.JsonSchema.cs | 16 ++++++++------ ...aGeneratorTests.JsonSchemaCrossPlatform.cs | 2 +- .../ZodSchemaGeneratorTests.NestedClasses.cs | 2 +- ...odSchemaGeneratorTests.OptionsValidator.cs | 2 +- ...dSchemaGeneratorTests.PositionalRecords.cs | 2 +- ...aGeneratorTests.RuleAttributeGeneration.cs | 12 +++++----- ...odSchemaGeneratorTests.ScalarAdaptation.cs | 2 +- ...odSchemaGeneratorTests.StaticProperties.cs | 4 ++-- .../ZodSchemaGeneratorTests.SyncValidation.cs | 4 ++-- .../ZodSchemaGeneratorTests.TypeRules.cs | 2 +- ...maGeneratorTests.UnresolvableProperties.cs | 2 +- 23 files changed, 74 insertions(+), 72 deletions(-) diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs index 346be42..301a65d 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs @@ -42,7 +42,7 @@ public class Empty { } "; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs index b9e0ca8..68f3018 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs @@ -389,7 +389,7 @@ public readonly partial record struct InstallationId """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -474,7 +474,7 @@ public readonly partial record struct TenantId """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs index db24042..b8a64b3 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs @@ -104,7 +104,7 @@ public readonly record struct UpperBoundRule(T maxValue, string? message = nu """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -305,7 +305,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( BuiltInAttributeSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -412,7 +412,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( BuiltInScalarAttributeSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -500,7 +500,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( NewBuiltInAttributeSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -643,7 +643,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( UuidAttributeSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -743,7 +743,7 @@ public sealed class RequiredParityModel } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -825,7 +825,7 @@ public partial class Choice """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs index 73248d0..dd241bd 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs @@ -122,7 +122,7 @@ public class Limits } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComplexTypes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComplexTypes.cs index 78b0639..516c17e 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComplexTypes.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComplexTypes.cs @@ -30,7 +30,7 @@ public class Child } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -83,7 +83,7 @@ public class Child } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -147,7 +147,7 @@ public class Child } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -205,7 +205,7 @@ public class Child } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -257,7 +257,7 @@ public class Child } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -317,7 +317,7 @@ public class Child } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs index b316cab..34b0264 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs @@ -72,7 +72,7 @@ public class RefineModel "; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -127,7 +127,7 @@ public static class CompositionUser // Act — compile and invoke, proving the generated validator can be cast to // IZodSchema and used in the composition API. - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs index 18b9621..bcde18b 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs @@ -58,7 +58,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( CustomRuleSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidationRuntime.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidationRuntime.cs index 43211ca..998b118 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidationRuntime.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidationRuntime.cs @@ -30,7 +30,7 @@ internal static ValueTask> CustomValidationAsync( } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -79,7 +79,7 @@ internal static ValueTask> CustomValidationAsync( } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -133,7 +133,7 @@ internal static ValueTask> CustomValidationAsync } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -184,7 +184,7 @@ internal static ValueTask> CustomValidationA } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs index 9fc5fa4..ab95740 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs @@ -10,7 +10,7 @@ partial class ZodSchemaGeneratorTests [Test] public async Task GeneratedValidate_GivenUserWithValidData_ReturnsSuccess(CancellationToken cancellationToken) { - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( UserSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -44,7 +44,7 @@ public async Task GeneratedValidate_GivenInvalidUser_ReturnsFailureWithExpectedP CancellationToken cancellationToken ) { - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( UserSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -64,7 +64,7 @@ CancellationToken cancellationToken [Test] public async Task GeneratedParse_GivenInvalidUser_ThrowsZodException(CancellationToken cancellationToken) { - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( UserSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -178,7 +178,7 @@ public sealed class AttributeExamples } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -216,7 +216,7 @@ public sealed class BackslashPatternModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -260,7 +260,7 @@ public sealed class ResourceModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -310,7 +310,7 @@ public sealed class Inventory } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -362,7 +362,7 @@ public sealed class AttributeRuntimeModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -440,7 +440,7 @@ public sealed class FullAttributeCoverageModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -565,7 +565,7 @@ public sealed class StringFormatRuntimeModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -627,7 +627,7 @@ public sealed class StringFormatValidModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs index 859d136..001caf2 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs @@ -60,7 +60,7 @@ public async Task EnumProperty_GivenUndefinedValue_ReportsInvalidEnumValue(Cance const string source = EnumModel; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -91,7 +91,7 @@ public async Task EnumProperty_GivenDefinedValue_ReturnsSuccess(CancellationToke const string source = EnumModel; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -290,7 +290,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -380,7 +380,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -434,7 +434,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -527,7 +527,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -585,7 +585,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ErrorMessage.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ErrorMessage.cs index 4277301..3641d91 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ErrorMessage.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ErrorMessage.cs @@ -26,7 +26,7 @@ public sealed class RouteOptions } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -65,7 +65,7 @@ public sealed class PagingOptions } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs index fa6b97e..1dc51a5 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs @@ -80,7 +80,7 @@ await Assert public async Task GenericRule_GivenScalarLikeStruct_FailsAtRuntimeForEmptyGuid(CancellationToken cancellationToken) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( GenericRuleSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -222,7 +222,7 @@ CancellationToken cancellationToken // Arrange — the attribute maps to a value-object-only rule and is applied to a `string` member and to // a scalar value object. // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( RuleFamilySource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -257,7 +257,7 @@ CancellationToken cancellationToken ); // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -314,7 +314,7 @@ public partial class Repository """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs index 00eca7e..8cbd25b 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs @@ -78,7 +78,7 @@ public class Widget }"; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -119,7 +119,7 @@ public class Inner }"; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -183,7 +183,7 @@ public class Wrapper }"; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -225,7 +225,7 @@ public class Widget }"; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -443,7 +443,7 @@ public sealed class Item }"; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -677,7 +677,7 @@ async Task BuildDefinitionAsync( CancellationToken cancellationToken ) { - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -706,7 +706,9 @@ async Task BuildEnumDefinitionAsync( CancellationToken cancellationToken ) { - var driverResult = await GenerateAsync(source, options, cancellationToken); + // The emitted assembly is loaded into a collectible context; dispose it once the schema has been + // built so the context unloads instead of accumulating for the lifetime of the test run. + using var driverResult = await GenerateAsync(source, options, cancellationToken); var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); return (JsonSchemaDefinition) assembly.GetType("Testing.WidgetSchema")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs index 6ed154e..2341b7c 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs @@ -128,7 +128,7 @@ public async Task JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts( var postgreSqlGuid = FastGuid.NewPostgreSqlGuid(); var v7Guid = FastGuid.UUID.CreateVersion7(); - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( GuidParitySource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs index c686457..17554b7 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs @@ -80,7 +80,7 @@ private class Inner } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs index 4e1c8d1..31acd2f 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs @@ -190,7 +190,7 @@ public sealed class AdminPortalOptions } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs index 45813a1..24d5ff0 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs @@ -821,7 +821,7 @@ CancellationToken cancellationToken CancellationToken cancellationToken ) { - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs index 7c4c475..afefcc4 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs @@ -69,7 +69,7 @@ public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? M """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -209,7 +209,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( GenericRuleMarkerSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -245,7 +245,7 @@ public readonly record struct NonWhiteSpaceStringRule(string? Message = null) ); // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -290,7 +290,7 @@ public readonly record struct SingleRule(int Step = 1, string? Message = null) : """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -344,7 +344,7 @@ public partial class Sample """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -635,7 +635,7 @@ public partial class Sample """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs index 9e5b727..3593e9f 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs @@ -214,7 +214,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( ScalarAdaptationSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StaticProperties.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StaticProperties.cs index 98bbf3f..075ea49 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StaticProperties.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StaticProperties.cs @@ -30,7 +30,7 @@ public readonly partial record struct ProviderType """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -71,7 +71,7 @@ public string this[int index] """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs index d4a2c5d..2b3b880 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs @@ -138,7 +138,7 @@ partial void OnZodValidate(RefineCtx context) } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -193,7 +193,7 @@ partial void OnZodValidate(RefineCtx context) } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs index 5b622fa..788e4ad 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs @@ -217,7 +217,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( TypeRuleSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs index e915338..360bf81 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs @@ -66,7 +66,7 @@ public partial class WithDynamic """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken From 62411f5a22e60e7db5cc4027df4a2eac4d6dfb1c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 12 Oct 2026 18:54:33 +0100 Subject: [PATCH 09/21] chore: bumped psgf --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9d643d2..9d26b67 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,8 +10,8 @@ --> 5.9.0 5.9.0 - 1.68.17 - 1.0.0 + 1.74.0 + 1.1.0 10.0.12 5.9.0 5.9.0 - 1.74.0 + + 1.75.0 1.1.0 10.0.12 + diff --git a/global.json b/global.json index b713310..5820a3b 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.5" + "Purview.BuildSdk": "1.0.6" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/skills/purview-zodsharp-aspnetcore/SKILL.md b/skills/purview-zodsharp-aspnetcore/SKILL.md new file mode 100644 index 0000000..a11c258 --- /dev/null +++ b/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -0,0 +1,242 @@ +--- +name: purview-zodsharp-aspnetcore +description: >- + Use when wiring Purview.ZodSharp into ASP.NET Core or any DI host - ProblemDetails from a + ValidationResult or a thrown ZodException, minimal-API endpoint validation, registering + IZodSchemaFactory with AddZodSharp or AddZodSharpFactory, validating options objects with + IValidateOptions, and mapping error codes to HTTP statuses with ErrorType. +license: MIT +metadata: + product: Purview.ZodSharp.AspNetCore + repository: purview-dev/zodsharp +--- + +# ASP.NET Core and dependency injection + +`Purview.ZodSharp.AspNetCore` turns failed validation into standard `ProblemDetails` / +`HttpValidationProblemDetails` payloads **without losing the structured issues**, and registers +schema resolution into DI. + +```bash +dotnet add package Purview.ZodSharp.AspNetCore +``` + +## ProblemDetails from a result + +```csharp +using ZodSharp.AspNetCore; + +var result = BasketSchema.Validate(basket); + +if (!result.IsSuccess) +{ + var problem = result.ToHttpValidationProblemDetails(); + + return Results.ValidationProblem( + problem.Errors, + extensions: new Dictionary { ["issues"] = problem.Extensions["issues"] }); +} +``` + +| Member | Produces | +| --- | --- | +| `ToHttpValidationProblemDetails(result, statusCode = 400)` | `HttpValidationProblemDetails`; paths flattened to dotted keys (`user.email`, array indexes as `[0]`) | +| `ToValidationProblemDetails(result, statusCode = 400)` | `ValidationProblemDetails` | + +Both **throw `InvalidOperationException` when the result is successful** — they are failure +converters, not general mappers. The structured metadata survives in the `issues` extension as a +`ValidationIssue[]`: + +```csharp +public sealed class ValidationIssue +{ + public required string Code { get; init; } + public string? Category { get; init; } + public string? Origin { get; init; } + public int? Minimum { get; init; } + public int? Maximum { get; init; } + public bool? Inclusive { get; init; } + public required string[] Path { get; init; } + public required string Message { get; init; } + public IReadOnlyDictionary? Parameters { get; init; } +} +``` + +## Minimal APIs + +```csharp +// Validate the bound DTO automatically - needs AddZodSharp to have registered the validator. +app.MapPost("/users", (UserDto dto) => dto) + .WithZodSharpValidation(); + +// Or validate in the handler and return the IResult directly. +app.MapPost("/users", (UserDto dto) => +{ + var result = UserDtoSchema.Validate(dto); + return result.IsSuccess ? TypedResults.Ok(result.Value) : result.ToValidationProblem(); +}); +``` + +`WithZodSharpValidation()` adds an endpoint filter that validates the first bound argument of type +`T` against the `IZodSchemaFactory` registered by `AddZodSharp`/`AddZodSharpFactory`. On failure it +returns the same payload as `ToHttpValidationProblemDetails()`; on success it passes through. + +## Thrown `ZodException` + +A `ZodException` from `Parse`, `GetValueOrThrow()`, or a value object's generated `Create` under +strict deserialization maps the same way: + +```csharp +catch (ZodException ex) +{ + return Results.ValidationProblem(ex.ToHttpValidationProblemDetails().Errors); +} +``` + +Or handle it globally: + +```csharp +builder.Services.AddZodSharpProblemDetails(); + +var app = builder.Build(); +app.UseExceptionHandler(); +``` + +`AddZodSharpProblemDetails(configure?, problemDetails?)` registers `ZodExceptionHandler`, calls +`services.AddProblemDetails(problemDetails)`, and configures `ZodProblemDetailsOptions`: + +- `ErrorTypeRegistry Registry` — resolves codes to `ErrorType`s; defaults to `ErrorTypeRegistry.Default`. +- `bool FormatMessages` — format messages from the matched `ErrorType.MessageFormat`. +- `Func, int>? StatusCodeSelector` — total control of the status code. + +> It does **not** register `IZodSchemaFactory`. Pair it with `AddZodSharp` (or the core +> `AddZodSharpFactory`) when you also want DI validator resolution. + +## Error codes to HTTP statuses + +`ErrorType`, the `[ErrorType]` attribute, the generator that emits `Create`/`Throw` helpers and the +`ZODSASP001`–`ZODSASP003` analyzers all ship in the **core** package and need no ASP.NET Core +dependency. The AspNetCore package adds `ErrorTypeRegistry` and the ProblemDetails mapping on top. + +```csharp +public static partial class ConcurrentErrorType +{ + [ErrorType] + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + Category: "invalid_value", + Description: "The aggregate could not be saved.", + HttpStatus: 409, + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", + Parameters: + [ + new("AggregateId", typeof(string)), + ErrorType.Param("AggregateType"), + ]); +} + +ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); +``` + +`HttpStatus` lives on `ErrorType` purely for convenience — the core library never reads it; only the +ASP.NET Core mapping does. `Category` is a broad grouping that can span many specific codes (code +`invalid_tenant_id`, category `invalid_value`); the generated helpers copy it onto each +`ValidationError` and it surfaces on the serialized `ValidationIssue`. + +Marking the field `[ErrorType]` and the containing class `partial` gets you strongly typed helpers, +one parameter per declared entry: + +```csharp +var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); +ConcurrentErrorType.ThrowSaveFailed("agg-123", "Invoice"); + +var detailed = ConcurrentErrorType.CreateSaveFailed( + "agg-123", "Invoice", + path: ["order", "items", "[0]"], + origin: "collection", minimum: 1, maximum: 10, inclusive: true); +``` + +`Create` builds a typed `ErrorTypeParameters` (validated against the declared types, exposed through +`ValidationError.Parameters`) and sets the message from `ErrorType.FormatMessage`, so +`error.Message` already reads the formatted text. + +Mapping rules: + +- **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to `400`. + `StatusCodeSelector` overrides everything. +- **Title / Type / Detail** — from the highest-status matched `ErrorType`, otherwise defaulted. +- **Message** — named placeholders are substituted from `ValidationError.Parameters`. A placeholder + with no matching value is **left as-is**, so a templating gap stays visible rather than silently + disappearing. `ZODSASP001` warns at compile time when a placeholder is not declared in `Parameters`. +- **Parameters** — surfaced per-issue in `issues`, and merged (camel-cased) into the top-level + extensions for client correlation. + +`ToHttpValidationProblemDetails` / `ToValidationProblemDetails` also accept an `ErrorTypeRegistry` or +a `Func` for on-demand mapping. + +## Registering the factory + +`IZodSchemaFactory` (`ZodSharp.Core`) resolves validators by type: + +| Member | Behaviour | +| --- | --- | +| `Resolve()` | the validator, or `null` | +| `ResolveRequired()` | the validator, or `InvalidOperationException` | +| `Validate(value)` | validate through the registered validator | +| `Register(…)` / `Register(Type, …)` / `TryRegister(…)` / `IsRegistered()` | registration | + +```csharp +builder.Services.AddZodSharp(options => +{ + options.ScanAssemblies.Add(typeof(UserDto).Assembly); + options.ScanAssemblyGraphs.Add(typeof(Program).Assembly); + options.ScanLoadedAssemblies = true; +}); + +// Additive, for modular apps: +builder.Services.AddZodSharpAssembly(typeof(UserDto).Assembly); +builder.Services.AddZodSharpAssemblyGraph(typeof(Program).Assembly); +builder.Services.AddZodSharpLoadedAssemblies(); +``` + +Scanning finds the generator's `[assembly: ZodSchemaGenerated(typeof(…))]` markers and registers each +`{Type}SchemaValidator`. A hand-built schema registers by wrapping: +`factory.Register(new ZodSchemaValidator>(mySchema))`. + +> **`TryAdd` semantics.** The **first** `AddZodSharp`/`AddZodSharpFactory` call wins; later calls — +> including their options and `ConfigureFactory` callbacks — are ignored. State is never overwritten, +> so calling it more than once is safe, and the additive helpers still contribute. + +| Method | Package | Registers | Use when | +| --- | --- | --- | --- | +| `AddZodSharp(options)` | AspNetCore | factory + auto-registered generated validators | an ASP.NET Core app | +| `AddZodSharpAssembly/AssemblyGraph/LoadedAssemblies` | AspNetCore | additive assembly sources | modular apps where deeper layers contribute their own schemas | +| `AddZodSharpFactory(configure)` | core | the factory only | any host; you call `RegisterFromAssembly` yourself | +| `AddZodSharpProblemDetails(…)` | AspNetCore | exception handler + ProblemDetails, **not** the factory | mapping thrown `ZodException`s | +| `AddZodSchemaOptionsValidator(…)` | core | `IValidateOptions` | options validation; needs a factory first | + +## Options validation + +```csharp +builder.Services.AddZodSharpFactory(f => f.RegisterFromAssembly(typeof(UserOptions).Assembly)); +builder.Services.AddZodSchemaOptionsValidator(); + +// or chained: +builder.Services + .AddOptions() + .Bind(configuration.GetSection("User")) + .AddZodSchemaValidator() + .ValidateOnStart(); // fail at startup, not first access +``` + +`MissingValidatorBehavior` decides what happens with no validator registered for `T`: +`Throw` (default — `ResolveRequired()`, so a missing schema is loud rather than a silent skip) or +`Ignore` (passes through as success). + +The generator also auto-generates `IValidateOptions` for types whose names end in configurable +suffixes — see the `purview-zodsharp-source-generator` skill. + +## Related skills + +`purview-zodsharp-core` for the runtime API, `purview-zodsharp-source-generator` for the compile-time +validators this resolves, and `purview-zodsharp-diagnostics` for `ZODSASP*`. diff --git a/skills/purview-zodsharp-core/SKILL.md b/skills/purview-zodsharp-core/SKILL.md new file mode 100644 index 0000000..98280dd --- /dev/null +++ b/skills/purview-zodsharp-core/SKILL.md @@ -0,0 +1,176 @@ +--- +name: purview-zodsharp-core +description: >- + Use when validating data with Purview.ZodSharp at runtime - picking the right package, building + schemas with the Z factory, Validate/SafeParse/Parse, reading ValidationResult and ValidationError, + composing with Transform/Refine/Pipe/Catch/Default, and how null handling and type coercion work. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# Purview.ZodSharp core + +A C# port of [Zod](https://github.com/colinhacks/zod): schemas you build fluently, validated without +exceptions, reporting machine-readable issues. + +## Packages + +```bash +dotnet add package Purview.ZodSharp # core, source generator, JSON Schema export +dotnet add package Purview.ZodSharp.SystemTextJson # STJ deserialize-and-validate + schema import +dotnet add package Purview.ZodSharp.NewtonsoftJson # Newtonsoft equivalent +dotnet add package Purview.ZodSharp.AspNetCore # ProblemDetails + DI +``` + +JSON Schema **export** (`Z.ToJsonSchema`) is in the core package; **import** (`Z.FromJsonSchema`) comes +from whichever JSON integration you reference, so import its namespace +(`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`). + +## First schema + +```csharp +using ZodSharp; + +var nameSchema = Z.String().Min(3).Max(50); +var result = nameSchema.Validate("John"); + +if (result.IsSuccess) + Console.WriteLine(result.Value); +``` + +## Validate, SafeParse, Parse, ValidateAsync + +| Member | Behaviour | +| --- | --- | +| `Validate(value)` | returns `ValidationResult`; **never throws** | +| `SafeParse(value)` | alias of `Validate` | +| `Parse(value)` | returns the validated value, or throws `ZodException` (via `GetValueOrThrow()`) | +| `ValidateAsync(value, ct)` | `ValueTask` wrapper. The pipeline is synchronous; the token is observed before validation and throws `OperationCanceledException` if already cancelled. Genuinely async work only happens in the source generator's custom async validation. | + +Reach for `Validate` by default and `Parse` only where a throw is the right control flow. Exceptions +for expected invalid input are expensive and lose the structured issue list unless you catch. + +## `ValidationResult` + +A `readonly record struct` in `ZodSharp.Core`: + +- `bool IsSuccess` — annotated `[MemberNotNullWhen(true, nameof(Value))]`, so the compiler knows + `Value` is non-null inside the success branch. +- `T? Value` — valid only when `IsSuccess`. +- `ImmutableArray Errors`. + +Factories: `Success(value)`, `Failure(ValidationError)`, `Failure(ImmutableArray)`, +`Failure(IEnumerable)`, and `Merge(lhs, rhs)` — succeeds only if both succeed, and +concatenates the errors otherwise. + +## `ValidationError` + +A `readonly record struct` carrying machine-readable metadata, not just a message: + +| Member | Meaning | +| --- | --- | +| `Code` | `invalid_type`, `too_small`, `too_big`, `invalid_union`, `missing_field`, `unrecognized_key`, `validation_failed`, `refinement_failed`, `transform_error`, … | +| `Message` | human-readable | +| `Path` | `ImmutableArray` — `["user", "email"]`; array indexes are string segments, `"0"` | +| `Origin` | category for size issues: `"string"`, `"array"`, `"collection"` | +| `Minimum` / `Maximum` / `Inclusive` | the bound, for size issues | +| `Parameters` | `IReadOnlyDictionary`; a union failure carries every option error under `"errors"` | + +Build one with +`ValidationError.Create(code, message, path, parameters, origin, minimum, maximum, inclusive)`. + +`ZodException` carries `ImmutableArray Errors`; its `ToString()` renders one line per +error as `"{joinedPath}: {Message} ({Code})"`. + +## The `Z` factory + +| Method | Returns | +| --- | --- | +| `Z.String()` / `Z.Number()` / `Z.Boolean()` / `Z.Null()` / `Z.Date()` / `Z.BigInt()` | the matching `Zod*` schema | +| `Z.Array(elementSchema)` | `ZodArray` | +| `Z.Optional(schema)` (`T : class`) / `Z.Nullable(schema)` (`T : struct`) | `ZodOptional` / `ZodNullable` | +| `Z.Object()` | `ZodObjectBuilder` | +| `Z.Union(params ...)` / `Z.Union(...)` | `ZodUnion` / `ZodTypedUnion` | +| `Z.DiscriminatedUnion(discriminator)` | `ZodDiscriminatedUnionBuilder` | +| `Z.Intersection(left, right)` | `ZodIntersection` | +| `Z.Literal(value)` (`T : IEquatable`) | `ZodLiteral` | +| `Z.Lazy(schemaGetter)` | `ZodLazy` — for recursive schemas | +| `Z.Enum(params string[])` / `Z.Enum()` | `ZodEnum` / `ZodNativeEnum` | +| `Z.Record(valueSchema)` | `ZodRecord` | +| `Z.Tuple(…)` / `Z.Tuple(…)` | `ZodTuple<…>` | +| `Z.ToJsonSchema(schema, options?)` | `JsonSchemaDefinition` | + +`Enum` and `Union` are overloaded — `Enum(params string[])` versus `Enum()`, and +`Union(params …)` versus `Union(…)`. + +```csharp +var userSchema = Z.Object() + .Field("name", Z.String().Min(1)) + .Field("age", Z.Number().Min(0).Max(120).Int()) + .Field("email", Z.String().Email()) + .Build(); +``` + +## The validation pipeline + +Every schema derives from `ZodType` (`ZodSharp.Core`). Two phases: + +1. **`ParseInternal`** — the schema's own type check and traversal: rejecting `null` where not + allowed, coercing, walking objects/arrays/tuples/unions, producing structured failures. +2. **Rules** — on success, the accumulated `IValidationRule` structs run. A failing rule emits + a `ValidationError` with its Zod-compatible code. + +Rules are `readonly record struct`s implementing `IValidationRule` (`bool IsValid(in T value)`, +`string GetErrorMessage(in T value)`) — zero allocation. Each exposes `public const string ErrorCode` +and `MessageFormat`, so a test asserts against the rule rather than a duplicated literal. (A rule +without them is `ZODSGEN042`.) + +`ZodString` also exposes `ValidateSpan(ReadOnlySpan)` and `IsValidSpan(value, out errors)` for +allocation-free string validation; string rules implementing `IStringValidationRule` participate. + +## Composition + +`ZodType` composes by **wrapping**, never mutating. Each of these returns a new schema: + +| Call | Produces | +| --- | --- | +| `.Transform(func)` | `ZodTransform` | +| `.Refine(predicate, message?)` | `ZodRefinement` | +| `.SuperRefine(Action>)` | `ZodSuperRefinement` | +| `.Pipe(schema)` | `ZodPipe` | +| `.Catch(value \| func)` | `ZodCatch` | +| `.Prefault(value)` / `.Default(value)` | `ZodPrefault` / `ZodDefault` | +| `.And(schema)` / `.Or(schema)` | `ZodIntersection` / `ZodTypedUnion` | +| `.Describe("…")` | sets `Description` | + +Because composition lives on the base type, every schema chains all of them. + +## Null and optionality + +Schemas report optionality through the internal `IOptionalSchema`: + +- `IsOptional` — true for `ZodOptional`, `ZodNullable`, `ZodDefault`, `ZodPrefault`. +- `ProvidesValueOnMissing` — true for `ZodDefault` and `ZodPrefault`. Object fields route a missing + value through `Validate(null!)` so the produced value is injected. +- `IAcceptsNull.ValidateNull()` is implemented by the nullable/optional/default/prefault schemas and + `ZodNull`; object-field and union wrappers route `null` to it rather than failing coercion. + +## Type coercion + +Object fields and union options wrap typed schemas, so boxed values out of a +`Dictionary` validate: + +- `null` routes to `IAcceptsNull.ValidateNull()` where supported. +- An exactly-typed value reuses the original box. +- Numeric coercion goes through `IConvertible` in the **invariant** culture, so a boxed `long` + satisfies a `Z.Number()` field. +- A non-nullable value type rejects `null`; anything else falls through to failure. + +## Related skills + +`purview-zodsharp-source-generator` for compile-time `[ZodSchema]` validators (which is what you want +for a DTO), `purview-zodsharp-custom-rules` for writing rules and rule attributes, +`purview-zodsharp-aspnetcore` for ProblemDetails and DI, and `purview-zodsharp-diagnostics` for the +`ZODSGEN`/`ZODSASP` catalogue. diff --git a/skills/purview-zodsharp-custom-rules/SKILL.md b/skills/purview-zodsharp-custom-rules/SKILL.md new file mode 100644 index 0000000..4167b52 --- /dev/null +++ b/skills/purview-zodsharp-custom-rules/SKILL.md @@ -0,0 +1,239 @@ +--- +name: purview-zodsharp-custom-rules +description: >- + Use when writing a custom validation rule for Purview.ZodSharp - implementing IValidationRule and + IStringValidationRule, the ErrorCode/MessageFormat constants convention, IZodRule runtime identity, + attaching a rule with AddRule or a fluent extension, exposing it as a DataAnnotations-style + attribute with [ZodRule], generic rules, and type-level rules on scalar value objects. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# Custom rules + +A **rule** is a `readonly record struct` (or class) implementing `ZodSharp.Core.IValidationRule`. +Rules run after a schema's type and structural check succeeds; each failing rule adds a +`ValidationError`. + +Rules are first class in two directions: + +- attach one to a schema with the public `AddRule`/`Rule` API, or +- surface it as a `System.ComponentModel.DataAnnotations`-style attribute that the `[ZodSchema]` + generator honours exactly like `[EmailAddress]` or `[Range]`. + +## The contract + +```csharp +namespace ZodSharp.Core; + +public interface IValidationRule +{ + bool IsValid(in T value); + string GetErrorMessage(in T value); +} +``` + +Implement it as a **struct** so validation does not allocate. `IsValid` is called only when the +surrounding schema succeeded and — for a nullable property — the value is not `null`. + +For a **string** rule, also implement `ZodSharp.Core.IStringValidationRule` +(`bool IsValid(ReadOnlySpan)` / `string GetErrorMessage(ReadOnlySpan)`) so the rule +participates in `ZodString.ValidateSpan`/`IsValidSpan` without materialising the input. A rule that +implements only `IValidationRule` still works; it falls back to the string pipeline. + +## `ErrorCode` and `MessageFormat` — not optional in practice + +Every rule **should** expose its error identity as public constants, so code and tests assert against +the rule rather than re-typing literals: + +```csharp +public readonly record struct EmailRule : IValidationRule +{ + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Invalid email format: {0}"; + + public string Code => ErrorCode; + + public string GetErrorMessage(in string value) => + string.Format(CultureInfo.CurrentCulture, MessageFormat, value); +} +``` + +- **`ErrorCode`** is the canonical code. `IValidationRule.Code` defaults to `"validation_failed"`; + a rule accepting a per-usage `code` override returns it (falling back to `ErrorCode`) from both + `Code` and `IZodRule.Code`, so every route reports the same effective value. +- **`MessageFormat`** is a `string.Format` template. `{0}` is the offending value; `{1}`, … are + rule-specific arguments. Format with `CultureInfo.CurrentCulture`. +- The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a + shared base can host them for its concrete derivations. + +A source-declared rule missing either constant is reported as **`ZODSGEN042`**. The payoff: + +```csharp +await Assert.That(error.Code).IsEqualTo(EmailRule.ErrorCode); +await Assert.That(error.Message).IsEqualTo( + string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, value)); +``` + +### Runtime identity: `IZodRule` + +The constants are the rule's *static* default. When one attribute must report a different code per +annotated member, implement `IZodRule` and supply code/origin at runtime: + +```csharp +public interface IZodRule +{ + string? Code { get; } // null falls back to the attribute-mapped code + string? Origin { get; } // null falls back to the attribute-mapped origin +} +``` + +The generated code casts to `IZodRule` when reading them, so identity can depend on constructor +arguments. A rule that **accepts** a `code`/`origin` constructor parameter without implementing +`IZodRule` never reaches the reported identity — that is `ZODSGEN039`. + +Code and origin resolve first-match-wins: + +1. the rule, when it implements `IZodRule`; +2. `Code`/`Origin` named arguments on the applied attribute; +3. the `[ZodRule(typeof(X), Code = "…", Origin = "…")]` mapping on the attribute type; +4. default — `validation_failed`, no origin. + +### Message precedence + +1. A `message` argument on the rule (typically a `Message` constructor parameter), when set. +2. The attribute's `ErrorMessage` / `ErrorMessageResourceName` / `ErrorMessageResourceType`, mapped + to a constructor parameter named `message`. +3. `GetErrorMessage(value)`, formatting `MessageFormat`. + +`MessageFormat` is the fallback, not the only message. + +## Writing one + +```csharp +using System.Globalization; +using ZodSharp.Core; + +namespace MyRules; + +/// Rejects strings that contain whitespace. +public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule +{ + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Whitespace is not allowed in '{0}'."; + + public bool IsValid(in string value) + { + if (value is null) + return false; + + foreach (var character in value) + { + if (char.IsWhiteSpace(character)) + return false; + } + + return true; + } + + public string Code => ErrorCode; + + public string GetErrorMessage(in string value) => + Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat, value); +} +``` + +It works standalone, and attaches to any schema: + +```csharp +var rule = new NoWhitespaceRule(); +if (!rule.IsValid("John Doe")) + Console.WriteLine(rule.GetErrorMessage("John Doe")); + +var schema = Z.String().Rule(new NoWhitespaceRule("No spaces allowed.")); +``` + +`ZodType.AddRule(IValidationRule)` and the generic `Rule(TRule)` +helper are both public. Give the rule a first-class fluent method with an ordinary extension method on +its schema type, the way the built-in rules are surfaced. + +## Exposing it as an attribute + +Two routes. + +**Map a hand-authored attribute to the rule.** The open generic form serves both a primitive member +and a scalar value object: + +```csharp +[ZodRule(typeof(NoWhitespaceRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NoWhitespaceAttribute : ValidationAttribute +{ + public string? Message { get; set; } +} +``` + +**Or let the generator write it.** Mark the rule with the parameterless `[ZodRule]` and a +`{RuleName}Attribute` is emitted in the rule's namespace — a trailing `Rule` becomes `Attribute`, and +a name colliding with a DataAnnotations attribute takes a `Zod` suffix: + +```csharp +[ZodRule] // emits NoWhitespaceAttribute +public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule; +``` + +The generator runs over the compilation that **declares** the rule, so the marker form suits a rule +in the same project, or in a rules library referencing `Purview.ZodSharp`. + +## Generic rules and rule families + +A generic rule closed with the property type lets one attribute serve every member of that shape — +`[ZodRule(typeof(NonSentinelRule<>))]` on a `NonSentinelAttribute` covers `Guid`, `DateTime`, +`string` and the rest. + +`ZodSharp.Rules.NonSentinelRule` is the canonical example: it rejects the framework +default/boundary values an ORM stores to mean "no value" — `Guid.Empty`, +`DateTime.MinValue`/`MaxValue`, the `DateTimeOffset`/`DateOnly`/`TimeOnly` bounds, and +null/empty/whitespace strings. A type with no known sentinel always passes, so the rule never rejects +something it does not understand. + +```csharp +var schema = Z.Date().AddRule(new NonSentinelRule()); +var result = schema.Validate(DateTime.MinValue); +// result.Errors[0].Code == NonSentinelRule.ErrorCode ("invalid_value") +``` + +## Type-level rules and scalar value objects + +An attribute applied to a **type** validates the value as a unit, reporting an **empty path**. That is +what makes a rule usable on a `Purview.ValueObjects` `[Scalar]`: + +```csharp +[Scalar] +[ZodSchema] +[NonSentinel(Message = "UserId must not be empty.")] +public readonly partial record struct UserId +{ + public Guid Value { get; init; } +} +``` + +A rule written against the *underlying* value — `NonSentinelRule` — is adapted automatically: +the generator closes it with the underlying type and wraps it, so it runs against the value object +with an empty path, and the wrapped rule owns the reported `Code`. Closing such a rule with the +scalar type instead (`NonSentinelRule`) compiles but **always passes**, because the check only +knows the primitive. + +## Diagnostics you will meet + +`ZODSGEN030` (mapped rule does not implement `IValidationRule` for the property type), +`ZODSGEN031` (a constructor parameter could not be mapped from the attribute), `ZODSGEN032` (no +attribute could be generated), `ZODSGEN033` (a rule-mapped attribute on a type that gets no generated +schema, so the rule never runs), `ZODSGEN037`/`ZODSGEN038` (attribute-name collisions and incomplete +mappings), `ZODSGEN039` (`code`/`origin` without `IZodRule`), `ZODSGEN040` (an attribute argument with +no effect), `ZODSGEN042` (missing `ErrorCode`/`MessageFormat`) and `ZODSGEN043` (a built-in rule whose +constructor parameter cannot be an attribute property). + +The `purview-zodsharp-diagnostics` skill carries the full catalogue. diff --git a/skills/purview-zodsharp-diagnostics/SKILL.md b/skills/purview-zodsharp-diagnostics/SKILL.md new file mode 100644 index 0000000..c8fc134 --- /dev/null +++ b/skills/purview-zodsharp-diagnostics/SKILL.md @@ -0,0 +1,119 @@ +--- +name: purview-zodsharp-diagnostics +description: >- + Use when a Purview.ZodSharp build reports a ZODSGEN or ZODSASP diagnostic - the [ZodSchema] + generator's configuration and usage rules, the custom-rule mapping rules, and the ErrorType + analyzers - to find what triggered it and how to fix it. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# Purview.ZodSharp diagnostics + +Two families, both enabled by default: + +- **`ZODSGEN*`** — the `[ZodSchema]` generator's analyzer (category `ZodSharp.SourceGenerator`). +- **`ZODSASP*`** — the `ErrorType` factory's analyzer and generator, which ship in the **core** + package and need no ASP.NET Core dependency. + +`ZODSGEN033` and `ZODSGEN037`–`ZODSGEN040` and `ZODSGEN042` are warnings; `ZODSGEN041` and +`ZODSGEN043` are informational; the rest are errors. + +IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused. Rule identifiers are never +renumbered or reused, so a sighting of one means stale tooling or a stale suppression. + +## Generator failures and DataAnnotations usage + +| ID | Meaning | +| --- | --- | +| `ZODSGEN001` | Unhandled generator exception. | +| `ZODSGEN003` | `[Length]` with `min > max`. | +| `ZODSGEN004` | `[Length]` on an unsupported target. | +| `ZODSGEN005` | Error-message resource name without type, or type without name. | +| `ZODSGEN006` | Unsupported DataAnnotations usage: a string-only attribute on a non-string, `[AllowedValues]`/`[DeniedValues]` on an unsupported type, `[RegularExpression]` on a non-string, `[Range]` on an unsupported type. | +| `ZODSGEN020` | `[Compare]` references an unknown property. | +| `ZODSGEN021` | `System.ComponentModel.DataAnnotations` reference is missing. | + +## Custom async validation method + +All of these mean the method the generator was told to call cannot be called: + +| ID | Meaning | +| --- | --- | +| `ZODSGEN007` | Configured but not found. | +| `ZODSGEN008` | Return type is not `ValueTask>`. | +| `ZODSGEN009` | Parameter count is not 2. | +| `ZODSGEN010` | First parameter is not the model type. | +| `ZODSGEN011` | Second parameter is not `CancellationToken`. | +| `ZODSGEN012` | The method is generic. | +| `ZODSGEN013` | Declared on the model type but not `static`. | +| `ZODSGEN014` | Inaccessible from the generated validator (private/protected). | +| `ZODSGEN015` | Ambiguous overloads — only when at least two valid candidates exist. | +| `ZODSGEN016` | The configured name is not a valid C# identifier. | +| `ZODSGEN017` | The method is abstract. | +| `ZODSGEN018` | An unimplemented partial method. | +| `ZODSGEN019` | Uses `ref`/`in`/`out`/`params`/`scoped` parameters. | + +## `IValidateOptions` + +| ID | Meaning | +| --- | --- | +| `ZODSGEN027` | Requested, but the `Microsoft.Extensions.Options` reference is missing. | +| `ZODSGEN028` | Requested on a struct — it requires a class. | + +## The refinement hook + +| ID | Meaning | +| --- | --- | +| `ZODSGEN029` | The model declares **both** `OnZodValidate` and an async custom validation method. Exactly one is allowed. | +| `ZODSGEN034` | `OnZodValidate` is on a type that is not `partial`, or whose containing types are not all `partial`, so the generated declaration cannot be emitted. | +| `ZODSGEN035` | Not declared as `partial void OnZodValidate(RefineCtx context)` — wrong modifiers, return type or parameters. | +| `ZODSGEN036` | A member still uses the retired `IEnumerable Validate()` contract, which the generator no longer binds. Implement `OnZodValidate`. | + +## Custom rules and rule attributes + +| ID | Meaning | +| --- | --- | +| `ZODSGEN030` | A rule mapped through `[ZodRule(typeof(…))]` does not implement `IValidationRule` for the property type — including an unbound generic rule that cannot be closed with it. | +| `ZODSGEN031` | A rule constructor parameter could not be mapped from the attribute. | +| `ZODSGEN032` | No validation attribute could be generated for a rule marked `[ZodRule]`. | +| `ZODSGEN033` | *(warning)* A rule-mapped attribute is applied to a type that gets no generated schema — no `[ZodSchema]`, and not reachable as a complex property — so the rule never runs. | +| `ZODSGEN037` | *(warning)* A `[ZodRule]`-derived attribute name (`XRule` → `XAttribute`) is already declared by hand, so no attribute is generated and that declaration's own mapping governs every usage. | +| `ZODSGEN038` | *(warning)* A hand-authored attribute's `[ZodRule(typeof(…))]` mapping does not address every rule declared under the name it encodes (`XAttribute` → `XRule`), so some usages resolve to no rule. | +| `ZODSGEN039` | *(warning)* A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity. | +| `ZODSGEN040` | *(warning)* An attribute argument has no effect: the resolved rule has no matching constructor parameter, and the value is not part of the reported identity. | +| `ZODSGEN042` | *(warning)* A rule does not expose a public `const string ErrorCode` **and** a public `const string MessageFormat`, so its error identity cannot be asserted in tests without duplicating literals. | +| `ZODSGEN043` | *(info)* A built-in rule marked `[ZodRule]` generates no attribute, because a constructor parameter cannot be represented as an attribute property. The rule is still reachable through the runtime/fluent API. A name colliding with DataAnnotations is **not** skipped — it is emitted with a `Zod` suffix. | + +## Unions, value objects, JSON Schema + +| ID | Meaning | +| --- | --- | +| `ZODSGEN041` | *(info)* A typed `Z.Union` whose option types are all reference types can use the allocation-free native C# 15 union from `Z.NativeUnion` on .NET 11+. A code fix is offered. | +| `ZODSGEN044` | A `[ZodSchema]` type using an automatic `[Scalar]` form (`[Scalar]` / `[Scalar(typeof(TValue))]`) cannot produce a usable schema, so the value-object generator's `Create` would reference a `{Type}Schema` that does not exist or cannot validate. Reported when `GenerateValidateMethod = false` suppresses the `Validate` it calls, or the declared underlying type cannot be represented. | +| `ZODSGEN045` | A type referenced by an exported type has `GenerateJsonSchema = false`, so the cross-platform contract surface is incomplete. | + +## `ErrorType` — `ZODSASP*` + +These ship with the **core** package, alongside the `ErrorType` factory, the `[ErrorType]` attribute +and the generator that emits `Create`/`Throw` helpers: + +| ID | Severity | Meaning | +| --- | --- | --- | +| `ZODSASP001` | Warning | A `MessageFormat` placeholder is not declared in `ErrorType.Parameters`. | +| `ZODSASP002` | Warning | The containing type of an `[ErrorType]` field is not `partial`. | +| `ZODSASP003` | Warning | An `[ErrorType]` field is not `static readonly`. | +| `ZODSASP100` | Error | Unhandled exception in the `ErrorType` source generator. | +| `ZODSASP101` | Error | The `Parameters` of an `[ErrorType]` field could not be extracted. | + +`ZODSASP001`–`ZODSASP003` explain why `Create`/`Throw` helpers were **not** generated — a silent +absence otherwise. `ZODSASP100`/`ZODSASP101` are fatal generator failures and name the field. + +## Suppressing + +Standard mechanisms: `#pragma warning disable ZODSGEN006`, a `NoWarn` entry, or an `.editorconfig` +severity override. The canonical catalogue is the analyzer's `AnalyzerReleases.Shipped.md` / +`AnalyzerReleases.Unshipped.md` in the generator project — a new or changed rule must be recorded +there in the same change. diff --git a/skills/purview-zodsharp-json/SKILL.md b/skills/purview-zodsharp-json/SKILL.md new file mode 100644 index 0000000..0b325fb --- /dev/null +++ b/skills/purview-zodsharp-json/SKILL.md @@ -0,0 +1,206 @@ +--- +name: purview-zodsharp-json +description: >- + Use when combining Purview.ZodSharp with JSON - deserialize-and-validate and validating converters + for System.Text.Json or Newtonsoft.Json, choosing between the two packages, exporting a schema to + JSON Schema with Z.ToJsonSchema, importing one with Z.FromJsonSchema, and sharing a contract with + TypeScript Zod. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# JSON and JSON Schema + +Two integration packages, and they are **mutually exclusive in practice** — pick the one matching +your JSON library: + +```bash +dotnet add package Purview.ZodSharp.SystemTextJson +dotnet add package Purview.ZodSharp.NewtonsoftJson +``` + +Each adds deserialize-and-validate, a validating converter, and JSON Schema **import**. JSON Schema +**export** is in the core package. + +Both can be referenced from one project without `extern alias` — the JSON Schema types live in +`ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson` — but the +deserialize/serialize extension overloads share names, so **import exactly one package namespace per +file**. + +## Deserialize and validate + +```csharp +using ZodSharp; + +var userSchema = Z.Object() + .Field("name", Z.String().Min(3)) + .Field("age", Z.Number().Min(0).Int()) + .Build(); + +var result = userSchema.DeserializeAndValidate("""{ "name": "John", "age": 30 }"""); + +await using var stream = File.OpenRead("user.json"); +var streamed = await userSchema.DeserializeAndValidateAsync(stream); +``` + +Going the other way: + +```csharp +var json = userSchema.ValidateAndSerialize(user); // ValidationResult +await userSchema.ValidateAndSerializeAsync(user, outputStream); +``` + +Failures add `deserialization_failed` and `json_error` codes alongside the schema's own. + +## Validating converters + +```csharp +// System.Text.Json +var options = new JsonSerializerOptions { Converters = { userSchema.CreateValidatingConverter() } }; +var value = JsonSerializer.Deserialize(json, options); + +// Newtonsoft.Json +var value2 = JsonConvert.DeserializeObject(json, userSchema.CreateValidatingConverter()); +``` + +Invalid JSON **throws** — `JsonException` (STJ) or `JsonSerializationException` (Newtonsoft), with a +`"Validation failed: …"` message. Each converter removes itself from the options/serializer it uses +internally, to avoid recursing into itself. + +## Which package differs how + +| Aspect | SystemTextJson | NewtonsoftJson | +| --- | --- | --- | +| Async result | `ValueTask<…>` | `Task<…>` | +| Options parameter | `JsonSerializerOptions` | `JsonSerializerSettings` | +| Converter return | generic `JsonConverter` | non-generic `JsonConverter` | +| `JToken` overload | no | yes | +| Formatting | via options | explicit `Formatting` argument | +| Invalid-data exception | `JsonException` | `JsonSerializationException` | +| Plumbing type | `JsonElement` | `JToken`/`JObject`/`JArray` | + +## JSON Schema export + +`Z.ToJsonSchema` (core package) produces a Draft 2020-12 `JsonSchemaDefinition`: + +```csharp +var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJsonSchemaOptions +{ + Title = "User", + Id = "https://example.com/schemas/user.json", +}); +``` + +`ToJsonSchemaOptions`: `IncludeSchema` (default `true`, emits `$schema`), `Id`, `Title`. + +`JsonSchemaDefinition` (`ZodSharp.JsonSchema`) is a mutable POCO mirroring the keywords — identity +(`$schema`/`$id`/`$ref`), type and title, string/number/array/object constraints, composition +(`anyOf`/`oneOf`/`allOf`), `enum`/`const`, `$defs` and draft-07 `definitions`, and the metadata +keywords including OpenAPI 3.0 `nullable`. + +Covered schema types: `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, +`ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, `ZodLazy`. Notable +behaviours: + +- objects emit `additionalProperties: false`; +- literals emit `const` plus `type`; +- native enums emit `enum` in their System.Text.Json wire form — numbers by default, member names + when serialized as strings. A `[Flags]` enum emits `type: integer` instead, because a combination is + valid without being a defined member; +- lazy/recursive schemas emit `$ref` entries under `$defs` (`#/$defs/__lazyN`); +- rules that describe their own constraint implement `IJsonSchemaRule` and are applied during export. + +### Exporting a generated `[ZodSchema]` + +No need to rebuild the rules by hand with `Z.Object()`: + +```csharp +var jsonSchema = RepositoryInventoryItemSchema.BuildJsonSchema(); +var exported = Z.ToJsonSchema(new RepositoryInventoryItemSchemaValidator()); +``` + +The definition comes from the same metadata the generated validator uses, so the two cannot drift. +Things worth knowing: + +- **Property names** use the System.Text.Json wire form: `[JsonPropertyName]` wins, otherwise the name + is camelCased to match `JsonSerializerDefaults.Web`. A non-camelCase global naming policy configured + at runtime is invisible to the generator, so adjust the exported names or declare + `[JsonPropertyName]`. +- **Required** follows the nullable reference-type annotation: a non-nullable reference type (or + `Nullable`) is required; a nullable one is wrapped in `anyOf: [ …, { "type": "null" } ]` and is + not required. `[Required]` also makes a field required, and a rule that rejects an absent value — + `[RequiredZod]` — makes the field non-null even with a nullable annotation. The generated validator + agrees with all of this. +- **Scalar value objects** (`[Scalar]`) export as the **underlying** value's schema, not as an + object wrapping a `Value` property. A `Guid` scalar exports as a canonical 8-4-4-4-12 hexadecimal + `pattern`, not Zod's RFC `uuid` format, because a C# `Guid` accepts any 128-bit value. + +## JSON Schema import + +```csharp +using ZodSharp; +using ZodSharp.JsonSchema.SystemTextJson; // or …NewtonsoftJson + +var userSchema = Z.FromJsonSchema(jsonSchemaString); +var result = userSchema.Validate(userData); +``` + +Overloads take a `string` or an already-deserialized `JsonSchemaDefinition`, and both return +`IZodSchema`. + +`Z.FromJsonSchema` is a **C# 14 extension member on `Z`**, so it only exists when a JSON integration +package is referenced. `Z.ToJsonSchema` is a real static member in the core package. + +Mapped keywords: + +- `type` — `string`, `number`, `integer`, `boolean`, `null`, `object`, `array`. +- `enum` → a union of literals (a single member becomes a literal); `const` → a literal. +- `anyOf`/`oneOf` → union; `allOf` → the intersection of every branch, plus the base schema when the + schema also declares a `type`. A type-less branch is `any`, matching Zod's `fromJSONSchema`. +- Strings — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The + `uuid`/`guid` format maps to the versionless `.UUID()`; a versioned `.UUID(UuidVersion.V7)` schema + exports `format: "uuid"` plus a version-pinning `pattern`, and the `allOf` composition preserves it + on import. Multiple `pattern` constraints compose under `allOf`, and every branch is applied. +- Numbers — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`. +- Objects — `required` and optional fields. Arrays — `items`, `minItems`, `maxItems`. + +### Limits + +`$ref` resolves **local** references only (`#/…`, for example `#/$defs/Name`). An external target +throws `NotSupportedException` naming it — inline the referenced schema, or move it under the root +`$defs`, before importing. Local references **may be cyclic**: one still being resolved becomes a lazy +schema. + +The reader binds `$schema`, `$id`, `$ref`, `$defs` and the draft-07 `definitions` through the +integration package's `JsonSchemaSerializerOptions`, and the same options write them back, so exported +definitions round-trip through either package. `JsonSchemaDefinition` itself carries no serializer +annotations — serialize it with `JsonSchemaSerializerOptions` to get keyword-compliant output. + +## Sharing a contract with TypeScript Zod + +```typescript +import { z } from "zod"; +const UserSchema = z.object({ username: z.string().min(3), email: z.string().email() }); +const jsonSchema = z.toJSONSchema(UserSchema); // Zod v4+ +``` + +```csharp +var userSchema = Z.FromJsonSchema(jsonSchemaString); +``` + +This repository also proves agreement by fixture rather than by assertion. `bun run generate-fixtures` +runs `src/ts/generate-fixtures.ts`, writing `src/ts/fixtures/*.json` plus a `manifest.json` in which +**Zod is the authority** on each fixture's validity. `just test` then reads both, asserts the C# +outcome matches, and writes validated C# output to +`src/tests/cross-platform/output/{systemtext,newtonsoft}-valid.json`. Finally `bun run test` (vitest) +re-checks the fixtures under Zod and parses the C# output — closing the loop in both directions. + +If the C# output directory is empty, the vitest suite tells you to run the C# cross-platform tests +first. + +## Related skills + +`purview-zodsharp-core` for the schema API, and `purview-zodsharp-source-generator` for the generated +validators that export through `BuildJsonSchema`. diff --git a/skills/purview-zodsharp-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md new file mode 100644 index 0000000..53e8322 --- /dev/null +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -0,0 +1,202 @@ +--- +name: purview-zodsharp-source-generator +description: >- + Use when generating a compile-time validator with Purview.ZodSharp's [ZodSchema] attribute - which + types it emits, the attribute options, DataAnnotations coverage, the OnZodValidate refinement hook, + async custom validation, automatic enum validation, IValidateOptions support, and the MSBuild + switches that control it. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# The `[ZodSchema]` source generator + +Mark a class, struct or record with `[ZodSchema]` and a static, zero-allocation validator is emitted +at compile time — no reflection at runtime. The attribute itself is generated into the `ZodSharp` +namespace by `Purview.ZodSharp.SourceGenerators`, so nothing beyond `Purview.ZodSharp` is needed. + +```csharp +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +[ZodSchema] +public class User +{ + [Required] + [StringLength(50, MinimumLength = 3)] + public string Name { get; set; } = string.Empty; + + [Range(0, 120)] + public int Age { get; set; } + + [EmailAddress] + public string? Email { get; set; } +} + +var result = UserSchema.Validate(user); +var validated = UserSchema.Parse(user); // throws ZodException on failure +``` + +## What gets emitted + +| Artifact | Shape | +| --- | --- | +| `{TypeName}Schema` | static partial class — the validator. Accessibility mirrors the target. Holds `Validate`, `Parse`, `BuildJsonSchema`, and (with composition on) `ApplyAnd`, `ApplyOr`, `ApplyRefine`. Renamed by `SchemaName`. | +| `{TypeName}SchemaValidator` | `partial class : IZodSchemaValidator<{TypeName}>, IJsonSchemaProvider` — the DI adapter, with `Validate`/`ValidateAsync`/`BuildJsonSchema`. Primary schema only; `{SchemaName}Validator` when renamed. | +| `{TypeName}Validator` | `sealed partial class : IValidateOptions<{TypeName}>` — only when `IValidateOptions` support is on, and the target is a class. | +| `[assembly: ZodSchemaGenerated(typeof({TypeName}))]` | the marker `IZodSchemaFactory` assembly scanning consumes. Primary, non-nested schemas only. | + +`BuildJsonSchema` is built from the same metadata as `Validate`, so a generated schema exports through +`Z.ToJsonSchema` without rebuilding its rules by hand. + +```csharp +var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); +var both = UserSchema.ApplyAnd(user, u => u.Name.Length > 5, "Name too short"); +var either = UserSchema.ApplyOr(user, u => u.Age < 18, "Must be an adult, or a minor with consent"); +``` + +## Attribute options + +| Property | Default | Purpose | +| --- | --- | --- | +| `SchemaName` | `null` | Renames `{TypeName}Schema`; the DI adapter becomes `{SchemaName}Validator`. | +| `GenerateValidateMethod` | `true` | `false` omits `Validate` **and everything that depends on it**. | +| `GenerateParseMethod` | `true` | `false` omits `Parse`. | +| `EnableComposition` | `true` | Emits `ApplyAnd`/`ApplyOr`/`ApplyRefine`. | +| `CustomValidationMethodName` | `null` | Names the async custom validation method (default lookup `CustomValidationAsync`). Mutually exclusive with `OnZodValidate`. | +| `GenerateIValidateOptions` | `false` | Forces `IValidateOptions` generation. | +| `SuppressIValidateOptions` | `false` | Opts out even when auto-detection would enable it. | +| `ValidateEnumValues` | `true` | `false` skips automatic enum validation. | +| `GenerateJsonSchema` | `true` | `false` omits `BuildJsonSchema` and `IJsonSchemaProvider`. A type referenced by an exported type is `ZODSGEN045`. | + +`Parse`, the composition methods, the `IZodSchemaValidator` adapter and the `IValidateOptions` +validator all depend on `Validate` — `GenerateValidateMethod = false` removes them together. + +## What is validated + +- Properties must be public, non-static, non-indexer. +- A property gets validation when it carries **any** DataAnnotations attribute, when its type is a + source-defined complex type with a nested schema, or when its type is an enum. +- Nested complex types are discovered recursively and get their own `{TypeName}Schema`, **even + without** their own `[ZodSchema]`. +- Nullable properties are null-guarded before value-set/type validation; a nullable target rejects + `null` with `invalid_type`. +- Classes, structs and records are supported. Structs get no `IValidateOptions` (`ZODSGEN028` if + requested). + +DataAnnotations coverage, positional-record attribute targets, structured size issues and the +`[Range]` shapes are in [references/dataannotations.md](references/dataannotations.md). + +## The `OnZodValidate` refinement hook + +Rules DataAnnotations cannot express are written as a **generator-declared partial method**. The +generator emits the declaration, so the IDE offers the implementation with the right signature and +nothing is resolved by naming convention: + +```csharp +[ZodSchema] +public partial class Order +{ + public decimal Total { get; set; } + + partial void OnZodValidate(RefineCtx context) + { + if (context.Value.Total < 0) + context.AddIssue("invalid_range", "Total cannot be negative", [nameof(Total)]); + } +} +``` + +- The type **and every containing type** must be `partial` (`ZODSGEN034`). That is the only shape + requirement the hook adds. +- The signature must be `partial void OnZodValidate(RefineCtx context)`, `T` being the model + (`ZODSGEN035`). The parameter is a plain by-value `RefineCtx`. +- The hook runs for **every** entry point — `Validate`, `Parse`, the `IZodSchemaValidator` adapter, + `IValidateOptions`, any factory that validates through the schema — so a rule written here behaves + exactly like an attribute rule. +- A type that does not implement it allocates nothing: the generated `Validate` only builds a + `RefineCtx` and calls the hook when a body exists. +- The retired `IEnumerable Validate()` contract is no longer bound, and is reported + as `ZODSGEN036`. + +Alongside the hook the generator emits one `internal static` +`InvokeZodRefinementHook(T value, RefineCtx context)` bridge on the target type. A classic partial +method is private and `{Type}Schema` is a different type, so the bridge is what lets `Validate` reach +the hook while keeping the hook optional. **It is not part of your API; never implement it by hand.** + +## Async custom validation + +Mutually exclusive with `OnZodValidate` — declaring both is `ZODSGEN029`. + +```csharp +public partial class UserSchemaValidator +{ + public async ValueTask> CustomValidationAsync(User value, CancellationToken ct) + { + await Task.Delay(1, ct); + return ValidationResult.Success(value); + } +} +``` + +Signature `ValueTask> Name(T value, CancellationToken ct)`. The default lookup +name is `CustomValidationAsync` unless `CustomValidationMethodName` overrides it. A method on the +**model type** must be `static`; one on the generated `{TypeName}SchemaValidator` partial may be an +instance method. The generated `ValidateAsync` runs the synchronous `Validate`, awaits the custom +method, and merges the error sets. + +## Automatic enum validation + +Every non-`[Flags]` enum property is validated automatically: a value that is not a defined member is +rejected with `invalid_enum_value`, emitted as an `EnumRule`. + +```csharp +public enum ExampleEnum +{ + [ZodIgnore] // never a valid value, for every property of this type + Unspecified, + AValidValue, + AnotherValidValue, +} + +[ZodSchema] +public class Model +{ + public ExampleEnum Status { get; set; } + + [DeniedValues(ExampleEnum.AnotherValidValue)] // excluded for this property only + public ExampleEnum SecondaryStatus { get; set; } +} +``` + +It is skipped when the enum is `[Flags]` (a combination is valid without being a defined member), +when the property declares an explicit `[AllowedValues]` allow-list (which governs instead), or with +`[ZodSchema(ValidateEnumValues = false)]`. A nullable enum is validated only when not `null`. + +## `IValidateOptions` support + +Enabled by `GenerateIValidateOptions = true`, by auto-detection (unset, target is not a value type, +and the type name ends with a configured suffix — `Options` or `Settings` by default), or by MSBuild. + +| MSBuild property | Default | Behaviour | +| --- | --- | --- | +| `DisableZodSharpSourceGenerator` | unset | truthy disables the generator entirely | +| `ZodSharpAutoGenerateOptionsValidators` | `true` | only an explicit `false` disables auto-detection | +| `ZodSharpAutoGenerateOptionsValidatorSuffixes` | `Options;Settings` | semicolon/comma-separated | +| `ZodSharpGenerateJsonSchemas` | `true` | explicit `false` omits `BuildJsonSchema` and `IJsonSchemaProvider` assembly-wide | +| `ZodSharpEnumSerialization` | `Number` | `String` exports enums as member names, matching a global `JsonStringEnumConverter` | +| `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | + +## Value objects + +A `Purview.ValueObjects` scalar marked `[Scalar]` can carry `[ZodSchema]` on the same type. The +generated validator validates the scalar **as a unit** and reports an empty path, and a rule written +against the underlying value is adapted automatically. + +## Related skills + +`purview-zodsharp-custom-rules` for `[ZodRule]` and the built-in rule attributes, +`purview-zodsharp-diagnostics` for the `ZODSGEN` catalogue, and `purview-zodsharp-core` for the +runtime API the generated code reports through. diff --git a/skills/purview-zodsharp-source-generator/references/dataannotations.md b/skills/purview-zodsharp-source-generator/references/dataannotations.md new file mode 100644 index 0000000..30e92ec --- /dev/null +++ b/skills/purview-zodsharp-source-generator/references/dataannotations.md @@ -0,0 +1,137 @@ +# DataAnnotations coverage + +The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attributes and emits +direct, typed codegen. Nothing is reflected at runtime. + +## Supported attributes + +| Attribute | Generated behaviour | Failure code | +| --- | --- | --- | +| `[Required]` | nullable property must not be null; a string with `AllowEmptyStrings=false` must be non-empty | `missing_field` | +| `[Length(min, max)]` | min/max size; strings, arrays (including jagged and rectangular), countable collections | `too_small` / `too_big` | +| `[StringLength(max)]`, `[StringLength(max, MinimumLength=min)]` | string size via direct `.Length` | `too_small` / `too_big` | +| `[MinLength(n)]` | checked only when `n > 0` | `too_small` | +| `[MaxLength(n)]` | checked only when `n >= 0` | `too_big` | +| `[Range(…)]` | inclusive (or exclusive) numeric/parsed bounds | `invalid_range` | +| `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings | `invalid_string` | +| `[AllowedValues(…)]` | typed equality against the allowed set | `invalid_value` | +| `[DeniedValues(…)]` | typed equality against the denied set; on an enum property the values are absorbed into the automatic enum rule instead | `invalid_value` | +| `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` | +| `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]` | reuse `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` | `invalid_string` | +| `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` | +| `[Display(Name=…)]` | not validated; `Name` is the display name in messages and `{0}` placeholders | — | + +`[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also +present. + +`[RequiredZod]` — the generated `RequiredRule` attribute — mirrors `[Required]`: it is emitted +*outside* the generator's non-null guard, so an absent value is reported as `missing_field` even +though every other rule bound to the member is skipped for `null`. Its `AllowEmptyStrings` has the +same meaning as `RequiredAttribute.AllowEmptyStrings`, and it adds `TrimWhitespace`, so it is a +drop-in replacement. + +## Positional records + +A positional record declares its properties from the primary-constructor parameter list, and the C# +compiler applies an attribute valid on both a parameter and a property **to the parameter only**. The +generator therefore reads attributes on the synthesized property *and* on the primary-constructor +parameter: + +```csharp +[ZodSchema] +public sealed record RepositoryInventorySummary( + [NonSentinel] string Name, + [NonSentinel] string CanonicalReference, + [NullOrNonWhiteSpace] string? DefaultBranch, + [NonSentinel] DateTimeOffset LastObservedAt +); +``` + +`[property: NonSentinel]` is equally honoured. When both targets carry the same attribute type the +property target wins and the rule runs **once**. Declared properties, classes and record structs are +unaffected. + +## Size validators and structured issues + +Size attributes generate direct `Length`/`Count` access where possible: + +- `string` → `.Length` +- arrays, including rectangular → `.Length` +- jagged arrays → outer-array `.Length` +- countable collections → `.Count` +- `IEnumerable` / `IEnumerable` → one counted pass via `CollectionCountHelper.GetCount`, with fast + paths for `ICollection`, `IReadOnlyCollection` and non-generic `ICollection` + +A structured size failure carries the same metadata as the runtime API: `Code` (`too_small` / +`too_big`), `Origin` (`string` for strings, `array` for arrays, `collection` for countable and +`IEnumerable` collections), `Minimum`/`Maximum` (the inclusive bound), `Inclusive` (`true`) and +`Path`. + +```csharp +[ZodSchema] +public sealed class Basket +{ + [Required] + [Length(2, 5)] + public List? Items { get; set; } +} + +var result = BasketSchema.Validate(new Basket { Items = ["apple"] }); +// Code == "too_small", Minimum == 2, Origin == "array", Inclusive == true +``` + +## `[Range]` + +Three constructor shapes, plus `MinimumIsExclusive`, `MaximumIsExclusive`, +`ConvertValueInInvariantCulture` and `ParseLimitsInInvariantCulture`: + +- `[Range(int, int)]` and `[Range(double, double)]` — literal numeric bounds. +- `[Range(typeof(T), "min", "max")]` — parsed bounds for numeric and comparable types. + +Comparable targets include `TimeSpan`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly` and +`Version` (which uses `CompareTo`), plus any `IComparable` with user-defined comparison operators. +Bounds are emitted as static typed fields and compared without running the attribute at runtime. + +## Error messages + +`ErrorMessage`, or `ErrorMessageResourceName` + `ErrorMessageResourceType`, are honoured, with `{0}` +(display name), `{1}` and `{2}` (bound) formatted through +`string.Format(CultureInfo.CurrentCulture, …)`. Supplying only one half of the resource name/type +pair is `ZODSGEN005`. + +## Applicability is a compile error, not a silent skip + +- `[Length]` with `min > max` → `ZODSGEN003`. +- `[Length]` on an unsupported target, such as `decimal` → `ZODSGEN004`. +- String-only attributes (`[RegularExpression]`, `[EmailAddress]`, `[Url]`, `[Phone]`, + `[CreditCard]`, `[Base64String]`) on a non-string, `[AllowedValues]`/`[DeniedValues]` on an + unsupported type, or `[Range]` on an unsupported type → `ZODSGEN006`. +- `[Compare]` naming an unknown property → `ZODSGEN020`. + +## Built-in rule attributes + +Every built-in rule ships a generated attribute in the `ZodSharp.Rules` namespace, usable alongside +the DataAnnotations above: + +`[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, +`[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, +`[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, +`[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[NullOrNonWhiteSpace]`, +`[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, +`[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, +`[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`. + +A value the rule declares **without a default** becomes a required constructor argument — +`[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while +defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors +mirrors each overload, so `[Uuid]` is the versionless rule and `[UUID(UuidVersion.V4)]` the versioned +one. `[UUID]` also applies to a `Guid` member or a `[Scalar]` type, because a `Guid` *is* a +UUID — which is how a time-ordered identifier is constrained to an RFC version. + +Names colliding with a DataAnnotations attribute take a `Zod` suffix: `[MinLengthZod]`, +`[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, +`[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, +`[DeniedValuesZod]`. + +A custom rule reaches the same pipeline through `[ZodRule(typeof(MyRule))]` on an attribute, or +`[ZodRule]` on the rule to have the attribute generated. diff --git a/src/ZodSharp.slnx b/src/ZodSharp.slnx index e5db138..35f048c 100644 --- a/src/ZodSharp.slnx +++ b/src/ZodSharp.slnx @@ -19,6 +19,7 @@ + diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj new file mode 100644 index 0000000..92cbdf2 --- /dev/null +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + + false + + + + + + + diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs new file mode 100644 index 0000000..ddaca68 --- /dev/null +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -0,0 +1,60 @@ +using Purview.Skills.Testing; + +namespace ZodSharp; + +/// +/// Runs the Agent Skills conformance rule set against this repository's authored skills in +/// skills/. +/// +/// +/// +/// [InheritsTests] is load-bearing. TUnit does not walk base types when it discovers +/// tests, so without it this class compiles, the run is green, and nothing is validated. +/// +/// +/// There is no RepositoryRoot override: the default is the SDK's repository-root marker walk, +/// the same resolution InstallPurviewSkills uses, so an ordinary test run exercises it. +/// is what catches a walk that landed elsewhere - +/// every inherited rule passes vacuously against a catalogue that was never found. +/// +/// +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests +{ + [Test] + public async Task SkillsFolder_ResolvesToThisRepository() + { + var catalogue = Evaluator.Catalogue; + + await Assert + .That(catalogue.FolderExists) + .IsTrue() + .Because($"the marker walk resolved '{catalogue.RepositoryRoot}', whose skills folder does not exist"); + + await Assert + .That(File.Exists(Path.Combine(catalogue.RepositoryRoot, "AGENTS.md"))) + .IsTrue() + .Because($"'{catalogue.RepositoryRoot}' does not look like this repository"); + + await Assert.That(catalogue.Skills).IsNotEmpty(); + } + + [Test] + public async Task EverySkill_IsNamedForThisProduct() + { + // Skills install into one flat directory per agent, shared with every skill a developer has + // installed from anywhere. An unprefixed name collides with somebody else's, and the loser is + // whichever installed second. + var unprefixed = Evaluator + .Catalogue.Skills.Select(skill => skill.FolderName) + .Where(name => !name.StartsWith("purview-zodsharp", StringComparison.Ordinal)) + .ToArray(); + + await Assert + .That(unprefixed) + .IsEmpty() + .Because( + "every skill this repository publishes must carry the product prefix: " + string.Join(", ", unprefixed) + ); + } +} From 48c6fde651ac1332e281bda8510ab6a2660e97cf Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 15 Oct 2026 16:17:53 +0100 Subject: [PATCH 16/21] chore: run CSharpier on save in Visual Studio A tracked solution-level setting, so format-on-save matches the lint check the pipeline runs rather than being set per developer. --- src/settings.VisualStudio.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/settings.VisualStudio.json diff --git a/src/settings.VisualStudio.json b/src/settings.VisualStudio.json new file mode 100644 index 0000000..194ba35 --- /dev/null +++ b/src/settings.VisualStudio.json @@ -0,0 +1,4 @@ +/* Visual Studio Settings File */ +{ + "csharpier.general.runonsave": true +} From 23252944e00ad904ec7923dcd323a4f65d059178 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 15 Oct 2026 19:45:22 +0100 Subject: [PATCH 17/21] fix: updated to latest build-sdk + sgf --- .config/dotnet-tools.json | 2 +- Directory.Packages.props | 8 +- .../CodeWriterExtensions.cs | 1 - .../Helpers/EnumJsonResolver.cs | 1 + .../Helpers/SourceGenLibrary.cs | 4 +- .../ZodSchemaGenerator.JsonSchema.cs | 73 +++++++++------- .../JsonSchema/ToJsonSchemaConverter.cs | 2 +- src/src/ZodSharp/Rules/EnumRule.cs | 9 +- src/src/ZodSharp/Rules/GuidRule.cs | 2 +- src/tests/Repository.UnitTests/SkillTests.cs | 2 +- .../ErrorTypeGeneratorTests.cs | 18 ++-- .../ZodSchemaGeneratorTests.AttributeGen.cs | 4 +- ...odSchemaGeneratorTests.AttributeOptions.cs | 10 +-- ...ZodSchemaGeneratorTests.AutomaticScalar.cs | 64 +++++++++++--- .../ZodSchemaGeneratorTests.BasicGen.cs | 4 +- ...emaGeneratorTests.BuiltInRuleAttributes.cs | 33 ++++--- ...odSchemaGeneratorTests.ComparableRanges.cs | 2 +- ...SchemaGeneratorTests.CompositionMethods.cs | 5 +- .../ZodSchemaGeneratorTests.CustomRules.cs | 6 +- ...odSchemaGeneratorTests.CustomValidation.cs | 8 +- ...ZodSchemaGeneratorTests.DataAnnotations.cs | 6 +- .../ZodSchemaGeneratorTests.EnumValidation.cs | 14 +-- ...ZodSchemaGeneratorTests.ExternalSchemas.cs | 18 ++-- ...SchemaGeneratorTests.GeneratedStructure.cs | 6 +- .../ZodSchemaGeneratorTests.GenericRules.cs | 8 +- .../ZodSchemaGeneratorTests.JsonSchema.cs | 6 +- .../ZodSchemaGeneratorTests.NestedClasses.cs | 8 +- ...odSchemaGeneratorTests.OptionsValidator.cs | 12 +-- ...dSchemaGeneratorTests.PositionalRecords.cs | 86 +++++++------------ ...aGeneratorTests.RuleAttributeGeneration.cs | 22 ++--- ...odSchemaGeneratorTests.ScalarAdaptation.cs | 12 ++- .../ZodSchemaGeneratorTests.SyncValidation.cs | 6 +- .../ZodSchemaGeneratorTests.TypeRules.cs | 12 ++- ...maGeneratorTests.UnresolvableProperties.cs | 12 ++- ...dSchemaGeneratorTests.ValidationMethods.cs | 2 +- ...odSchemaGeneratorTests.ValidatorAdapter.cs | 10 +-- .../ToJsonSchemaRuleMappingTests.cs | 2 +- 37 files changed, 272 insertions(+), 228 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index efe5599..d51c9f0 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.5.3", + "version": "0.5.4", "commands": [ "purview-build" ], diff --git a/Directory.Packages.props b/Directory.Packages.props index 81e27f0..1dc350b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,10 +10,9 @@ --> 5.9.0 5.9.0 - 1.75.0 - 1.1.0 - 10.0.12 + 1.1.1 + 10.0.13 diff --git a/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs b/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs index 24ead81..cb5c286 100644 --- a/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs +++ b/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs @@ -1,4 +1,3 @@ -#pragma warning disable CS1591 using System.ComponentModel; namespace Purview.SourceGeneratorFramework; diff --git a/src/src/SourceGenerators/Helpers/EnumJsonResolver.cs b/src/src/SourceGenerators/Helpers/EnumJsonResolver.cs index a3b13fc..235833c 100644 --- a/src/src/SourceGenerators/Helpers/EnumJsonResolver.cs +++ b/src/src/SourceGenerators/Helpers/EnumJsonResolver.cs @@ -129,6 +129,7 @@ static bool IsDenied(IFieldSymbol field, EquatableArray deniedVal /// The member name. /// The naming policy. /// The transformed name. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] public static string ApplyNamingPolicy(string name, EnumJsonNamingPolicy policy) => policy switch { diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 4cdf963..7abb0dc 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -564,9 +564,7 @@ rangeAttribute with var ignoredEnumMembers = isEnum && !isFlagsEnum ? GetIgnoredEnumMembers(enumType!) : default; var enumSerializedAsString = isEnum && EnumJsonResolver.IsSerializedAsString(enumType!, property); var enumJsonNames = - isEnum && !isFlagsEnum - ? EnumJsonResolver.GetJsonNames(enumType!, validationAttributes) - : new(ImmutableArray.Empty); + isEnum && !isFlagsEnum ? EnumJsonResolver.GetJsonNames(enumType!, validationAttributes) : new([]); var customRules = CustomRuleResolver.Resolve(validationAttributes, originalPropertyType, diagnostics); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs index e2976f2..b983ea1 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs @@ -89,7 +89,7 @@ static void EmitScalarJsonSchema(CodeWriter body, ZodSchemaDescriptor schema, Ty body.Assignment("var", "inner", BuildBaseSchemaExpression(underlying)); foreach (var rule in schema.TypeRules) - body.Line($"{JsonSchemaRulesTypeName}.Apply(inner, {BuildRuleInstanceExpression(rule)});"); + body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", "inner", BuildRuleInstanceExpression(rule)); foreach (var property in schema.Properties) { @@ -98,7 +98,7 @@ static void EmitScalarJsonSchema(CodeWriter body, ZodSchemaDescriptor schema, Ty EmitAttributeConstraints(body, property.Value, "inner"); foreach (var rule in property.Value.CustomRules) - body.Line($"{JsonSchemaRulesTypeName}.Apply(inner, {BuildRuleInstanceExpression(rule)});"); + body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", "inner", BuildRuleInstanceExpression(rule)); } body.Return( @@ -125,27 +125,29 @@ static void EmitJsonSchemaProperty(CodeWriter body, ZodPropertyDescriptor proper EmitAttributeConstraints(body, property, localName); foreach (var rule in property.CustomRules) - body.Line($"{JsonSchemaRulesTypeName}.Apply({localName}, {BuildRuleInstanceExpression(rule)});"); + body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", localName, BuildRuleInstanceExpression(rule)); if (isNullable) { - body.Line( - $"schema.Properties[{nameLiteral}] = new {JsonSchemaDefinitionTypeName} {{ AnyOf = new() {{ {localName}, new {JsonSchemaDefinitionTypeName} {{ Type = \"null\" }} }} }};" + body.Assignment( + $"schema.Properties[{nameLiteral}]", + $"new {JsonSchemaDefinitionTypeName} {{ AnyOf = new() {{ {localName}, new {JsonSchemaDefinitionTypeName} {{ Type = \"null\" }} }} }}" ); } else { - body.Line($"schema.Properties[{nameLiteral}] = {localName};"); + body.Assignment($"schema.Properties[{nameLiteral}]", localName); } if (isRequired) - body.Line($"schema.Required.Add({nameLiteral});"); + body.MethodCallOn("schema.Required", "Add", nameLiteral); } /// /// Builds the C# expression for a property's base schema (its JSON type, nested schema, enum values, or /// array items), before any rule or attribute constraint is applied. /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] static string BuildPropertySchemaExpression(ZodPropertyDescriptor property, ZodSchemaDescriptor schema) => property.ValidationKind switch { @@ -235,6 +237,7 @@ static string BuildBaseSchemaExpression(TypeIdentity type) if (IsWellKnown(type, "System", "Version")) return Schema("string"); + // A type with no JSON Schema representation (or a type the generator cannot classify) exports as the empty schema (any) rather than being guessed. return $"new {JsonSchemaDefinitionTypeName}()"; } @@ -308,8 +311,9 @@ string localName var maxKeyword = isCollection ? "MaxItems" : "MaxLength"; if (attributes.MinLength.ShouldProcess && attributes.MinLength.Value.Exists) - body.Line( - $"{localName}.{minKeyword} = {attributes.MinLength.Value.Length.ToString(CultureInfo.InvariantCulture)};" + body.Assignment( + $"{localName}.{minKeyword}", + attributes.MinLength.Value.Length.ToString(CultureInfo.InvariantCulture) ); if ( @@ -318,8 +322,9 @@ string localName && attributes.MaxLength.Value.Length >= 0 ) { - body.Line( - $"{localName}.{maxKeyword} = {attributes.MaxLength.Value.Length.ToString(CultureInfo.InvariantCulture)};" + body.Assignment( + $"{localName}.{maxKeyword}", + attributes.MaxLength.Value.Length.ToString(CultureInfo.InvariantCulture) ); } @@ -327,13 +332,15 @@ string localName { if (attributes.StringLength.Value.MinimumLength > 0) { - body.Line( - $"{localName}.MinLength = {attributes.StringLength.Value.MinimumLength.ToString(CultureInfo.InvariantCulture)};" + body.Assignment( + $"{localName}.MinLength", + attributes.StringLength.Value.MinimumLength.ToString(CultureInfo.InvariantCulture) ); } - body.Line( - $"{localName}.MaxLength = {attributes.StringLength.Value.MaximumLength.ToString(CultureInfo.InvariantCulture)};" + body.Assignment( + $"{localName}.MaxLength", + attributes.StringLength.Value.MaximumLength.ToString(CultureInfo.InvariantCulture) ); } @@ -342,18 +349,18 @@ string localName var isString = property.PropertyType.SpecialType == SpecialType.System_String; var minimum = attributes.Length.Value.MinimumLength.ToString(CultureInfo.InvariantCulture); var maximum = attributes.Length.Value.MaximumLength.ToString(CultureInfo.InvariantCulture); - body.Line(isString ? $"{localName}.MinLength = {minimum};" : $"{localName}.MinItems = {minimum};"); - body.Line(isString ? $"{localName}.MaxLength = {maximum};" : $"{localName}.MaxItems = {maximum};"); + body.Assignment(isString ? $"{localName}.MinLength" : $"{localName}.MinItems", minimum); + body.Assignment(isString ? $"{localName}.MaxLength" : $"{localName}.MaxItems", maximum); } } static void EmitFormatConstraints(CodeWriter body, ValidationAttributes attributes, string localName) { if (attributes.EmailAddress.ShouldProcess && attributes.EmailAddress.Value.Exists) - body.Line($"{localName}.Format = \"email\";"); + body.Assignment($"{localName}.Format", "email".StringLiteral()); if (attributes.Url.ShouldProcess && attributes.Url.Value.Exists) - body.Line($"{localName}.Format = \"uri\";"); + body.Assignment($"{localName}.Format", "uri".StringLiteral()); if ( attributes.RegularExpression.ShouldProcess @@ -361,11 +368,11 @@ static void EmitFormatConstraints(CodeWriter body, ValidationAttributes attribut && attributes.RegularExpression.Value.Pattern is { Length: > 0 } pattern ) { - body.Line($"{localName}.Pattern = {pattern.StringLiteral()};"); + body.Assignment($"{localName}.Pattern", pattern.StringLiteral()); } if (attributes.Base64String.ShouldProcess && attributes.Base64String.Value.Exists) - body.Line($"{localName}.ContentEncoding = \"base64\";"); + body.Assignment($"{localName}.ContentEncoding", "base64".StringLiteral()); } static void EmitValueConstraints(CodeWriter body, ValidationAttributes attributes, string localName) @@ -374,7 +381,7 @@ static void EmitValueConstraints(CodeWriter body, ValidationAttributes attribute { var values = RenderJsonValues(attributes.AllowedValues.Value.Values); if (values.Count > 0) - body.Line($"{localName}.Enum = new() {{ {string.Join(", ", values)} }};"); + body.Assignment($"{localName}.Enum", $"new() {{ {string.Join(", ", values)} }}"); } if (attributes.DeniedValues.ShouldProcess && attributes.DeniedValues.Value.Exists) @@ -382,8 +389,9 @@ static void EmitValueConstraints(CodeWriter body, ValidationAttributes attribute var values = RenderJsonValues(attributes.DeniedValues.Value.Values); if (values.Count > 0) { - body.Line( - $"{localName}.Not = new {JsonSchemaDefinitionTypeName} {{ Enum = new() {{ {string.Join(", ", values)} }} }};" + body.Assignment( + $"{localName}.Not", + $"new {JsonSchemaDefinitionTypeName} {{ Enum = new() {{ {string.Join(", ", values)} }} }}" ); } } @@ -392,10 +400,15 @@ static void EmitValueConstraints(CodeWriter body, ValidationAttributes attribute static void EmitUnsupportedAttributeConstraints(CodeWriter body, ValidationAttributes attributes, string localName) { if (attributes.CreditCard.ShouldProcess && attributes.CreditCard.Value.Exists) - body.Line($"{JsonSchemaRulesTypeName}.MarkUnsupported({localName}, \"CreditCardAttribute\");"); + body.MethodCallOn( + JsonSchemaRulesTypeName, + "MarkUnsupported", + localName, + "CreditCardAttribute".StringLiteral() + ); if (attributes.Phone.ShouldProcess && attributes.Phone.Value.Exists) - body.Line($"{JsonSchemaRulesTypeName}.MarkUnsupported({localName}, \"PhoneAttribute\");"); + body.MethodCallOn(JsonSchemaRulesTypeName, "MarkUnsupported", localName, "PhoneAttribute".StringLiteral()); } static void EmitRangeConstraint(CodeWriter body, GeneratorResult range, string localName) @@ -406,15 +419,15 @@ static void EmitRangeConstraint(CodeWriter body, GeneratorResult() + .. Enum.GetValues() .Where(value => disallowed is null || Array.IndexOf(disallowed, value) < 0) .Select(static value => - Convert.ChangeType( - value, - System.Enum.GetUnderlyingType(typeof(TEnum)), - CultureInfo.InvariantCulture - ) + Convert.ChangeType(value, Enum.GetUnderlyingType(typeof(TEnum)), CultureInfo.InvariantCulture) ), ]; } diff --git a/src/src/ZodSharp/Rules/GuidRule.cs b/src/src/ZodSharp/Rules/GuidRule.cs index 986deee..d9ada14 100644 --- a/src/src/ZodSharp/Rules/GuidRule.cs +++ b/src/src/ZodSharp/Rules/GuidRule.cs @@ -20,7 +20,7 @@ public readonly record struct GuidRule /// /// The JSON Schema pattern for a canonical 8-4-4-4-12 hexadecimal identifier (any version or - /// variant nibble). The source generator emits this for a , so the exported + /// variant nibble). The source generator emits this for a , so the exported /// constraint matches rather than Zod's RFC UUID validator. /// public const string JsonSchemaPattern = diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs index ddaca68..4392cc3 100644 --- a/src/tests/Repository.UnitTests/SkillTests.cs +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -1,6 +1,6 @@ using Purview.Skills.Testing; -namespace ZodSharp; +namespace ZodSharp.Repository; /// /// Runs the Agent Skills conformance rule set against this repository's authored skills in diff --git a/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs b/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs index a826a93..79a6278 100644 --- a/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs +++ b/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs @@ -36,7 +36,7 @@ CancellationToken cancellationToken var source = Source; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -78,7 +78,7 @@ CancellationToken cancellationToken var source = Source; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var generated = result.GetSource(); // Assert @@ -110,7 +110,7 @@ public static partial class ConcurrentErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -152,7 +152,7 @@ public static partial class SizedErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -202,7 +202,7 @@ public static partial class SizeErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -243,7 +243,7 @@ public static partial class ConcurrentErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -285,7 +285,7 @@ public static partial class ConcurrentErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -329,7 +329,7 @@ public static class ConcurrentErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -353,7 +353,7 @@ public static partial class SomeType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs index 301a65d..6e9f30e 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs @@ -23,7 +23,7 @@ public class Empty { } "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult.AllSyntaxTrees.Length).IsEqualTo(ExpectedFileCount); @@ -74,7 +74,7 @@ public class Empty { } "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert — the refinement name is no longer configured by convention; the OnZodValidate hook // is declared by the generator instead. diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs index b9245de..25a90c0 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs @@ -23,7 +23,7 @@ public class User """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert var schema = driverResult.GetSource("UserZod.g.cs", HintNameMatchMode.Suffix); @@ -55,7 +55,7 @@ public class User """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var schema = driverResult.GetSource("UserSchema.g.cs", HintNameMatchMode.Suffix); // Assert @@ -85,7 +85,7 @@ public class User """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var schema = driverResult.GetSource("UserSchema.g.cs", HintNameMatchMode.Suffix); // Assert @@ -117,7 +117,7 @@ public class Basket """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var schema = driverResult.GetSource("BasketSchema.g.cs", HintNameMatchMode.Suffix); // Assert @@ -146,7 +146,7 @@ public class Basket """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var schema = driverResult.GetSource("BasketSchema.g.cs", HintNameMatchMode.Suffix); // Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs index 68f3018..4d4a405 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs @@ -90,7 +90,11 @@ public readonly partial record struct InstallationId { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("InstallationIdSchema"); // Assert — the rule is closed over the underlying Guid and adapted to the value object. @@ -128,7 +132,11 @@ public readonly partial record struct InstallationId { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("InstallationIdSchema"); // Assert @@ -163,7 +171,11 @@ public readonly partial record struct Nickname { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("NicknameSchema"); // Assert — the wrapped rule and the adapter's TValue keep the nullable annotation. @@ -194,7 +206,11 @@ public readonly partial record struct Nickname { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("NicknameSchema"); // Assert @@ -223,7 +239,11 @@ public readonly partial record struct Score { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("ScoreSchema"); // Assert — the synthesized nullable property is guarded, so null is accepted. @@ -261,12 +281,12 @@ public readonly partial record struct TenantId { } """; // Act - var genericResult = await GenerateAsync( + using var genericResult = await GenerateAsync( genericSource, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken ); - var typeofResult = await GenerateAsync( + using var typeofResult = await GenerateAsync( typeofSource, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken @@ -298,7 +318,11 @@ public readonly partial record struct Name { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("NameSchema"); // Assert — [RequiredZod] is closed over the underlying string? and adapted, with an empty path. @@ -348,7 +372,11 @@ public readonly partial record struct ExternalId { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("ExternalIdSchema"); // Assert — the wrapped rule owns the identity, not the adapter. @@ -439,7 +467,11 @@ public readonly partial record struct TenantId """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("TenantIdSchema"); // Assert — the rule's own default message is reworded to name the scalar, since the error has an @@ -517,7 +549,11 @@ public readonly partial record struct TenantId """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("TenantIdSchema"); // Assert @@ -550,7 +586,11 @@ public partial class Tenant """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("TenantSchema"); // Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BasicGen.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BasicGen.cs index 4d908ba..0adf44f 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BasicGen.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BasicGen.cs @@ -16,7 +16,7 @@ class BasicModel { } "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult.EnsureValid).ThrowsNothing(); @@ -46,7 +46,7 @@ namespace Testing "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource(); // Assert — generated file starts with auto-generated header diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs index b8a64b3..4f8227a 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs @@ -31,7 +31,7 @@ public readonly record struct RequiredRule(string? Message = null) : IValidation """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("RequiredZodAttribute"); // Assert - the DataAnnotations name is left alone and the rule's attribute is emitted under the "Zod" @@ -67,7 +67,7 @@ public readonly record struct ThresholdRule(T threshold) : IValidationRule """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ThresholdAttribute"); // Assert @@ -119,6 +119,7 @@ public readonly record struct UpperBoundRule(T maxValue, string? message = nu await Assert.That(generated).ContainsGeneratedCode("public string? Message { get; set; } = null;"); await Assert.That(attributeType.GetConstructor([typeof(double)])).IsNotNull(); await Assert.That(attributeType.GetConstructor([])).IsNull(); + driverResult.AssertNoCompilationErrors(); } @@ -145,7 +146,11 @@ public partial class Sample // Act - the harness normally rejects a run whose compilation has errors, so validation is disabled here // to assert the error explicitly. - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert - a missing required value is a compile error at the attribute's usage site (CS7036), not a // generator diagnostic that only surfaces when a schema is generated. @@ -176,7 +181,7 @@ public readonly record struct WholeRule(string? message = null) : IValidation """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WholeAttribute"); // Assert @@ -207,7 +212,7 @@ public readonly record struct BlobRule(Guid Blob, string? Message = null) : IVal """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).HasDiagnostic("ZODSGEN043"); @@ -237,7 +242,7 @@ public readonly record struct BlobRule(Guid Blob, string? Message = null) : IVal """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).HasDiagnostic("ZODSGEN032"); @@ -279,7 +284,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(BuiltInAttributeSource, cancellationToken); + using var driverResult = await GenerateAsync(BuiltInAttributeSource, cancellationToken); var generated = driverResult.GetSource("ContactSchema"); // Assert - each shipped attribute resolves to its built-in rule. The regex attribute mirrors the @@ -386,7 +391,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(BuiltInScalarAttributeSource, cancellationToken); + using var driverResult = await GenerateAsync(BuiltInScalarAttributeSource, cancellationToken); var generated = driverResult.GetSource("TenantIdSchema"); // Assert - the shipped open-generic attribute closes over the scalar's underlying value and the wrapped @@ -472,7 +477,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(NewBuiltInAttributeSource, cancellationToken); + using var driverResult = await GenerateAsync(NewBuiltInAttributeSource, cancellationToken); var generated = driverResult.GetSource("MetricsSchema"); // Assert - the generic bound rules are closed with the member type (their double bound converted to @@ -561,7 +566,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(OptionalValueAttributeSource, cancellationToken); + using var driverResult = await GenerateAsync(OptionalValueAttributeSource, cancellationToken); var generated = driverResult.GetSource("PreferencesSchema"); // Assert - the string comparison and IP family flow into the rule (rendered as their enum value) and @@ -618,7 +623,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(UuidAttributeSource, cancellationToken); + using var driverResult = await GenerateAsync(UuidAttributeSource, cancellationToken); var generated = driverResult.GetSource("IdentifiersSchema"); // Assert - the bare [UUID] maps to the versionless overload and [UUID(UuidVersion.V4)] to the versioned @@ -708,7 +713,7 @@ public partial class Account """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AccountSchema"); // Assert - the open generic is closed with the member type and carries both options. @@ -792,7 +797,7 @@ public partial class Choice """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ChoiceSchema"); // Assert @@ -869,7 +874,7 @@ public partial class Rating """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("RatingSchema"); // Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs index dd241bd..62b6279 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs @@ -39,7 +39,7 @@ public class Limits } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("LimitsSchema"); await Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs index 34b0264..506c559 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs @@ -21,7 +21,7 @@ public class ComposeModel "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("ComposeModelSchema"); await Assert.That(generatedSource).ContainsGeneratedCode("ApplyAnd"); @@ -46,7 +46,7 @@ public class NoComposeModel "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("NoComposeModelSchema"); // Assert @@ -142,6 +142,7 @@ public static class CompositionUser var getSchemaMethod = helper.GetMethod("GetSchema")!; var schema = getSchemaMethod.Invoke(null, [])!; + await Assert.That(schema).IsNotNull(); } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs index bcde18b..444635b 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs @@ -41,7 +41,7 @@ public class User public async Task CustomRule_GivenZodRuleMappedAttribute_EmitsRuleValidation(CancellationToken cancellationToken) { // Arrange / Act - var driverResult = await GenerateAsync(CustomRuleSource, cancellationToken); + using var driverResult = await GenerateAsync(CustomRuleSource, cancellationToken); var generated = driverResult.GetSource("UserSchema"); // Assert @@ -116,7 +116,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions { ThrowOnGenerationException = false }, cancellationToken @@ -159,7 +159,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert — a required, non-nullable Code cannot be defaulted to null, so the rule is not emitted. diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs index 4da4cf0..1f610ce 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs @@ -15,7 +15,7 @@ namespace Testing public class NoCustom { public string? Name { get; set; } } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("NoCustomSchema"); // No async state machine @@ -51,7 +51,7 @@ internal static ValueTask> CustomValidationAsync( } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WithDefaultSchema"); await Assert @@ -85,7 +85,7 @@ internal static ValueTask> ValidateRulesAsync( } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WithOverrideSchema"); await Assert.That(generated).ContainsGeneratedCode("await global::Testing.WithOverride.ValidateRulesAsync"); @@ -121,7 +121,7 @@ internal static ValueTask> CustomValidationAsync( // The second overload has 3 params so it won't match — only one valid candidate. // This test verifies the generator doesn't falsely report ambiguity. - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); await Assert.That(driverResult.EnsureValid).ThrowsNothing(); } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs index ab95740..c7f1800 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs @@ -87,7 +87,7 @@ public async Task Generate_GivenDataAnnotations_GeneratedSourceContainsExpectedV CancellationToken cancellationToken ) { - var driverResult = await GenerateAsync(UserSource, cancellationToken); + using var driverResult = await GenerateAsync(UserSource, cancellationToken); var generatedSource = driverResult.GetSource("UserSchema"); @@ -138,7 +138,7 @@ public sealed class LengthExamples } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("LengthExamplesSchema"); @@ -511,7 +511,7 @@ public sealed class StringFormatModel } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("StringFormatModelSchema"); await Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs index 001caf2..927c3ed 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs @@ -36,7 +36,7 @@ public async Task EnumProperty_GivenNoAttributes_GeneratesEnumRuleValidation(Can const string source = EnumModel; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -138,7 +138,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -174,7 +174,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -212,7 +212,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -249,7 +249,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert - the denied value is the rule's disallowed set, and no standalone check duplicates it. @@ -341,7 +341,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -489,7 +489,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs index 4e86aa6..1a57e46 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs @@ -110,7 +110,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); // Assert var hintNames = GeneratedHintNames(driverResult); @@ -128,7 +128,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); // Assert var generated = driverResult.GetSource("AppOptionsSchema"); @@ -146,7 +146,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); // Assert var diagnostics = driverResult.CompilationResult.Compilation.GetDiagnostics(cancellationToken); @@ -166,7 +166,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsCollectionSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsCollectionSource, options, cancellationToken); // Assert var hintNames = GeneratedHintNames(driverResult); @@ -187,7 +187,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); // Assert var hintNames = GeneratedHintNames(driverResult); @@ -208,7 +208,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); // Assert var hintNames = GeneratedHintNames(driverResult); @@ -226,7 +226,7 @@ CancellationToken cancellationToken { // Arrange // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( LocalOptionsSource, new ZodSourceGeneratorTestOptions(), cancellationToken @@ -273,7 +273,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(NestedAppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(NestedAppOptionsSource, options, cancellationToken); // Assert var hintNames = GeneratedHintNames(driverResult); @@ -298,7 +298,7 @@ .. driverResult async Task CompileContractAsync(string source, CancellationToken cancellationToken) { ZodSourceGeneratorTestOptions options = new() { CompilationAssemblyName = "Testing.Contracts" }; - var contractRun = await GenerateAsync(source, options, cancellationToken); + using var contractRun = await GenerateAsync(source, options, cancellationToken); using MemoryStream stream = new(); var emitResult = contractRun.CompilationResult.Compilation.Emit(stream, cancellationToken: cancellationToken); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GeneratedStructure.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GeneratedStructure.cs index 9075225..8be5138 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GeneratedStructure.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GeneratedStructure.cs @@ -19,7 +19,7 @@ public class Customer "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("CustomerSchema"); // Assert @@ -41,7 +41,7 @@ public class Customer { } "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("CustomerSchema"); // Assert @@ -68,7 +68,7 @@ public record Order { } "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult.GetSource("CustomerSchema")).IsNotEmpty(); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs index 1dc51a5..dfb5433 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs @@ -53,7 +53,7 @@ public partial record struct Sequence public async Task GenericRule_GivenUnboundGenericRule_ClosesWithPropertyType(CancellationToken cancellationToken) { // Act - var driverResult = await GenerateAsync(GenericRuleSource, cancellationToken); + using var driverResult = await GenerateAsync(GenericRuleSource, cancellationToken); var assetSchema = driverResult.GetSource("AssetIdSchema"); var sequenceSchema = driverResult.GetSource("SequenceSchema"); @@ -66,7 +66,7 @@ public async Task GenericRule_GivenUnboundGenericRule_ClosesWithPropertyType(Can public async Task GenericRule_GivenRuleOwningIdentity_EmitsRuleCodeExpression(CancellationToken cancellationToken) { // Act - var driverResult = await GenerateAsync(GenericRuleSource, cancellationToken); + using var driverResult = await GenerateAsync(GenericRuleSource, cancellationToken); var generated = driverResult.GetSource("AssetIdSchema"); // Assert — the rule implements IZodRule, so its own identity is preferred over the attribute's. @@ -146,7 +146,7 @@ public class Broken """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("BrokenSchema"); // Assert @@ -364,7 +364,7 @@ public partial record struct AssetId """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AssetIdSchema"); // Assert — the attribute-level Code wins over the [ZodRule] Code. diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs index 8cbd25b..e9d7f66 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs @@ -23,7 +23,7 @@ public class Widget }"; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WidgetSchema"); // Assert @@ -49,7 +49,7 @@ public class Widget }"; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WidgetSchema"); // Assert — the adapter is what Z.ToJsonSchema receives, so it must forward to the schema. @@ -152,7 +152,7 @@ public class Widget }"; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WidgetSchema"); // Assert — the validation members are still generated; only the export members are omitted. diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs index 17554b7..d8dfc6a 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs @@ -25,7 +25,7 @@ private class Inner } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("InnerSchema"); await Assert.That(generatedSource).ContainsGeneratedCode("private static partial class InnerSchema"); @@ -53,7 +53,7 @@ private class Inner } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); await Assert.That(driverResult.GetSource("InnerSchema")).IsNotEmpty(); } @@ -124,7 +124,7 @@ private class Inner } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("InnerSchema"); await Assert.That(generatedSource).IsNotNull(); @@ -168,7 +168,7 @@ sealed partial class WebAppKitOptions } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("WebAppKitOptionsSchema"); await Assert.That(generatedSource).IsNotNull(); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs index 31acd2f..c626766 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs @@ -35,7 +35,7 @@ public sealed class AdminPortal } """; - var driverResult = await GenerateAsync(source, new ZodSourceGeneratorTestOptions(), cancellationToken); + using var driverResult = await GenerateAsync(source, new ZodSourceGeneratorTestOptions(), cancellationToken); var generated = driverResult.GetSource("AdminPortalSchema"); await Assert @@ -68,7 +68,7 @@ public sealed class AdminPortal } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AdminPortalSchema"); await Assert.That(generated).DoesNotContain("AdminPortalValidator"); @@ -79,7 +79,7 @@ public async Task OptionsValidator_GivenDefaultAutoDetect_GeneratesForOptionsSuf CancellationToken cancellationToken ) { - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( OptionsValidatorSource, new ZodSourceGeneratorTestOptions(), cancellationToken @@ -109,7 +109,7 @@ public sealed class AdminPortalOptions } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AdminPortalOptionsSchema"); await Assert.That(generated).DoesNotContain("AdminPortalOptionsValidator"); @@ -128,7 +128,7 @@ CancellationToken cancellationToken ), }; - var driverResult = await GenerateAsync(OptionsValidatorSource, options, cancellationToken); + using var driverResult = await GenerateAsync(OptionsValidatorSource, options, cancellationToken); var generated = driverResult.GetSource("AdminPortalOptionsSchema"); await Assert.That(generated).DoesNotContain("AdminPortalOptionsValidator"); @@ -162,7 +162,7 @@ public sealed class AdminPortalOptions ), }; - var driverResult = await GenerateAsync(source, options, cancellationToken); + using var driverResult = await GenerateAsync(source, options, cancellationToken); var configSource = driverResult.GetSource("AdminPortalConfigSchema"); await Assert.That(configSource).ContainsGeneratedCode("public sealed partial class AdminPortalConfigValidator"); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs index 24d5ff0..c2b9445 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs @@ -36,7 +36,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(NonSentinelDateTimeOffsetSource, cancellationToken); + using var driverResult = await GenerateAsync(NonSentinelDateTimeOffsetSource, cancellationToken); var generated = driverResult.GetSource("SummarySchema"); // Assert - the open generic is closed with the property's own type, not dropped. @@ -80,7 +80,7 @@ TimeOnly Hour """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("IdentifiersSchema"); // Assert @@ -125,7 +125,7 @@ DateTimeOffset LastObservedAt """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("SummarySchema"); // Assert - the named Message argument flows into the rule like it does for a declared property. @@ -160,7 +160,7 @@ DateTimeOffset LastObservedAt """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("SummarySchema")!; // Assert — the merged attribute set must not apply the same rule twice. The rule is constructed once @@ -201,7 +201,7 @@ DateTimeOffset Window """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AccountSchema"); // Assert - the display name, the length bounds and the parsed DateTimeOffset bounds all come from the @@ -228,7 +228,7 @@ CancellationToken cancellationToken // Arrange var (assembly, validate) = await CompileSummarySchema(cancellationToken); var modelType = assembly.GetType("Testing.Summary")!; - var summary = Activator.CreateInstance(modelType, new object?[] { default(DateTimeOffset) })!; + var summary = Activator.CreateInstance(modelType, [default(DateTimeOffset)])!; // Act var result = InvokeValidate(validate, summary); @@ -277,10 +277,7 @@ CancellationToken cancellationToken // Arrange var (assembly, validate) = await CompileSummarySchema(cancellationToken); var modelType = assembly.GetType("Testing.Summary")!; - var summary = Activator.CreateInstance( - modelType, - new object?[] { new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero) } - )!; + var summary = Activator.CreateInstance(modelType, [new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero)])!; // Act var result = InvokeValidate(validate, summary); @@ -332,15 +329,14 @@ CancellationToken cancellationToken var modelType = assembly.GetType("Testing.InventorySummary")!; var summary = Activator.CreateInstance( modelType, - new object?[] - { + [ "changeops", "purview-dev/changeops", null, Enum.ToObject(modelType.GetProperty("Visibility")!.PropertyType, 1), false, default(DateTimeOffset), - } + ] )!; // Act @@ -364,15 +360,14 @@ CancellationToken cancellationToken var modelType = assembly.GetType("Testing.InventorySummary")!; var summary = Activator.CreateInstance( modelType, - new object?[] - { + [ "changeops", "purview-dev/changeops", null, Enum.ToObject(modelType.GetProperty("Visibility")!.PropertyType, 1), false, new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), - } + ] )!; // Act @@ -392,15 +387,14 @@ CancellationToken cancellationToken var modelType = assembly.GetType("Testing.InventorySummary")!; var summary = Activator.CreateInstance( modelType, - new object?[] - { + [ string.Empty, "purview-dev/changeops", null, Enum.ToObject(modelType.GetProperty("Visibility")!.PropertyType, 1), false, new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), - } + ] )!; // Act @@ -433,11 +427,8 @@ public sealed record Named([NonSentinel] string Name); var (assembly, validate) = await CompileAsync(source, "Testing.NamedSchema", cancellationToken); var modelType = assembly.GetType("Testing.Named")!; - var emptyResult = InvokeValidate( - validate, - Activator.CreateInstance(modelType, new object?[] { string.Empty })! - ); - var namedResult = InvokeValidate(validate, Activator.CreateInstance(modelType, new object?[] { "changeops" })!); + var emptyResult = InvokeValidate(validate, Activator.CreateInstance(modelType, [string.Empty])!); + var namedResult = InvokeValidate(validate, Activator.CreateInstance(modelType, ["changeops"])!); // Assert await Assert.That(IsSuccess(emptyResult)).IsFalse(); @@ -464,8 +455,8 @@ public sealed record Branch([NullOrNonWhiteSpace] string? DefaultBranch); var (assembly, validate) = await CompileAsync(source, "Testing.BranchSchema", cancellationToken); var modelType = assembly.GetType("Testing.Branch")!; - var nullResult = InvokeValidate(validate, Activator.CreateInstance(modelType, new object?[] { null })!); - var whitespaceResult = InvokeValidate(validate, Activator.CreateInstance(modelType, new object?[] { " " })!); + var nullResult = InvokeValidate(validate, Activator.CreateInstance(modelType, [null])!); + var whitespaceResult = InvokeValidate(validate, Activator.CreateInstance(modelType, [" "])!); // Assert await Assert.That(IsSuccess(nullResult)).IsTrue(); @@ -497,7 +488,7 @@ DateTimeOffset LastObservedAt var (assembly, validate) = await CompileAsync(source, "Testing.SummarySchema", cancellationToken); var modelType = assembly.GetType("Testing.Summary")!; - var summary = Activator.CreateInstance(modelType, new object?[] { default(DateTimeOffset) })!; + var summary = Activator.CreateInstance(modelType, [default(DateTimeOffset)])!; // Act var result = InvokeValidate(validate, summary); @@ -525,7 +516,7 @@ public sealed record Account([Required] string Name); var (assembly, validate) = await CompileAsync(source, "Testing.AccountSchema", cancellationToken); var modelType = assembly.GetType("Testing.Account")!; - var missing = Activator.CreateInstance(modelType, new object?[] { null })!; + var missing = Activator.CreateInstance(modelType, [null])!; // Act var result = InvokeValidate(validate, missing); @@ -560,7 +551,7 @@ string Name var (assembly, validate) = await CompileAsync(source, "Testing.AccountSchema", cancellationToken); var modelType = assembly.GetType("Testing.Account")!; - var missing = Activator.CreateInstance(modelType, new object?[] { null })!; + var missing = Activator.CreateInstance(modelType, [null])!; // Act var result = InvokeValidate(validate, missing); @@ -593,14 +584,8 @@ DateTimeOffset When var (assembly, validate) = await CompileAsync(source, "Testing.WindowSchema", cancellationToken); var modelType = assembly.GetType("Testing.Window")!; - var inRange = Activator.CreateInstance( - modelType, - new object?[] { new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero) } - )!; - var outOfRange = Activator.CreateInstance( - modelType, - new object?[] { new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero) } - )!; + var inRange = Activator.CreateInstance(modelType, [new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero)])!; + var outOfRange = Activator.CreateInstance(modelType, [new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero)])!; // Act var inRangeResult = InvokeValidate(validate, inRange); @@ -637,10 +622,10 @@ Guid Id var (assembly, validate) = await CompileAsync(source, "Testing.ObservationSchema", cancellationToken); var modelType = assembly.GetType("Testing.Observation")!; - var invalid = Activator.CreateInstance(modelType, new object?[] { default(DateTimeOffset), Guid.Empty })!; + var invalid = Activator.CreateInstance(modelType, [default(DateTimeOffset), Guid.Empty])!; var valid = Activator.CreateInstance( modelType, - new object?[] { new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), Guid.NewGuid() } + [new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), Guid.NewGuid()] )!; // Act @@ -721,14 +706,8 @@ public sealed record Order([Required] Customer Customer); customerType.GetProperty("Name")!.SetValue(validCustomer, "changeops"); // Act - var invalidResult = InvokeValidate( - validate, - Activator.CreateInstance(orderType, new object?[] { invalidCustomer })! - ); - var validResult = InvokeValidate( - validate, - Activator.CreateInstance(orderType, new object?[] { validCustomer })! - ); + var invalidResult = InvokeValidate(validate, Activator.CreateInstance(orderType, [invalidCustomer])!); + var validResult = InvokeValidate(validate, Activator.CreateInstance(orderType, [validCustomer])!); // Assert await Assert.That(IsSuccess(invalidResult)).IsFalse(); @@ -772,7 +751,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(CustomRuleOnPositionalParameterSource, cancellationToken); + using var driverResult = await GenerateAsync(CustomRuleOnPositionalParameterSource, cancellationToken); var generated = driverResult.GetSource("UserSchema"); // Assert @@ -794,7 +773,7 @@ CancellationToken cancellationToken cancellationToken ); var modelType = assembly.GetType("Testing.User")!; - var instance = Activator.CreateInstance(modelType, new object?[] { "John Doe" })!; + var instance = Activator.CreateInstance(modelType, ["John Doe"])!; // Act var result = InvokeValidate(validate, instance); @@ -807,15 +786,14 @@ CancellationToken cancellationToken await Assert.That(errors[0].Origin).IsEqualTo("string"); } - async Task<(System.Reflection.Assembly Assembly, MethodInfo Validate)> CompileSummarySchema( - CancellationToken cancellationToken - ) => await CompileAsync(NonSentinelDateTimeOffsetSource, "Testing.SummarySchema", cancellationToken); + async Task<(Assembly Assembly, MethodInfo Validate)> CompileSummarySchema(CancellationToken cancellationToken) => + await CompileAsync(NonSentinelDateTimeOffsetSource, "Testing.SummarySchema", cancellationToken); - async Task<(System.Reflection.Assembly Assembly, MethodInfo Validate)> CompileInventorySummarySchema( + async Task<(Assembly Assembly, MethodInfo Validate)> CompileInventorySummarySchema( CancellationToken cancellationToken ) => await CompileAsync(InventorySummarySource, "Testing.InventorySummarySchema", cancellationToken); - async Task<(System.Reflection.Assembly Assembly, MethodInfo Validate)> CompileAsync( + async Task<(Assembly Assembly, MethodInfo Validate)> CompileAsync( string source, string schemaTypeName, CancellationToken cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs index afefcc4..da48965 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs @@ -27,7 +27,7 @@ public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? M """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("NoWhitespaceAttribute"); // Assert @@ -125,7 +125,7 @@ public readonly record struct OtherRule(string? Message = null) : IValidationRul """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert — the hand-authored attribute is left alone, the unmapped rule still gets one. await Assert.That(driverResult.GetSource("OtherAttribute")).Contains("class OtherAttribute"); @@ -159,7 +159,7 @@ public sealed class NoWhitespaceAttribute : ValidationAttribute { } """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert — the hand-authored declaration wins, nothing is generated, and the suppression is reported // rather than silent (previously the hand-authored mapping re-mapped the name with no notice). @@ -431,7 +431,7 @@ CancellationToken cancellationToken ); // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert — the generic half of the family is not covered by the declared mapping. await Assert.That(driverResult).HasDiagnostic("ZODSGEN038"); @@ -451,7 +451,7 @@ CancellationToken cancellationToken ); // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN038"); @@ -491,7 +491,7 @@ public sealed class WidgetAttribute : ValidationAttribute { } """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).HasDiagnostic("ZODSGEN038"); @@ -524,7 +524,7 @@ public sealed class NoSpacesAttribute : ValidationAttribute { } """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN038"); @@ -552,7 +552,7 @@ public readonly record struct RankRule(string? Code = null, string? Message = nu """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("RankAttribute"); // Assert — the warning is raised and the attribute is still generated. @@ -588,7 +588,7 @@ public readonly record struct RankRule(string? Code = null, string? Message = nu """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN039"); @@ -676,7 +676,7 @@ public sealed class RankAttribute : ValidationAttribute { } """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).HasDiagnostic("ZODSGEN039"); @@ -712,7 +712,7 @@ public sealed class RankAttribute : ValidationAttribute { } """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).HasDiagnostics("ZODSGEN039", 1); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs index 3593e9f..7f14884 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs @@ -158,7 +158,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( NullableScalarAdaptationSource, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken @@ -186,7 +186,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(ScalarAdaptationSource, cancellationToken); + using var driverResult = await GenerateAsync(ScalarAdaptationSource, cancellationToken); var generated = driverResult.GetSource("TenantIdSchema"); // Assert — the rule is constructed for the Guid and wrapped in the scalar adapter, which validates @@ -306,7 +306,7 @@ public partial record struct AssetId : Purview.ValueObjects.IScalarValueObject context) } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("PagingSchema"); await Assert @@ -71,7 +71,7 @@ public partial class Paging } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("PagingSchema"); // The validator neither allocates a context nor invokes the hook when no body is supplied. @@ -98,7 +98,7 @@ public class Paging } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var emitted = driverResult.AllSyntaxTrees.Select(static tree => tree.GetText().ToString()); await Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs index 788e4ad..041a7e5 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs @@ -53,7 +53,7 @@ public partial record struct AssetId : IScalarValueObject public async Task TypeRule_GivenRuleAttributeOnSchemaType_EmitsWholeObjectRule(CancellationToken cancellationToken) { // Act - var driverResult = await GenerateAsync(TypeRuleSource, cancellationToken); + using var driverResult = await GenerateAsync(TypeRuleSource, cancellationToken); var generated = driverResult.GetSource("AssetIdSchema"); // Assert — the rule runs against the value object itself with an empty path. @@ -121,7 +121,7 @@ public partial record struct AssetId : IScalarValueObject """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AssetIdSchema"); // Assert @@ -200,7 +200,11 @@ public readonly partial record struct AssetId // Act — the harness normally compiles the generated code, which a single synthetic compilation cannot // do for this shape: the interface the constraint asks for is contributed by another generator, so the // emitted rule is asserted instead of compiled. - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("AssetIdSchema"); // Assert — the rule still closes over the value object and validates the whole value. @@ -303,7 +307,7 @@ public class Order """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AssetIdSchema"); // Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs index 360bf81..905be1d 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs @@ -30,7 +30,11 @@ public partial class Broken """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("BrokenSchema"); // Assert @@ -106,7 +110,11 @@ public partial class BrokenCollection """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("BrokenCollectionSchema"); // Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs index fe911e0..6c24579 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs @@ -30,7 +30,7 @@ internal static ValueTask> CustomValidationAsync( } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WithBothSchema"); // The refinement hook is honoured... diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs index a0744b5..d7c71a2 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs @@ -18,7 +18,7 @@ public class Widget public string? Name { get; set; } } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("WidgetSchema"); await Assert.That(generatedSource).ContainsGeneratedCode("class WidgetSchemaValidator"); @@ -54,7 +54,7 @@ public partial record struct Inner }"; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var schemaSource = driverResult.GetSource("InnerSchema"); var registrationSource = driverResult.GetSource("InnerSchemaRegistration"); @@ -73,7 +73,7 @@ namespace Testing [ZodSchema] public class Gadget { } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var allGenerated = string.Join("\n", driverResult.AllSyntaxTrees.Select(static t => t.GetText().ToString())); await Assert.That(allGenerated).ContainsGeneratedCode("ZodSchemaGenerated"); @@ -95,7 +95,7 @@ public class Gizmo public string? Name { get; set; } } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("GizmoSchema"); await Assert.That(generatedSource).ContainsGeneratedCode("ValidateAsync"); @@ -132,7 +132,7 @@ async ValueTask> CustomValidationAsync(Gizmo value, Canc }} }} }}"; - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.Compile, cancellationToken); + using var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.Compile, cancellationToken); var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); var gizmoType = assembly.GetType("Testing.Gizmo"); diff --git a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs index f06753f..8841807 100644 --- a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs +++ b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs @@ -99,7 +99,7 @@ public async Task EnumSchema_GivenDisallowedMember_ExcludesIt(CancellationToken cancellationToken.ThrowIfCancellationRequested(); // Act - var schema = JsonSchemaRules.EnumSchema(SampleStatus.Disabled); + var schema = JsonSchemaRules.EnumSchema(SampleStatus.Disabled); // Assert await Assert.That(schema.Enum).IsNotNull(); From d34ff84c5f68a1183089f4f097aa9ba4754cdc84 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 16 Oct 2026 09:57:18 +0100 Subject: [PATCH 18/21] chore(release): name the 2.1.0 release and guard the public API Rename the CHANGELOG's `Unreleased` heading to `2.1.0`, consolidating the 2.0.1, 2.0.1.1 and 2.0.2 incremental releases under it so a merge to main cannot publish an unnamed release. Restructure the former "breaking changes" section as `Upgrade notes`, each item naming the old form, the new form and the edit to make. Record ZODSGEN045 under a new `## Release 2.1.0` block. The guard that was supposed to catch it being left unshipped was passing vacuously: the rule-row regex matched only the pipe-delimited form used by Shipped.md, while Unshipped.md is written without leading pipes, so a rule parked there parsed as no rules at all and the assertion compared two empty sets. Accept both table styles. Enable package validation against the 2.0.2 baseline so an unintended public API change fails `dotnet pack`. All four packages validate clean with no suppressions, confirming 2.1.0 is additive over the published 2.0.2. Also document the pre-merge release checklist and correct the target framework list in README.md. --- CHANGELOG.md | 82 +++++++++++++------ README.md | 2 +- docs/wiki/Release-Flow.md | 11 ++- src/Directory.Build.targets | 17 ++++ .../AnalyzerReleases.Shipped.md | 8 ++ .../AnalyzerReleases.Unshipped.md | 6 -- .../AnalyzerReleaseTrackingTests.cs | 20 ++++- 7 files changed, 108 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b81555e..e3db76a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,34 +6,46 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `package.json` is the authoritative version; see [Release Flow](docs/wiki/Release-Flow.md). -Released versions correspond to `v` GitHub releases. Entries below the `Unreleased` heading have not -been published to NuGet. +Released versions correspond to `v` GitHub releases. -## Unreleased +## 2.1.0 -> **This release contains breaking changes.** See [Breaking changes](#breaking-changes) below before -> upgrading from `2.0.0`. The analyzer catalogue records them under `2.0.2`. -> -> Note the changes below are consumer-visible: a rule's public `Code` constant was renamed to `ErrorCode`, -> and rule constructor signatures changed, so attribute usages and code referencing those constants may not -> compile against this version. Read the migration notes before upgrading. +Consolidates the `2.0.1`, `2.0.1.1` and `2.0.2` incremental releases: upgrading from `2.0.0` goes straight to +`2.1.0`, and the notes below cover that whole arc. The analyzer catalogue records each diagnostic under the +release that introduced it, so `AnalyzerReleases.Shipped.md` shows `ZODSGEN037`–`ZODSGEN044` under +`## Release 2.0.2` and `ZODSGEN045` under `## Release 2.1.0`. -### Breaking changes +> **Upgrading from `2.0.0`?** Start with [Upgrade notes](#upgrade-notes). A few rule and attribute APIs were +> renamed and standardised across these releases, so some usages need a mechanical edit before they compile. +> Each note names the old form, the new form, and the edit to make. -- **Rule error-identity constants renamed from `Code` to `ErrorCode`.** Every validation rule now exposes its - error identity as public `const string ErrorCode` and `const string MessageFormat`. Code that referenced a - rule's `Code` constant must use `ErrorCode`. The new `ZODSGEN042` analyzer enforces the convention on - source-declared rules. -- **Rule constructors are standardised.** Every rule now has a consistent constructor, extension method and - generated attribute. Generated rule attributes carry the rule's *value* parameters as constructor arguments: - a parameter declared without a default becomes a required positional argument, a parameter with a default - keeps it, and `message`/`code`/`origin` remain properties. Attribute usages for required values become - positional — `[MinValue(3)]`, `[Regex("^[a-z]+$")]`. Usages that previously set a required value by property - name no longer compile. -- **`NonSentinelRule.MessageFormat` reworded and the `{0}` argument now describes the sentinel.** The format - is now `Value has a sentinel value, but got {0}`, and `{0}` is a description (`an empty GUID`, - `DateTime.MinValue`, `an empty string`, …) instead of the raw value. Code or tests that assert against the old - string must be updated. +### Upgrade notes + +- **A rule's error-identity constants are now `ErrorCode` and `MessageFormat`.** Every validation rule exposes + its error identity as public `const string ErrorCode` and `const string MessageFormat`. + - *Previously:* a rule's identity constant was named `Code`. + - *Now:* it is named `ErrorCode`. + - *To upgrade:* rename references from `SomeRule.Code` to `SomeRule.ErrorCode`. `ZODSGEN042` reports + source-declared rules that do not follow the convention, so the compiler points at each one. +- **Rule constructors, extension methods and generated attributes now follow one shape.** A generated rule + attribute carries the rule's *value* parameters as constructor arguments: a parameter declared without a + default becomes a required positional argument, a parameter with a default keeps it, and + `message`/`code`/`origin` remain properties. + - *Previously:* a required value could be supplied by property name — the property derived from the rule's + parameter, so `MinValueRule(T minValue, …)` was settable as `[MinValue(MinValue = 3)]`. + - *Now:* required values are positional — `[MinValue(3)]`, `[Regex("^[a-z]+$")]`. + - *To upgrade:* move a required value from a property assignment to a positional argument. Defaulted values + may still be set by property name. +- **`NonSentinelRule.MessageFormat` is reworded, and `{0}` now describes the sentinel.** + - *Previously:* `{0}` interpolated the raw value. + - *Now:* the format is `Value has a sentinel value, but got {0}`, and `{0}` is a description — `an empty + GUID`, `DateTime.MinValue`, `an empty string`, and so on. + - *To upgrade:* update any code or test that asserts against the old message text. Asserting against + `NonSentinelRule.ErrorCode` instead keeps the assertion stable across future wording changes. +- **`ZodSchemaGeneratedAttribute` now takes `(Type targetType, Type validatorType)`.** See + [`RegisterFromAssembly` is now genuinely trim- and AOT-safe](#changed--trimming-and-native-aot) for why. + - *To upgrade:* nothing, in the normal case — the attribute is generator-emitted and regenerates on rebuild. + Only a hand-written usage needs the second argument. ### Added @@ -75,7 +87,7 @@ been published to NuGet. (which always rejects `null`) cannot express the check. - **`ZODSGEN044`.** A `[ZodSchema]` type using an automatic `[Scalar]` form that cannot produce a usable schema (a suppressed `Validate` method, or an unrepresentable underlying type) is reported instead of - leaving a dangling `{Type}Schema` reference. Recorded in `AnalyzerReleases.Unshipped.md`. + leaving a dangling `{Type}Schema` reference. Recorded under `## Release 2.0.2`. - **A `[ZodSchema]` schema now exports a complete JSON Schema.** The generated `{Type}Schema` class emits a static `BuildJsonSchema()` method and the generated `{Type}SchemaValidator` implements the new `ZodSharp.JsonSchema.IJsonSchemaProvider`, so `Z.ToJsonSchema(new {Type}SchemaValidator())` returns a real @@ -112,8 +124,9 @@ been published to NuGet. `[DynamicallyAccessedMembers(PublicParameterlessConstructor)]` so its constructor survives. No string resolution remains, and the `[RequiresUnreferencedCode]` annotation has been removed entirely. Discovery is also now proportional to the number of generated schemas rather than the size of the assembly. - - **Breaking:** `ZodSchemaGeneratedAttribute` now takes `(Type targetType, Type validatorType)`. The - attribute is generator-emitted, so it regenerates on rebuild; only hand-written usages need updating. + - `ZodSchemaGeneratedAttribute` now takes `(Type targetType, Type validatorType)`. The attribute is + generator-emitted, so it regenerates on rebuild; only hand-written usages need updating. Listed under + [Upgrade notes](#upgrade-notes). - **`Purview.ZodSharp.SystemTextJson` is AOT-clean and marked.** - The validating `JsonConverter` resolves a `JsonTypeInfo` from the serializer options instead of calling the reflection-based `JsonSerializer` overloads, so it defers to whatever resolver the host @@ -140,6 +153,11 @@ been published to NuGet. ### Changed +- **NuGet package validation.** Every packable project now enables `EnablePackageValidation` against the + previous stable release (`2.0.2`), so an unintended change to the public API fails `dotnet pack` rather than + shipping. All four packages validate clean with no suppressions — the rule and attribute changes under + [Upgrade notes](#upgrade-notes) landed in the `2.0.x` releases, so they are already part of the baseline, and + `2.1.0` is additive over it. This is the guard that was missing when those changes went out. - Clarified in `SourceGenerators.csproj` that `EnforceExtendedAnalyzerRules` and `TreatWarningsAsErrors` are supplied and enforced by `Purview.BuildSdk` for every `IsRoslynComponent` project, rather than being optional project settings. Both were already `true`; the commented-out block implied otherwise. @@ -249,6 +267,16 @@ been published to NuGet. `IValidationRule` constraint nor the value object's `IScalarValueObject` — reporting CS8631 at every consumer. The value argument is now carried as a `TypeReference`, so both sides stay in step. +### Fixed — analyzer release tracking guard + +- **`AnalyzerReleaseTrackingTests` was passing vacuously, and `ZODSGEN045` was never recorded as shipped.** The + guard that asserts the unshipped file declares no rules matched a rule row only in the pipe-delimited form + (`| ZODSGEN045 |`), but `AnalyzerReleases.Unshipped.md` is written without the leading pipe — so a rule parked + there parsed as *no rules at all* and the assertion compared two empty sets. `ZODSGEN045` sat unshipped with a + green suite. The row pattern now accepts both table styles, `ZODSGEN045` is recorded under a new + `## Release 2.1.0` block, and the test asserts that block exists. The guard now fails when a diagnostic is left + unshipped, which is the only thing it was ever meant to do. + ### Added — packed generator smoke test - `just smoke-packed-generator` (and a `packed-generator` CI job) packs the package, then builds and runs a diff --git a/README.md b/README.md index cb27479..deb46f4 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ src/ └── tests/ # TUnit test projects ``` -The source generator itself targets `netstandard2.0` so it can run in any compiler. The library packages target `net8.0`, `net9.0` and `net10.0`. +The source generator itself targets `netstandard2.0` so it can run in any compiler. The library packages target `net8.0`, `net9.0`, `net10.0` and `net11.0`. ## Advanced Features diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index b9bcf6b..7cdcfa1 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -4,10 +4,19 @@ Purview.ZodSharp releases are driven by the shared [purview-dev/build](https://g ## Versioning -The package version comes from `package.json` (`version` field). The current stable line is `2.0.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). +The package version comes from `package.json` (`version` field). The current stable line is `2.1.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). Package identities are `Purview.ZodSharp.*` (core, SystemTextJson, NewtonsoftJson, AspNetCore). Central package management lives in `Directory.Packages.props`; package versions there are minimum requirements, not exact pins, so the resolved graph can drift. +## Releasing a version + +A push to `main` publishes, so everything below has to be true *before* the merge. + +1. **Rename the changelog heading to the version being released.** `CHANGELOG.md`'s newest `## ` heading is the one that ships; there is no `Unreleased` heading, precisely so a merge cannot publish an unnamed release. Consumer-visible renames and signature changes go under that version's `### Upgrade notes`, each naming the old form, the new form and the edit to make. +2. **Move new diagnostics into a shipped block.** Anything in `AnalyzerReleases.Unshipped.md` moves to a `## Release ` block in `AnalyzerReleases.Shipped.md`. A rule stays recorded under the release that *introduced* it, so earlier blocks are never rewritten. +3. **Bump the package-validation baseline.** `src/Directory.Build.targets` sets `PackageValidationBaselineVersion` to the previous stable release; it must name the version currently on NuGet, not the one being released. The build then fails `dotnet pack` on an unintended public-API change. An intended one needs a `CompatibilitySuppressions.xml` beside the owning project (`dotnet pack /p:GenerateCompatibilitySuppressionFile=true`) *and* a matching upgrade note — the two are each other's evidence. +4. **Run `just pipeline-pr`**, which covers restore, build, lint, tests, pack and pack validation in one pass. + ## Workflows | Workflow | Trigger | Pipeline mode | diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ab68169..fdf37cf 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,3 +1,20 @@ + + + + true + 2.0.2 + diff --git a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md index 8f41bac..88b2aeb 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md @@ -61,3 +61,11 @@ | ZODSGEN042 | ZodSharp.SourceGenerator | Warning | A validation rule does not expose its error identity as public const ErrorCode/MessageFormat constants, so tests cannot assert against the rule without duplicating literals | | ZODSGEN043 | ZodSharp.SourceGenerator | Info | A built-in rule marked [ZodRule] does not generate a validation attribute because a constructor parameter cannot be represented as an attribute property (a derived name that collides with System.ComponentModel.DataAnnotations is emitted under a "Zod" suffix instead) | | ZODSGEN044 | ZodSharp.SourceGenerator | Error | A [ZodSchema] type that uses an automatic [Scalar] form ([Scalar] or [Scalar(typeof(TValue))]) cannot produce a usable schema, because the generated Validate method is suppressed or the underlying type cannot be represented | + +## Release 2.1.0 + +### New Rules + +| Rule ID | Category | Severity | Notes | +|---|---|---|---| +| ZODSGEN045 | ZodSharp.SourceGenerator | Warning | A type that exports JSON Schema references a nested [ZodSchema] type whose export is disabled ([ZodSchema(GenerateJsonSchema = false)] or the assembly-level ZodSharpGenerateJsonSchemas=false switch), so the parent's exported schema falls back to an empty schema for that property | diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index 32171d2..d50fc1a 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -5,9 +5,3 @@ ; across when the next version ships. ; ; ZODSGEN002 and ZODSGEN022-ZODSGEN026 were retired during development and are never reused. - -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|------- -ZODSGEN045 | ZodSharp.SourceGenerator | Warning | A type that exports JSON Schema references a nested [ZodSchema] type whose export is disabled ([ZodSchema(GenerateJsonSchema = false)] or the assembly-level ZodSharpGenerateJsonSchemas=false switch), so the parent's exported schema falls back to an empty schema for that property diff --git a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs index 56bdf5a..7cba360 100644 --- a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs +++ b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs @@ -9,8 +9,19 @@ namespace ZodSharp.SourceGenerators; /// (RS2000/RS2001), which report at build time when a diagnostic is missing from either file. /// /// -/// ZODSGEN037–ZODSGEN044 are recorded under a ## Release 2.0.2 block. The unshipped -/// file declares no rules; the next diagnostic added goes there and moves across when it ships. +/// +/// ZODSGEN037–ZODSGEN044 are recorded under a ## Release 2.0.2 block and +/// ZODSGEN045 under ## Release 2.1.0. A rule stays recorded under the release that introduced +/// it, so an existing block is never rewritten. The unshipped file declares no rules; the next diagnostic +/// added goes there and moves across when it ships. +/// +/// +/// accepts a rule row with or without a leading pipe, because the two files are +/// written in different table styles — AnalyzerReleases.Shipped.md delimits rows with pipes and +/// AnalyzerReleases.Unshipped.md does not. Matching only the pipe-delimited form made +/// vacuous: a rule parked in the +/// unshipped file parsed as no rules at all, so the guard passed while the catalogue was wrong. +/// /// public partial class AnalyzerReleaseTrackingTests { @@ -72,6 +83,8 @@ public partial class AnalyzerReleaseTrackingTests "ZODSGEN042", "ZODSGEN043", "ZODSGEN044", + // Release 2.1.0. + "ZODSGEN045", ]; /// @@ -92,6 +105,7 @@ public async Task ShippedReleases_GivenEveryRelease_DeclareEveryDiagnosticId(Can // Assert — each release block the catalogue has published, and the full rule set across them. await Assert.That(markdown).Contains("## Release 2.0.0"); await Assert.That(markdown).Contains("## Release 2.0.2"); + await Assert.That(markdown).Contains("## Release 2.1.0"); await Assert.That(SortedRuleIds(ruleIds)).IsEqualTo(SortedRuleIds(ExpectedShippedRuleIds)); } @@ -143,7 +157,7 @@ static async Task ReadReleaseFileAsync(string fileName, CancellationToke static string[] ReadRuleIds(string markdown) => [.. RuleIdRegex().Matches(markdown).Select(static match => match.Groups[1].Value)]; - [GeneratedRegex(@"\|[ \t]*(ZODS[A-Z]{3}\d{3})[ \t]*\|")] + [GeneratedRegex(@"(?m)^[ \t]*\|?[ \t]*(ZODS[A-Z]{3}\d{3})[ \t]*\|")] private static partial Regex RuleIdRegex(); static string SortedRuleIds(IEnumerable ruleIds) => From acd37c8bad07bcd1dcf30f1ac0c4397407136c2d Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 16 Oct 2026 10:24:56 +0100 Subject: [PATCH 19/21] ci: run the Guid/UUID parity producer in the cross-platform job The cross-platform job selects its C# fixture producers with a treenode filter and carried only one, `/*/ZodSharp.Json/*CrossPlatformTests/*`, which writes src/tests/cross-platform/output/. The parity artifacts tests/ts/guid-uuid-parity.test.ts reads come from a second producer added with the Guid/UUID export work, in a different assembly, namespace and class (ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/ JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts), which that filter does not match. src/tests/cross-platform/ is gitignored, so CI starts empty, guid-parity/ was never written, and `bun run test` failed every run. Run a step per producer. They cannot be combined: `a|b` is not honoured as a top-level alternation by the treenode filter - it selects a broader set (264 tests) and still writes no parity artifacts, which was verified while fixing this. `just test` was unaffected, because it runs the whole suite and so covers both producers. Verified by reproducing the CI sequence from a clean src/tests/cross-platform: both producers now write their fixtures (8 output files, 3 parity artifacts) and `bun run test` passes 29/29. Document the two producer/consumer pairs in AGENTS.md, including that adding a producer means adding a step. --- .github/workflows/pr.yml | 21 ++++++++++++++++++++- AGENTS.md | 11 ++++++++++- CHANGELOG.md | 11 +++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0ea2bc7..c37a565 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -48,11 +48,30 @@ jobs: - run: bun install --frozen-lockfile - - name: Run the C# cross-platform tests (writes the fixtures vitest reads) + # There are TWO producers of the fixtures vitest reads, in different assemblies, namespaces and + # classes, so they cannot be selected by one treenode filter: + # + # tests/ts/cross-platform.test.ts <- src/tests/cross-platform/output/ + # ZodSharp.Json/*CrossPlatformTests (SystemTextJson and + # NewtonsoftJson unit tests) + # tests/ts/guid-uuid-parity.test.ts <- src/tests/cross-platform/guid-parity/ + # ZodSharp.SourceGenerators/ZodSchemaGeneratorTests + # + # Both run as separate steps rather than one OR-ed filter: `a|b` is not honoured as a top-level + # alternation by the treenode filter — it silently selects a different, broader set and skips the + # parity producer, which is how the parity artifacts came to be missing in CI while this job stayed + # green. Keep a step per producer, and keep each filter anchored to its class. + - name: Run the C# cross-platform tests (writes cross-platform/output) run: >- dotnet test --solution src/ZodSharp.slnx --configuration Debug --treenode-filter '/*/ZodSharp.Json/*CrossPlatformTests/*' + - name: Run the C# Guid/UUID parity test (writes cross-platform/guid-parity) + run: >- + dotnet test --solution src/ZodSharp.slnx --configuration Debug + --treenode-filter + '/*/ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts' + - name: Run the TypeScript/Zod cross-platform tests run: bun run test diff --git a/AGENTS.md b/AGENTS.md index cabd752..a53bb3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,16 @@ Keep `purview-build.json`'s `PackValidation` requirements in sync with any packa ## Cross-platform fixtures -Schemas and fixtures shared between TypeScript/Zod and C#/Purview.ZodSharp live under `src/ts/`. The fixture generator (`bun run generate-fixtures`) writes JSON fixtures and a `manifest.json` that the C# tests consume; the C# cross-platform tests write output to `src/tests/cross-platform/output/`, which the vitest tests (`tests/ts/cross-platform.test.ts`) read. Keep the TS `UserSchema` and the C# `CrossPlatformUserSchema` in sync. +Schemas and fixtures shared between TypeScript/Zod and C#/Purview.ZodSharp live under `src/ts/`. The fixture generator (`bun run generate-fixtures`) writes JSON fixtures and a `manifest.json` that the C# tests consume. Keep the TS `UserSchema` and the C# `CrossPlatformUserSchema` in sync. + +`src/tests/cross-platform/` is gitignored, so it starts empty on a clean checkout and in CI: **the C# producers must run before vitest**, in the same workspace. There are **two** producer/consumer pairs, in different assemblies, namespaces and classes: + +| Vitest consumer | Reads | C# producer | +| --- | --- | --- | +| `tests/ts/cross-platform.test.ts` | `src/tests/cross-platform/output/` | `ZodSharp.Json/*CrossPlatformTests` (the SystemTextJson and NewtonsoftJson unit tests) | +| `tests/ts/guid-uuid-parity.test.ts` | `src/tests/cross-platform/guid-parity/` | `ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts` | + +`just test` runs the whole suite, so it covers both. The `cross-platform` job in `.github/workflows/pr.yml` selects them with treenode filters instead, and therefore needs **one step per producer** — a single `a|b` filter is not honoured as a top-level alternation and silently selects a broader set while skipping the parity producer. Adding a producer means adding a step. Both vitest suites fail loudly rather than passing vacuously when their directory is empty, which is the backstop for exactly this mistake. ## Commit conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index e3db76a..7fece90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -312,6 +312,17 @@ release that introduced it, so `AnalyzerReleases.Shipped.md` shows `ZODSGEN037` - The vitest cross-platform suite now runs in CI, and it **fails** rather than silently passing when the C# cross-platform fixtures are absent. It also now reads the C# output recursively — the previous flat directory read never matched a file, so the C#-to-Zod handshake had never actually been asserted. +- **The CI cross-platform job never produced the Guid/UUID parity artifacts, so `bun run test` failed there.** + The job selects the C# producers with a treenode filter, and carried only one — + `/*/ZodSharp.Json/*CrossPlatformTests/*`, which writes `cross-platform/output/`. The parity artifacts + `tests/ts/guid-uuid-parity.test.ts` reads come from a *second* producer added with the Guid/UUID export work, + in a different assembly, namespace and class + (`ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts`), + which that filter does not match. `cross-platform/guid-parity/` was therefore always empty in CI and the + parity suite failed every run. The job now runs a step per producer. They cannot be combined into one + OR-ed filter: `a|b` is not honoured as a top-level alternation — it silently selects a broader set and still + skips the parity producer, which was verified when fixing this. `just test` was unaffected, because it runs + the whole suite. ## 2.0.0 From ea9404c4fb34a84b558aa74d4c8dabf59233841d Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 16 Oct 2026 10:38:17 +0100 Subject: [PATCH 20/21] test: drive the vitest cross-platform suites from the .NET test run The TypeScript/Zod half of the cross-platform parity guarantee ran only in the dedicated `cross-platform` CI job, so a Zod-side regression could not fail a local `just test` - and a gap in that job's own wiring (a fixture producer it never ran) went unnoticed for a whole release. Repository.UnitTests now executes each tests/ts/*.test.ts suite as a child process through CliWrap, one test per suite, so a failure names the file and the child's stdout/stderr becomes the assertion message. This duplicates the CI job deliberately rather than replacing it: the job stays the authoritative gate because it controls producer ordering and installs bun from scratch. Three rules keep the duplication honest: - One test per suite, for attributable failures. - VitestSuites_GivenEveryTestFileOnDisk_AreAllCovered fails when a .test.ts file has no entry in Suites(), so a new suite cannot end up driven by the CI job alone - the same class of gap as the producer above. - Missing prerequisites skip rather than fail. bun and node_modules/ are absent on the shared pipeline's build runner and before `bun install`; reporting a missing toolchain as a broken repository would be wrong. The coverage guard needs neither, so it still runs there. src/tests/cross-platform/ is gitignored and the producers live in other assemblies the platform may still be running in parallel, so these tests wait for their fixture directory to fill before skipping, rather than nesting a second `dotnet test` inside the run. Verified all three paths, not just the green one: an injected parity violation fails the .NET test with the vitest output attached; an unlisted .test.ts file fails the coverage guard; and with bun off PATH the two suite tests skip with their reason while the guard still passes. Full suite 5476/0, pack validation clean. --- AGENTS.md | 12 +- CHANGELOG.md | 15 ++ Directory.Packages.props | 3 + .../CrossPlatformVitestTests.cs | 217 ++++++++++++++++++ .../Repository.UnitTests.csproj | 6 + 5 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs diff --git a/AGENTS.md b/AGENTS.md index a53bb3c..6ca764d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ bun run test # vitest run (cross-platform TS tests) bun run generate-fixtures # regenerates src/ts/fixtures/*.json from Zod ``` +`just test` also runs the vitest suites through CliWrap (see [Cross-platform fixtures](#cross-platform-fixtures)), so `bun run test` is for iterating on the TypeScript side rather than the only way to exercise it. It needs `bun install` to have been run. + Formatting is enforced with CSharpier; `.editorconfig` at the repo root defines style (tabs for code, 2-space for XML/JSON/YAML/markdown). ## Testing @@ -126,7 +128,15 @@ Schemas and fixtures shared between TypeScript/Zod and C#/Purview.ZodSharp live | `tests/ts/cross-platform.test.ts` | `src/tests/cross-platform/output/` | `ZodSharp.Json/*CrossPlatformTests` (the SystemTextJson and NewtonsoftJson unit tests) | | `tests/ts/guid-uuid-parity.test.ts` | `src/tests/cross-platform/guid-parity/` | `ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts` | -`just test` runs the whole suite, so it covers both. The `cross-platform` job in `.github/workflows/pr.yml` selects them with treenode filters instead, and therefore needs **one step per producer** — a single `a|b` filter is not honoured as a top-level alternation and silently selects a broader set while skipping the parity producer. Adding a producer means adding a step. Both vitest suites fail loudly rather than passing vacuously when their directory is empty, which is the backstop for exactly this mistake. +`just test` runs the whole suite, so it covers both producers. The `cross-platform` job in `.github/workflows/pr.yml` selects them with treenode filters instead, and therefore needs **one step per producer** — a single `a|b` filter is not honoured as a top-level alternation and silently selects a broader set while skipping the parity producer. Adding a producer means adding a step. Both vitest suites fail loudly rather than passing vacuously when their directory is empty, which is the backstop for exactly this mistake. + +`src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs` additionally drives the vitest suites **from the .NET test run**, as child processes through CliWrap, so `just test` alone proves both halves of the guarantee and a TypeScript-side regression fails a local run. It is a deliberate duplicate of the CI job, not a replacement — the job stays the authoritative gate because it controls producer ordering and installs bun from scratch. Three rules keep the duplication honest: + +- **One test per suite**, so a failure names the `.test.ts` file, and the child process's stdout/stderr becomes the assertion message. +- **`VitestSuites_GivenEveryTestFileOnDisk_AreAllCovered`** fails when a `tests/ts/*.test.ts` file has no entry in `Suites()`, so a new suite cannot be driven by the CI job alone. +- **Missing prerequisites skip rather than fail.** bun and `node_modules/` are absent on the shared pipeline's build runner and before `bun install`; failing there would report a missing toolchain as a broken repository. The coverage guard needs neither, so it still runs. + +Because `src/tests/cross-platform/` is gitignored and the producers live in other assemblies that may still be running in parallel, these tests wait for their fixture directory to fill before skipping, rather than nesting a second `dotnet test` inside the run. ## Commit conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fece90..e86682c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -277,6 +277,21 @@ release that introduced it, so `AnalyzerReleases.Shipped.md` shows `ZODSGEN037` `## Release 2.1.0` block, and the test asserts that block exists. The guard now fails when a diagnostic is left unshipped, which is the only thing it was ever meant to do. +### Added — the vitest suites run from the .NET test run + +- **`just test` now proves both halves of the cross-platform guarantee.** The TypeScript/Zod suites were driven + only by the dedicated `cross-platform` CI job, so a Zod-side regression could not fail a local run — and a gap + in that job's own wiring went unnoticed for a whole release. `Repository.UnitTests` now runs each + `tests/ts/*.test.ts` suite as a child process through **CliWrap** (one test per suite, so a failure names the + file and the child's stdout/stderr becomes the assertion message). The CI job remains the authoritative gate, + because it controls producer ordering and installs bun from scratch. +- `VitestSuites_GivenEveryTestFileOnDisk_AreAllCovered` fails when a vitest suite has no entry in the C# list, so + a new suite cannot end up driven by the CI job alone — the same class of gap as the parity producer above. +- Missing prerequisites **skip** rather than fail: bun and `node_modules/` are absent on the shared pipeline's + build runner and before `bun install`, and reporting a missing toolchain as a broken repository would be wrong. + The coverage guard needs neither, so it still runs there. Both behaviours are verified, as is the failure + path — an injected parity violation fails the .NET test with the vitest output attached. + ### Added — packed generator smoke test - `just smoke-packed-generator` (and a `packed-generator` CI job) packs the package, then builds and runs a diff --git a/Directory.Packages.props b/Directory.Packages.props index 1dc350b..ea8b403 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,6 +38,9 @@ + + +/// Runs the TypeScript/Zod cross-platform suites in tests/ts/ from the .NET test run, as child +/// processes through CliWrap, so just test proves both halves of the cross-platform parity +/// guarantee instead of only the C# half. +/// +/// +/// +/// The two sides are one guarantee: the C# tests write fixtures under +/// src/tests/cross-platform/ and the vitest suites assert that Zod agrees with what C# produced. +/// Until this existed, the vitest half ran only in the dedicated cross-platform job in +/// .github/workflows/pr.yml, so a TypeScript-side regression could not fail a local run — and a +/// gap in that job's own wiring (a producer it never ran) went unnoticed for a whole release. +/// +/// +/// This is a duplicate of that job's work, deliberately: the job stays the authoritative gate because it +/// controls producer ordering and installs bun from scratch, while these tests make the same failure +/// reproducible with one command on a developer machine. One test per vitest suite keeps a failure +/// attributable to a file, and fails +/// when a suite is added that is not listed here, so the duplication cannot silently fall behind. +/// +/// +/// Prerequisites are skipped, not failed. bun and node_modules/ are absent on the shared +/// build pipeline's runner (only the cross-platform job sets bun up) and on a checkout where +/// bun install has not been run. Failing there would report a missing toolchain as a broken +/// repository. A skip is reported by the test platform with its reason, so it stays visible. +/// +/// +public sealed class CrossPlatformVitestTests +{ + /// + /// How long to wait for a producer's fixtures to appear. src/tests/cross-platform/ is + /// gitignored, so a clean checkout starts with nothing, and the C# producers live in other assemblies + /// that the test platform may still be running in parallel with this one. Waiting removes that race + /// without nesting a second dotnet test inside this one. + /// + static readonly TimeSpan FixtureWaitTimeout = TimeSpan.FromMinutes(2); + + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + /// + /// A vitest suite and the fixture directory its assertions read. Keep in step with + /// tests/ts/*.test.ts and with the producer steps in .github/workflows/pr.yml. + /// + public sealed record VitestSuite(string TestFile, string FixtureDirectory) + { + public override string ToString() => TestFile; + } + + public static IEnumerable> Suites() => + [ + () => new VitestSuite("tests/ts/cross-platform.test.ts", "src/tests/cross-platform/output"), + () => new VitestSuite("tests/ts/guid-uuid-parity.test.ts", "src/tests/cross-platform/guid-parity"), + ]; + + [Test] + [MethodDataSource(nameof(Suites))] + public async Task Vitest_GivenACrossPlatformSuite_PassesAgainstTheCSharpFixtures( + VitestSuite suite, + CancellationToken cancellationToken + ) + { + // Arrange + var root = RepositoryRoot.Value; + SkipUnlessToolchainIsAvailable(root); + + var fixtureDirectory = Path.Combine(root, suite.FixtureDirectory.Replace('/', Path.DirectorySeparatorChar)); + if (!await WaitForFixturesAsync(fixtureDirectory, cancellationToken)) + { + Skip.Test( + $"No fixtures in '{suite.FixtureDirectory}' after waiting {FixtureWaitTimeout.TotalSeconds:N0}s. " + + "The C# producer for this suite did not run — run the whole suite with `just test`, which " + + "covers every producer, then re-run." + ); + } + + // Act — vitest exits non-zero on failure; capture rather than throw so the output reaches the assertion. + var result = await Cli.Wrap("bun") + .WithArguments(["run", "vitest", "run", suite.TestFile]) + .WithWorkingDirectory(root) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(0) + .Because( + $"`bun run vitest run {suite.TestFile}` failed:{Environment.NewLine}" + + $"{result.StandardOutput}{Environment.NewLine}{result.StandardError}" + ); + } + + /// + /// Every tests/ts/*.test.ts file must be listed in . Without this, adding a + /// vitest suite leaves it driven only by the CI job, which is the gap this class exists to close. + /// + [Test] + public async Task VitestSuites_GivenEveryTestFileOnDisk_AreAllCovered() + { + // Arrange + var testsDirectory = Path.Combine(RepositoryRoot.Value, "tests", "ts"); + var covered = Suites().Select(suite => Path.GetFileName(suite().TestFile)).ToHashSet(StringComparer.Ordinal); + + // Act + var onDisk = Directory + .EnumerateFiles(testsDirectory, "*.test.ts", SearchOption.AllDirectories) + .Select(Path.GetFileName) + .ToArray(); + + // Assert + await Assert.That(onDisk).IsNotEmpty().Because($"'{testsDirectory}' declares no vitest suites"); + + var uncovered = onDisk.Where(file => !covered.Contains(file!)).ToArray(); + await Assert + .That(uncovered) + .IsEmpty() + .Because( + "every vitest suite needs an entry in Suites() (and a producer step in pr.yml): " + + string.Join(", ", uncovered) + ); + } + + /// + /// Polls for a non-empty fixture directory. Returns false once elapses. + /// + static async Task WaitForFixturesAsync(string fixtureDirectory, CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + FixtureWaitTimeout; + + while (true) + { + if ( + Directory.Exists(fixtureDirectory) + && Directory.EnumerateFiles(fixtureDirectory, "*.json", SearchOption.AllDirectories).Any() + ) + return true; + + if (DateTimeOffset.UtcNow >= deadline) + return false; + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } + + static void SkipUnlessToolchainIsAvailable(string root) + { + // `bun install` writes node_modules/, which is gitignored; vitest is resolved from it. + Skip.Unless( + Directory.Exists(Path.Combine(root, "node_modules")), + "node_modules/ is absent — run `bun install` to drive the vitest suites from the .NET test run." + ); + + Skip.Unless( + IsOnPath("bun"), + "bun is not on PATH — the vitest suites run in the `cross-platform` CI job, which installs it." + ); + } + + static bool IsOnPath(string executable) + { + var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? []; + + // On Windows an executable is found by appending a PATHEXT suffix; elsewhere the name is used as-is, + // which the single empty suffix expresses. PATHEXT's separator is Path.PathSeparator on Windows. + var pathExtensions = OperatingSystem.IsWindows() + ? Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT" + : string.Empty; + var extensions = pathExtensions.Split(Path.PathSeparator); + + return paths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Any(path => + extensions.Any(extension => + { + try + { + return File.Exists(Path.Combine(path, executable + extension)); + } + catch (ArgumentException) + { + // A malformed PATH entry is not this test's problem. + return false; + } + }) + ); + } + + /// + /// Walks up from the test binaries to the repository root, identified by the markers this repository + /// keeps there. A relative ../../../../../.. hop would silently resolve elsewhere if the output + /// path depth ever changed. + /// + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, "vitest.config.ts")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries both AGENTS.md and vitest.config.ts." + ); + } +} diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj index 92cbdf2..5f27085 100644 --- a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -12,5 +12,11 @@ + + + From b4b5f827feb450b8fd205c5ae6d2db53cc8936e5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 16 Oct 2026 10:51:07 +0100 Subject: [PATCH 21/21] test: run the vitest suites on bun or node, and never skip A skipped test proves nothing, and the cross-platform parity guarantee is not optional - so the vitest driver no longer skips when its toolchain is missing. The earlier skip was also based on a wrong assumption: bun IS installed on the shared build pipeline's runner. Resolve a runner per run instead: - bun when it is on PATH (what CI uses). - otherwise node, against vitest's own entry point. - neither: the test fails and says to install one. node_modules/ is gitignored, so when vitest is missing the driver installs it first - `bun install --frozen-lockfile`, or `npm install` when only node is present. bun.lock is the only lockfile, so npm resolves from package.json, which is a second reason bun is preferred for both steps. An install or run failure fails the test with the captured output. The node path invokes node_modules/vitest/vitest.mjs (vitest's own package.json `bin` entry) rather than npx or node_modules/.bin. Both are unreliable: the .bin shims belong to whichever package manager installed the tree - a bun install leaves vitest.exe/vitest.bunx and no node shim - and on Windows npx resolves to a .cmd/.ps1 shim, which cannot be started directly as a child process. The PATH probe therefore accepts only a real executable image, not a shim. Verified every path: bun 3/3; node fallback 3/3 with bun off PATH, confirmed to be the node branch because an injected parity violation reports `vitest (node)`; and with both off PATH the two suite tests fail rather than skip, while the coverage guard still passes. Full pipeline 5476 tests, 0 failed, 0 skipped. --- AGENTS.md | 6 +- CHANGELOG.md | 15 +- .../CrossPlatformVitestTests.cs | 216 +++++++++++++----- 3 files changed, 180 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ca764d..eb31747 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,11 @@ Schemas and fixtures shared between TypeScript/Zod and C#/Purview.ZodSharp live - **One test per suite**, so a failure names the `.test.ts` file, and the child process's stdout/stderr becomes the assertion message. - **`VitestSuites_GivenEveryTestFileOnDisk_AreAllCovered`** fails when a `tests/ts/*.test.ts` file has no entry in `Suites()`, so a new suite cannot be driven by the CI job alone. -- **Missing prerequisites skip rather than fail.** bun and `node_modules/` are absent on the shared pipeline's build runner and before `bun install`; failing there would report a missing toolchain as a broken repository. The coverage guard needs neither, so it still runs. +- **These tests never skip.** A skipped test proves nothing, and the parity guarantee is not optional. The runner is resolved per run: **bun** when it is on PATH, otherwise **node**. If neither is available, or the install or the run fails, the test fails and names the step. + +The node fallback invokes vitest's entry point (`node_modules/vitest/vitest.mjs`, from vitest's own `package.json` `bin` field) directly, rather than `npx` or `node_modules/.bin`. Both are unreliable: the `.bin` shims are written by whichever package manager installed the tree — a bun install leaves `vitest.exe`/`vitest.bunx` and no node shim — and on Windows `npx` resolves to a `.cmd`/`.ps1` shim, which cannot be started directly as a child process. For the same reason the PATH probe only accepts a real executable image (`.exe`/`.com`), not a shim. + +`node_modules/` is gitignored, so when vitest is missing the tests install it first — with `bun install --frozen-lockfile`, or `npm install` when only node is present. There is no npm lockfile (`bun.lock` is the only one), so npm resolves from `package.json`; that is a second reason bun is preferred for both the install and the run. Because `src/tests/cross-platform/` is gitignored and the producers live in other assemblies that may still be running in parallel, these tests wait for their fixture directory to fill before skipping, rather than nesting a second `dotnet test` inside the run. diff --git a/CHANGELOG.md b/CHANGELOG.md index e86682c..0a5826c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -287,10 +287,17 @@ release that introduced it, so `AnalyzerReleases.Shipped.md` shows `ZODSGEN037` because it controls producer ordering and installs bun from scratch. - `VitestSuites_GivenEveryTestFileOnDisk_AreAllCovered` fails when a vitest suite has no entry in the C# list, so a new suite cannot end up driven by the CI job alone — the same class of gap as the parity producer above. -- Missing prerequisites **skip** rather than fail: bun and `node_modules/` are absent on the shared pipeline's - build runner and before `bun install`, and reporting a missing toolchain as a broken repository would be wrong. - The coverage guard needs neither, so it still runs there. Both behaviours are verified, as is the failure - path — an injected parity violation fails the .NET test with the vitest output attached. +- **These tests never skip, and they run on bun or node.** A skipped test proves nothing, so the runner is + resolved per run — bun when it is on PATH, otherwise node against vitest's own entry point — and the test + **fails** when neither is available, or when the install or the run fails. `node_modules/` is gitignored, so + vitest is installed first when it is missing (`bun install --frozen-lockfile`, or `npm install` when only + node is present). The node path deliberately avoids `npx` and `node_modules/.bin`: the `.bin` shims belong to + whichever package manager installed the tree, and on Windows `npx` is a `.cmd`/`.ps1` shim that cannot be + started as a child process. +- Every path is verified, not just the green one: an injected parity violation fails the .NET test with the + vitest output attached and names the runner that produced it; an unlisted `.test.ts` file fails the coverage + guard; the node fallback is confirmed to execute with bun removed from PATH; and with both removed the suite + tests fail rather than skipping. ### Added — packed generator smoke test diff --git a/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs b/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs index 9f3a5e5..abdc9ec 100644 --- a/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs +++ b/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs @@ -18,16 +18,16 @@ namespace ZodSharp.Repository; /// /// /// This is a duplicate of that job's work, deliberately: the job stays the authoritative gate because it -/// controls producer ordering and installs bun from scratch, while these tests make the same failure -/// reproducible with one command on a developer machine. One test per vitest suite keeps a failure -/// attributable to a file, and fails -/// when a suite is added that is not listed here, so the duplication cannot silently fall behind. +/// controls producer ordering, while these tests make the same failure reproducible with one command on a +/// developer machine. One test per vitest suite keeps a failure attributable to a file, and +/// fails when a suite is added that is +/// not listed here, so the duplication cannot silently fall behind. /// /// -/// Prerequisites are skipped, not failed. bun and node_modules/ are absent on the shared -/// build pipeline's runner (only the cross-platform job sets bun up) and on a checkout where -/// bun install has not been run. Failing there would report a missing toolchain as a broken -/// repository. A skip is reported by the test platform with its reason, so it stays visible. +/// These tests never skip. A skipped test proves nothing, and the parity guarantee is not optional. +/// The runner is resolved per run: bun when it is on PATH (the shared build pipeline and the +/// cross-platform job both have it), otherwise node against vitest's own entry point. If +/// neither is available, or the install or the run fails, the test fails and says which step and why. /// /// public sealed class CrossPlatformVitestTests @@ -40,8 +40,20 @@ public sealed class CrossPlatformVitestTests /// static readonly TimeSpan FixtureWaitTimeout = TimeSpan.FromMinutes(2); + /// + /// Installing dependencies on a cold checkout fetches the whole tree, so this is generous. + /// + static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(10); + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + /// + /// Resolved once per test run: the toolchain probe and any install are shared by every suite, and + /// running two installs concurrently in the same working directory would corrupt node_modules/. + /// + static readonly SemaphoreSlim RunnerGate = new(1, 1); + static VitestRunner? CachedRunner; + /// /// A vitest suite and the fixture directory its assertions read. Keep in step with /// tests/ts/*.test.ts and with the producer steps in .github/workflows/pr.yml. @@ -51,6 +63,12 @@ public sealed record VitestSuite(string TestFile, string FixtureDirectory) public override string ToString() => TestFile; } + /// + /// How to invoke vitest. are the leading arguments, to which the suite's + /// test-file path is appended. + /// + sealed record VitestRunner(string Name, string Executable, IReadOnlyList Arguments); + public static IEnumerable> Suites() => [ () => new VitestSuite("tests/ts/cross-platform.test.ts", "src/tests/cross-platform/output"), @@ -66,21 +84,21 @@ CancellationToken cancellationToken { // Arrange var root = RepositoryRoot.Value; - SkipUnlessToolchainIsAvailable(root); + var runner = await GetRunnerAsync(root, cancellationToken); var fixtureDirectory = Path.Combine(root, suite.FixtureDirectory.Replace('/', Path.DirectorySeparatorChar)); if (!await WaitForFixturesAsync(fixtureDirectory, cancellationToken)) { - Skip.Test( - $"No fixtures in '{suite.FixtureDirectory}' after waiting {FixtureWaitTimeout.TotalSeconds:N0}s. " - + "The C# producer for this suite did not run — run the whole suite with `just test`, which " - + "covers every producer, then re-run." + Assert.Fail( + $"No fixtures in '{suite.FixtureDirectory}' after waiting {FixtureWaitTimeout.TotalSeconds:N0}s, so " + + "this suite would verify nothing. Its C# producer did not run — run the whole suite with " + + "`just test`, which covers every producer." ); } // Act — vitest exits non-zero on failure; capture rather than throw so the output reaches the assertion. - var result = await Cli.Wrap("bun") - .WithArguments(["run", "vitest", "run", suite.TestFile]) + var result = await Cli.Wrap(runner.Executable) + .WithArguments([.. runner.Arguments, suite.TestFile]) .WithWorkingDirectory(root) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(cancellationToken); @@ -90,7 +108,7 @@ await Assert .That(result.ExitCode) .IsEqualTo(0) .Because( - $"`bun run vitest run {suite.TestFile}` failed:{Environment.NewLine}" + $"vitest ({runner.Name}) failed for {suite.TestFile}:{Environment.NewLine}" + $"{result.StandardOutput}{Environment.NewLine}{result.StandardError}" ); } @@ -125,6 +143,107 @@ await Assert ); } + static async Task GetRunnerAsync(string root, CancellationToken cancellationToken) + { + await RunnerGate.WaitAsync(cancellationToken); + try + { + return CachedRunner ??= await ResolveRunnerAsync(root, cancellationToken); + } + finally + { + RunnerGate.Release(); + } + } + + /// + /// Prefers bun, falls back to node, and fails when neither can run the suites. + /// + /// + /// The node path invokes vitest's entry point directly rather than going through npx or + /// node_modules/.bin. Both are unreliable here: the .bin shims are written by whichever + /// package manager installed the tree (a bun install leaves vitest.exe/vitest.bunx and no + /// node shim), and npx resolves to a .cmd or .ps1 shim on Windows, which is not + /// directly executable as a child process. The entry point is declared by vitest's own + /// package.json bin field and is the same file either shim would invoke. + /// + static async Task ResolveRunnerAsync(string root, CancellationToken cancellationToken) + { + var bun = FindOnPath("bun"); + var node = FindOnPath("node"); + + if (bun is null && node is null) + { + Assert.Fail( + "Neither bun nor node is on PATH, so the TypeScript cross-platform suites cannot run. Install " + + "either one: bun is what CI uses, and node is supported as the fallback." + ); + } + + await EnsureDependenciesInstalledAsync(root, bun, cancellationToken); + + if (bun is not null) + return new VitestRunner("bun", bun, ["run", "vitest", "run"]); + + var entryPoint = VitestEntryPoint(root); + if (!File.Exists(entryPoint)) + { + Assert.Fail( + $"vitest's entry point is missing at '{entryPoint}' after installing dependencies, so the " + + "TypeScript cross-platform suites cannot run." + ); + } + + return new VitestRunner("node", node!, [entryPoint, "run"]); + } + + /// + /// Installs node_modules/ when vitest is not already present. node_modules/ is gitignored, + /// so a fresh checkout and a CI runner both start without it. + /// + /// + /// bun installs from bun.lock; npm has no lockfile to read here, so it resolves from + /// package.json. That is why bun is preferred for the install as well as the run. + /// + static async Task EnsureDependenciesInstalledAsync(string root, string? bun, CancellationToken cancellationToken) + { + if (File.Exists(VitestEntryPoint(root))) + return; + + var installer = + bun is not null ? new VitestRunner("bun", bun, ["install", "--frozen-lockfile"]) + : FindOnPath("npm") is { } npm ? new VitestRunner("npm", npm, ["install"]) + : null; + + if (installer is null) + { + Assert.Fail( + $"vitest is not installed at '{VitestEntryPoint(root)}' and neither bun nor npm is on PATH to " + + "install it, so the TypeScript cross-platform suites cannot run." + ); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(InstallTimeout); + + var result = await Cli.Wrap(installer!.Executable) + .WithArguments(installer.Arguments) + .WithWorkingDirectory(root) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(timeout.Token); + + if (result.ExitCode != 0) + { + Assert.Fail( + $"`{installer.Name} {string.Join(' ', installer.Arguments)}` failed with exit code " + + $"{result.ExitCode}, so the TypeScript cross-platform suites cannot run:" + + $"{Environment.NewLine}{result.StandardOutput}{Environment.NewLine}{result.StandardError}" + ); + } + } + + static string VitestEntryPoint(string root) => Path.Combine(root, "node_modules", "vitest", "vitest.mjs"); + /// /// Polls for a non-empty fixture directory. Returns false once elapses. /// @@ -147,47 +266,40 @@ static async Task WaitForFixturesAsync(string fixtureDirectory, Cancellati } } - static void SkipUnlessToolchainIsAvailable(string root) - { - // `bun install` writes node_modules/, which is gitignored; vitest is resolved from it. - Skip.Unless( - Directory.Exists(Path.Combine(root, "node_modules")), - "node_modules/ is absent — run `bun install` to drive the vitest suites from the .NET test run." - ); - - Skip.Unless( - IsOnPath("bun"), - "bun is not on PATH — the vitest suites run in the `cross-platform` CI job, which installs it." - ); - } - - static bool IsOnPath(string executable) + /// + /// Resolves an executable's full path from PATH, so CliWrap starts it without depending on the host's + /// own resolution. Returns null when it is not found. + /// + static string? FindOnPath(string executable) { var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? []; // On Windows an executable is found by appending a PATHEXT suffix; elsewhere the name is used as-is, - // which the single empty suffix expresses. PATHEXT's separator is Path.PathSeparator on Windows. - var pathExtensions = OperatingSystem.IsWindows() - ? Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT" - : string.Empty; - var extensions = pathExtensions.Split(Path.PathSeparator); - - return paths - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Any(path => - extensions.Any(extension => + // which the single empty suffix expresses. Only genuinely executable images are considered: a .cmd or + // .ps1 shim cannot be started directly as a child process. + var extensions = OperatingSystem.IsWindows() ? [".EXE", ".COM"] : new[] { string.Empty }; + + foreach (var path in paths.Where(path => !string.IsNullOrWhiteSpace(path))) + { + foreach (var extension in extensions) + { + string candidate; + try { - try - { - return File.Exists(Path.Combine(path, executable + extension)); - } - catch (ArgumentException) - { - // A malformed PATH entry is not this test's problem. - return false; - } - }) - ); + candidate = Path.Combine(path, executable + extension); + } + catch (ArgumentException) + { + // A malformed PATH entry is not this test's problem. + continue; + } + + if (File.Exists(candidate)) + return candidate; + } + } + + return null; } ///