From b3719725467fd9870b8b03a20ee8b9b0d4361871 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 10:45:01 +0000 Subject: [PATCH 01/23] build: take Purview.SourceGeneratorFramework 1.2.2 and Purview.ZodSharp 3.0.0-prerelease.1 SourceGeneratorFramework 1.2.2 stops its framework assembly leaking into the build output of projects that reference a generator as an analyzer, so the in-repo samples and tests no longer carry Purview.SourceGeneratorFramework.dll in their bin folders. Purview.ZodSharp 3 ships net10.0 and net11.0 only, so the two ZodSharp samples move from the repository's 'All' set to 'Supported'. Every other ZodSharp consumer in the repository already targeted 'Supported'. The runtime package does not depend on ZodSharp and keeps 'All'. ZodSharp 3 breaks surfaced by the tests: - built-in rules now report a structured origin and category, so the NonSentinel test asserts origin 'string' and category 'invalid_value' instead of no origin - generated schemas gain ValidateAsync, so CA1849 flags the synchronous Validate in an async test; it now awaits ValidateAsync --- Directory.Packages.props | 4 ++-- .../ZodSharp.AspNetCoreSample.csproj | 3 +++ src/src/ZodSharpSample/ZodSharpSample.csproj | 3 +++ .../Serialization/ZodSchemaIntegrationTests.cs | 12 +++++++----- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 40ef604..556a0db 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,8 @@ true 5.9.0 1.76.0 - 1.2.1 - 2.4.2 + 1.2.2 + 3.0.0-prerelease.1 diff --git a/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj b/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj index 9beb905..487733b 100644 --- a/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj +++ b/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj @@ -1,6 +1,9 @@  false + + Supported $(NoWarn);CA1506 diff --git a/src/src/ZodSharpSample/ZodSharpSample.csproj b/src/src/ZodSharpSample/ZodSharpSample.csproj index a10a067..68f46f1 100644 --- a/src/src/ZodSharpSample/ZodSharpSample.csproj +++ b/src/src/ZodSharpSample/ZodSharpSample.csproj @@ -2,6 +2,9 @@ Exe false + + Supported diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs index c10097a..6a36a88 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs @@ -89,8 +89,10 @@ public async Task AssetId_GivenNonSentinelRule_ReportsInvalidValueThroughCreate( var error = exception!.Errors.Single(); await Assert.That(error.Code).IsEqualTo(NonSentinelRule.ErrorCode); await Assert.That(error.Message).IsEqualTo("AssetId must not be empty."); - // Built-in rules own their code but report no structured origin. - await Assert.That(error.Origin).IsNull(); + // Purview.ZodSharp 3 gives every built-in rule a structured identity: the origin is the Zod type name + // of the underlying value (a Guid is a string in Zod), and the category is the Zod v4 issue code. + await Assert.That(error.Origin).IsEqualTo(ZodTypeNames.String); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidValue); // A scalar is validated as a unit, so the adapted rule reports an empty path. await Assert.That(error.Path).IsEmpty(); @@ -162,7 +164,7 @@ public async Task CorrelationId_GivenNonSentinelCodeOverride_ReportsTheOverride( } [Test] - public async Task ContactDto_GivenShippedMemberAttributes_ValidatesMembers() + public async Task ContactDto_GivenShippedMemberAttributes_ValidatesMembers(CancellationToken cancellationToken) { // Arrange — [Email], [E164], [UUID], and [MinLengthZod] all ship in ZodSharp.Rules. ContactDto valid = new() @@ -174,7 +176,7 @@ public async Task ContactDto_GivenShippedMemberAttributes_ValidatesMembers() }; // Act - var validResult = ContactDtoSchema.Validate(valid); + var validResult = await ContactDtoSchema.ValidateAsync(valid, services: null, cancellationToken); // Assert await Assert.That(validResult.IsSuccess).IsTrue(); @@ -187,7 +189,7 @@ public async Task ContactDto_GivenShippedMemberAttributes_ValidatesMembers() Code = "ab", }; - var invalidResult = ContactDtoSchema.Validate(invalid); + var invalidResult = await ContactDtoSchema.ValidateAsync(invalid, services: null, cancellationToken); await Assert.That(invalidResult.IsSuccess).IsFalse(); var codes = invalidResult.Errors.Select(error => error.Code).ToHashSet(StringComparer.Ordinal); From 2bc9a197dc4588f892c05fe59713779da8cca83b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 10:45:24 +0000 Subject: [PATCH 02/23] fix: make the generated JSON converters trim and Native AOT safe The generated scalar converter read and wrote its underlying value through JsonSerializer.Deserialize(ref reader, options) and JsonSerializer.Serialize(writer, value, options), and the complex converter round-tripped a generated {Name}JsonModel class the same way. Those overloads are [RequiresUnreferencedCode]/[RequiresDynamicCode], so every trimmed or Native AOT consumer - Blazor WebAssembly included - got IL2026/IL3050 from generated code, and the model class needed metadata no source-generated context could list. Nothing in the repository analyzed generated code under trimming, so it went unnoticed; the new packed-consumer gate found it. The converters now go through ValueObjectJson, a new runtime helper: - the options' own metadata when they have it, so converters, naming policies and number handling behave exactly as before - the generated converter of a value-object-typed member, which a source-generated context in the declaring project cannot see because one generator never sees another's output - the built-in System.Text.Json converter for a primitive the context never listed - otherwise a NotSupportedException naming the type to add with [JsonSerializable] The complex converter reads and writes members directly, honouring PropertyNamingPolicy, PropertyNameCaseInsensitive and DefaultIgnoreCondition, skipping unknown members and defaulting missing ones, as the model class did. The public {Name}JsonModel nested type is no longer emitted. --- .../Common/TypeLibrary.Constants.cs | 6 + ...ComplexValueObjectEmitter.Serialization.cs | 148 ++++---- .../ComplexValueObjectModelBuilder.cs | 118 ++++++- .../Models/ComplexValueObjectModel.cs | 15 +- .../ScalarValueObjectEmitter.Equality.cs | 13 +- .../Serialization/ValueObjectJson.cs | 316 ++++++++++++++++++ .../SourceGeneratedJsonContextTests.cs | 206 ++++++++++++ 7 files changed, 760 insertions(+), 62 deletions(-) create mode 100644 src/src/ValueObjects/Serialization/ValueObjectJson.cs create mode 100644 src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs diff --git a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs index 0b4d42d..084c3cb 100644 --- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs +++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs @@ -40,6 +40,12 @@ public static partial class TypeLibrary public const string StringNormalizeAttributeFullTypeName = SerializationNamespace + ".StringNormalizeAttribute"; + /// + /// The runtime's trim- and Native AOT-safe JSON helpers, which every generated JSON converter reads and writes + /// through instead of the reflection-based JsonSerializer overloads. + /// + public const string ValueObjectJsonGlobalName = "global::" + SerializationNamespace + ".ValueObjectJson"; + // ZodSharp's runtime and generator-emitted types. The ZodSharp attribute and schema types are produced // by the ZodSharp source generator, so these names are matched and emitted as qualified text rather than // resolved as symbols (the value object generator must keep working when ZodSharp is not referenced). diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs index b3a6cd0..e1f37aa 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs @@ -2,18 +2,28 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static partial class ComplexValueObjectEmitter { + /// + /// Emits the complex value object's JSON converter. It reads and writes the object member by member through the + /// runtime's ValueObjectJson helpers, with no reflection: the previous converter deserialized a generated + /// {Name}JsonModel class through JsonSerializer.Deserialize<T>(ref reader, options), which is + /// [RequiresUnreferencedCode]/[RequiresDynamicCode] (IL2026/IL3050 in every trimmed or Native AOT + /// consumer) and needs metadata for a type no source-generated context could list. + /// + /// + /// Member names follow the options' PropertyNamingPolicy and PropertyNameCaseInsensitive, unknown + /// members are skipped, a missing member keeps its default, and DefaultIgnoreCondition is honoured when + /// writing - the behaviour the model-class converter had. + /// static void EmitJsonConverter(CodeWriter writer, ComplexValueObjectModel model) { if (!model.Options.GenerateJsonConverter) return; - var modelTypeName = $"{model.TypeModel.Name}JsonModel"; - var jsonModelIdentity = new TypeIdentity(model.TypeModel.Name, model.TypeModel.Namespace).Nested( - modelTypeName, - 0 - ); - var hydrateArgs = string.Join(", ", model.Properties.Select(static property => $"model.{property.Name}")); + var helper = TypeLibrary.ValueObjectJsonGlobalName; + var properties = model.Properties.ToArray(); + var hydrateArgs = string.Join(", ", properties.Select(static (_, index) => MemberLocal(index))); var valueObjectType = ValueObjectType(model); + var unableToDeserialize = $"\"Unable to deserialize {model.TypeModel.Name}.\""; writer.Class( new($"{model.TypeModel.Name}JsonConverter") @@ -24,6 +34,26 @@ static void EmitJsonConverter(CodeWriter writer, ComplexValueObjectModel model) }, body => { + for (var index = 0; index < properties.Length; index++) + { + var property = properties[index]; + if (property.JsonConverterExpression is not { } converter) + continue; + + body.Field( + new FieldDeclarationOptions( + MemberConverterField(index), + TypeLibrary.System.Text.Json.Serialization.JsonConverter.MakeGeneric(property.Type) + ) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsStatic = true, + IsReadOnly = true, + Initializer = converter, + } + ); + } + body.Method( new("Read", valueObjectType, TypeDeclarationAccessibility.Public) { @@ -37,20 +67,48 @@ static void EmitJsonConverter(CodeWriter writer, ComplexValueObjectModel model) }, methodBody => { - methodBody.Assignment( - "var", - "model", - $"global::System.Text.Json.JsonSerializer.Deserialize<{modelTypeName}>(ref reader, options)" - ); methodBody.IfBlock( - "model is null", + "reader.TokenType != global::System.Text.Json.JsonTokenType.StartObject", ifBody => - ifBody.Throw( - TypeLibrary.System.Text.Json.JsonException, - $"Unable to deserialize {model.TypeModel.Name}." - ) + ifBody.Line($"throw new global::System.Text.Json.JsonException({unableToDeserialize});") + ); + + for (var index = 0; index < properties.Length; index++) + methodBody.Line($"{properties[index].TypeName} {MemberLocal(index)} = default!;"); + + methodBody.While( + "reader.Read()", + loop => + { + loop.IfBlock( + "reader.TokenType == global::System.Text.Json.JsonTokenType.EndObject", + ifBody => ifBody.Return($"{model.HydrateFactoryName}({hydrateArgs})") + ); + + loop.Line("var jsonName = reader.GetString();"); + loop.Line("reader.Read();"); + + for (var index = 0; index < properties.Length; index++) + { + var property = properties[index]; + var condition = $"{helper}.IsMember(jsonName, \"{property.Name}\", options)"; + var statement = + $"{MemberLocal(index)} = {helper}.Read<{property.TypeName}>(ref reader, options{ConverterArgument(property, index)})!;"; + + if (index == 0) + loop.IfBlock(condition, branch => branch.Line(statement)); + else + loop.ElseIf(condition, branch => branch.Line(statement)); + } + + if (properties.Length == 0) + loop.Line("reader.Skip();"); + else + loop.Else(static branch => branch.Line("reader.Skip();")); + } ); - methodBody.Return($"{model.HydrateFactoryName}({hydrateArgs})"); + + methodBody.Line($"throw new global::System.Text.Json.JsonException({unableToDeserialize});"); } ); @@ -67,48 +125,26 @@ static void EmitJsonConverter(CodeWriter writer, ComplexValueObjectModel model) }, methodBody => { - methodBody.Assignment( - "var", - "model", - new ObjectCreationOptions(jsonModelIdentity) - { - WriteInitializerMembersOnSeparateLines = false, - InitializerMembers = - [ - .. model.Properties.Select(static property => new ObjectInitializerMemberOptions( - property.Name, - $"value.{property.Name}" - )), - ], - } - ); - methodBody.MethodCallOn( - $"{TypeLibrary.System.Text.Json.JsonSerializer}", - "Serialize", - "writer", - "model", - "options" - ); - } - ); - } - ); - - writer.Class( - new(modelTypeName) { IsPartial = false, IsSealed = true }, - body => - { - foreach (var property in model.Properties) - { - body.Property( - new(property.Name, property.Type, TypeDeclarationAccessibility.Public) + methodBody.Line("writer.WriteStartObject();"); + for (var index = 0; index < properties.Length; index++) { - HasSetter = true, - Initializer = "default!", + var property = properties[index]; + methodBody.Line( + $"{helper}.WriteMember<{property.TypeName}>(writer, \"{property.Name}\", value.{property.Name}, options{ConverterArgument(property, index)});" + ); } - ); - } + + methodBody.Line("writer.WriteEndObject();"); + } + ); } ); } + + static string ConverterArgument(ComplexPropertyModel property, int index) => + property.JsonConverterExpression is null ? string.Empty : $", {MemberConverterField(index)}"; + + static string MemberLocal(int index) => $"member{index}"; + + static string MemberConverterField(int index) => $"Member{index}Converter"; } diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index 43dd2ba..8f8a083 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs @@ -244,6 +244,121 @@ out var efCtorArgs return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); } + /// + /// The expression constructing the JSON converter a member serializes through when its type is a value object + /// (or a nullable one), or for any other type. A type that declares + /// [JsonConverter(typeof(C))] - every compiled value object does, because the generator emits it - uses + /// C; a value object declared in this compilation, whose attribute this generator has not emitted yet, + /// uses the {Name}JsonConverter it is about to emit. The expression is only a fallback: the generated + /// converter prefers the metadata of the options it is called with. + /// + static string? ResolveMemberJsonConverter( + ITypeSymbol memberType, + IAssemblySymbol compilationAssembly, + ValueObjectDefaultsAttributeData assemblyDefaults + ) + { + var isNullableValueType = + memberType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }; + var type = isNullableValueType ? ((INamedTypeSymbol)memberType).TypeArguments[0] : memberType; + if (type is not INamedTypeSymbol { IsGenericType: false } named) + return null; + + var converter = + ResolveDeclaredJsonConverter(named) + ?? ResolveGeneratedJsonConverter(named, compilationAssembly, assemblyDefaults); + if (converter is null) + return null; + + return isNullableValueType ? $"{TypeLibrary.ValueObjectJsonGlobalName}.Nullable({converter})" : converter; + } + + static string? ResolveDeclaredJsonConverter(INamedTypeSymbol type) + { + foreach (var attribute in type.GetAttributes()) + { + if ( + !TypeLibrary.System.Text.Json.Serialization.JsonConverterAttribute.Equals(attribute.AttributeClass) + || attribute.ConstructorArguments.Length != 1 + || attribute.ConstructorArguments[0].Value is not INamedTypeSymbol converterType + ) + { + continue; + } + + // Only a concrete JsonConverter for exactly this type, constructed without arguments, can be passed + // as the member's converter; a JsonConverterFactory or anything else is left to the options. + var constructible = + !converterType.IsAbstract + && !converterType.IsGenericType + && converterType.InstanceConstructors.Any(static ctor => + ctor.Parameters.Length == 0 && ctor.DeclaredAccessibility == Accessibility.Public + ); + if (constructible && IsJsonConverterOf(converterType, type)) + return $"new {ValueObjectSymbolInspector.ToTypeName(converterType)}()"; + + return null; + } + + return null; + } + + static string? ResolveGeneratedJsonConverter( + INamedTypeSymbol type, + IAssemblySymbol compilationAssembly, + ValueObjectDefaultsAttributeData assemblyDefaults + ) + { + // A compiled value object already carries the [JsonConverter] this generator emitted, which + // ResolveDeclaredJsonConverter reads; only a type in this compilation has not been given it yet. + if (!SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, compilationAssembly)) + return null; + + var attributes = type.GetAttributes(); + bool generateJsonConverter; + if (ScalarAttributeParser.Find(attributes) is { } scalarAttribute) + { + generateJsonConverter = ValueObjectDefaultsHelper + .Apply(ScalarAttributeParser.Parse(scalarAttribute), assemblyDefaults, attributes) + .GenerateJsonConverter; + } + else if ( + ValueObjectSymbolInspector.HasAttribute( + attributes, + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute + ) + ) + { + generateJsonConverter = ValueObjectDefaultsHelper + .Apply(ValueObjectAttributeData.FromAttributeData(attributes), assemblyDefaults, attributes) + .GenerateJsonConverter; + } + else + { + return null; + } + + return generateJsonConverter + ? $"new {ValueObjectSymbolInspector.ToTypeName(type)}.{type.Name}JsonConverter()" + : null; + } + + static bool IsJsonConverterOf(INamedTypeSymbol converterType, INamedTypeSymbol valueType) + { + for (var current = converterType.BaseType; current is not null; current = current.BaseType) + { + if ( + current is { Name: "JsonConverter", Arity: 1 } + && current.ContainingNamespace.ToDisplayString() == "System.Text.Json.Serialization" + ) + { + return SymbolEqualityComparer.Default.Equals(current.TypeArguments[0], valueType); + } + } + + return false; + } + /// /// Builds the complex member models and resolves their built-in string normalization. The option is /// only applied by the generated Create, so a hand-written OnNormalize hook or a @@ -275,7 +390,8 @@ List diagnostics TypeReference.Create(property.Type), normalizeTrim, normalizeCasing, - normalizeExplicit + normalizeExplicit, + ResolveMemberJsonConverter(property.Type, typeSymbol.ContainingAssembly, assemblyDefaults) ) ); } diff --git a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs index 4d5e4a1..9804a21 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs @@ -39,11 +39,24 @@ sealed record class ComplexValueObjectModel( bool IsEF8Referenced ); +/// A member of a complex value object. +/// The member's name. +/// The member's fully qualified type, with its nullable annotation. +/// The member's type. +/// True when the generated Create trims the member. +/// The casing the generated Create applies to the member. +/// True when the normalization was set on the member rather than defaulted. +/// +/// The expression that constructs the JSON converter a value-object-typed member serializes through, or +/// . The generated converter passes it to ValueObjectJson, so a member that is itself +/// a value object still round-trips when a source-generated JsonSerializerContext never listed its type. +/// readonly record struct ComplexPropertyModel( string Name, string TypeName, TypeReference Type, bool NormalizeTrim, string NormalizeCasing, - bool NormalizeExplicit + bool NormalizeExplicit, + string? JsonConverterExpression ); diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs index 9d1f54f..096ce02 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs @@ -312,14 +312,19 @@ static void EmitJsonConverter(CodeWriter writer, ScalarValueObjectModel model) }, methodBody => { + // Through the runtime's ValueObjectJson rather than JsonSerializer.Deserialize(ref + // reader, options): that overload is [RequiresUnreferencedCode]/[RequiresDynamicCode], so + // every trimmed or Native AOT consumer got IL2026/IL3050 from this generated code. The + // helper resolves the same metadata from the options, and falls back to the built-in + // converter for a primitive that a source-generated context never listed. methodBody.Assignment( "var", "value", writeValue => writeValue.MethodCall( - "Deserialize", + "Read", [new MethodCallArgumentOptions("reader", ParameterModifier.Ref), "options"], - receiver: $"{TypeLibrary.System.Text.Json.JsonSerializer}", + receiver: TypeLibrary.ValueObjectJsonGlobalName, genericArguments: [model.ScalarTypeReference] ) ); @@ -355,8 +360,8 @@ [new MethodCallArgumentOptions("reader", ParameterModifier.Ref), "options"], }, methodBody => methodBody.MethodCallOn( - $"{TypeLibrary.System.Text.Json.JsonSerializer}", - "Serialize", + TypeLibrary.ValueObjectJsonGlobalName, + "Write", "writer", $"value.{model.ScalarPropertyName}", "options" diff --git a/src/src/ValueObjects/Serialization/ValueObjectJson.cs b/src/src/ValueObjects/Serialization/ValueObjectJson.cs new file mode 100644 index 0000000..b6ddec7 --- /dev/null +++ b/src/src/ValueObjects/Serialization/ValueObjectJson.cs @@ -0,0 +1,316 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Purview.ValueObjects.Serialization; + +/// +/// Trim- and Native AOT-safe JSON plumbing for the converters the value object generator emits. Generated +/// <Type>JsonConverter classes read and write their underlying values and members through these +/// helpers rather than through the reflection-based overloads. +/// +/// +/// +/// A value of type T in the helpers below is resolved in this order: +/// +/// +/// +/// +/// The converter the generator supplies for a member that is itself a value object, unless the options +/// register a converter for that type. A source-generated in the +/// project that declares the value object cannot see the [JsonConverter] attribute the value object +/// generator adds (one source generator never sees another's output), so the context would otherwise +/// describe the value object as a plain object with a Value property. +/// +/// +/// +/// +/// The the caller's provide - the +/// reflection resolver in an ordinary application, or a source-generated . +/// This honours every converter, naming policy and number-handling option exactly as +/// JsonSerializer.Deserialize<T>(ref reader, options) would. +/// +/// +/// +/// +/// The built-in System.Text.Json converter for a primitive (, , the +/// numeric types, , , , +/// , , , , +/// and their nullable forms) when the options cannot describe it - typically a +/// source-generated context that never listed the primitive because it only saw the value object. +/// +/// +/// +/// +/// Anything else (an enum, or a custom underlying type) needs metadata: add +/// [JsonSerializable(typeof(T))] to the application's . The +/// thrown otherwise names the type. +/// +/// +/// These members exist for generated code. They are public so a consumer's generated converters can call them, +/// and hidden from IntelliSense because application code has no reason to. +/// +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ValueObjectJson +{ + /// Reads a value of type from . + /// The type to read. + /// The reader, positioned on the value. + /// The options the enclosing converter was called with. + /// + /// The converter of a value-object-typed member, used unless register one for . + /// + /// The value read. + /// No metadata, supplied converter or built-in converter applies. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1045:Do not pass types by reference", + Justification = "Mirrors JsonConverter.Read, which receives the reader by reference." + )] + public static T? Read( + ref Utf8JsonReader reader, + JsonSerializerOptions options, + JsonConverter? converter = null + ) + { + ArgumentNullException.ThrowIfNull(options); + + if (!PrefersSupplied(converter, options) && TryGetTypeInfo(options) is { } typeInfo) + return JsonSerializer.Deserialize(ref reader, typeInfo); + + converter ??= BuiltInConverter.Instance ?? throw CreateNotSupported(typeof(T)); + if (reader.TokenType == JsonTokenType.Null && !converter.HandleNull) + { + return default(T) is null + ? default + : throw new JsonException($"The JSON value null could not be converted to {typeof(T)}."); + } + + return converter.Read(ref reader, typeof(T), options); + } + + /// Writes to . + /// The type to write. + /// The writer. + /// The value to write. + /// The options the enclosing converter was called with. + /// + /// The converter of a value-object-typed member, used unless register one for . + /// + /// No metadata, supplied converter or built-in converter applies. + public static void Write( + Utf8JsonWriter writer, + T value, + JsonSerializerOptions options, + JsonConverter? converter = null + ) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(options); + + if (!PrefersSupplied(converter, options) && TryGetTypeInfo(options) is { } typeInfo) + { + JsonSerializer.Serialize(writer, value, typeInfo); + return; + } + + converter ??= BuiltInConverter.Instance ?? throw CreateNotSupported(typeof(T)); + if (value is null && !converter.HandleNull) + { + writer.WriteNullValue(); + return; + } + + converter.Write(writer, value, options); + } + + /// + /// Writes a member of a complex value object: its name (with the options' naming policy applied) and its value, + /// honouring . + /// + /// The member type. + /// The writer, positioned inside an object. + /// The member's CLR name. + /// The member's value. + /// The options the enclosing converter was called with. + /// An optional converter for the member, as for . + public static void WriteMember( + Utf8JsonWriter writer, + string memberName, + T value, + JsonSerializerOptions options, + JsonConverter? converter = null + ) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(options); + + var ignore = options.DefaultIgnoreCondition; + if ( + (ignore == JsonIgnoreCondition.WhenWritingNull && value is null) + || (ignore == JsonIgnoreCondition.WhenWritingDefault && EqualityComparer.Default.Equals(value, default)) + ) + { + return; + } + + writer.WritePropertyName(GetMemberName(memberName, options)); + Write(writer, value, options, converter); + } + + /// + /// The JSON name of a complex value object member: with the options' + /// applied. + /// + /// The member's CLR name. + /// The options the enclosing converter was called with. + /// The name written to, and expected in, JSON. + public static string GetMemberName(string memberName, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return options.PropertyNamingPolicy?.ConvertName(memberName) ?? memberName; + } + + /// + /// True when a JSON property name read by a complex value object converter names + /// , honouring the naming policy and + /// . + /// + /// The property name read from JSON. + /// The member's CLR name. + /// The options the enclosing converter was called with. + /// when the names match. + public static bool IsMember(string? jsonName, string memberName, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return string.Equals( + jsonName, + GetMemberName(memberName, options), + options.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal + ); + } + + /// Adapts a converter for to . + /// The underlying value type. + /// The converter for the underlying type. + /// A converter that reads and writes JSON null as . + public static JsonConverter Nullable(JsonConverter converter) + where T : struct + { + ArgumentNullException.ThrowIfNull(converter); + + return new NullableConverter(converter); + } + + static bool PrefersSupplied(JsonConverter? converter, JsonSerializerOptions options) + { + if (converter is null) + return false; + + // A converter the application registered for the value object wins, exactly as it would for a member the + // serializer resolved itself. + foreach (var registered in options.Converters) + { + if (registered.CanConvert(typeof(T))) + return false; + } + + return true; + } + + static JsonTypeInfo? TryGetTypeInfo(JsonSerializerOptions options) + { + // Options with no resolver resolve through reflection only while it is enabled; once trimming or Native AOT + // turns it off, asking would throw, so those options can only use the supplied or built-in converters. + if (options.TypeInfoResolver is null && !JsonSerializer.IsReflectionEnabledByDefault) + return null; + + return options.TryGetTypeInfo(typeof(T), out var typeInfo) ? typeInfo as JsonTypeInfo : null; + } + + static NotSupportedException CreateNotSupported(Type type) => + new( + $"No JSON metadata is available for '{type}'. A value object converter reads and writes it through the " + + "JsonSerializerOptions it is called with; when those come from a source-generated JsonSerializerContext, " + + $"add [JsonSerializable(typeof({type}))] to that context." + ); + + sealed class NullableConverter(JsonConverter converter) : JsonConverter + where T : struct + { + public override bool HandleNull => true; + + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.Null ? null : converter.Read(ref reader, typeof(T), options); + + public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) + { + if (value is { } inner) + converter.Write(writer, inner, options); + else + writer.WriteNullValue(); + } + } + + /// + /// The built-in System.Text.Json converter for a primitive, or . Each test is against a + /// constant , so the JIT and ILC fold the chain to the one branch that applies. + /// + static class BuiltInConverter + { + public static readonly JsonConverter? Instance = (JsonConverter?)Create(); + + static object? Create() + { + if (typeof(T) == typeof(string)) + return JsonMetadataServices.StringConverter; + if (typeof(T) == typeof(Uri)) + return JsonMetadataServices.UriConverter; + if (typeof(T) == typeof(Version)) + return JsonMetadataServices.VersionConverter; + if (typeof(T) == typeof(byte[])) + return JsonMetadataServices.ByteArrayConverter; + + return CreateNumber() ?? CreateOther(); + } + + static object? CreateNumber() => + CreateValueType(JsonMetadataServices.ByteConverter) + ?? CreateValueType(JsonMetadataServices.SByteConverter) + ?? CreateValueType(JsonMetadataServices.CharConverter) + ?? CreateValueType(JsonMetadataServices.Int16Converter) + ?? CreateValueType(JsonMetadataServices.UInt16Converter) + ?? CreateValueType(JsonMetadataServices.Int32Converter) + ?? CreateValueType(JsonMetadataServices.UInt32Converter) + ?? CreateValueType(JsonMetadataServices.Int64Converter) + ?? CreateValueType(JsonMetadataServices.UInt64Converter) + ?? CreateValueType(JsonMetadataServices.Int128Converter) + ?? CreateValueType(JsonMetadataServices.UInt128Converter) + ?? CreateValueType(JsonMetadataServices.SingleConverter) + ?? CreateValueType(JsonMetadataServices.DoubleConverter) + ?? CreateValueType(JsonMetadataServices.DecimalConverter) + ?? CreateValueType(JsonMetadataServices.HalfConverter); + + static object? CreateOther() => + CreateValueType(JsonMetadataServices.BooleanConverter) + ?? CreateValueType(JsonMetadataServices.GuidConverter) + ?? CreateValueType(JsonMetadataServices.DateTimeConverter) + ?? CreateValueType(JsonMetadataServices.DateTimeOffsetConverter) + ?? CreateValueType(JsonMetadataServices.DateOnlyConverter) + ?? CreateValueType(JsonMetadataServices.TimeOnlyConverter) + ?? CreateValueType(JsonMetadataServices.TimeSpanConverter); + + static object? CreateValueType(JsonConverter converter) + where TValue : struct + { + if (typeof(T) == typeof(TValue)) + return converter; + + return typeof(T) == typeof(TValue?) ? new NullableConverter(converter) : null; + } + } +} diff --git a/src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs b/src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs new file mode 100644 index 0000000..21037dc --- /dev/null +++ b/src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs @@ -0,0 +1,206 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Purview.ValueObjects.Serialization; + +/// +/// The generated JSON converters must not depend on reflection, so they keep working with the source-generated +/// a trimmed or Native AOT application serializes through. +/// +/// +/// A context declared in the project that declares the value objects cannot see the [JsonConverter] +/// attribute the value object generator adds, so it describes as an object with a +/// Value property. The application registers the root value objects' generated converters on the options +/// (); , a member of , is +/// deliberately not registered, so these tests prove the converter is handed for a +/// value-object-typed member wins over the context's object metadata. +/// +public sealed class SourceGeneratedJsonContextTests +{ + static readonly JsonSerializerOptions ContextOptions = new(ContextOnlyJsonContext.Default.Options) + { + Converters = + { + new ContextMoney.ContextMoneyJsonConverter(), + new ContextReference.ContextReferenceJsonConverter(), + }, + }; + + static readonly JsonSerializerOptions IgnoreNullOptions = new(ContextOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + // A resolver that describes nothing, as a trimmed application's would for a type it never listed. + static readonly JsonSerializerOptions EmptyResolverOptions = new() + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(), + }; + + [Test] + public async Task ComplexValueObject_GivenContextWithoutMemberConverters_RoundTrips() + { + // Arrange + ContextOrder order = new() + { + Total = ContextMoney.Create(19.99m, ContextCurrencyCode.Create("usd")), + Reference = ContextReference.Create("REF-1"), + }; + + // Act + var json = JsonSerializer.Serialize(order, ContextOptions); + var roundTripped = JsonSerializer.Deserialize(json, ContextOptions); + + // Assert + await Assert + .That(json) + .IsEqualTo("""{"total":{"amount":19.99,"currency":"USD","fallback":null},"reference":"REF-1"}"""); + await Assert.That(roundTripped!.Total).IsEqualTo(order.Total); + await Assert.That(roundTripped.Reference).IsEqualTo(order.Reference); + } + + [Test] + public async Task ComplexValueObject_GivenNullableValueObjectMember_RoundTripsValueAndNull() + { + // Arrange + var withFallback = ContextMoney.Create( + 5m, + ContextCurrencyCode.Create("GBP"), + ContextCurrencyCode.Create("EUR") + ); + + // Act + var json = JsonSerializer.Serialize(withFallback, ContextOptions); + var roundTripped = JsonSerializer.Deserialize(json, ContextOptions); + + // Assert + await Assert.That(json).IsEqualTo("""{"amount":5,"currency":"GBP","fallback":"EUR"}"""); + await Assert.That(roundTripped).IsEqualTo(withFallback); + } + + [Test] + public async Task ComplexValueObject_GivenUnknownAndMissingMembers_SkipsAndDefaults() + { + // Act - "extra" is skipped, including its nested object, and the missing "fallback" stays null. + var money = JsonSerializer.Deserialize( + """{"extra":{"nested":[1,2]},"amount":1.5,"currency":"CHF"}""", + ContextOptions + ); + + // Assert + await Assert.That(money.Amount).IsEqualTo(1.5m); + await Assert.That(money.Currency).IsEqualTo(ContextCurrencyCode.Create("CHF")); + await Assert.That(money.Fallback).IsNull(); + } + + [Test] + public async Task ComplexValueObject_GivenWhenWritingNull_OmitsNullMember() + { + // Act + var json = JsonSerializer.Serialize( + ContextMoney.Create(2m, ContextCurrencyCode.Create("USD")), + IgnoreNullOptions + ); + + // Assert + await Assert.That(json).IsEqualTo("""{"amount":2,"currency":"USD"}"""); + } + + [Test] + public async Task ComplexValueObject_GivenNonObjectToken_ThrowsJsonException() + { + // Act & Assert + await Assert.That(() => JsonSerializer.Deserialize("42", ContextOptions)).Throws(); + } + + [Test] + public async Task Read_GivenResolverWithoutMetadataForPrimitive_UsesBuiltInConverter() + { + // Arrange + var id = Guid.NewGuid(); + Utf8JsonReader reader = new(JsonSerializer.SerializeToUtf8Bytes(id.ToString())); + reader.Read(); + + // Act + var read = ValueObjectJson.Read(ref reader, EmptyResolverOptions); + + // Assert + await Assert.That(read).IsEqualTo(id); + } + + [Test] + public async Task Read_GivenResolverWithoutMetadataForNullablePrimitive_ReadsNull() + { + // Arrange + Utf8JsonReader reader = new("null"u8); + reader.Read(); + + // Act + var read = ValueObjectJson.Read(ref reader, EmptyResolverOptions); + + // Assert + await Assert.That(read).IsNull(); + } + + [Test] + public async Task Read_GivenResolverWithoutMetadataForCustomType_ThrowsNotSupportedNamingTheType() + { + // Arrange + Utf8JsonReader reader = new("""{"x":1}"""u8); + reader.Read(); + + // Act + NotSupportedException? exception = null; + try + { + ValueObjectJson.Read(ref reader, EmptyResolverOptions); + } + catch (NotSupportedException ex) + { + exception = ex; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains(typeof(ContextOrder).FullName!); + } +} + +[Scalar] +public readonly partial record struct ContextCurrencyCode +{ + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToUpperInvariant()!; +} + +[Scalar] +public readonly partial record struct ContextReference +{ + public string Value { get; } +} + +[ValueObject] +public readonly partial record struct ContextMoney +{ + public decimal Amount { get; } + + public ContextCurrencyCode Currency { get; } + + public ContextCurrencyCode? Fallback { get; } + + public static ContextMoney Create(decimal amount, ContextCurrencyCode currency) => Create(amount, currency, null); +} + +public sealed class ContextOrder +{ + public ContextMoney Total { get; set; } + + public ContextReference Reference { get; set; } +} + +[JsonSerializable(typeof(ContextOrder))] +[JsonSerializable(typeof(ContextMoney))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +sealed partial class ContextOnlyJsonContext : JsonSerializerContext; From ddcd2039a5383ae9bef70013c10aa24f1e89fa91 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 10:49:10 +0000 Subject: [PATCH 03/23] build: take Purview.BuildSdk 1.2.0 and Purview.Skills.Testing 1.2.0 BuildSdk 1.2.0 rewrote the managed .editorconfig with its new version marker; the rule set is unchanged. It added Blazor WebAssembly, Razor class library, BFF and Blazor host detection, PurviewBrowserSafe with the PRBLZ0001 reference check, PDS0007-PDS0009 and the unmarked .editorconfig warning. None of them applies to a project in this repository - there is no Blazor project in the solution, and the packed-consumer gate builds its Blazor apps outside the SDK - and the build produced no new warning. Skills.Testing 1.2.0 passes every skill unchanged. --- .editorconfig | 2 +- Directory.Packages.props | 2 +- global.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.editorconfig b/.editorconfig index b511789..ea03058 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.1.0 - managed file, do not edit +# Purview.BuildSdk 1.2.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/Directory.Packages.props b/Directory.Packages.props index 556a0db..5d25f2f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,7 +25,7 @@ - + diff --git a/global.json b/global.json index 8b388e3..c9e8fce 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" }, "test": { "runner": "Microsoft.Testing.Platform" From 3031a90f50f151560b0433ec09e4788eb431ad02 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:08:56 +0000 Subject: [PATCH 04/23] test(packed): add a packed-consumer gate for Native AOT, Blazor WebAssembly and Blazor Server Every in-repo build references the generator and runtime as projects, which exercises nothing of what ships. Modelled on purview-dev/zodsharp's gate, `just smoke-packed-consumers` packs Purview.ValueObjects to an isolated feed and NUGET_PACKAGES, guards Directory.Build.* and Directory.Packages.props in a temp root, and builds consumers outside the repository from the package alone, with Purview.ZodSharp 3: - a Native AOT console over scalars, a complex value object, [ZodSchema] DTOs and System.Text.Json source generation, published with -warnaserror and run - a Blazor WebAssembly app with a form and a form over a model of value objects, published trimmed and with RunAOTCompilation=true, served and run in a headless browser driven over the DevTools protocol - a Blazor Server app whose statically rendered form is posted invalid and valid, and whose interactive page runs the same self-test over a live circuit - a net8.0 console, so the runtime's oldest target framework keeps restoring and running Every bin and publish folder is scanned for compiler-host assemblies, including the value object generator and code fix, by scripts/assert-no-compiler-host-assemblies.ps1. Purview.ZodSharp prerelease gaps are reported as known issues rather than failures (-StrictKnownIssues fails on them), and the WebAssembly AOT publish leaves out the type-level rule that crashes the Mono AOT compiler while a separate probe keeps checking it. PackValidation gains ForbiddenContent rules mirroring ZodSharp's, and `just verify-pack-rules` (scripts/test-pack-validation-rules.ps1) proves each of the 25 rules rejects a package. --- .config/purview-build.json | 41 + Justfile | 20 + .../assert-no-compiler-host-assemblies.ps1 | 159 ++++ .../AotConsole/AotConsole.csproj | 39 + scripts/packed-consumers/AotConsole/Models.cs | 94 ++ .../packed-consumers/AotConsole/Program.cs | 255 ++++++ .../BlazorServer/BlazorServer.csproj | 35 + .../BlazorServer/Components/App.razor | 13 + .../Components/Pages/Interactive.razor | 6 + .../Components/Pages/Register.razor | 45 + .../BlazorServer/Components/Routes.razor | 8 + .../BlazorServer/Components/_Imports.razor | 7 + .../packed-consumers/BlazorServer/Program.cs | 16 + scripts/packed-consumers/BlazorWasm/App.razor | 8 + .../BlazorWasm/BlazorWasm.csproj | 45 + .../BlazorWasm/Pages/Index.razor | 5 + .../packed-consumers/BlazorWasm/Program.cs | 14 + .../BlazorWasm/_Imports.razor | 6 + .../Net8Console/Net8Console.csproj | 28 + .../packed-consumers/Net8Console/Program.cs | 60 ++ .../packed-consumers/Shared/InputScalar.cs | 52 ++ scripts/packed-consumers/Shared/Models.cs | 77 ++ .../Shared/RegistrationSelfTest.razor | 182 ++++ scripts/packed-consumers/WasmHost/Program.cs | 37 + .../packed-consumers/WasmHost/WasmHost.csproj | 14 + scripts/test-pack-validation-rules.ps1 | 180 ++++ scripts/test-packed-consumers.ps1 | 808 ++++++++++++++++++ 27 files changed, 2254 insertions(+) create mode 100644 scripts/assert-no-compiler-host-assemblies.ps1 create mode 100644 scripts/packed-consumers/AotConsole/AotConsole.csproj create mode 100644 scripts/packed-consumers/AotConsole/Models.cs create mode 100644 scripts/packed-consumers/AotConsole/Program.cs create mode 100644 scripts/packed-consumers/BlazorServer/BlazorServer.csproj create mode 100644 scripts/packed-consumers/BlazorServer/Components/App.razor create mode 100644 scripts/packed-consumers/BlazorServer/Components/Pages/Interactive.razor create mode 100644 scripts/packed-consumers/BlazorServer/Components/Pages/Register.razor create mode 100644 scripts/packed-consumers/BlazorServer/Components/Routes.razor create mode 100644 scripts/packed-consumers/BlazorServer/Components/_Imports.razor create mode 100644 scripts/packed-consumers/BlazorServer/Program.cs create mode 100644 scripts/packed-consumers/BlazorWasm/App.razor create mode 100644 scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj create mode 100644 scripts/packed-consumers/BlazorWasm/Pages/Index.razor create mode 100644 scripts/packed-consumers/BlazorWasm/Program.cs create mode 100644 scripts/packed-consumers/BlazorWasm/_Imports.razor create mode 100644 scripts/packed-consumers/Net8Console/Net8Console.csproj create mode 100644 scripts/packed-consumers/Net8Console/Program.cs create mode 100644 scripts/packed-consumers/Shared/InputScalar.cs create mode 100644 scripts/packed-consumers/Shared/Models.cs create mode 100644 scripts/packed-consumers/Shared/RegistrationSelfTest.razor create mode 100644 scripts/packed-consumers/WasmHost/Program.cs create mode 100644 scripts/packed-consumers/WasmHost/WasmHost.csproj create mode 100644 scripts/test-pack-validation-rules.ps1 create mode 100644 scripts/test-packed-consumers.ps1 diff --git a/.config/purview-build.json b/.config/purview-build.json index 1a770f3..5772e2d 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -26,6 +26,47 @@ "README.md", "purview-logo-light.png" ] + }, + // Every package ("*"). RequireExplicitContent (on by default) already rejects anything RequiredContent does not + // declare; these rules hold even if a RequiredContent glob is ever widened. Mirrors purview-dev/zodsharp's rules. + // Globs are Microsoft.Extensions.FileSystemGlobbing patterns: "*" stays within one path segment, "**" spans any + // number, including none. scripts/test-pack-validation-rules.ps1 proves every rule fails a package. + "ForbiddenContent": { + "*": [ + // The generator ships IL-merged, with Purview.SourceGeneratorFramework internalized into it; a loose copy + // under analyzers/ means the merge did not run. + "analyzers/**/Purview.SourceGeneratorFramework*", + // The compiler supplies Roslyn to analyzers; a packed copy would shadow or conflict with it. + "analyzers/**/Microsoft.CodeAnalysis*", + // Compiler-host assemblies never appear outside analyzers/: anywhere else NuGet hands them to the + // application, which is how Purview.SourceGeneratorFramework.dll reached app and Blazor WebAssembly + // output and broke WebAssembly AOT compilation. + "Purview.SourceGeneratorFramework*", + "Microsoft.CodeAnalysis*", + "lib/**/Purview.SourceGeneratorFramework*", + "lib/**/Microsoft.CodeAnalysis*", + "ref/**/Purview.SourceGeneratorFramework*", + "ref/**/Microsoft.CodeAnalysis*", + "runtimes/**/Purview.SourceGeneratorFramework*", + "runtimes/**/Microsoft.CodeAnalysis*", + "build/**/Purview.SourceGeneratorFramework*", + "build/**/Microsoft.CodeAnalysis*", + "buildTransitive/**/Purview.SourceGeneratorFramework*", + "buildTransitive/**/Microsoft.CodeAnalysis*", + "buildMultiTargeting/**/Purview.SourceGeneratorFramework*", + "buildMultiTargeting/**/Microsoft.CodeAnalysis*", + "tools/**/Purview.SourceGeneratorFramework*", + "tools/**/Microsoft.CodeAnalysis*", + "content/**/Purview.SourceGeneratorFramework*", + "content/**/Microsoft.CodeAnalysis*", + "contentFiles/**/Purview.SourceGeneratorFramework*", + "contentFiles/**/Microsoft.CodeAnalysis*", + // The generator and its code fix run only inside the compiler, so they ship under analyzers/ alone: under + // lib/, ref/ or runtimes/ every application would load them. + "lib/**/Purview.ValueObjects.SourceGenerator*", + "ref/**/Purview.ValueObjects.SourceGenerator*", + "runtimes/**/Purview.ValueObjects.SourceGenerator*" + ] } }, "Release": { diff --git a/Justfile b/Justfile index 09035bf..3636b57 100644 --- a/Justfile +++ b/Justfile @@ -99,6 +99,26 @@ pack *args: echo "Packing {{ BLUE }}{{ solution_file }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet pack "{{ solution_file }}" --configuration "{{ build_configuration }}" --no-restore --output "{{ artifact_folder }}" {{ args }} +# Prove the packed package works for real consumers: packs Purview.ValueObjects to an isolated feed, then publishes +# and runs a Native AOT console, a Blazor WebAssembly app (trimmed and RunAOTCompilation, run in a headless browser), +# a Blazor Server app and a net8.0 console against it, and leak-scans every output for compiler-host assemblies. +# Needs the Native AOT toolchain, the wasm-tools workload for the WebAssembly AOT step, and Chrome or Edge for the +# browser steps. Purview.ZodSharp/Purview.SourceGeneratorFramework versions not yet on nuget.org restore from +# LOCAL_NUGET_FEED_PATH (or VALUEOBJECTS_DEPENDENCY_FEED). See scripts/test-packed-consumers.ps1 for the options. +[group('Build and Test')] +smoke-packed-consumers *args: + echo "Running the packed consumer gate..." + pwsh -NoProfile -File scripts/test-packed-consumers.ps1 {{ args }} + +# Prove every PackValidation ForbiddenContent rule in .config/purview-build.json rejects a package: injects a matching +# file into scratch copies of the packed packages and runs the real purview-build validation on them. Reads artifacts/, +# so pack first (`just pipeline-pack-validate`); `just pipeline-local-release` moves its packages to the local feed, so +# pass -PackagesFolder after it. +[group('Build and Test')] +verify-pack-rules *args: + just ensure-pipeline-tool + pwsh -NoProfile -File scripts/test-pack-validation-rules.ps1 {{ args }} + # ----------------------------------------------------------------------------- # Formatting # ----------------------------------------------------------------------------- diff --git a/scripts/assert-no-compiler-host-assemblies.ps1 b/scripts/assert-no-compiler-host-assemblies.ps1 new file mode 100644 index 0000000..ba126cd --- /dev/null +++ b/scripts/assert-no-compiler-host-assemblies.ps1 @@ -0,0 +1,159 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Fails when a build or publish output carries a compiler-host-only assembly. + +.DESCRIPTION + Adapted from purview-dev/zodsharp's scripts/assert-no-compiler-host-assemblies.ps1. + + The Purview.ValueObjects source generator, its code fix, the framework they are built on + (Purview.SourceGeneratorFramework), Roslyn, and the Purview.ZodSharp generator and code fixes a consumer pulls in + alongside them all run inside the compiler. None of them may reach an application: Purview.SourceGeneratorFramework.dll + once leaked into app build and publish output through analyzer-only references, and Blazor WebAssembly AOT + compilation failed on it ("Could not load Microsoft.CodeAnalysis"). + + Every file under each -Path is checked by name, and every dependency manifest (*.deps.json, *.runtimeconfig.json, + blazor.boot.json, dotnet.boot.js, and dotnet.js, which embeds the boot manifest since .NET 10 - including fingerprinted + and Brotli/gzip compressed copies) is checked by content. A file, or a manifest entry, naming any of these fails: + + Purview.SourceGeneratorFramework* + Microsoft.CodeAnalysis* + Purview.ValueObjects.SourceGenerator* (the generator and Purview.ValueObjects.SourceGenerator.Refactorings) + Purview.ZodSharp.SourceGenerators* + Purview.ZodSharp.CodeFixes* + + A path that does not exist, or that holds no files, fails too: a scan of nothing proves nothing. + + Used by scripts/test-packed-consumers.ps1 on every consumer bin and publish folder. + +.PARAMETER Path + One or more build or publish output directories to scan, recursively. Space-separated when run with -File. + +.PARAMETER Allow + Wildcard patterns (PowerShell -like, matched against each file's path relative to the scanned directory, with + forward slashes) for third-party tools that legitimately carry Roslyn and are never loaded by the application. + Allowed files are counted and reported, never silently ignored. Keep each pattern as narrow as the tool's folder. + +.EXAMPLE + ./scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/wasm-trimmed + +.EXAMPLE + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/aot-console bin/Release +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory, Position = 0)] + [string[]] $Path, + + [string[]] $Allow = @(), + + # Collects the rest of `pwsh -File ... -Path a b` (space-separated): -File passes arguments as strings and does + # not parse PowerShell's comma array syntax. + [Parameter(ValueFromRemainingArguments, DontShow)] + [string[]] $AdditionalPath = @() +) + +$ErrorActionPreference = 'Stop' +$Path = @($Path) + @($AdditionalPath) + +# File-name prefixes. Published WebAssembly files carry a fingerprint (Name.abc123.wasm), which a prefix still matches. +$forbiddenNames = @( + 'Purview.SourceGeneratorFramework' + 'Microsoft.CodeAnalysis' + 'Purview.ValueObjects.SourceGenerator' + 'Purview.ZodSharp.SourceGenerators' + 'Purview.ZodSharp.CodeFixes' +) + +$forbiddenContent = [regex]::new( + (($forbiddenNames | ForEach-Object { [regex]::Escape($_) }) -join '|'), + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +# Dependency manifests, optionally fingerprinted (dotnet.boot.abc123.js) and optionally compressed (.br, .gz). Since .NET 10 +# the WebAssembly boot manifest is embedded in _framework/dotnet.js, so that file is a manifest too; dotnet.native.*.js and +# dotnet.runtime.*.js are the runtime itself and never list assemblies. +$manifestPattern = [regex]::new( + '(\.deps\.json|\.runtimeconfig\.json|^blazor\.boot(\.[a-z0-9]+)?\.json|^dotnet(\.boot)?(\.[a-z0-9]+)?\.js)(\.br|\.gz)?$', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +function Read-ManifestText([System.IO.FileInfo] $file) { + $stream = [System.IO.File]::OpenRead($file.FullName) + try { + $source = switch -Regex ($file.Name) { + '\.br$' { [System.IO.Compression.BrotliStream]::new($stream, [System.IO.Compression.CompressionMode]::Decompress) } + '\.gz$' { [System.IO.Compression.GZipStream]::new($stream, [System.IO.Compression.CompressionMode]::Decompress) } + default { $stream } + } + + $reader = [System.IO.StreamReader]::new($source) + try { return $reader.ReadToEnd() } finally { $reader.Dispose() } + } + finally { + $stream.Dispose() + } +} + +function Write-Failure([string] $message) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } +} + +$violations = [System.Collections.Generic.List[string]]::new() + +foreach ($directory in $Path) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + $violations.Add("'$directory' does not exist or is not a directory, so it cannot be scanned.") + continue + } + + $root = (Resolve-Path -LiteralPath $directory).Path + $files = @(Get-ChildItem -LiteralPath $root -Recurse -File -Force) + if ($files.Count -eq 0) { + $violations.Add("'$root' holds no files, so the scan would prove nothing.") + continue + } + + $manifests = 0 + $allowed = 0 + foreach ($file in $files) { + $relative = [System.IO.Path]::GetRelativePath($root, $file.FullName) + $forwardSlashed = $relative.Replace('\', '/') + if ($Allow | Where-Object { $forwardSlashed -like $_ }) { + $allowed++ + continue + } + + foreach ($name in $forbiddenNames) { + if ($file.Name.StartsWith($name, [System.StringComparison]::OrdinalIgnoreCase)) { + $violations.Add("compiler-host file '$relative' in '$root'.") + break + } + } + + if ($manifestPattern.IsMatch($file.Name)) { + $manifests++ + $matched = $forbiddenContent.Matches((Read-ManifestText $file)) | + ForEach-Object Value | + Sort-Object -Unique + if ($matched) { + $violations.Add("manifest '$relative' in '$root' names $($matched -join ', ').") + } + } + } + + $allowedNote = $allowed -gt 0 ? (", {0} allowed by -Allow {1}" -f $allowed, ($Allow -join ', ')) : '' + Write-Host ("Scanned {0}: {1} file(s), {2} manifest(s){3}." -f $root, $files.Count, $manifests, $allowedNote) +} + +if ($violations.Count -gt 0) { + foreach ($violation in $violations) { Write-Failure $violation } + Write-Failure ( + "$($violations.Count) compiler-host leak(s) found. Source generators, analyzers and their dependencies run " + + 'inside the compiler and must never reach build or publish output; check analyzer-only references ' + + '(OutputItemType="Analyzer", ReferenceOutputAssembly="false", PrivateAssets="all") and the package layout.' + ) + exit 1 +} + +Write-Host 'No compiler-host assemblies or manifest references found.' -ForegroundColor Green diff --git a/scripts/packed-consumers/AotConsole/AotConsole.csproj b/scripts/packed-consumers/AotConsole/AotConsole.csproj new file mode 100644 index 0000000..66b5091 --- /dev/null +++ b/scripts/packed-consumers/AotConsole/AotConsole.csproj @@ -0,0 +1,39 @@ + + + + Exe + net11.0 + PackedValueObjectsAot + PackedValueObjects + enable + enable + true + true + false + true + true + true + + + + + + + + + + + + diff --git a/scripts/packed-consumers/AotConsole/Models.cs b/scripts/packed-consumers/AotConsole/Models.cs new file mode 100644 index 0000000..325c42a --- /dev/null +++ b/scripts/packed-consumers/AotConsole/Models.cs @@ -0,0 +1,94 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Purview.ValueObjects.Serialization; +using ZodSharp; +using ZodSharp.Rules; + +namespace PackedValueObjects; + +/// A scalar validated by both generators: Create normalizes, then runs EmailAddressSchema. +[Scalar] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [Required] + [EmailAddress] + [StringLength(254, MinimumLength = 3)] + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; +} + +/// A Guid scalar rejected by the built-in, type-level [NonSentinel] rule. +[Scalar] +[ZodSchema] +[NonSentinel(Message = "CustomerId must not be empty.")] +public readonly partial record struct CustomerId +{ + public Guid Value { get; } +} + +/// A scalar validated by the value-object hook alone, with no ZodSharp involvement. +[Scalar] +public readonly partial record struct Quantity +{ + public int Value { get; } + + static partial void OnValidate(int value) + { + if (value is < 1 or > 100) + throw new ArgumentOutOfRangeException(nameof(value), value, "Quantity must be between 1 and 100."); + } +} + +/// A complex value object. +[ValueObject] +public readonly partial record struct Money +{ + public decimal Amount { get; } + + public string Currency { get; } + + partial void OnValidate(decimal amount, string currency) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount), amount, "Amount cannot be negative."); + + if (currency is not { Length: 3 }) + throw new ArgumentException("Currency must be a three-letter code.", nameof(currency)); + } +} + +/// A request DTO over value objects, validated by the ZodSharp generator. +[ZodSchema] +public sealed class Order +{ + [Required] + public CustomerId? Customer { get; set; } + + // Not EmailAddress?: Purview.ZodSharp 3.0.0-prerelease.1 emits `value.ContactEmail == null` for a [Required] + // nullable struct, which is ambiguous (CS9342) with the scalar's generated operator ==(string?, EmailAddress). + // A non-nullable member is validated by EmailAddress's own schema, whose [Required] rejects a missing address. + public EmailAddress ContactEmail { get; set; } + + [MinCount(1)] + public List Lines { get; set; } = []; +} + +[ZodSchema] +public sealed class OrderLine +{ + [Required] + [RegularExpression("^[A-Z]{3}-[0-9]{4}$")] + public string? Sku { get; set; } + + public Quantity Quantity { get; set; } + + public Money Price { get; set; } +} + +[JsonSerializable(typeof(Order))] +[JsonSerializable(typeof(EmailAddress))] +[JsonSerializable(typeof(Money))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public sealed partial class ConsumerJsonContext : JsonSerializerContext { } diff --git a/scripts/packed-consumers/AotConsole/Program.cs b/scripts/packed-consumers/AotConsole/Program.cs new file mode 100644 index 0000000..bacd531 --- /dev/null +++ b/scripts/packed-consumers/AotConsole/Program.cs @@ -0,0 +1,255 @@ +// Packed-consumer gate, consumer (a): a Native AOT console built from the packages alone. Every check records a +// failure rather than throwing, so one run reports every mismatch; any failure makes the process exit non-zero. +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using PackedValueObjects; +using ZodSharp; +using ZodSharp.Core; +using ZodSharp.Rules; + +var failures = new List(); +var passed = 0; + +void Check(bool condition, string description) +{ + if (condition) + { + passed++; + Console.WriteLine($" pass {description}"); + } + else + { + failures.Add(description); + Console.WriteLine($" FAIL {description}"); + } +} + +void CheckPaths(ImmutableArray errors, string[] expected, string description) +{ + var actual = errors.IsDefault ? [] : errors.Select(static e => e.FullPath.ToDottedString()).Distinct().ToArray(); + var missing = expected.Except(actual).ToArray(); + var unexpected = actual.Except(expected).ToArray(); + Check( + missing.Length == 0 && unexpected.Length == 0, + missing.Length == 0 && unexpected.Length == 0 + ? description + : $"{description} (missing: [{string.Join(", ", missing)}], unexpected: [{string.Join(", ", unexpected)}])" + ); +} + +void Section(string name, Action body) +{ + Console.WriteLine(name); + try + { + body(); + } + catch (Exception ex) + { + Check(false, $"{name} threw {ex.GetType().Name}: {ex.Message}"); + } +} + +static T? Catch(Action action) + where T : Exception +{ + try + { + action(); + return null; + } + catch (T ex) + { + return ex; + } +} + +// The value objects are declared in this project, so the System.Text.Json source generator - which never sees +// another generator's output - cannot see the [JsonConverter] attribute the value object generator adds, and would +// describe each value object as an object with a Value property. Registering the generated converters on the +// options fixes that; a value object declared in a referenced library needs nothing, because the attribute is then +// compiled into it. +JsonSerializerOptions JsonOptions = new(ConsumerJsonContext.Default.Options) +{ + Converters = + { + new EmailAddress.EmailAddressJsonConverter(), + new CustomerId.CustomerIdJsonConverter(), + new Quantity.QuantityJsonConverter(), + new Money.MoneyJsonConverter(), + }, +}; + +static Order ValidOrder() => + new() + { + Customer = CustomerId.Create(Guid.NewGuid()), + ContactEmail = EmailAddress.Create("buyer@example.com"), + Lines = + [ + new() + { + Sku = "ABC-1234", + Quantity = Quantity.Create(2), + Price = Money.Create(9.99m, "GBP"), + }, + ], + }; + +Section( + "native image", + () => + // A native image compiles nothing at run time; `dotnet run` of the same project would JIT. + Check(System.Runtime.JitInfo.GetCompiledMethodCount() == 0, "the process is a Native AOT image (no JIT)") +); + +Section( + "generated scalar factories", + () => + { + var email = EmailAddress.Create(" Buyer@Example.COM "); + Check(email.Value == "buyer@example.com", $"Create normalizes before validating (got '{email.Value}')"); + Check( + EmailAddress.Hydrate("not-an-email").Value == "not-an-email", + "Hydrate is replay-safe and does not validate" + ); + Check(!EmailAddress.TryCreate("not-an-email", out _), "TryCreate returns false for a schema failure"); + Check(Quantity.Create(5).Value == 5, "a hook-validated scalar creates"); + Check( + Catch(() => Quantity.Create(0)) is not null, + "a hook-validated scalar rejects an invalid value with ArgumentOutOfRangeException" + ); + Check(Money.Create(1.5m, "EUR").Amount == 1.5m, "a complex value object creates"); + Check( + Catch(() => Money.Create(-1m, "EUR")) is not null, + "a complex value object runs its OnValidate hook" + ); + Check(EmailAddress.Create("a@b.co") == EmailAddress.Create("A@B.CO"), "equality is by normalized value"); + } +); + +Section( + "Create routes through the ZodSharp schema", + () => + { + var email = Catch(() => EmailAddress.Create("not-an-email")); + Check(email is { Errors.Length: > 0 }, "EmailAddress.Create throws ZodException for an invalid address"); + if (email is not null) + CheckPaths(email.Errors, ["Value"], "the scalar's member rule reports the Value path"); + + var customer = Catch(() => CustomerId.Create(Guid.Empty)); + ValidationError? error = customer is null ? null : customer.Errors.Single(); + Check( + error is { Code: NonSentinelRule.ErrorCode, Message: "CustomerId must not be empty." }, + $"a type-level [NonSentinel] rejects Guid.Empty (got {error?.Code}: {error?.Message})" + ); + Check( + error?.Path.IsEmpty == true, + "a type-level rule reports an empty path: the scalar is validated as a unit" + ); + Check( + error?.Origin == ZodTypeNames.String, + $"ZodSharp 3 reports the rule's structured origin (got '{error?.Origin}')" + ); + } +); + +Section( + "generated DTO validation over value objects", + () => + { + Check(OrderSchema.Validate(ValidOrder()).IsSuccess, "a valid order passes"); + + Order invalid = new() + { + Customer = null, + ContactEmail = EmailAddress.Hydrate("not-an-email"), + Lines = + [ + new() + { + Sku = "abc", + Quantity = Quantity.Hydrate(1), + Price = Money.Hydrate(1m, "GBP"), + }, + ], + }; + var errors = OrderSchema.Validate(invalid).Errors; + foreach (var e in errors) + Console.WriteLine($" {e.FullPath.ToDottedString()}: {e.Code} - {e.Message}"); + + CheckPaths( + errors, + ["Customer", "ContactEmail.Value", "Lines[0].Sku"], + "a missing value object, a nested value object's own rule and a collection element report typed paths" + ); + + var empty = ValidOrder(); + empty.Lines = []; + var tooFew = OrderSchema.Validate(empty).Errors.Single(); + Check( + tooFew is { Code: "too_small", Minimum: 1 } && tooFew.FullPath.ToDottedString() == "Lines", + $"[MinCount] reports too_small at the member (got {tooFew.Code} at {tooFew.FullPath.ToDottedString()})" + ); + } +); + +Section( + "System.Text.Json with a source-generated context", + () => + { + var emailInfo = (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(EmailAddress)); + var moneyInfo = (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(Money)); + var orderInfo = (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(Order)); + + var json = JsonSerializer.Serialize(EmailAddress.Create("demo@example.com"), emailInfo); + Check(json == "\"demo@example.com\"", $"a scalar serializes as its underlying value (got {json})"); + + var moneyJson = JsonSerializer.Serialize(Money.Create(12.5m, "USD"), moneyInfo); + Check( + moneyJson == """{"amount":12.5,"currency":"USD"}""", + $"a complex value object writes its members (got {moneyJson})" + ); + Check( + JsonSerializer.Deserialize(moneyJson, moneyInfo) == Money.Create(12.5m, "USD"), + "a complex value object round-trips" + ); + + var order = ValidOrder(); + var orderJson = JsonSerializer.Serialize(order, orderInfo); + Console.WriteLine($" {orderJson}"); + var roundTripped = JsonSerializer.Deserialize(orderJson, orderInfo); + Check( + roundTripped is not null + && roundTripped.ContactEmail == order.ContactEmail + && roundTripped.Customer == order.Customer + && roundTripped.Lines.Single().Quantity == Quantity.Create(2), + "a DTO of value objects round-trips through the generated converters" + ); + + var validator = new OrderSchemaValidator(); + var invalid = validator.DeserializeAndValidate( + """{"customer":"3f2504e0-4f89-11d3-9a0c-0305e82c3301","contactEmail":"not-an-email","lines":[]}""", + orderInfo + ); + CheckPaths( + invalid.Errors, + ["ContactEmail.Value", "Lines"], + "DeserializeAndValidate validates the value objects it read" + ); + } +); + +Console.WriteLine(); +if (failures.Count == 0) +{ + Console.WriteLine($"Packed Native AOT consumer passed: {passed} checks."); + return 0; +} + +Console.WriteLine($"Packed Native AOT consumer FAILED: {failures.Count} failure(s), {passed} passed."); +foreach (var failure in failures) + Console.WriteLine($" {failure}"); + +return 1; diff --git a/scripts/packed-consumers/BlazorServer/BlazorServer.csproj b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj new file mode 100644 index 0000000..e2c9634 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj @@ -0,0 +1,35 @@ + + + + net11.0 + PackedValueObjectsServer + PackedValueObjectsServer + enable + enable + true + true + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorServer/Components/App.razor b/scripts/packed-consumers/BlazorServer/Components/App.razor new file mode 100644 index 0000000..51c8636 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/App.razor @@ -0,0 +1,13 @@ + + + + + + Packed Purview.ValueObjects Blazor Server consumer + + + + + + + diff --git a/scripts/packed-consumers/BlazorServer/Components/Pages/Interactive.razor b/scripts/packed-consumers/BlazorServer/Components/Pages/Interactive.razor new file mode 100644 index 0000000..17152d9 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Pages/Interactive.razor @@ -0,0 +1,6 @@ +@page "/interactive" +@rendermode InteractiveServer + +

Register (interactive server)

+ + diff --git a/scripts/packed-consumers/BlazorServer/Components/Pages/Register.razor b/scripts/packed-consumers/BlazorServer/Components/Pages/Register.razor new file mode 100644 index 0000000..497e00e --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Pages/Register.razor @@ -0,0 +1,45 @@ +@page "/register" + +@* Statically rendered: the gate posts this form and reads the response. The value objects bind from the posted + strings through their generated IParsable implementation, so an unparseable value is a form mapping error and a + parsed one is validated by through the value objects' own schemas. *@ + +

Register (static server rendering)

+ + + + + + + + + + + + + + + + + @* The plan is chosen elsewhere; it travels with the form so its own rule still applies. *@ + + + + + + +@if (_registered) +{ +

Registered @Model!.Name <@Model.Email.Value> at @Model.Postcode.Value.

+} + +@code { + [SupplyParameterFromForm] + Registration? Model { get; set; } + + bool _registered; + + protected override void OnInitialized() => Model ??= new() { Plan = PlanId.Create(Guid.NewGuid()) }; + + void Submit() => _registered = true; +} diff --git a/scripts/packed-consumers/BlazorServer/Components/Routes.razor b/scripts/packed-consumers/BlazorServer/Components/Routes.razor new file mode 100644 index 0000000..e5dec77 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Routes.razor @@ -0,0 +1,8 @@ + + + + + +

Not found.

+
+
diff --git a/scripts/packed-consumers/BlazorServer/Components/_Imports.razor b/scripts/packed-consumers/BlazorServer/Components/_Imports.razor new file mode 100644 index 0000000..299250c --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/_Imports.razor @@ -0,0 +1,7 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using ZodSharp.Blazor +@using PackedValueObjectsBlazor +@using PackedValueObjectsServer.Components diff --git a/scripts/packed-consumers/BlazorServer/Program.cs b/scripts/packed-consumers/BlazorServer/Program.cs new file mode 100644 index 0000000..4f4522b --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Program.cs @@ -0,0 +1,16 @@ +using PackedValueObjectsServer.Components; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +// The generated, trim-safe registration of this assembly's [ZodSchema] types (the form model and the value objects), +// then ZodSharp for Blazor forms. +builder.Services.AddZodSharpPackedValueObjectsServerSchemas(); +builder.Services.AddZodSharpBlazor(); + +var app = builder.Build(); +app.UseAntiforgery(); +app.MapStaticAssets(); +app.MapRazorComponents().AddInteractiveServerRenderMode(); + +await app.RunAsync(); diff --git a/scripts/packed-consumers/BlazorWasm/App.razor b/scripts/packed-consumers/BlazorWasm/App.razor new file mode 100644 index 0000000..abdf5c3 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/App.razor @@ -0,0 +1,8 @@ + + + + + +

Not found.

+
+
diff --git a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj new file mode 100644 index 0000000..034d543 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj @@ -0,0 +1,45 @@ + + + + net11.0 + PackedValueObjectsWasm + PackedValueObjectsWasm + enable + enable + true + false + true + true + true + + + + + $(DefineConstants);EXCLUDE_TYPE_LEVEL_RULES + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorWasm/Pages/Index.razor b/scripts/packed-consumers/BlazorWasm/Pages/Index.razor new file mode 100644 index 0000000..b730068 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Pages/Index.razor @@ -0,0 +1,5 @@ +@page "/" + +

Register

+ + diff --git a/scripts/packed-consumers/BlazorWasm/Program.cs b/scripts/packed-consumers/BlazorWasm/Program.cs new file mode 100644 index 0000000..1f9a354 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using PackedValueObjectsWasm; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +// The generated, trim-safe registration of this assembly's [ZodSchema] types (the form model and the value objects), +// then ZodSharp for Blazor forms, which also makes validate them. +builder.Services.AddZodSharpPackedValueObjectsWasmSchemas(); +builder.Services.AddZodSharpBlazor(); + +await builder.Build().RunAsync(); diff --git a/scripts/packed-consumers/BlazorWasm/_Imports.razor b/scripts/packed-consumers/BlazorWasm/_Imports.razor new file mode 100644 index 0000000..8b369d3 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/_Imports.razor @@ -0,0 +1,6 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using ZodSharp.Blazor +@using PackedValueObjectsBlazor +@using PackedValueObjectsWasm diff --git a/scripts/packed-consumers/Net8Console/Net8Console.csproj b/scripts/packed-consumers/Net8Console/Net8Console.csproj new file mode 100644 index 0000000..1f2879d --- /dev/null +++ b/scripts/packed-consumers/Net8Console/Net8Console.csproj @@ -0,0 +1,28 @@ + + + + Exe + net8.0 + PackedValueObjectsNet8 + PackedValueObjectsNet8 + enable + enable + true + true + + + + + + + + + + diff --git a/scripts/packed-consumers/Net8Console/Program.cs b/scripts/packed-consumers/Net8Console/Program.cs new file mode 100644 index 0000000..ac3defd --- /dev/null +++ b/scripts/packed-consumers/Net8Console/Program.cs @@ -0,0 +1,60 @@ +// Packed-consumer gate, consumer (d): the net8.0 target of the runtime package, with the trim and AOT analyzers on +// (IsAotCompatible), so the generated code is analyzed for that target too. +using System.Text.Json; +using System.Text.Json.Serialization; +using Purview.ValueObjects.Serialization; + +var failures = 0; +void Check(bool condition, string description) +{ + Console.WriteLine($" {(condition ? "pass" : "FAIL")} {description}"); + if (!condition) + failures++; +} + +Console.WriteLine($"net8.0 consumer on {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); +Check(Environment.Version.Major == 8, "running on the .NET 8 runtime"); + +var sku = Sku.Create(" abc-1234 "); +Check(sku.Value == "ABC-1234", $"Create normalizes (got '{sku.Value}')"); +Check(!Sku.TryCreate("nope", out _), "TryCreate rejects an invalid value"); + +JsonSerializerOptions options = new(Net8JsonContext.Default.Options) +{ + Converters = { new Sku.SkuJsonConverter(), new Price.PriceJsonConverter() }, +}; +var price = Price.Create(9.5m, sku); +var json = JsonSerializer.Serialize(price, options.GetTypeInfo(typeof(Price))); +Check(json == """{"amount":9.5,"sku":"ABC-1234"}""", $"a complex value object serializes its members (got {json})"); +Check( + JsonSerializer.Deserialize(json, options.GetTypeInfo(typeof(Price))) is Price roundTripped && roundTripped == price, + "and round-trips" +); + +return failures == 0 ? 0 : 1; + +[Scalar] +public readonly partial record struct Sku +{ + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToUpperInvariant()!; + + static partial void OnValidate(string value) + { + if (value is not { Length: 8 }) + throw new ArgumentException("A SKU is eight characters.", nameof(value)); + } +} + +[ValueObject] +public readonly partial record struct Price +{ + public decimal Amount { get; } + + public Sku Sku { get; } +} + +[JsonSerializable(typeof(Price))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +partial class Net8JsonContext : JsonSerializerContext; diff --git a/scripts/packed-consumers/Shared/InputScalar.cs b/scripts/packed-consumers/Shared/InputScalar.cs new file mode 100644 index 0000000..a1510c4 --- /dev/null +++ b/scripts/packed-consumers/Shared/InputScalar.cs @@ -0,0 +1,52 @@ +// Shared by the Blazor consumers (copied in by scripts/test-packed-consumers.ps1). +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Rendering; +using Purview.ValueObjects; + +namespace PackedValueObjectsBlazor; + +/// +/// A text input bound to a string-backed scalar value object. What is typed is kept with Hydrate - which +/// never validates - so the form's validator reports what is wrong with it through the value object's own schema, +/// instead of the input rejecting it as unparseable before any rule runs. +/// +/// The scalar value object. +public sealed class InputScalar : InputBase + where TScalar : struct, IScalarValueObject +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "input"); + builder.AddMultipleAttributes(1, AdditionalAttributes); + // The name a statically rendered form posts the value under, exactly as InputText writes it. + if (!string.IsNullOrEmpty(NameAttributeValue)) + builder.AddAttribute(2, "name", NameAttributeValue); + builder.AddAttribute(3, "class", CssClass); + builder.AddAttribute(4, "value", CurrentValueAsString); + builder.AddAttribute( + 5, + "onchange", + EventCallback.Factory.CreateBinder( + this, + value => CurrentValueAsString = value, + CurrentValueAsString + ) + ); + builder.CloseElement(); + } + + protected override string? FormatValueAsString(TScalar value) => value.Value; + + protected override bool TryParseValueFromString( + string? value, + [MaybeNullWhen(false)] out TScalar result, + [NotNullWhen(false)] out string? validationErrorMessage + ) + { + result = TScalar.Hydrate(value ?? string.Empty); + validationErrorMessage = null; + return true; + } +} diff --git a/scripts/packed-consumers/Shared/Models.cs b/scripts/packed-consumers/Shared/Models.cs new file mode 100644 index 0000000..a0ea950 --- /dev/null +++ b/scripts/packed-consumers/Shared/Models.cs @@ -0,0 +1,77 @@ +// Shared by the Blazor consumers: scripts/test-packed-consumers.ps1 copies this folder into BlazorWasm and +// BlazorServer before building them, so both validate exactly the same value objects. +using System.ComponentModel.DataAnnotations; +using Purview.ValueObjects.Serialization; +using ZodSharp; +#if !EXCLUDE_TYPE_LEVEL_RULES +using ZodSharp.Rules; +#endif + +namespace PackedValueObjectsBlazor; + +/// A scalar whose generated Create normalizes, then validates through EmailAddressSchema. +[Scalar] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [Required] + [EmailAddress] + [StringLength(254, MinimumLength = 3)] + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; +} + +/// A scalar validated by a pattern on its underlying value. +[Scalar] +[ZodSchema] +public readonly partial record struct PostalCode +{ + [Required] + [RegularExpression("^[0-9]{5}$")] + public string Value { get; } +} + +/// +/// A Guid scalar rejected by the built-in, type-level [NonSentinel] rule. The WebAssembly AOT publish +/// builds without the rule (EXCLUDE_TYPE_LEVEL_RULES): Purview.ZodSharp 3.0.0-prerelease.1 validates a type-level +/// rule through ZodRuleErrors.Build(..., (object)rule as IZodRule), which crashes the Mono AOT compiler +/// (0xC0000005). The gate probes that publish separately and reports it as a known issue. +/// +[Scalar] +[ZodSchema] +#if !EXCLUDE_TYPE_LEVEL_RULES +[NonSentinel(Message = "Choose a plan.")] +#endif +public readonly partial record struct PlanId +{ + public Guid Value { get; } +} + +/// +/// The form model. Its value object members are validated by their own generated schemas, so the rules live with +/// the value objects and the form only declares what is specific to it. +/// +[ZodSchema] +public sealed class Registration +{ + [Required] + [StringLength(50, MinimumLength = 2)] + public string? Name { get; set; } + + public EmailAddress Email { get; set; } + + public PostalCode Postcode { get; set; } + + public PlanId Plan { get; set; } +} + +/// Whether this build carries the type-level rule (see ). +public static class TypeLevelRules +{ +#if EXCLUDE_TYPE_LEVEL_RULES + public static bool Enabled => false; +#else + public static bool Enabled => true; +#endif +} diff --git a/scripts/packed-consumers/Shared/RegistrationSelfTest.razor b/scripts/packed-consumers/Shared/RegistrationSelfTest.razor new file mode 100644 index 0000000..6b56cc5 --- /dev/null +++ b/scripts/packed-consumers/Shared/RegistrationSelfTest.razor @@ -0,0 +1,182 @@ +@* Shared by the Blazor consumers (copied in by scripts/test-packed-consumers.ps1). Two forms over the same model of + value objects - one validated by , one by - and a self-test that runs once + the component is interactive and reports, in #self-test, what each validator placed where. The gate loads the page + in a headless browser and reads that element. *@ +@namespace PackedValueObjectsBlazor +@using System.Text +@using Microsoft.AspNetCore.Components.Forms +@using ZodSharp.Blazor + +

ZodValidator

+ + + + + + + + + + + + + + + + + + + +

DataAnnotationsValidator

+ + + + + + + + + + + + + + + + + + + +
@_report
+ +@code { + readonly Registration _zodModel = new(); + readonly Registration _annotationsModel = new(); + EditContext _zodContext = default!; + EditContext _annotationsContext = default!; + string _result = "running"; + string _report = string.Empty; + + protected override void OnInitialized() + { + _zodContext = new(_zodModel); + _annotationsContext = new(_annotationsModel); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + StringBuilder report = new(); + var failures = 0; + try + { + failures += await SelfTestAsync("ZodValidator", _zodContext, _zodModel, report); + failures += await SelfTestAsync("DataAnnotationsValidator", _annotationsContext, _annotationsModel, report); + } + catch (Exception ex) + { + failures++; + report.AppendLine($"FAIL the self-test threw {ex.GetType().Name}: {ex.Message}"); + } + + _result = failures == 0 ? "passed" : "failed"; + _report = report.ToString(); + StateHasChanged(); + } + + static async Task SelfTestAsync(string name, EditContext context, Registration model, StringBuilder report) + { + var failures = 0; + void Check(bool condition, string description) + { + report.AppendLine($"{(condition ? "pass" : "FAIL")} {name}: {description}"); + if (!condition) + failures++; + } + + // A gap in a dependency, not in Purview.ValueObjects: reported as KNOWN and surfaced by the gate as a warning + // rather than a failure. Where the gap does not occur the line is an ordinary pass. + void Known(bool condition, string description, string issue) => + report.AppendLine(condition ? $"pass {name}: {description}" : $"KNOWN {name}: {description} - {issue}"); + + const string DroppedMessages = + "Purview.ZodSharp 3.0.0-prerelease.1 fails the form but drops the message of an error inside a struct member " + + "(Email.Value): no field and no summary shows it. keeps it on the model only when its " + + "reflection fallback is trimmed away (WebAssembly)"; + const string FieldPlacement = + "Purview.ZodSharp 3.0.0-prerelease.1 cannot place an error inside a struct member (Email.Value) on a " + + "FieldIdentifier of the struct, and does not fall back to the owning member (Email)"; + + string[] Messages(string field) => [.. context.GetValidationMessages(new FieldIdentifier(model, field))]; + + // Hydrate keeps what was "typed" without validating, exactly as InputScalar does. + model.Name = "x"; + model.Email = EmailAddress.Hydrate("not-an-email"); + model.Postcode = PostalCode.Hydrate("abc"); + model.Plan = PlanId.Hydrate(Guid.Empty); + + var invalid = await context.ValidateAsync(); + string[] all = [.. context.GetValidationMessages()]; + report.AppendLine($"{name}: invalid form -> valid={invalid}, {all.Length} message(s)"); + foreach (var message in all) + report.AppendLine($" * {message}"); + foreach (var field in new[] { "Name", "Email", "Postcode", "Plan" }) + { + foreach (var message in Messages(field)) + report.AppendLine($" {field}: {message}"); + } + + Check(!invalid, "an invalid registration fails validation"); + Check(Messages("Name").Length == 1, "the form's own rule is placed on Name"); + Known( + all.Any(static m => m.Contains("email", StringComparison.OrdinalIgnoreCase)), + "EmailAddress's own schema reports a visible message", + DroppedMessages + ); + Known( + all.Any(static m => m.Contains("^[0-9]{5}$", StringComparison.Ordinal)), + "PostalCode's own schema reports a visible message", + DroppedMessages + ); + if (TypeLevelRules.Enabled) + { + // A type-level rule's error is located at the member itself (Plan), the one value object error a + // FieldIdentifier of the model can hold. + Known( + Messages("Plan").Contains("Choose a plan."), + "PlanId's type-level [NonSentinel] rule is placed on Plan", + DroppedMessages + ); + } + + Known( + Messages("Email").Length > 0 && Messages("Postcode").Length > 0, + "a value object's own errors are placed on the member's field", + FieldPlacement + ); + + // Only the value objects are invalid: the form must still be rejected, or invalid data is submitted. + model.Name = "Ada Lovelace"; + model.Plan = PlanId.Create(Guid.NewGuid()); + var onlyValueObjectsInvalid = await context.ValidateAsync(); + string[] valueObjectMessages = [.. context.GetValidationMessages()]; + report.AppendLine( + $"{name}: only value objects invalid -> valid={onlyValueObjectsInvalid}, {valueObjectMessages.Length} message(s)" + ); + Check(!onlyValueObjectsInvalid, "a form whose only errors are inside value objects is rejected"); + Known(valueObjectMessages.Length == 2, "and both of those errors have a visible message", DroppedMessages); + + model.Email = EmailAddress.Create(" Ada@Example.COM "); + model.Postcode = PostalCode.Create("12345"); + + var valid = await context.ValidateAsync(); + string[] remaining = [.. context.GetValidationMessages()]; + report.AppendLine($"{name}: valid form -> valid={valid}, {remaining.Length} message(s)"); + Check(valid && remaining.Length == 0, "a valid registration passes and clears every message"); + Check(model.Email.Value == "ada@example.com", "Create normalized the address before validating it"); + + return failures; + } +} diff --git a/scripts/packed-consumers/WasmHost/Program.cs b/scripts/packed-consumers/WasmHost/Program.cs new file mode 100644 index 0000000..fbf5fd5 --- /dev/null +++ b/scripts/packed-consumers/WasmHost/Program.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.FileProviders; + +// Serves a published Blazor WebAssembly wwwroot folder: the files as they are, index.html for any other path. +if (args.Length != 1 || !Directory.Exists(args[0])) +{ + Console.Error.WriteLine("Usage: PackedWasmHost "); + return 2; +} + +var root = Path.GetFullPath(args[0]); +var builder = WebApplication.CreateSlimBuilder(); +var app = builder.Build(); + +PhysicalFileProvider files = new(root); +FileExtensionContentTypeProvider contentTypes = new(); +contentTypes.Mappings[".dat"] = "application/octet-stream"; +contentTypes.Mappings[".wasm"] = "application/wasm"; +contentTypes.Mappings[".blat"] = "application/octet-stream"; + +app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files }); +app.UseStaticFiles( + new StaticFileOptions + { + FileProvider = files, + ContentTypeProvider = contentTypes, + ServeUnknownFileTypes = true, + } +); +app.MapFallback(async context => +{ + context.Response.ContentType = "text/html"; + await context.Response.SendFileAsync(Path.Combine(root, "index.html")); +}); + +await app.RunAsync(); +return 0; diff --git a/scripts/packed-consumers/WasmHost/WasmHost.csproj b/scripts/packed-consumers/WasmHost/WasmHost.csproj new file mode 100644 index 0000000..09c72a7 --- /dev/null +++ b/scripts/packed-consumers/WasmHost/WasmHost.csproj @@ -0,0 +1,14 @@ + + + + net11.0 + PackedWasmHost + enable + enable + true + + diff --git a/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 new file mode 100644 index 0000000..b54459f --- /dev/null +++ b/scripts/test-pack-validation-rules.ps1 @@ -0,0 +1,180 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves every PackValidation ForbiddenContent rule in .config/purview-build.json actually fails a package. + (Copied from purview-dev/zodsharp; only the core package id differs.) + +.DESCRIPTION + A forbidden-content glob that matches nothing - a typo, a wrong folder, a "*" where "**" was meant - passes + silently forever. This script proves each rule bites, with the real tool (purview-build, the version + .config/dotnet-tools.json pins) and the real rules. Each run validates copies of the packed packages in a scratch + repository that holds the repository's purview-build.json verbatim and an empty solution, so the pipeline's build is + a no-op and only pack validation does any work. RequireExplicitContent is turned off in every run, so the + explicit-content rule cannot be what fails one: only ForbiddenContent can. + + 1. Control: every package, untouched. Must pass, or the harness itself is broken. + 2. Per package: each package on its own, with one file per rule injected - a concrete path the rule's glob should + match (lib/**/Microsoft.CodeAnalysis* becomes lib/probe/Microsoft.CodeAnalysis.dll). Must fail, reporting every + rule. This proves the rules apply to every package id. + 3. Per rule: Purview.ValueObjects with only that rule's file injected. Must fail, reporting that rule. This proves each + rule is triggered by its own file rather than by another rule's. + + The packages in -PackagesFolder are never modified. + +.PARAMETER PackagesFolder + Folder holding the packed .nupkg and .snupkg files of the current version. Defaults to artifacts/, where + `just pipeline-pack-validate` and `just pack` write them. `just pipeline-local-release` moves them to the local feed, + so pass that folder after it. + +.PARAMETER KeepArtifacts + Leave the scratch repositories in place for inspection. +#> +[CmdletBinding()] +param ( + [string] $PackagesFolder = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts'), + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$configPath = Join-Path $repositoryRoot '.config/purview-build.json' +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$config = Get-Content $configPath -Raw | ConvertFrom-Json + +$rules = @($config.PackValidation.ForbiddenContent.PSObject.Properties['*'].Value) +if ($rules.Count -eq 0) { throw "No ForbiddenContent rules for '*' in $configPath." } + +$packages = @(Get-ChildItem $PackagesFolder -Filter "*.$version.nupkg" -File | Sort-Object Name) +if ($packages.Count -eq 0) { + throw ( + "No $version packages in '$PackagesFolder'. Pack first (just pipeline-pack-validate), or pass -PackagesFolder: " + + 'just pipeline-local-release moves its packages to the local feed.' + ) +} + +# A concrete entry each glob must match: "**" spans one made-up folder, "*" becomes an assembly extension. +$cases = foreach ($rule in $rules) { + [pscustomobject]@{ Rule = $rule; Entry = ($rule -replace '\*\*', 'probe' -replace '\*', '.dll') } +} + +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) "vo-pack-rules-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$runs = 0 + +# Validates $nupkgs (and their .snupkg) with $entries injected into each, and returns the rules the tool reported. +function Invoke-Validation([string] $name, [System.IO.FileInfo[]] $nupkgs, [string[]] $entries) { + $root = Join-Path $scratch $name + $artifacts = Join-Path $root 'artifacts' + New-Item -ItemType Directory -Path (Join-Path $root '.config'), $artifacts -Force | Out-Null + Copy-Item $configPath (Join-Path $root '.config/purview-build.json') + Copy-Item (Join-Path $repositoryRoot '.config/dotnet-tools.json') (Join-Path $root '.config/dotnet-tools.json') + Copy-Item (Join-Path $repositoryRoot 'global.json'), (Join-Path $repositoryRoot 'package.json') $root + '' | Set-Content (Join-Path $root 'empty.slnx') -Encoding utf8 + & git -C $root init --quiet + + foreach ($nupkg in $nupkgs) { + $copy = Join-Path $artifacts $nupkg.Name + Copy-Item $nupkg.FullName $copy + $symbols = [System.IO.Path]::ChangeExtension($nupkg.FullName, '.snupkg') + if (Test-Path $symbols) { Copy-Item $symbols $artifacts } + + if ($entries) { + $archive = [System.IO.Compression.ZipFile]::Open($copy, [System.IO.Compression.ZipArchiveMode]::Update) + try { + foreach ($entry in $entries) { + $writer = [System.IO.StreamWriter]::new($archive.CreateEntry($entry).Open()) + try { $writer.Write('injected by scripts/test-pack-validation-rules.ps1') } finally { $writer.Dispose() } + } + } + finally { $archive.Dispose() } + } + } + + Push-Location $root + try { + & dotnet tool restore | Out-Null + $output = & dotnet purview-build ` + --Build:Solution=empty.slnx ` + --Build:ArtifactsFolder=artifacts ` + --Build:CleanArtifacts=false ` + --Build:RunPack=false ` + --Build:ValidatePack=true ` + --Build:RunTests=false ` + --Build:RunLint=false ` + --PackValidation:RequireExplicitContent=false ` + --Release:Mode=None 2>&1 | ForEach-Object { "$_" } + $exitCode = $LASTEXITCODE + } + finally { + Pop-Location + } + + $script:runs++ + # The console wraps long lines, so rejoin them before reading "Forbidden content '' must not be in ...". + $text = ($output -join "`n") -replace '\r?\n\s*', ' ' + $reported = @([regex]::Matches($text, "Forbidden content '(?[^']+)'") | ForEach-Object { $_.Groups['rule'].Value } | Sort-Object -Unique) + return [pscustomobject]@{ ExitCode = $exitCode; Reported = $reported; Output = ($output -join "`n") } +} + +$failures = [System.Collections.Generic.List[string]]::new() +$stopwatch = [System.Diagnostics.Stopwatch]::StartNew() +try { + Write-Host "Control: $($packages.Count) untouched package(s)..." -ForegroundColor Cyan + $control = Invoke-Validation 'control' $packages @() + if ($control.ExitCode -ne 0) { + Write-Host $control.Output + throw 'The control run failed on untouched packages, so the harness cannot prove anything. Fix that first.' + } + Write-Host ' passed' + + Write-Host "Per package: each with all $($cases.Count) rule file(s) injected..." -ForegroundColor Cyan + foreach ($package in $packages) { + $result = Invoke-Validation "package-$($package.BaseName)" @($package) @($cases.Entry) + $missed = @($cases.Rule | Where-Object { $result.Reported -notcontains $_ }) + if ($result.ExitCode -eq 0 -or $missed.Count -gt 0) { + $failures.Add("$($package.Name): exit $($result.ExitCode); rules not reported: $($missed -join ', ')") + Write-Host " NOT REJECTED $($package.Name) ($($missed.Count) rule(s) missed)" -ForegroundColor Red + } + else { + Write-Host " rejected $($package.Name) ($($result.Reported.Count) of $($cases.Count) rules)" + } + } + + $core = $packages | Where-Object { $_.Name -ieq "Purview.ValueObjects.$version.nupkg" } + if (-not $core) { throw "Purview.ValueObjects.$version.nupkg is not in '$PackagesFolder'." } + + Write-Host "Per rule: $($core.Name) with one rule's file injected..." -ForegroundColor Cyan + $index = 0 + foreach ($case in $cases) { + $index++ + $result = Invoke-Validation "rule-$index" @($core) @($case.Entry) + $others = @($result.Reported | Where-Object { $_ -ne $case.Rule }) + if ($result.ExitCode -eq 0 -or $result.Reported -notcontains $case.Rule) { + $failures.Add("$($case.Rule): injecting '$($case.Entry)' was not rejected by it (exit $($result.ExitCode)).") + Write-Host (" NOT REJECTED {0,-55} {1}" -f $case.Entry, $case.Rule) -ForegroundColor Red + } + else { + $also = $others.Count -gt 0 ? " (also matched: $($others -join ', '))" : '' + Write-Host (" rejected {0,-55} {1}{2}" -f $case.Entry, $case.Rule, $also) + } + } +} +catch { + $failures.Add($_.Exception.Message) +} +finally { + if ($KeepArtifacts) { Write-Host "Scratch repositories kept at $scratch" -ForegroundColor Yellow } + else { Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue } +} + +Write-Host ("{0} validation run(s) in {1:N1}s." -f $runs, $stopwatch.Elapsed.TotalSeconds) +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$failure" } else { Write-Host "ERROR: $failure" -ForegroundColor Red } + } + exit 1 +} + +Write-Host "Every ForbiddenContent rule ($($cases.Count)) rejected its file, in every package ($($packages.Count))." -ForegroundColor Green diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 new file mode 100644 index 0000000..720fd83 --- /dev/null +++ b/scripts/test-packed-consumers.ps1 @@ -0,0 +1,808 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves Purview.ValueObjects works for real consumers - Native AOT, Blazor WebAssembly (trimmed and AOT) and Blazor + Server - built from the .nupkg alone, alongside the Purview.ZodSharp packages it integrates with. + +.DESCRIPTION + Modelled on purview-dev/zodsharp's scripts/test-packed-consumers.ps1. Every in-repository build references the + generator and runtime as ProjectReferences, which exercises none of what ships: the packed runtime, the merged + analyzer under analyzers/, the buildTransitive imports, and the generated code compiled into somebody else's + trimmed or AOT application. That gap is how Purview.SourceGeneratorFramework.dll leaked into application output + (and broke Blazor WebAssembly AOT) while every gate stayed green, and how the generated JSON converters reached + every trimmed consumer with IL2026/IL3050. + + This gate consumes the package exactly as a user does: + + 1. Preflight: checks the build-time Purview.SourceGeneratorFramework and the Purview.ZodSharp packages the + consumers use can be restored, and fails early, with what to do, if they cannot. + 2. Packs every shipping package (the ids in .config/purview-build.json PackValidation.RequiredContent) to an + isolated feed, with an isolated NUGET_PACKAGES, so no previously extracted package of the same version can be + reused. Package validation against the API baseline runs as part of the pack. The feed must hold exactly the + expected packages, and no package may carry a compiler-host assembly outside analyzers/. + 3. Copies the consumers in scripts/packed-consumers/ outside the repository (the Blazor ones with the shared + files in scripts/packed-consumers/Shared), beside guard Directory.Build.props/.targets and + Directory.Packages.props files that stop any inheritance, with a nuget.config that resolves Purview.ValueObjects + from the isolated feed only: + (a) AotConsole - a Native AOT console over value objects, [ZodSchema] DTOs and System.Text.Json source + generation. Published with -warnaserror, then run; it exits non-zero on any mismatch. + (b) BlazorWasm - a Blazor WebAssembly app with a form and a form over + a model of value objects. Published trimmed with -warnaserror, then with RunAOTCompilation=true. Each publish + is served and loaded in a headless Chromium-family browser, where the page validates both forms and reports + the outcome in the DOM. + (c) BlazorServer - a Blazor Web App. Published and started: a statically rendered form is posted invalid and + valid, and an interactive-server page is prerendered and then run over a live circuit in the browser. + (d) Net8Console - a net8.0 console without ZodSharp, built and (when the runtime is installed) run: the runtime + package still serves every target framework it ships. + 4. Scans every consumer bin and publish folder with scripts/assert-no-compiler-host-assemblies.ps1. + + Gaps in a dependency (Purview.ZodSharp prerelease behaviour the consumers rely on) are reported as KNOWN ISSUES, + not failures, so they stay visible without blocking this repository; -StrictKnownIssues makes them failures. + + The temporary workspace is deleted afterwards unless -KeepArtifacts is passed. Packing restores the shipping + projects against the isolated cache, so they are restored again against the normal cache at the end. + +.PARAMETER Configuration + Configuration the packages are packed in. Defaults to Release: this gate checks what ships. + +.PARAMETER RuntimeIdentifier + RID for the Native AOT console. Native AOT cannot cross-compile between operating systems, so it defaults to this + machine's RID (win-x64, linux-x64, osx-arm64, ...). + +.PARAMETER DependencyFeed + A NuGet feed (folder or URL) to restore Purview.SourceGeneratorFramework* and Purview.ZodSharp* from, in addition + to nuget.org - for versions that are not on nuget.org yet. Used for those packages only. Defaults to + $env:VALUEOBJECTS_DEPENDENCY_FEED, then $env:LOCAL_NUGET_FEED_PATH. + +.PARAMETER WasmAot + Auto (default) runs the WebAssembly AOT publish when the wasm-tools workload is installed and says so when it is + skipped. Require fails when the workload is missing (CI installs it). Skip never runs it. + +.PARAMETER Browser + Auto (default) loads the published Blazor apps in a headless Chromium-family browser (Edge, Chrome or Chromium; + PACKED_CONSUMER_BROWSER names one explicitly) when one is found, and says so when it is not. Require fails when + none is found. Skip never runs a browser. + +.PARAMETER PackOutput + Also copy the packed .nupkg/.snupkg files here, so a later step can inspect exactly what this gate consumed. + +.PARAMETER PreflightOnly + Run only the dependency restore check, so CI can fail on it before installing toolchains. + +.PARAMETER StrictKnownIssues + Fail the gate when a known dependency issue reproduces. + +.PARAMETER KeepArtifacts + Leave the temporary workspace in place for inspection. + +.EXAMPLE + just smoke-packed-consumers + +.EXAMPLE + ./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require -Browser Require -KeepArtifacts +#> +[CmdletBinding()] +param ( + [string] $Configuration = 'Release', + [string] $RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier, + [string] $DependencyFeed = ($env:VALUEOBJECTS_DEPENDENCY_FEED ? $env:VALUEOBJECTS_DEPENDENCY_FEED : $env:LOCAL_NUGET_FEED_PATH), + [ValidateSet('Auto', 'Require', 'Skip')] + [string] $WasmAot = 'Auto', + [ValidateSet('Auto', 'Require', 'Skip')] + [string] $Browser = 'Auto', + [string] $PackOutput, + [switch] $PreflightOnly, + [switch] $StrictKnownIssues, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$leakScan = Join-Path $PSScriptRoot 'assert-no-compiler-host-assemblies.ps1' +$consumerTemplates = Join-Path $PSScriptRoot 'packed-consumers' + +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$packageVersions = [xml] (Get-Content (Join-Path $repositoryRoot 'Directory.Packages.props') -Raw) +$sgfVersion = $packageVersions.SelectSingleNode('//PurviewSGFVersion')?.InnerText +$zodSharpVersion = $packageVersions.SelectSingleNode('//PurviewZodSharpVersion')?.InnerText +$sdk = (Get-Content (Join-Path $repositoryRoot 'global.json') -Raw | ConvertFrom-Json).sdk + +if (-not $sgfVersion) { throw 'Could not read PurviewSGFVersion from Directory.Packages.props.' } +if (-not $zodSharpVersion) { throw 'Could not read PurviewZodSharpVersion from Directory.Packages.props.' } + +# The shipping packages are exactly the ids pack validation knows about, so a new package is covered without editing +# this script. Each id maps to src/src/ (Purview.ValueObjects to src/src/ValueObjects). +$packValidation = (Get-Content (Join-Path $repositoryRoot '.config/purview-build.json') -Raw | ConvertFrom-Json).PackValidation +$sourceProjects = Get-ChildItem (Join-Path $repositoryRoot 'src/src') -Directory +$shipping = foreach ($id in $packValidation.RequiredContent.PSObject.Properties.Name) { + $folder = $id -ireplace '^purview\.', '' + $directory = $sourceProjects | Where-Object { $_.Name -ieq $folder } + $project = $directory ? (Join-Path $directory.FullName "$($directory.Name).csproj") : $null + if (-not $project -or -not (Test-Path $project)) { + throw "No project for the shipping package '$id' (expected src/src/$folder/$folder.csproj)." + } + + [pscustomobject]@{ Id = $id; Project = $project } +} + +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) "vo-packed-consumers-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$feed = Join-Path $workspace 'feed' +$packages = Join-Path $workspace 'packages' +$logs = Join-Path $workspace 'logs' +$consumers = Join-Path $workspace 'consumers' +$output = Join-Path $workspace 'out' +$exe = $IsWindows ? '.exe' : '' + +$timings = [System.Collections.Generic.List[object]]::new() +$scanned = [System.Collections.Generic.List[string]]::new() +$knownIssues = [System.Collections.Generic.List[string]]::new() +$previousNuGetPackages = $env:NUGET_PACKAGES +$repositoryRestoreTouched = $false + +function Write-Failure([string] $message) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } +} + +function Write-KnownIssue([string] $message) { + $knownIssues.Add($message) + if ($env:GITHUB_ACTIONS) { Write-Host "::warning::Known issue: $message" } else { Write-Host "KNOWN ISSUE: $message" -ForegroundColor Yellow } +} + +function Invoke-Step([string] $name, [scriptblock] $body) { + if ($env:GITHUB_ACTIONS) { Write-Host "::group::$name" } else { Write-Host "`n=== $name" -ForegroundColor Cyan } + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $status = 'passed' + try { + & $body + } + catch { + $status = 'FAILED' + throw + } + finally { + $stopwatch.Stop() + $timings.Add([pscustomobject]@{ Step = $name; Result = $status; Seconds = [math]::Round($stopwatch.Elapsed.TotalSeconds, 1) }) + if ($env:GITHUB_ACTIONS) { Write-Host '::endgroup::' } + } +} + +function Invoke-Skipped([string] $name, [string] $reason) { + Write-Host "`n=== $name - SKIPPED: $reason" -ForegroundColor Yellow + if ($env:GITHUB_ACTIONS) { Write-Host "::warning::$name skipped: $reason" } + $timings.Add([pscustomobject]@{ Step = $name; Result = 'skipped'; Seconds = 0 }) +} + +# Runs dotnet, tees its output to a log, and fails on a non-zero exit. -warnaserror already fails the publish on any +# trim or AOT warning; the log check is a second gate in case one is ever logged without failing the command. +function Invoke-Dotnet([string] $logName, [string[]] $arguments) { + $log = Join-Path $logs "$logName.log" + Write-Host "dotnet $($arguments -join ' ')" -ForegroundColor DarkGray + & dotnet @arguments 2>&1 | Tee-Object -FilePath $log | Out-Host + if ($LASTEXITCODE -ne 0) { throw "dotnet $($arguments[0]) failed with exit code $LASTEXITCODE (log: $log)." } + + $warnings = Select-String -Path $log -Pattern 'warning IL[23][0-9]{3}' | Select-Object -First 5 + if ($warnings) { + throw "dotnet $($arguments[0]) reported trim or AOT warnings:`n$($warnings.Line -join "`n")" + } +} + +function Assert-NoLeaks([string[]] $directories) { + $global:LASTEXITCODE = 0 + & $leakScan -Path $directories + if ($LASTEXITCODE -ne 0) { throw "The compiler-host leak scan failed for: $($directories -join ', ')." } + $scanned.AddRange($directories) +} + +function Assert-Contains([string] $directory, [string] $pattern, [string] $what) { + $found = @(Get-ChildItem $directory -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match $pattern }) + if ($found.Count -eq 0) { throw "$what is missing from '$directory' (no file matches '$pattern')." } + Write-Host " found $what ($($found[0].Name))" +} + +function New-NuGetConfig([string] $path, [hashtable[]] $sources) { + $sourceXml = ($sources | ForEach-Object { "`t`t" }) -join "`n" + $mappingXml = ($sources | ForEach-Object { + $patterns = ($_.Patterns | ForEach-Object { "`t`t`t" }) -join "`n" + "`t`t`n$patterns`n`t`t" + }) -join "`n" + + @" + + + + + +$sourceXml + + + + + +$mappingXml + + +"@ | Set-Content $path -Encoding utf8 +} + +function Start-App([string] $executable, [string[]] $arguments, [string] $directory, [int] $port, [string] $logName) { + $env:ASPNETCORE_URLS = "http://127.0.0.1:$port" + try { + $parameters = @{ + FilePath = $executable + WorkingDirectory = $directory + PassThru = $true + RedirectStandardOutput = (Join-Path $logs "$logName.stdout.log") + RedirectStandardError = (Join-Path $logs "$logName.stderr.log") + } + if ($arguments) { $parameters.ArgumentList = $arguments } + return Start-Process @parameters + } + finally { + $env:ASPNETCORE_URLS = $null + } +} + +function Stop-App($process) { + if ($process -and -not $process.HasExited) { $process.Kill($true) } + if ($process) { $process.WaitForExit() } +} + +function Wait-Http($process, [string] $url) { + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while ([DateTime]::UtcNow -lt $deadline) { + if ($process.HasExited) { throw "The app exited with $($process.ExitCode) before $url answered." } + try { return Invoke-WebRequest $url -TimeoutSec 5 -SkipHttpErrorCheck } + catch { Start-Sleep -Milliseconds 500 } + } + throw "$url did not answer within 30 seconds." +} + +function Get-FreePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port } finally { $listener.Stop() } +} + +function Find-Browser { + if ($env:PACKED_CONSUMER_BROWSER) { + if (Test-Path $env:PACKED_CONSUMER_BROWSER) { return $env:PACKED_CONSUMER_BROWSER } + throw "PACKED_CONSUMER_BROWSER is set to '$($env:PACKED_CONSUMER_BROWSER)', which does not exist." + } + + $candidates = @( + "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe" + "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe" + "$env:ProgramFiles\Google\Chrome\Application\chrome.exe" + "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe" + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' + ) + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path $candidate)) { return $candidate } + } + + foreach ($command in 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge') { + $found = Get-Command $command -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($found) { return $found.Source } + } + + return $null +} + +# Loads $url in a headless browser and polls the page's #self-test element over the Chrome DevTools Protocol until it +# reports passed or failed. The page runs its own checks once it is interactive (WebAssembly started, or the server +# circuit connected), so this proves the app actually runs - not only that it published. +function Invoke-BrowserSelfTest([string] $url, [string] $name) { + $profile = Join-Path $workspace "browser-$([guid]::NewGuid().ToString('N').Substring(0, 8))" + New-Item -ItemType Directory -Path $profile -Force | Out-Null + $arguments = @( + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-extensions', + '--remote-debugging-port=0', "--user-data-dir=$profile", 'about:blank' + ) + if ($IsLinux) { $arguments = @('--no-sandbox') + $arguments } + + $process = Start-Process -FilePath $script:browserPath -ArgumentList $arguments -PassThru ` + -RedirectStandardOutput (Join-Path $logs "$name.browser.stdout.log") ` + -RedirectStandardError (Join-Path $logs "$name.browser.stderr.log") + $socket = $null + try { + $portFile = Join-Path $profile 'DevToolsActivePort' + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path $portFile) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 200 } + if (-not (Test-Path $portFile)) { throw "The browser did not open a DevTools port within 30 seconds." } + $port = (Get-Content $portFile -TotalCount 1).Trim() + + $target = Invoke-RestMethod -Method Put "http://127.0.0.1:$port/json/new?$([uri]::EscapeDataString($url))" + $socket = [System.Net.WebSockets.ClientWebSocket]::new() + [void] $socket.ConnectAsync([uri] $target.webSocketDebuggerUrl, [Threading.CancellationToken]::None).GetAwaiter().GetResult() + + $requestId = 0 + $expression = "(() => { const e = document.getElementById('self-test'); " + + "return e ? JSON.stringify({ result: e.dataset.result, report: e.textContent }) : null; })()" + $state = $null + $deadline = [DateTime]::UtcNow.AddSeconds(90) + while ([DateTime]::UtcNow -lt $deadline) { + $requestId++ + $payload = @{ id = $requestId; method = 'Runtime.evaluate'; params = @{ expression = $expression; returnByValue = $true } } | + ConvertTo-Json -Depth 5 -Compress + $bytes = [Text.Encoding]::UTF8.GetBytes($payload) + [void] $socket.SendAsync([ArraySegment[byte]]::new($bytes), 'Text', $true, [Threading.CancellationToken]::None).GetAwaiter().GetResult() + + $response = $null + while (-not $response) { + $buffer = [byte[]]::new(1MB) + $builder = [Text.StringBuilder]::new() + do { + $received = $socket.ReceiveAsync([ArraySegment[byte]]::new($buffer), [Threading.CancellationToken]::None).GetAwaiter().GetResult() + [void] $builder.Append([Text.Encoding]::UTF8.GetString($buffer, 0, $received.Count)) + } while (-not $received.EndOfMessage) + $message = $builder.ToString() | ConvertFrom-Json + if ($message.PSObject.Properties['id'] -and $message.id -eq $requestId) { $response = $message } + } + + $value = $response.PSObject.Properties['result'] ? $response.result.result.PSObject.Properties['value']?.Value : $null + if ($value) { + $state = $value | ConvertFrom-Json + if ($state.result -ne 'running') { break } + } + Start-Sleep -Milliseconds 500 + } + + if (-not $state) { throw "$url never rendered #self-test within 90 seconds: the app did not start in the browser." } + $state.report | Set-Content (Join-Path $logs "$name.self-test.log") + $state.report -split "\r?\n" | Where-Object { $_.Trim() } | ForEach-Object { Write-Host " $_" } + if ($state.result -eq 'running') { throw "$url did not finish its self-test within 90 seconds." } + + foreach ($line in ($state.report -split "\r?\n" | Where-Object { $_ -match '^KNOWN ' })) { + Write-KnownIssue "[$name] $($line.Substring(6).Trim())" + } + + if ($state.result -ne 'passed') { + $failed = ($state.report -split "\r?\n" | Where-Object { $_ -match '^FAIL ' }) -join "`n" + throw "The self-test at $url failed:`n$failed" + } + } + finally { + if ($socket) { $socket.Dispose() } + if (-not $process.HasExited) { $process.Kill($true) } + $process.WaitForExit() + } +} + +$exitCode = 0 +$totalStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + +try { + New-Item -ItemType Directory -Path $feed, $packages, $logs, $consumers, $output -Force | Out-Null + + Write-Host "Packed-consumer gate: Purview.ValueObjects $version ($Configuration), RID $RuntimeIdentifier" -ForegroundColor Cyan + Write-Host "Purview.ZodSharp $zodSharpVersion, Purview.SourceGeneratorFramework $sgfVersion" + Write-Host "Workspace: $workspace" + + # Guards at the workspace root: MSBuild stops at the first Directory.Build.props/.targets and + # Directory.Packages.props it finds walking up, so nothing above the workspace - a stray file in the temp folder, + # a user profile - can change how the consumers build. The consumers must see the packages and nothing else. + foreach ($guard in 'Directory.Build.props', 'Directory.Build.targets') { + @" + + + +"@ | Set-Content (Join-Path $workspace $guard) -Encoding utf8 + } + + @' + + + + false + + +'@ | Set-Content (Join-Path $workspace 'Directory.Packages.props') -Encoding utf8 + + # The repository's SDK, without its MSBuild SDKs (Purview.BuildSdk): the consumers are plain projects. + [ordered]@{ sdk = [ordered]@{ version = $sdk.version; rollForward = $sdk.rollForward; allowPrerelease = $true } } | + ConvertTo-Json | Set-Content (Join-Path $workspace 'global.json') -Encoding utf8 + + # The dependency feed serves the build-time framework and ZodSharp only; everything else comes from nuget.org. + $sgfPatterns = @('Purview.SourceGeneratorFramework', 'Purview.SourceGeneratorFramework.*') + $zodSharpPatterns = @('Purview.ZodSharp', 'Purview.ZodSharp.*') + $dependencySources = $DependencyFeed ? @(@{ Key = 'dependencies'; Value = $DependencyFeed; Patterns = $sgfPatterns + $zodSharpPatterns }) : @() + $nugetOrg = @{ Key = 'nuget.org'; Value = 'https://api.nuget.org/v3/index.json'; Patterns = @('*') + $sgfPatterns + $zodSharpPatterns } + + $packConfig = Join-Path $workspace 'pack.nuget.config' + New-NuGetConfig $packConfig (@($nugetOrg) + $dependencySources) + + # Consumer sources: Purview.ValueObjects from the package just packed and nowhere else - not nuget.org, not a local + # feed holding an older build of the same version. + $consumerConfig = Join-Path $consumers 'nuget.config' + New-NuGetConfig $consumerConfig (@( + @{ Key = 'packed'; Value = $feed; Patterns = @('Purview.ValueObjects') } + $nugetOrg + ) + $dependencySources) + + # An isolated extraction directory: NuGet reuses an already-extracted package of the same id and version, so a + # stale copy from an earlier run would be used instead. + $env:NUGET_PACKAGES = $packages + + Invoke-Step "Preflight: restore Purview.SourceGeneratorFramework $sgfVersion and Purview.ZodSharp $zodSharpVersion" { + $probe = Join-Path $workspace 'dependency-probe' + New-Item -ItemType Directory -Path $probe -Force | Out-Null + @" + + + net10.0 + + + + + + + + +"@ | Set-Content (Join-Path $probe 'probe.csproj') -Encoding utf8 + + & dotnet restore (Join-Path $probe 'probe.csproj') "-p:RestoreConfigFile=$packConfig" 2>&1 | + Tee-Object -FilePath (Join-Path $logs 'dependency-probe.log') | Out-Host + if ($LASTEXITCODE -ne 0) { + $from = ((@($nugetOrg) + $dependencySources) | ForEach-Object Value) -join ', ' + throw ( + "Purview.SourceGeneratorFramework $sgfVersion or Purview.ZodSharp $zodSharpVersion (Directory.Packages.props) " + + "could not be restored from $from. The generator is built against the first and the consumers use the " + + 'second, so nothing can be checked. If a version is not on nuget.org yet, publish it there, or point this ' + + 'gate at a feed that has it: -DependencyFeed , or set VALUEOBJECTS_DEPENDENCY_FEED ' + + '(LOCAL_NUGET_FEED_PATH is used when set).' + ) + } + + # The Blazor consumers use the ASP.NET Core version Purview.ZodSharp.Blazor was built against. + $blazorNuspec = Join-Path $packages "purview.zodsharp.blazor/$($zodSharpVersion.ToLowerInvariant())/purview.zodsharp.blazor.nuspec" + $nuspec = [xml] (Get-Content $blazorNuspec -Raw) + $namespace = [System.Xml.XmlNamespaceManager]::new($nuspec.NameTable) + $namespace.AddNamespace('n', $nuspec.DocumentElement.NamespaceURI) + $script:aspNetCoreVersion = $nuspec.SelectSingleNode( + "//n:dependency[@id='Microsoft.AspNetCore.Components.Web']", $namespace + )?.GetAttribute('version') -replace '^\[|[,\]].*$', '' + if (-not $script:aspNetCoreVersion) { throw "Could not read the ASP.NET Core version from $blazorNuspec." } + Write-Host " ASP.NET Core $($script:aspNetCoreVersion) (from Purview.ZodSharp.Blazor)" + } + + if ($PreflightOnly) { + Write-Host 'The dependencies restore; stopping after the preflight (-PreflightOnly).' -ForegroundColor Green + return + } + + if ($IsLinux -and -not (Get-Command clang -ErrorAction SilentlyContinue)) { + throw 'Native AOT on Linux needs clang and the zlib headers: sudo apt-get install -y clang zlib1g-dev.' + } + + Invoke-Step 'Pack every shipping package to the isolated feed' { + $script:repositoryRestoreTouched = $true + foreach ($package in $shipping) { + Invoke-Dotnet "restore-$($package.Id)" @('restore', $package.Project, "-p:RestoreConfigFile=$packConfig", '-nr:false') + Invoke-Dotnet "pack-$($package.Id)" @( + 'pack', $package.Project, '--configuration', $Configuration, '--output', $feed, '--no-restore', '-m:4', '-nr:false' + ) + } + + $expected = @($shipping.Id | ForEach-Object { "$_.$version.nupkg".ToLowerInvariant() } | Sort-Object) + $actual = @(Get-ChildItem $feed -Filter '*.nupkg' | ForEach-Object { $_.Name.ToLowerInvariant() } | Sort-Object) + $difference = Compare-Object $expected $actual + if ($difference) { throw "The feed does not hold exactly the shipping packages:`n$($difference | Out-String)" } + + # The bytes the consumers restore: a compiler-host assembly anywhere but analyzers/, or a generator or code-fix + # assembly under lib/, would reach every application that references the package. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $hostOnly = '(^|/)(Purview\.SourceGeneratorFramework|Microsoft\.CodeAnalysis)[^/]*$' + $generatorOnly = '^(lib|ref|runtimes)/.*Purview\.ValueObjects\.SourceGenerator[^/]*$' + foreach ($nupkg in Get-ChildItem $feed -Filter '*.nupkg') { + $archive = [System.IO.Compression.ZipFile]::OpenRead($nupkg.FullName) + try { + $bad = @($archive.Entries.FullName | Where-Object { + ($_ -match $hostOnly -and $_ -notmatch '^analyzers/') -or $_ -match $generatorOnly + }) + } + finally { $archive.Dispose() } + + if ($bad.Count -gt 0) { throw "$($nupkg.Name) ships compiler-host content outside analyzers/: $($bad -join ', ')." } + Write-Host " $($nupkg.Name): no compiler-host content outside analyzers/" + } + + if ($PackOutput) { + New-Item -ItemType Directory -Path $PackOutput -Force | Out-Null + Copy-Item (Join-Path $feed '*') $PackOutput -Force + Write-Host " copied the packages to $PackOutput" + } + } + + Copy-Item (Join-Path $consumerTemplates '*') $consumers -Recurse -Force -Exclude 'Shared' + foreach ($blazor in 'BlazorWasm', 'BlazorServer') { + Copy-Item (Join-Path $consumerTemplates 'Shared/*') (Join-Path $consumers $blazor) -Force + } + + $consumerProperties = @( + "-p:ValueObjectsPackageVersion=$version" + "-p:ZodSharpPackageVersion=$zodSharpVersion" + "-p:AspNetCorePackageVersion=$($script:aspNetCoreVersion)" + "-p:RestoreConfigFile=$consumerConfig" + '-nr:false' + ) + + Invoke-Step '(a) Native AOT console: publish and run' { + $project = Join-Path $consumers 'AotConsole/AotConsole.csproj' + $publish = Join-Path $output 'aot-console' + + # A framework-dependent build first: its bin holds the deps.json and runtimeconfig.json that list every + # runtime dependency, which is where an analyzer-only assembly that leaks shows up first. + Invoke-Dotnet 'aot-console-build' (@('build', $project, '--configuration', 'Release', '-warnaserror') + $consumerProperties) + Assert-Contains (Join-Path $consumers 'AotConsole/bin/Release/net11.0') '^PackedValueObjectsAot\.deps\.json$' 'the build deps.json' + + Invoke-Dotnet 'aot-console-publish' (@( + 'publish', $project, '--configuration', 'Release', '--runtime', $RuntimeIdentifier, '--output', $publish, + '-warnaserror' + ) + $consumerProperties) + + & (Join-Path $publish "PackedValueObjectsAot$exe") | Out-Host + if ($LASTEXITCODE -ne 0) { throw "The Native AOT console exited with ${LASTEXITCODE}: a packed behaviour is wrong." } + + Assert-NoLeaks @((Join-Path $consumers 'AotConsole/bin'), $publish) + } + + $script:browserPath = $null + if ($Browser -ne 'Skip') { + $script:browserPath = Find-Browser + if (-not $script:browserPath -and $Browser -eq 'Require') { + throw 'No Chromium-family browser was found (-Browser Require): install Chrome or Edge, or set PACKED_CONSUMER_BROWSER.' + } + } + $browserSkipReason = 'disabled with -Browser Skip.' + if ($Browser -ne 'Skip') { + $browserSkipReason = 'no Chromium-family browser was found, so the published app was NOT run. Install Chrome or ' + + 'Edge, set PACKED_CONSUMER_BROWSER, or pass -Browser Require to make this an error.' + } + if ($script:browserPath) { Write-Host "`nBrowser: $($script:browserPath)" } + + # A static file host for the published WebAssembly apps; it references no package. + $wasmHost = Join-Path $output 'wasm-host' + if ($script:browserPath) { + Invoke-Step 'WebAssembly static file host: publish' { + Invoke-Dotnet 'wasm-host-publish' @( + 'publish', (Join-Path $consumers 'WasmHost/WasmHost.csproj'), '--configuration', 'Release', '--output', $wasmHost, + "-p:RestoreConfigFile=$consumerConfig", '-nr:false' + ) + } + } + + function Assert-WasmOutput([string] $publish) { + $framework = Join-Path $publish 'wwwroot/_framework' + Assert-Contains $framework '^Purview\.ValueObjects\.[a-z0-9]+\.wasm$' 'Purview.ValueObjects' + Assert-Contains $framework '^Purview\.ZodSharp\.[a-z0-9]+\.wasm$' 'Purview.ZodSharp' + Assert-Contains $framework '^Purview\.ZodSharp\.Blazor\.[a-z0-9]+\.wasm$' 'Purview.ZodSharp.Blazor' + Assert-Contains $framework '^PackedValueObjectsWasm\.[a-z0-9]+\.wasm$' 'the app assembly' + } + + function Test-WasmInBrowser([string] $publish, [string] $name) { + if (-not $script:browserPath) { + Write-Host " browser self-test SKIPPED: $browserSkipReason" -ForegroundColor Yellow + return + } + + $port = Get-FreePort + $process = Start-App (Join-Path $wasmHost "PackedWasmHost$exe") @("`"$(Join-Path $publish 'wwwroot')`"") $wasmHost $port $name + try { + $index = Wait-Http $process "http://127.0.0.1:$port/" + if ($index.StatusCode -ne 200) { throw "The WebAssembly host answered $($index.StatusCode) for /." } + Invoke-BrowserSelfTest "http://127.0.0.1:$port/" $name + } + finally { Stop-App $process } + } + + $wasmProject = Join-Path $consumers 'BlazorWasm/BlazorWasm.csproj' + Invoke-Step '(b) Blazor WebAssembly: trimmed publish, run in a browser' { + $publish = Join-Path $output 'wasm-trimmed' + Invoke-Dotnet 'wasm-trimmed-publish' (@( + 'publish', $wasmProject, '--configuration', 'Release', '--output', $publish, '-warnaserror' + ) + $consumerProperties) + + Assert-WasmOutput $publish + Assert-NoLeaks @((Join-Path $consumers 'BlazorWasm/bin'), $publish) + Test-WasmInBrowser $publish 'wasm-trimmed' + } + + $wasmTools = $false + if ($WasmAot -ne 'Skip') { + Push-Location $workspace + try { $wasmTools = [bool] (& dotnet workload list 2>$null | Select-String -Pattern '^\s*wasm-tools\s') } + finally { Pop-Location } + } + + if ($WasmAot -eq 'Skip') { + Invoke-Skipped '(b) Blazor WebAssembly: RunAOTCompilation publish' 'disabled with -WasmAot Skip.' + } + elseif (-not $wasmTools -and $WasmAot -eq 'Auto') { + Invoke-Skipped '(b) Blazor WebAssembly: RunAOTCompilation publish' ( + 'the wasm-tools workload is not installed, so the WebAssembly AOT compile was NOT checked. ' + + 'Install it with `dotnet workload install wasm-tools` and rerun, or pass -WasmAot Require to make this an error.' + ) + } + else { + function Publish-WasmAot([string] $name, [string[]] $extra) { + if (-not $wasmTools) { + throw 'The wasm-tools workload is not installed (-WasmAot Require): run `dotnet workload install wasm-tools`.' + } + + # A copy of the project, so each AOT build starts from clean obj/bin rather than another build's. + $directory = Join-Path $consumers $name + Copy-Item (Join-Path $consumers 'BlazorWasm') $directory -Recurse -Force -Exclude 'bin', 'obj' + $publish = Join-Path $output $name + Invoke-Dotnet "$name-publish" (@( + 'publish', (Join-Path $directory 'BlazorWasm.csproj'), '--configuration', 'Release', + '--output', $publish, '-p:RunAOTCompilation=true', '-warnaserror' + ) + $extra + $consumerProperties) + return [pscustomobject]@{ Directory = $directory; Publish = $publish } + } + + Invoke-Step '(b) Blazor WebAssembly: RunAOTCompilation publish, run in a browser' { + # Without the type-level rule the Mono AOT compiler crashes on; see PlanId in Shared/Models.cs and the + # known-issue probe below. + $aot = Publish-WasmAot 'wasm-aot' @('-p:ExcludeTypeLevelRules=true') + Assert-WasmOutput $aot.Publish + Assert-Contains (Join-Path $aot.Publish 'wwwroot/_framework') '^dotnet\.native\.[a-z0-9]+\.wasm$' 'the AOT-compiled runtime' + Assert-NoLeaks @((Join-Path $aot.Directory 'bin'), $aot.Publish) + Test-WasmInBrowser $aot.Publish 'wasm-aot' + } + + Invoke-Step '(b) Known-issue probe: RunAOTCompilation with a type-level [ZodRule] on a scalar' { + try { + $probe = Publish-WasmAot 'wasm-aot-type-level-rules' @() + Write-Host ' the publish succeeded: the Mono AOT crash no longer reproduces. Remove EXCLUDE_TYPE_LEVEL_RULES from the gate.' -ForegroundColor Green + Assert-NoLeaks @((Join-Path $probe.Directory 'bin'), $probe.Publish) + } + catch { + $log = Join-Path $logs 'wasm-aot-type-level-rules-publish.log' + if (-not (Test-Path $log) -or -not (Select-String -Path $log -Pattern 'Precompiling failed' -Quiet)) { throw } + Write-KnownIssue ( + '[wasm-aot] RunAOTCompilation crashes the Mono AOT compiler (Precompiling failed, exit code -1073741819) ' + + 'on a scalar with a type-level [ZodRule] such as [NonSentinel]: Purview.ZodSharp 3.0.0-prerelease.1 ' + + 'emits ZodRuleErrors.Build(..., (object)rule as IZodRule) for it.' + ) + } + } + } + + Invoke-Step '(c) Blazor Server: publish, start, post the static form, run the interactive page' { + $project = Join-Path $consumers 'BlazorServer/BlazorServer.csproj' + $publish = Join-Path $output 'blazor-server' + Invoke-Dotnet 'blazor-server-publish' (@( + 'publish', $project, '--configuration', 'Release', '--output', $publish, '-warnaserror' + ) + $consumerProperties) + + $port = Get-FreePort + $base = "http://127.0.0.1:$port" + $process = Start-App (Join-Path $publish "PackedValueObjectsServer$exe") @() $publish $port 'blazor-server' + try { + $session = $null + $page = Wait-Http $process "$base/register" + if ($page.StatusCode -ne 200) { throw "/register answered $($page.StatusCode)." } + $session = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + $page = Invoke-WebRequest "$base/register" -WebSession $session + $token = [regex]::Match($page.Content, 'name="__RequestVerificationToken" value="([^"]+)"').Groups[1].Value + if (-not $token) { throw 'The statically rendered form carries no antiforgery token.' } + + function Send-Registration([hashtable] $fields) { + $body = @{ '__RequestVerificationToken' = $token; '_handler' = 'register' } + foreach ($key in $fields.Keys) { $body["Model.$key"] = $fields[$key] } + $response = Invoke-WebRequest "$base/register" -Method Post -Body $body -WebSession $session -SkipHttpErrorCheck + if ($response.StatusCode -ne 200) { throw "Posting the form answered $($response.StatusCode)." } + return [System.Net.WebUtility]::HtmlDecode($response.Content) + } + + $invalid = Send-Registration @{ Name = 'x'; Email = 'not-an-email'; Postcode = 'abc'; Plan = [guid]::NewGuid().ToString() } + $messages = @([regex]::Matches($invalid, '
]*>(.*?)
') | ForEach-Object { $_.Groups[1].Value -replace '<[^>]+>', ' ' }) + $messages | ForEach-Object { Write-Host " invalid post: $_" } + foreach ($expected in @( + @{ Text = "Field 'Name' must contain at least 2 characters."; What = "the form's own rule, through " } + @{ Text = "The value 'not-an-email' is not valid for 'Email'."; What = "EmailAddress rejecting the posted address (its generated TryParse runs Create)" } + @{ Text = "The value 'abc' is not valid for 'Postcode'."; What = 'PostalCode rejecting the posted postcode' } + )) { + if (-not ($messages | Where-Object { $_.Contains($expected.Text) })) { + throw "The invalid post does not show $($expected.What): '$($expected.Text)'." + } + } + if ($invalid.Contains('id="registered"')) { throw 'The invalid post was accepted.' } + if (-not $invalid.Contains('invalid_length')) { throw "The does not show the rule's code (invalid_length)." } + + $valid = Send-Registration @{ Name = 'Ada Lovelace'; Email = ' Ada@Example.COM '; Postcode = '12345'; Plan = [guid]::NewGuid().ToString() } + $registered = [regex]::Match($valid, '

]*>(.*?)

').Groups[1].Value + Write-Host " valid post: $registered" + if ($registered -ne 'Registered Ada Lovelace at 12345.') { + throw "The valid post did not register the normalized value objects (got '$registered')." + } + + $interactive = Invoke-WebRequest "$base/interactive" + if ($interactive.StatusCode -ne 200 -or -not $interactive.Content.Contains('id="zod-email"')) { + throw 'The interactive page did not prerender its form.' + } + Write-Host ' /interactive prerendered its form' + + if ($script:browserPath) { Invoke-BrowserSelfTest "$base/interactive" 'blazor-server' } + else { Write-Host " browser self-test SKIPPED: $browserSkipReason" -ForegroundColor Yellow } + } + finally { Stop-App $process } + + # The server publishes the WebAssembly debugging proxy only when it hosts a WebAssembly client, which this one + # does not, so nothing needs allowing. + Assert-NoLeaks @((Join-Path $consumers 'BlazorServer/bin'), $publish) + } + + Invoke-Step '(d) net8.0 console without ZodSharp: build and run' { + $project = Join-Path $consumers 'Net8Console/Net8Console.csproj' + Invoke-Dotnet 'net8-console-build' (@('build', $project, '--configuration', 'Release', '-warnaserror') + $consumerProperties) + $bin = Join-Path $consumers 'Net8Console/bin/Release/net8.0' + + $net8 = [bool] (& dotnet --list-runtimes 2>$null | Select-String -Pattern '^Microsoft\.NETCore\.App 8\.') + if ($net8) { + & dotnet (Join-Path $bin 'PackedValueObjectsNet8.dll') | Out-Host + if ($LASTEXITCODE -ne 0) { throw "The net8.0 console exited with ${LASTEXITCODE}." } + } + else { + Write-Host ' the .NET 8 runtime is not installed: built for net8.0 but NOT run.' -ForegroundColor Yellow + if ($env:GITHUB_ACTIONS) { Write-Host '::warning::The net8.0 consumer was built but not run: no .NET 8 runtime.' } + } + + Assert-NoLeaks @((Join-Path $consumers 'Net8Console/bin')) + } + + if ($StrictKnownIssues -and $knownIssues.Count -gt 0) { + throw "$($knownIssues.Count) known issue(s) reproduced (-StrictKnownIssues)." + } +} +catch { + $exitCode = 1 + Write-Failure "Packed-consumer gate failed: $($_.Exception.Message)" + if (Test-Path $logs) { Write-Host "Logs: $logs" } +} +finally { + if ($null -eq $previousNuGetPackages) { $env:NUGET_PACKAGES = $null } + else { $env:NUGET_PACKAGES = $previousNuGetPackages } + + # Packing restored the shipping projects against the isolated cache that is about to be deleted; restore them + # against the normal cache, so a later --no-restore build does not point at a missing folder. + if ($repositoryRestoreTouched) { + Write-Host "`nRestoring the shipping projects against the normal package cache..." -ForegroundColor Cyan + foreach ($package in $shipping) { + & dotnet restore $package.Project -nr:false | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Failure "Restoring $($package.Project) against the normal cache failed; run dotnet restore." } + } + } + + $totalStopwatch.Stop() + Write-Host "`nPacked-consumer gate timings:" -ForegroundColor Cyan + $timings | Format-Table -AutoSize | Out-Host + Write-Host ("Total: {0:N1}s" -f $totalStopwatch.Elapsed.TotalSeconds) + if ($scanned.Count -gt 0) { Write-Host "Leak-scanned $($scanned.Count) folder(s)." } + if ($knownIssues.Count -gt 0) { + # One line per distinct issue, naming every consumer it reproduced in. + $grouped = $knownIssues | ForEach-Object { + $match = [regex]::Match($_, '^\[(?[^\]]+)\] (?.*)$') + [pscustomobject]@{ Where = $match.Groups['where'].Value; What = $match.Groups['what'].Value } + } | Group-Object What + Write-Host "`nKnown dependency issues ($($grouped.Count) distinct, $($knownIssues.Count) occurrences):" -ForegroundColor Yellow + foreach ($issue in $grouped) { + $where = ($issue.Group.Where | Sort-Object -Unique) -join ', ' + Write-Host " - [$where] $($issue.Name)" -ForegroundColor Yellow + } + } + + if ($KeepArtifacts) { + Write-Host "Artifacts kept at $workspace" -ForegroundColor Yellow + } + else { + Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if ($exitCode -ne 0) { exit $exitCode } + +Write-Host 'Packed-consumer gate passed.' -ForegroundColor Green From ea75ad07fea9b71808780047b5024972b180a0a8 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:09:05 +0000 Subject: [PATCH 05/23] ci: pin the .NET 11 rc.3 SDK and run the packed-consumer gate on pull requests Purview.ZodSharp 3.0.0-prerelease.1 and its Blazor package are built against the rc.3 ASP.NET Core packages, and the WebAssembly consumers need the matching rc.3 WebAssembly SDK, so global.json and both workflows move from rc.2 to rc.3 together. The new packed-consumers job runs scripts/test-packed-consumers.ps1 on ubuntu-latest with -WasmAot Require and -Browser Require (the image ships Chrome), then proves every pack-validation ForbiddenContent rule. Purview.SourceGeneratorFramework 1.2.2 and Purview.ZodSharp 3.0.0-prerelease.1 are not on nuget.org yet, so until they are - or the VALUEOBJECTS_DEPENDENCY_FEED repository variable names a feed that has them - the job fails at its first step, the dependency preflight, with what to do, instead of skipping. --- .github/workflows/pr.yml | 56 +++++++++++++++++++++++++++++++++-- .github/workflows/release.yml | 2 +- global.json | 2 +- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 28dfb99..5d1ba03 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,7 +15,59 @@ jobs: with: # The shared workflow defaults to the 10.0.x SDK, which cannot build the net11.0 target this # repository gained (NETSDK1045). Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100-rc.2.26504.105" + dotnet-version: "11.0.100-rc.3.26522.148" run-pack: true validate-pack: true - secrets: inherit \ No newline at end of file + secrets: inherit + + # Every other job builds against ProjectReferences; this one builds what ships. It packs Purview.ValueObjects to an + # isolated feed and package cache, then publishes and runs consumers that reference the package only - a Native AOT + # console, a Blazor WebAssembly app (trimmed and RunAOTCompilation, run in headless Chrome), a Blazor Server app + # and a net8.0 console - and leak-scans every bin and publish folder for compiler-host assemblies. It then proves + # every PackValidation ForbiddenContent rule rejects a package. `just smoke-packed-consumers` and + # `just verify-pack-rules` run the same locally. + packed-consumers: + name: Packed consumers (Native AOT, Blazor WebAssembly, Blazor Server) + runs-on: ubuntu-latest + env: + # An extra feed for Purview.SourceGeneratorFramework* and Purview.ZodSharp* only, for versions that are not on + # nuget.org yet. Unset, the gate restores them from nuget.org alone. + VALUEOBJECTS_DEPENDENCY_FEED: ${{ vars.VALUEOBJECTS_DEPENDENCY_FEED }} + steps: + - uses: actions/checkout@v7 + + # The SDK from global.json, plus the .NET 8 runtime the net8.0 consumer runs on. + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + dotnet-version: 8.0.x + + # First, before any toolchain is installed: the generator is built against the Purview.SourceGeneratorFramework + # version Directory.Packages.props pins, and the consumers use the pinned Purview.ZodSharp. Until both are on + # nuget.org (or the VALUEOBJECTS_DEPENDENCY_FEED repository variable names a feed that has them) this fails here, + # with what to do, rather than skipping. + - name: Check the build-time and consumer dependencies restore + shell: pwsh + run: ./scripts/test-packed-consumers.ps1 -PreflightOnly + + # Native AOT links with clang and needs the zlib headers; the hosted image may not carry both. + - name: Install the Native AOT prerequisites + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + # RunAOTCompilation needs the Emscripten toolchain and the Mono AOT cross compiler. -WasmAot Require below makes + # a missing workload an error instead of a skipped step. + - name: Install the wasm-tools workload + run: dotnet workload install wasm-tools + + # The hosted image ships Google Chrome; -Browser Require fails if it ever stops doing so. + - name: Pack, then publish, run and leak-scan consumers of the package + shell: pwsh + run: >- + ./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require -Browser Require + -PackOutput artifacts/packed + + - name: Prove every pack-validation forbidden-content rule rejects a package + shell: pwsh + run: | + dotnet tool restore + ./scripts/test-pack-validation-rules.ps1 -PackagesFolder artifacts/packed \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce008df..62482be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: with: # The shared workflow defaults to the 10.0.x SDK, which cannot build the net11.0 target this # repository gained. Keep in sync with `sdk.version` in global.json (and with pr.yml). - dotnet-version: "11.0.100-rc.2.26504.105" + dotnet-version: "11.0.100-rc.3.26522.148" release-mode: NuGet release-branch: main secrets: inherit \ No newline at end of file diff --git a/global.json b/global.json index c9e8fce..054e74d 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "11.0.100-rc.2.26504.105", + "version": "11.0.100-rc.3.26522.148", "rollForward": "latestMajor", "allowPrerelease": true }, From 4501baa864e55e3c9898cfbda2a205eae1577d4d Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:09:19 +0000 Subject: [PATCH 06/23] docs: document trimming, Native AOT and Blazor, and the ZodSharp 3 upgrade A new guide, docs/Trimming-AOT-and-Blazor.md, covers the target frameworks (the runtime keeps net8.0-net11.0; ZodSharp 3 needs net10.0+), the generated JSON converters under trimming, registering generated converters on a source-generated JsonSerializerContext declared in the same project, binding a value object in a Blazor form, validating it with Purview.ZodSharp.Blazor, and the Purview.ZodSharp 3.0.0-prerelease.1 gaps the packed-consumer gate found. The README, the package README, the ZodSharp guide, both affected skills and AGENTS.md point at it, and AGENTS.md names `just smoke-packed-consumers` as the proof that the generated path is trim- and AOT-clean. --- AGENTS.md | 16 +- README.md | 18 ++- docs/Trimming-AOT-and-Blazor.md | 152 ++++++++++++++++++ docs/ZodSharp-Validation.md | 9 ++ docs/index.md | 1 + .../purview-value-objects-zodsharp/SKILL.md | 13 ++ skills/purview-value-objects/SKILL.md | 8 + src/src/ValueObjects/Sdk/README.md | 7 +- 8 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 docs/Trimming-AOT-and-Blazor.md diff --git a/AGENTS.md b/AGENTS.md index 5d43896..915e2b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,8 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar - `ScalarJsonConverterFactory` uses only reflection over `ScalarAttribute` + `Create`/`Hydrate`/constructor; keep it free of event-sourcing dependencies. - Keep the runtime package dependency-free (System.Text.Json is in-box for the supported TFMs). +- Purview.ZodSharp 3 ships `net10.0`/`net11.0` only. A project that references it selects + `PurviewTargetFrameworkSet=Supported`; the runtime package does not reference it and keeps `All`. ## Trimming and Native AOT (non-negotiable) @@ -92,7 +94,12 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar suppressions accurate. Do not add reflection anywhere else in the runtime. - Never weaken or delete an AOT/trim annotation or suppression to silence a warning; fix the call site or move the reflection into the annotated factory. -- A feature is not complete until the generated path is confirmed clean under the trim/AOT analyzers. +- A feature is not complete until the generated path is confirmed clean under the trim/AOT analyzers. The in-repo + projects never trim-analyze generated code, so the proof is the packed-consumer gate (`just smoke-packed-consumers`, + `scripts/test-packed-consumers.ps1`): it publishes a Native AOT console and a Blazor WebAssembly app (trimmed and + `RunAOTCompilation`) from the packed package with `-warnaserror`, runs them, and leak-scans every output. +- Generated JSON converters read and write through `ValueObjectJson` (runtime), never through the + `JsonSerializer` overloads that take only `JsonSerializerOptions`. ## Testing conventions @@ -128,8 +135,15 @@ just test just lint-check just pack just pipeline-pr +just smoke-packed-consumers +just verify-pack-rules ``` +`just smoke-packed-consumers` is the packed-consumer gate (Native AOT, Blazor WebAssembly trimmed and AOT in a +headless browser, Blazor Server, net8.0, compiler-host leak scan). It needs the Native AOT toolchain, the +`wasm-tools` workload and Chrome or Edge; dependency versions not yet on nuget.org restore from +`LOCAL_NUGET_FEED_PATH`. Run it when generated code, the runtime, packaging or a dependency version changes. + The repository uses the shared `Purview.Build` pipeline (`purview-build.json` at the root). `just pipeline-pr` runs `dotnet tool restore`, which installs the pinned `Purview.Build` from `.config/dotnet-tools.json`. diff --git a/README.md b/README.md index 7154ff4..f285299 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,10 @@ options.Converters.Add(new ScalarJsonConverterFactory()); ``` The generator also emits a `[JsonConverter]` per value object, so scalar/complex value objects serialize correctly -even when the factory is not registered. +even when the factory is not registered. The generated converters are trim- and Native AOT-safe; the factory uses +reflection and is annotated `[RequiresUnreferencedCode]`, so a trimmed or AOT application should rely on the +generated converters instead (see [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md), which also +covers source-generated `JsonSerializerContext`s). Use the same options for Entity Framework JSON columns: @@ -168,6 +171,17 @@ var result = EmailAddressSchema.Validate(EmailAddress.Create("demo@example.com") See [ZodSharp Validation](docs/ZodSharp-Validation.md), the `src/src/ZodSharpSample` project, and the `src/src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details for strict deserialization failures). +Purview.ZodSharp 3 targets `net10.0`/`net11.0` (this package still ships `net8.0`-`net11.0` and does not depend on +it); `Purview.ZodSharp.Blazor` validates Blazor forms over value objects - see +[Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md#blazor). + +## Trimming, Native AOT and Blazor + +Generated code uses no reflection, so trimmed, Native AOT and Blazor WebAssembly applications get no IL2xxx/IL3xxx +warning from it. `just smoke-packed-consumers` proves it against the packed package: a Native AOT console, a Blazor +WebAssembly app (trimmed and `RunAOTCompilation`, run in a headless browser), a Blazor Server app and a `net8.0` +console, all built with warnings as errors and scanned for compiler-host assemblies. See +[Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md). ## How it works @@ -222,4 +236,6 @@ Set `DisableValueObjectsSourceGenerator` to `true` in your project: is the merged, self-contained artifact the `Purview.SourceGeneratorFramework` merge pass produces, and that pass strips every `InternalsVisibleTo` declaration. - `src/tests` – unit and source-generator tests. +- `scripts` – the packed-consumer gate (`test-packed-consumers.ps1` and the consumers in `scripts/packed-consumers`), + the compiler-host leak scan and the pack-validation rule prover. - `docs` – design and usage guidance. diff --git a/docs/Trimming-AOT-and-Blazor.md b/docs/Trimming-AOT-and-Blazor.md new file mode 100644 index 0000000..dec91aa --- /dev/null +++ b/docs/Trimming-AOT-and-Blazor.md @@ -0,0 +1,152 @@ +# Trimming, Native AOT and Blazor + +The code the generator writes into your project - the factories, equality, comparison, parsing and formatting +members, the JSON converters and the ZodSharp integration - is trim- and Native AOT-safe. It uses no reflection, +so a trimmed or AOT-compiled application, a Blazor WebAssembly client among them, gets no IL2xxx/IL3xxx warning +from it. + +That is proven against the packed package, not the repository's own projects: `just smoke-packed-consumers` +packs `Purview.ValueObjects`, then publishes and runs a Native AOT console, a Blazor WebAssembly app (trimmed, and +with `RunAOTCompilation=true`, loaded in a headless browser), a Blazor Server app and a `net8.0` console against +it, every one with warnings as errors, and scans every output for compiler-host assemblies. + +## Target frameworks + +| Package | Target frameworks | +| --- | --- | +| `Purview.ValueObjects` | `net8.0`, `net9.0`, `net10.0`, `net11.0` | +| `Purview.ZodSharp` 3.x (optional) | `net10.0`, `net11.0` | +| `Purview.ZodSharp.Blazor` 3.x (optional) | `net11.0` | + +`Purview.ValueObjects` has no dependency on ZodSharp. A project that adds the ZodSharp integration needs the +target frameworks ZodSharp supports; a project that does not keeps the full range. + +## The one reflection-based component + +`ScalarJsonConverterFactory` builds converters with reflection and expression trees, and is annotated +`[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`, so registering it in a trimmed or AOT application warns at +your call site. You do not need it for a generated value object: each one carries `[JsonConverter]` pointing at +its generated converter. + +## System.Text.Json source generation + +The generated converters read and write through the `JsonSerializerOptions` they are called with, so naming +policies, number handling and your own converters apply as usual. With a source-generated `JsonSerializerContext` +there is one thing to know. + +**A context in the project that declares the value objects cannot see their `[JsonConverter]` attribute.** The +value object generator adds it, and one source generator never sees another's output, so the context describes +each value object as a plain object with a `Value` property. Register the generated converters on the options: + +```csharp +[JsonSerializable(typeof(Order))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public sealed partial class AppJsonContext : JsonSerializerContext; + +JsonSerializerOptions options = new(AppJsonContext.Default.Options) +{ + Converters = + { + new EmailAddress.EmailAddressJsonConverter(), + new Money.MoneyJsonConverter(), + }, +}; + +var json = JsonSerializer.Serialize(order, (JsonTypeInfo)options.GetTypeInfo(typeof(Order))); +``` + +A value object declared in a **referenced library** needs none of this: the attribute is compiled into the library, +and the context in your application sees it. + +Inside a generated converter: + +- a member of a complex value object that is itself a value object is written with that value object's generated + converter, so `Money.Currency` (a `CurrencyCode`) does not have to be registered or listed; +- an underlying or member value of a primitive type (`string`, `Guid`, the numeric types, `bool`, the date and time + types, `Uri`, `Version`, and their nullable forms) falls back to the built-in System.Text.Json converter when the + context never listed it; +- anything else - an enum, or a custom underlying type - needs metadata, so add `[JsonSerializable(typeof(T))]` to + the context. The `NotSupportedException` thrown otherwise names the type. + +## Blazor + +### Binding a value object in a form + +The built-in inputs bind primitives. A small input over a string-backed scalar binds the value object itself, and +uses `Hydrate` - which never validates - so the form's validator reports what is wrong with the value, through the +value object's own rules, instead of the input rejecting it before any rule runs: + +```csharp +public sealed class InputScalar : InputBase + where TScalar : struct, IScalarValueObject +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "input"); + builder.AddMultipleAttributes(1, AdditionalAttributes); + if (!string.IsNullOrEmpty(NameAttributeValue)) + builder.AddAttribute(2, "name", NameAttributeValue); + builder.AddAttribute(3, "class", CssClass); + builder.AddAttribute(4, "value", CurrentValueAsString); + builder.AddAttribute(5, "onchange", + EventCallback.Factory.CreateBinder(this, v => CurrentValueAsString = v, CurrentValueAsString)); + builder.CloseElement(); + } + + protected override string? FormatValueAsString(TScalar value) => value.Value; + + protected override bool TryParseValueFromString( + string? value, [MaybeNullWhen(false)] out TScalar result, [NotNullWhen(false)] out string? validationErrorMessage) + { + result = TScalar.Hydrate(value ?? string.Empty); + validationErrorMessage = null; + return true; + } +} +``` + +```razor + +``` + +In a **statically rendered** form, a posted value binds through the value object's generated `IParsable` +implementation, whose `TryParse` runs `TryCreate`: an invalid value is reported as a form mapping error on its +field ("The value 'abc' is not valid for 'Postcode'."), and a valid one arrives normalized. + +### Validating with ZodSharp + +Mark the form model and its value objects `[ZodSchema]` and register the generated schemas and ZodSharp for Blazor +(`Purview.ZodSharp.Blazor`, `net11.0`) in the client and the server: + +```csharp +builder.Services.AddZodSharpMyAppSchemas(); // generated: AddZodSharp{Assembly}Schemas() +builder.Services.AddZodSharpBlazor(); +``` + +```razor + + + + + + +``` + +Each value object member is validated by the value object's own generated schema, so the rules live with the value +object rather than being repeated on every form. + +### Known gaps in Purview.ZodSharp 3.0.0-prerelease.1 + +The packed-consumer gate records these as known issues; they are in ZodSharp, not in this package. + +- **An error inside a value object member is not placed on the member's field.** The error is located at + `Email.Value`, and a struct cannot be a `FieldIdentifier` model, so `` + shows nothing. With `` in Blazor WebAssembly the message reaches ``. +- **With ``, and with `` on Blazor Server, that message is dropped.** The + form is still invalid - it cannot be submitted - but no field and no summary says why. Prefer `` in + WebAssembly, or validate the value objects before binding them, until this is fixed. +- **Blazor WebAssembly AOT (`RunAOTCompilation=true`) fails** - the Mono AOT compiler crashes - for a scalar with a + type-level `[ZodRule]` such as `[NonSentinel]`. Trimmed WebAssembly, Native AOT and Blazor Server are unaffected. +- A `[Required]` `Nullable` member whose type is a string-backed scalar does not compile (`CS9342`): the + generated `value.Member == null` is ambiguous with the scalar's `==(string?, T)` operator. Use a non-nullable + member and put `[Required]` on the value object's own `Value`. diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md index a62c3db..1e0e807 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -22,6 +22,15 @@ against the underlying value. ```text dotnet add package Purview.ZodSharp ``` +Purview.ZodSharp 3 targets `net10.0` and `net11.0` only, so a project that uses this integration must target +`net10.0` or later; `Purview.ValueObjects` itself still ships `net8.0` to `net11.0` and does not depend on ZodSharp. +The value object generator's output is the same for ZodSharp 2.x and 3.x. Two 3.x behaviour changes show up first in +value object code: built-in rules report a structured `Origin` (the Zod type name of the underlying value, so +`"string"` for a `Guid`) and `Category`, and generated schemas gain `ValidateAsync`, so CA1849 flags a synchronous +`{Type}Schema.Validate` inside an async method. ZodSharp's own changelog lists the rest (paths, codes, messages). + +For Blazor forms over value objects (`Purview.ZodSharp.Blazor`) and for trimmed and AOT applications, see +[Trimming, Native AOT and Blazor](Trimming-AOT-and-Blazor.md). ## 1. Generated validators on value objects diff --git a/docs/index.md b/docs/index.md index e0b7f98..23e5e04 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,6 +9,7 @@ Strongly typed value objects with Entity Framework and ZodSharp validation integ - [Value object design](Value-Object-Design.md) - [Entity Framework integration](Entity-Framework.md) - [ZodSharp validation](ZodSharp-Validation.md) +- [Trimming, Native AOT and Blazor](Trimming-AOT-and-Blazor.md) ## Reference diff --git a/skills/purview-value-objects-zodsharp/SKILL.md b/skills/purview-value-objects-zodsharp/SKILL.md index ad82684..f5d19b2 100644 --- a/skills/purview-value-objects-zodsharp/SKILL.md +++ b/skills/purview-value-objects-zodsharp/SKILL.md @@ -23,6 +23,19 @@ ZodSharp source generator is active in any project that references the package. ```text dotnet add package Purview.ZodSharp ``` +**Purview.ZodSharp 3** targets `net10.0`/`net11.0` only, so a project using the integration needs +`net10.0`+ (Purview.ValueObjects itself still ships `net8.0`-`net11.0`). The generated integration works +with 2.x and 3.x. In 3.x built-in rules report `Origin`/`Category`, paths render as `Items[0].Name` +(`FullPath.ToDottedString()`), and generated schemas gain `ValidateAsync` (CA1849 flags a synchronous +`Validate` in async code). + +**Blazor** (`Purview.ZodSharp.Blazor`, `net11.0`): register `AddZodSharp{Assembly}Schemas()` and +`AddZodSharpBlazor()`, bind a scalar with an `InputBase` that uses `Hydrate`, and validate with +``. Known 3.0.0-prerelease.1 gaps: an error inside a value object member (`Email.Value`) +is not placed on the member's field (WebAssembly `` shows it in the summary only; +`` and Blazor Server drop the message, though the form stays invalid), and +WebAssembly AOT crashes for a scalar with a type-level `[ZodRule]`. Repository guide: +`docs/Trimming-AOT-and-Blazor.md`. Four ways to use it, in rough order of how often you want them: diff --git a/skills/purview-value-objects/SKILL.md b/skills/purview-value-objects/SKILL.md index 9636d32..a42b1fa 100644 --- a/skills/purview-value-objects/SKILL.md +++ b/skills/purview-value-objects/SKILL.md @@ -205,6 +205,14 @@ Both attributes default to `ValueObjectDeserializationMode.Hydrate`, so a JSON r re-validate. Choose `Strict` only when round-trip fidelity genuinely requires re-running validation on read — and then make sure a `Create` overload exists to re-validate through, or you get `VO1007`. +The generated `[JsonConverter]`s use no reflection, so trimmed, Native AOT and Blazor WebAssembly apps +get no IL2026/IL3050 from them (`ScalarJsonConverterFactory` is the reflection-based exception). A +source-generated `JsonSerializerContext` **in the project that declares the value objects** cannot see the +generated `[JsonConverter]` attribute (one generator never sees another's output) and describes each value +object as an object with a `Value` property: register the generated converters on its options +(`new JsonSerializerOptions(AppJsonContext.Default.Options) { Converters = { new EmailAddress.EmailAddressJsonConverter() } }`). +A value object from a referenced library needs nothing. Repository guide: `docs/Trimming-AOT-and-Blazor.md`. + ## Assembly-level defaults Repeat nothing per type that the assembly can say once: diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index ae68742..3e7d85e 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -11,7 +11,8 @@ incremental source generator produces: and format overloads), and implicit conversions (with explicit extraction for nullable scalars) - The underlying value's standard interfaces — `IEquatable`, `ISpanFormattable`, `IUtf8SpanFormattable`, `IParsable`, `ISpanParsable`, and `IUtf8SpanParsable` — mirrored per type -- JSON converters (scalar value objects serialize as their underlying value) +- JSON converters (scalar value objects serialize as their underlying value), with no reflection: generated code is + trim- and Native AOT-safe, including in Blazor WebAssembly - Contextual creation via `IContextualValueObject<,>` + `ValueObjectContext` **Use cases** @@ -21,6 +22,10 @@ incremental source generator produces: (value converters, comparers, complex-type mapping) plus a `ConfigureValueObjects` extension for automatic mapping. Queries use the value object type directly — no `.Value` required. - **Domain models** – the F#-style single-case union pattern in C#. +- **Blazor and Native AOT** – trimmed, AOT-compiled and Blazor WebAssembly apps; validate forms over value objects + with Purview.ZodSharp 3 (`net10.0`+). See the + [Trimming, Native AOT and Blazor](https://github.com/purview-dev/value-objects/blob/main/docs/Trimming-AOT-and-Blazor.md) + guide. ## Install From 0f6b7ae0e95c1fdcb79ebb08b4e7d862043f887e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:09:19 +0000 Subject: [PATCH 07/23] chore(release): 1.3.0-prerelease.1 A minor prerelease: no target framework is dropped, and the public API only grows (ValueObjectJson). The generated complex converter no longer emits its public {Name}JsonModel nested type, which is generated into consumer code rather than shipped in the package. The changelog's former "Unreleased" section shipped across 1.2.0-1.2.3, so it is now headed that way. --- CHANGELOG.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f620ac3..53fc1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,68 @@ Released versions correspond to `v` GitHub releases. `package.json` is newest heading below is the version in development. Earlier releases are listed under [releases](https://github.com/purview-dev/value-objects/releases). -## Unreleased +## 1.3.0-prerelease.1 + +A minor prerelease: no target framework is dropped and the public API only grows. `Purview.ValueObjects` still +ships `net8.0`, `net9.0`, `net10.0` and `net11.0`, and has no dependency on ZodSharp. + +### Fixed + +- **The generated JSON converters are trim- and Native AOT-safe.** The scalar converter read and wrote its + underlying value through `JsonSerializer.Deserialize(ref reader, options)` and + `JsonSerializer.Serialize(writer, value, options)`, and the complex converter round-tripped a generated + `{Name}JsonModel` class the same way. Those overloads are `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`, + so every trimmed or AOT application - Blazor WebAssembly included - got IL2026/IL3050 from generated code, and the + model class needed metadata no source-generated context could provide. The converters now go through the new + `ValueObjectJson` runtime helper, which uses the options' metadata when they have it (so converters, naming + policies and number handling behave as before), the generated converter of a value-object-typed member, and the + built-in converter for a primitive a source-generated context never listed. The complex converter reads and + writes its members directly, honouring `PropertyNamingPolicy`, `PropertyNameCaseInsensitive` and + `DefaultIgnoreCondition`, skipping unknown members and defaulting missing ones, as before. + +### Added + +- `ValueObjectJson` (`Purview.ValueObjects.Serialization`, hidden from IntelliSense): the reflection-free JSON + helpers the generated converters call. +- **A packed-consumer gate** (`just smoke-packed-consumers`, `scripts/test-packed-consumers.ps1`, PR job + `packed-consumers`). It packs the package to an isolated feed and cache, then publishes and runs, from the package + alone: a Native AOT console; a Blazor WebAssembly app with a `` form and a + `` form over a model of value objects, published trimmed and with + `RunAOTCompilation=true` and run in a headless browser; a Blazor Server app whose static form is posted and whose + interactive page runs over a live circuit; and a `net8.0` console. Every publish uses `-warnaserror`, and every + output is scanned for compiler-host assemblies (`scripts/assert-no-compiler-host-assemblies.ps1`). +- **Pack-validation `ForbiddenContent` rules** keep Purview.SourceGeneratorFramework, Roslyn and the generator out + of every folder an application loads from; `just verify-pack-rules` proves each rule rejects a package. +- [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md): target frameworks, source-generated JSON, + binding a value object in a Blazor form, and validating it with ZodSharp. + +### Changed + +- **The generated complex converter no longer emits a public `{Name}JsonModel` nested type.** It was an + implementation detail of the converter; code that referenced it should serialize the value object itself. +- Built and tested against Purview.SourceGeneratorFramework 1.2.2, Purview.ZodSharp 3.0.0-prerelease.1, + Purview.BuildSdk 1.2.0 and Purview.Skills.Testing 1.2.0, on the .NET 11 rc.3 SDK. The ZodSharp samples target + `net10.0`/`net11.0`. + +### Upgrade notes + +- **Upgrading to Purview.ZodSharp 3.** It ships `net10.0` and `net11.0` only, so a project that uses the ZodSharp + integration must target `net10.0` or later; the value object generator's output is unchanged and works with 2.x + and 3.x. Read ZodSharp's own upgrade notes for the behaviour changes - the ones a value object user meets first: + built-in rules now report `Origin` (`"string"` for a `Guid`) and `Category`; collection paths render as `"0"` and + `Items[0].Name` (`FullPath.ToDottedString()`); `MergeParametersIntoExtensions` defaults to `false`; generated + schemas gain `ValidateAsync`, so CA1849 flags a synchronous `Validate` inside an async method. +- **Source-generated JSON contexts.** A `JsonSerializerContext` in the project that declares the value objects + cannot see their generated `[JsonConverter]` attribute, so register the generated converters on the options (see + [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md#systemtextjson-source-generation)). +- **Known gaps in Purview.ZodSharp 3.0.0-prerelease.1 with value objects in Blazor**, found by the new gate and + listed in that guide: errors inside a value object member are not placed on its field (and are dropped by + `` and by `` on Blazor Server, though the form stays invalid), and + Blazor WebAssembly AOT fails for a scalar with a type-level `[ZodRule]`. + +## 1.2.0 - 1.2.3 + +These changes shipped across the 1.2.x releases while they were still recorded under *Unreleased*. ### Added diff --git a/package.json b/package.json index 46b4166..33a658b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "value-objects", - "version": "1.2.3", + "version": "1.3.0-prerelease.1", "license": "MIT", "author": { "name": "Kieron Lanning", From 97cf48d96342a4bbadfbed73707ce16053fd255c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:21:30 +0000 Subject: [PATCH 08/23] test(packed): validate the type-level rule under wasm aot and assert field placement Purview.ZodSharp 3.0.0-prerelease.1 now compiles a type-level [ZodRule] under the Mono AOT compiler and places an error inside a value object on the owning member's field, so the gate stops accommodating either. The WebAssembly AOT publish builds the same model as every other publish, PlanId's [NonSentinel] included; the EXCLUDE_TYPE_LEVEL_RULES define, the ExcludeTypeLevelRules property and the separate crash probe are gone. The self-test's KNOWN lines are ordinary checks: each value object's message sits on its own field and names it ("Field 'Email' must be a valid email address."), the type-level rule's message sits on Plan, and no message names Value. The static Blazor Server post asserts the same placement. The known-issue reporting and -StrictKnownIssues are removed, so every check fails the gate. --- .../BlazorWasm/BlazorWasm.csproj | 5 -- scripts/packed-consumers/Shared/Models.cs | 20 +---- .../Shared/RegistrationSelfTest.razor | 62 +++++++------- scripts/test-packed-consumers.ps1 | 81 ++++--------------- 4 files changed, 45 insertions(+), 123 deletions(-) diff --git a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj index 034d543..f61eb2d 100644 --- a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj +++ b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj @@ -24,11 +24,6 @@ true - - - $(DefineConstants);EXCLUDE_TYPE_LEVEL_RULES - - diff --git a/scripts/packed-consumers/Shared/Models.cs b/scripts/packed-consumers/Shared/Models.cs index a0ea950..9c1a2a7 100644 --- a/scripts/packed-consumers/Shared/Models.cs +++ b/scripts/packed-consumers/Shared/Models.cs @@ -3,9 +3,7 @@ using System.ComponentModel.DataAnnotations; using Purview.ValueObjects.Serialization; using ZodSharp; -#if !EXCLUDE_TYPE_LEVEL_RULES using ZodSharp.Rules; -#endif namespace PackedValueObjectsBlazor; @@ -33,16 +31,12 @@ public readonly partial record struct PostalCode } /// -/// A Guid scalar rejected by the built-in, type-level [NonSentinel] rule. The WebAssembly AOT publish -/// builds without the rule (EXCLUDE_TYPE_LEVEL_RULES): Purview.ZodSharp 3.0.0-prerelease.1 validates a type-level -/// rule through ZodRuleErrors.Build(..., (object)rule as IZodRule), which crashes the Mono AOT compiler -/// (0xC0000005). The gate probes that publish separately and reports it as a known issue. +/// A Guid scalar rejected by the built-in, type-level [NonSentinel] rule. Every publish carries it, the +/// WebAssembly AOT one included, so the gate proves the Mono AOT compiler compiles a type-level rule. /// [Scalar] [ZodSchema] -#if !EXCLUDE_TYPE_LEVEL_RULES [NonSentinel(Message = "Choose a plan.")] -#endif public readonly partial record struct PlanId { public Guid Value { get; } @@ -65,13 +59,3 @@ public sealed class Registration public PlanId Plan { get; set; } } - -/// Whether this build carries the type-level rule (see ). -public static class TypeLevelRules -{ -#if EXCLUDE_TYPE_LEVEL_RULES - public static bool Enabled => false; -#else - public static bool Enabled => true; -#endif -} diff --git a/scripts/packed-consumers/Shared/RegistrationSelfTest.razor b/scripts/packed-consumers/Shared/RegistrationSelfTest.razor index 6b56cc5..7f66b21 100644 --- a/scripts/packed-consumers/Shared/RegistrationSelfTest.razor +++ b/scripts/packed-consumers/Shared/RegistrationSelfTest.razor @@ -96,18 +96,9 @@ failures++; } - // A gap in a dependency, not in Purview.ValueObjects: reported as KNOWN and surfaced by the gate as a warning - // rather than a failure. Where the gap does not occur the line is an ordinary pass. - void Known(bool condition, string description, string issue) => - report.AppendLine(condition ? $"pass {name}: {description}" : $"KNOWN {name}: {description} - {issue}"); - - const string DroppedMessages = - "Purview.ZodSharp 3.0.0-prerelease.1 fails the form but drops the message of an error inside a struct member " - + "(Email.Value): no field and no summary shows it. keeps it on the model only when its " - + "reflection fallback is trimmed away (WebAssembly)"; - const string FieldPlacement = - "Purview.ZodSharp 3.0.0-prerelease.1 cannot place an error inside a struct member (Email.Value) on a " - + "FieldIdentifier of the struct, and does not fall back to the owning member (Email)"; + const string EmailMessage = "Field 'Email' must be a valid email address."; + const string PostcodeMessage = "Field 'Postcode' must match the regular expression '^[0-9]{5}$'."; + const string PlanMessage = "Choose a plan."; string[] Messages(string field) => [.. context.GetValidationMessages(new FieldIdentifier(model, field))]; @@ -130,31 +121,27 @@ Check(!invalid, "an invalid registration fails validation"); Check(Messages("Name").Length == 1, "the form's own rule is placed on Name"); - Known( - all.Any(static m => m.Contains("email", StringComparison.OrdinalIgnoreCase)), - "EmailAddress's own schema reports a visible message", - DroppedMessages + + // An error inside a value object (Email.Value) is placed on the owning member's field, and a scalar's + // messages name that member rather than its own Value. + Check( + Messages("Email").SequenceEqual([EmailMessage]), + $"EmailAddress's own rule is placed on Email and reads \"{EmailMessage}\"" ); - Known( - all.Any(static m => m.Contains("^[0-9]{5}$", StringComparison.Ordinal)), - "PostalCode's own schema reports a visible message", - DroppedMessages + Check( + Messages("Postcode").SequenceEqual([PostcodeMessage]), + $"PostalCode's own rule is placed on Postcode and reads \"{PostcodeMessage}\"" ); - if (TypeLevelRules.Enabled) - { - // A type-level rule's error is located at the member itself (Plan), the one value object error a - // FieldIdentifier of the model can hold. - Known( - Messages("Plan").Contains("Choose a plan."), - "PlanId's type-level [NonSentinel] rule is placed on Plan", - DroppedMessages - ); - } - Known( - Messages("Email").Length > 0 && Messages("Postcode").Length > 0, - "a value object's own errors are placed on the member's field", - FieldPlacement + // A type-level rule's error is located at the member itself (Plan) and keeps the rule's own message. + Check( + Messages("Plan").SequenceEqual([PlanMessage]), + $"PlanId's type-level [NonSentinel] rule is placed on Plan and reads \"{PlanMessage}\"" + ); + Check(all.Length == 4, "every error has exactly one visible message (Name, Email, Postcode, Plan)"); + Check( + !all.Any(static m => m.Contains("'Value'", StringComparison.Ordinal)), + "no message names a value object's own Value member" ); // Only the value objects are invalid: the form must still be rejected, or invalid data is submitted. @@ -166,7 +153,12 @@ $"{name}: only value objects invalid -> valid={onlyValueObjectsInvalid}, {valueObjectMessages.Length} message(s)" ); Check(!onlyValueObjectsInvalid, "a form whose only errors are inside value objects is rejected"); - Known(valueObjectMessages.Length == 2, "and both of those errors have a visible message", DroppedMessages); + Check( + valueObjectMessages.Length == 2 + && Messages("Email").SequenceEqual([EmailMessage]) + && Messages("Postcode").SequenceEqual([PostcodeMessage]), + "and both of those errors are shown on their own fields" + ); model.Email = EmailAddress.Create(" Ada@Example.COM "); model.Postcode = PostalCode.Create("12345"); diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 index 720fd83..de39a25 100644 --- a/scripts/test-packed-consumers.ps1 +++ b/scripts/test-packed-consumers.ps1 @@ -36,8 +36,7 @@ package still serves every target framework it ships. 4. Scans every consumer bin and publish folder with scripts/assert-no-compiler-host-assemblies.ps1. - Gaps in a dependency (Purview.ZodSharp prerelease behaviour the consumers rely on) are reported as KNOWN ISSUES, - not failures, so they stay visible without blocking this repository; -StrictKnownIssues makes them failures. + Every check is a hard failure, including the ones that exercise Purview.ZodSharp behaviour the consumers rely on. The temporary workspace is deleted afterwards unless -KeepArtifacts is passed. Packing restores the shipping projects against the isolated cache, so they are restored again against the normal cache at the end. @@ -69,9 +68,6 @@ .PARAMETER PreflightOnly Run only the dependency restore check, so CI can fail on it before installing toolchains. -.PARAMETER StrictKnownIssues - Fail the gate when a known dependency issue reproduces. - .PARAMETER KeepArtifacts Leave the temporary workspace in place for inspection. @@ -92,7 +88,6 @@ param ( [string] $Browser = 'Auto', [string] $PackOutput, [switch] $PreflightOnly, - [switch] $StrictKnownIssues, [switch] $KeepArtifacts ) @@ -137,7 +132,6 @@ $exe = $IsWindows ? '.exe' : '' $timings = [System.Collections.Generic.List[object]]::new() $scanned = [System.Collections.Generic.List[string]]::new() -$knownIssues = [System.Collections.Generic.List[string]]::new() $previousNuGetPackages = $env:NUGET_PACKAGES $repositoryRestoreTouched = $false @@ -145,11 +139,6 @@ function Write-Failure([string] $message) { if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } } -function Write-KnownIssue([string] $message) { - $knownIssues.Add($message) - if ($env:GITHUB_ACTIONS) { Write-Host "::warning::Known issue: $message" } else { Write-Host "KNOWN ISSUE: $message" -ForegroundColor Yellow } -} - function Invoke-Step([string] $name, [scriptblock] $body) { if ($env:GITHUB_ACTIONS) { Write-Host "::group::$name" } else { Write-Host "`n=== $name" -ForegroundColor Cyan } $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() @@ -355,10 +344,6 @@ function Invoke-BrowserSelfTest([string] $url, [string] $name) { $state.report -split "\r?\n" | Where-Object { $_.Trim() } | ForEach-Object { Write-Host " $_" } if ($state.result -eq 'running') { throw "$url did not finish its self-test within 90 seconds." } - foreach ($line in ($state.report -split "\r?\n" | Where-Object { $_ -match '^KNOWN ' })) { - Write-KnownIssue "[$name] $($line.Substring(6).Trim())" - } - if ($state.result -ne 'passed') { $failed = ($state.report -split "\r?\n" | Where-Object { $_ -match '^FAIL ' }) -join "`n" throw "The self-test at $url failed:`n$failed" @@ -627,47 +612,25 @@ try { ) } else { - function Publish-WasmAot([string] $name, [string[]] $extra) { + Invoke-Step '(b) Blazor WebAssembly: RunAOTCompilation publish, run in a browser' { if (-not $wasmTools) { throw 'The wasm-tools workload is not installed (-WasmAot Require): run `dotnet workload install wasm-tools`.' } - # A copy of the project, so each AOT build starts from clean obj/bin rather than another build's. - $directory = Join-Path $consumers $name + # A copy of the project, so the AOT build starts from clean obj/bin rather than the trimmed build's. The + # model is the same one the trimmed publish runs, type-level [ZodRule] (PlanId's [NonSentinel]) included. + $directory = Join-Path $consumers 'wasm-aot' Copy-Item (Join-Path $consumers 'BlazorWasm') $directory -Recurse -Force -Exclude 'bin', 'obj' - $publish = Join-Path $output $name - Invoke-Dotnet "$name-publish" (@( + $publish = Join-Path $output 'wasm-aot' + Invoke-Dotnet 'wasm-aot-publish' (@( 'publish', (Join-Path $directory 'BlazorWasm.csproj'), '--configuration', 'Release', '--output', $publish, '-p:RunAOTCompilation=true', '-warnaserror' - ) + $extra + $consumerProperties) - return [pscustomobject]@{ Directory = $directory; Publish = $publish } - } + ) + $consumerProperties) - Invoke-Step '(b) Blazor WebAssembly: RunAOTCompilation publish, run in a browser' { - # Without the type-level rule the Mono AOT compiler crashes on; see PlanId in Shared/Models.cs and the - # known-issue probe below. - $aot = Publish-WasmAot 'wasm-aot' @('-p:ExcludeTypeLevelRules=true') - Assert-WasmOutput $aot.Publish - Assert-Contains (Join-Path $aot.Publish 'wwwroot/_framework') '^dotnet\.native\.[a-z0-9]+\.wasm$' 'the AOT-compiled runtime' - Assert-NoLeaks @((Join-Path $aot.Directory 'bin'), $aot.Publish) - Test-WasmInBrowser $aot.Publish 'wasm-aot' - } - - Invoke-Step '(b) Known-issue probe: RunAOTCompilation with a type-level [ZodRule] on a scalar' { - try { - $probe = Publish-WasmAot 'wasm-aot-type-level-rules' @() - Write-Host ' the publish succeeded: the Mono AOT crash no longer reproduces. Remove EXCLUDE_TYPE_LEVEL_RULES from the gate.' -ForegroundColor Green - Assert-NoLeaks @((Join-Path $probe.Directory 'bin'), $probe.Publish) - } - catch { - $log = Join-Path $logs 'wasm-aot-type-level-rules-publish.log' - if (-not (Test-Path $log) -or -not (Select-String -Path $log -Pattern 'Precompiling failed' -Quiet)) { throw } - Write-KnownIssue ( - '[wasm-aot] RunAOTCompilation crashes the Mono AOT compiler (Precompiling failed, exit code -1073741819) ' + - 'on a scalar with a type-level [ZodRule] such as [NonSentinel]: Purview.ZodSharp 3.0.0-prerelease.1 ' + - 'emits ZodRuleErrors.Build(..., (object)rule as IZodRule) for it.' - ) - } + Assert-WasmOutput $publish + Assert-Contains (Join-Path $publish 'wwwroot/_framework') '^dotnet\.native\.[a-z0-9]+\.wasm$' 'the AOT-compiled runtime' + Assert-NoLeaks @((Join-Path $directory 'bin'), $publish) + Test-WasmInBrowser $publish 'wasm-aot' } } @@ -705,6 +668,10 @@ try { @{ Text = "Field 'Name' must contain at least 2 characters."; What = "the form's own rule, through " } @{ Text = "The value 'not-an-email' is not valid for 'Email'."; What = "EmailAddress rejecting the posted address (its generated TryParse runs Create)" } @{ Text = "The value 'abc' is not valid for 'Postcode'."; What = 'PostalCode rejecting the posted postcode' } + # The unparsed members stay default, so each value object's own [Required] on Value fails - inside the + # struct (Email.Value) - and places it on the owning member's field, named after it. + @{ Text = 'Email is required'; What = "EmailAddress's own rule placed on the Email field" } + @{ Text = 'Postcode is required'; What = "PostalCode's own rule placed on the Postcode field" } )) { if (-not ($messages | Where-Object { $_.Contains($expected.Text) })) { throw "The invalid post does not show $($expected.What): '$($expected.Text)'." @@ -753,10 +720,6 @@ try { Assert-NoLeaks @((Join-Path $consumers 'Net8Console/bin')) } - - if ($StrictKnownIssues -and $knownIssues.Count -gt 0) { - throw "$($knownIssues.Count) known issue(s) reproduced (-StrictKnownIssues)." - } } catch { $exitCode = 1 @@ -782,18 +745,6 @@ finally { $timings | Format-Table -AutoSize | Out-Host Write-Host ("Total: {0:N1}s" -f $totalStopwatch.Elapsed.TotalSeconds) if ($scanned.Count -gt 0) { Write-Host "Leak-scanned $($scanned.Count) folder(s)." } - if ($knownIssues.Count -gt 0) { - # One line per distinct issue, naming every consumer it reproduced in. - $grouped = $knownIssues | ForEach-Object { - $match = [regex]::Match($_, '^\[(?[^\]]+)\] (?.*)$') - [pscustomobject]@{ Where = $match.Groups['where'].Value; What = $match.Groups['what'].Value } - } | Group-Object What - Write-Host "`nKnown dependency issues ($($grouped.Count) distinct, $($knownIssues.Count) occurrences):" -ForegroundColor Yellow - foreach ($issue in $grouped) { - $where = ($issue.Group.Where | Sort-Object -Unique) -join ', ' - Write-Host " - [$where] $($issue.Name)" -ForegroundColor Yellow - } - } if ($KeepArtifacts) { Write-Host "Artifacts kept at $workspace" -ForegroundColor Yellow From 71f4af976fd7f006eab9e776d12b58e213b77011 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:21:41 +0000 Subject: [PATCH 09/23] test(packed): restore the required nullable contact email in the aot console The Native AOT console's Order declared ContactEmail as a non-nullable EmailAddress only because Purview.ZodSharp's generated null test for a [Required] nullable struct bound the scalar's operator ==(string?, EmailAddress) and failed with CS9342. That is fixed, so the member is back to its natural shape, [Required] EmailAddress?. The console proves it compiles under -warnaserror and validates: a missing address is reported at ContactEmail with RequiredRule's code, and a present but invalid one is reported at ContactEmail.Value with a message that names the owning member. --- scripts/packed-consumers/AotConsole/Models.cs | 8 ++++---- scripts/packed-consumers/AotConsole/Program.cs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/packed-consumers/AotConsole/Models.cs b/scripts/packed-consumers/AotConsole/Models.cs index 325c42a..119940c 100644 --- a/scripts/packed-consumers/AotConsole/Models.cs +++ b/scripts/packed-consumers/AotConsole/Models.cs @@ -66,10 +66,10 @@ public sealed class Order [Required] public CustomerId? Customer { get; set; } - // Not EmailAddress?: Purview.ZodSharp 3.0.0-prerelease.1 emits `value.ContactEmail == null` for a [Required] - // nullable struct, which is ambiguous (CS9342) with the scalar's generated operator ==(string?, EmailAddress). - // A non-nullable member is validated by EmailAddress's own schema, whose [Required] rejects a missing address. - public EmailAddress ContactEmail { get; set; } + // A [Required] nullable member of a string-backed scalar: the generated null test must not bind the scalar's + // operator ==(string?, EmailAddress), and a present address is validated by EmailAddress's own schema. + [Required] + public EmailAddress? ContactEmail { get; set; } [MinCount(1)] public List Lines { get; set; } = []; diff --git a/scripts/packed-consumers/AotConsole/Program.cs b/scripts/packed-consumers/AotConsole/Program.cs index bacd531..42405c8 100644 --- a/scripts/packed-consumers/AotConsole/Program.cs +++ b/scripts/packed-consumers/AotConsole/Program.cs @@ -185,6 +185,21 @@ static Order ValidOrder() => "a missing value object, a nested value object's own rule and a collection element report typed paths" ); + var nested = errors.Where(static e => e.FullPath.ToDottedString() == "ContactEmail.Value").ToArray(); + Check( + nested is [{ Message: "Field 'ContactEmail' must be a valid email address." }], + $"a nested scalar's message names the owning member (got '{string.Join("', '", nested.Select(static e => e.Message))}')" + ); + + var missingEmail = ValidOrder(); + missingEmail.ContactEmail = null; + var required = OrderSchema.Validate(missingEmail).Errors; + Check( + required is [{ Code: RequiredRule.ErrorCode }] + && required[0].FullPath.ToDottedString() == "ContactEmail", + $"[Required] on a nullable scalar member rejects a missing address at the member (got {string.Join(", ", required.Select(static e => $"{e.Code} at {e.FullPath.ToDottedString()}"))})" + ); + var empty = ValidOrder(); empty.Lines = []; var tooFew = OrderSchema.Validate(empty).Errors.Single(); From 61ce2ec33b3b7ef50b3e12e11bd3040deb95dbb8 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:21:41 +0000 Subject: [PATCH 10/23] docs: remove the zodsharp prerelease gaps that are now fixed The Trimming, Native AOT and Blazor guide, the CHANGELOG and the purview-value-objects-zodsharp skill listed Purview.ZodSharp 3.0.0-prerelease.1 defects found by the packed-consumer gate. All of them are fixed in ZodSharp, so the wording is gone. In its place the guide and the skill describe the behaviour the gate now asserts: an error inside a value object is shown on the owning member's field and named after it, a type-level rule is located at the member, and a [Required] nullable value object member is supported. The upgrade notes record the changed message text. --- CHANGELOG.md | 7 +++--- docs/Trimming-AOT-and-Blazor.md | 24 +++++++------------ .../purview-value-objects-zodsharp/SKILL.md | 9 ++++--- 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53fc1bd..7e207fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,10 +63,9 @@ ships `net8.0`, `net9.0`, `net10.0` and `net11.0`, and has no dependency on ZodS - **Source-generated JSON contexts.** A `JsonSerializerContext` in the project that declares the value objects cannot see their generated `[JsonConverter]` attribute, so register the generated converters on the options (see [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md#systemtextjson-source-generation)). -- **Known gaps in Purview.ZodSharp 3.0.0-prerelease.1 with value objects in Blazor**, found by the new gate and - listed in that guide: errors inside a value object member are not placed on its field (and are dropped by - `` and by `` on Blazor Server, though the form stays invalid), and - Blazor WebAssembly AOT fails for a scalar with a type-level `[ZodRule]`. +- **A nested scalar's ZodSharp messages name the owning member.** With ZodSharp 3, an error inside a value object + member (`Email.Value`) reads `Field 'Email' ...` rather than `Field 'Value' ...`, and Blazor shows it on the + owning member's field; its path is still `Email.Value`. Update any assertion on the old text. ## 1.2.0 - 1.2.3 diff --git a/docs/Trimming-AOT-and-Blazor.md b/docs/Trimming-AOT-and-Blazor.md index dec91aa..2f9b6a7 100644 --- a/docs/Trimming-AOT-and-Blazor.md +++ b/docs/Trimming-AOT-and-Blazor.md @@ -135,18 +135,12 @@ builder.Services.AddZodSharpBlazor(); Each value object member is validated by the value object's own generated schema, so the rules live with the value object rather than being repeated on every form. -### Known gaps in Purview.ZodSharp 3.0.0-prerelease.1 - -The packed-consumer gate records these as known issues; they are in ZodSharp, not in this package. - -- **An error inside a value object member is not placed on the member's field.** The error is located at - `Email.Value`, and a struct cannot be a `FieldIdentifier` model, so `` - shows nothing. With `` in Blazor WebAssembly the message reaches ``. -- **With ``, and with `` on Blazor Server, that message is dropped.** The - form is still invalid - it cannot be submitted - but no field and no summary says why. Prefer `` in - WebAssembly, or validate the value objects before binding them, until this is fixed. -- **Blazor WebAssembly AOT (`RunAOTCompilation=true`) fails** - the Mono AOT compiler crashes - for a scalar with a - type-level `[ZodRule]` such as `[NonSentinel]`. Trimmed WebAssembly, Native AOT and Blazor Server are unaffected. -- A `[Required]` `Nullable` member whose type is a string-backed scalar does not compile (`CS9342`): the - generated `value.Member == null` is ambiguous with the scalar's `==(string?, T)` operator. Use a non-nullable - member and put `[Required]` on the value object's own `Value`. +An error inside a value object is located at `Email.Value`, but a struct cannot be a `FieldIdentifier` model, so +ZodSharp places it on the owning member's field: `` (or +``) shows it next to the value object's input, with `` and with +``, on Blazor Server and in WebAssembly alike. A scalar's messages name the owning +member, not its own `Value`: `Field 'Email' must be a valid email address.` A type-level rule such as +`[NonSentinel(Message = "Choose a plan.")]` is located at the member itself and keeps its own message. + +A form or request model may declare a value object member as `[Required]` and nullable (`EmailAddress?`): a missing +value is reported at the member, and a present one is validated by the value object's own schema. diff --git a/skills/purview-value-objects-zodsharp/SKILL.md b/skills/purview-value-objects-zodsharp/SKILL.md index f5d19b2..7e79542 100644 --- a/skills/purview-value-objects-zodsharp/SKILL.md +++ b/skills/purview-value-objects-zodsharp/SKILL.md @@ -31,11 +31,10 @@ with 2.x and 3.x. In 3.x built-in rules report `Origin`/`Category`, paths render **Blazor** (`Purview.ZodSharp.Blazor`, `net11.0`): register `AddZodSharp{Assembly}Schemas()` and `AddZodSharpBlazor()`, bind a scalar with an `InputBase` that uses `Hydrate`, and validate with -``. Known 3.0.0-prerelease.1 gaps: an error inside a value object member (`Email.Value`) -is not placed on the member's field (WebAssembly `` shows it in the summary only; -`` and Blazor Server drop the message, though the form stays invalid), and -WebAssembly AOT crashes for a scalar with a type-level `[ZodRule]`. Repository guide: -`docs/Trimming-AOT-and-Blazor.md`. +`` or ``. An error inside a value object member +(`Email.Value`) is shown on the owning member's field (`For="() => model.Email"`) and named after it +(`Field 'Email' must be ...`), on Blazor Server and WebAssembly (trimmed or AOT) alike; a type-level +`[ZodRule]` is located at the member. Repository guide: `docs/Trimming-AOT-and-Blazor.md`. Four ways to use it, in rough order of how often you want them: From 986629bde1ab1241f2608f616d81de9ad676d937 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:21:53 +0000 Subject: [PATCH 11/23] chore(skills): install the upstream skills under .agents only The skills this repository depends on are installed by the skills CLI into the universal .agents/skills/ location and committed with skills-lock.json, so a clone has them without running anything. Nothing was written outside .agents/ and skills-lock.json: npx -y skills@1 add purview-dev/build-sdk -s '*' -a universal -y npx -y skills@1 add purview-dev/sourcegenerator-framework -s '*' -a universal -y npx -y skills@1 add purview-dev/zodsharp -s '*' -a universal -y That is the eight purview-buildsdk-*, six purview-sourcegenerator-* and six purview-zodsharp-* skills. This repository's own skills stay in skills/. /.claude/ is gitignored, and src/Directory.Build.props sets PurviewSkillsAgents to universal, so the SDK's InstallPurviewSkills target no longer defaults to writing a copy for every agent. --- .../skills/purview-buildsdk-adoption/SKILL.md | 114 +++++ .../purview-buildsdk-configuration/SKILL.md | 115 +++++ .../references/properties.md | 168 ++++++++ .../SKILL.md | 157 +++++++ .../references/blazor.md | 105 +++++ .../references/detection.md | 83 ++++ .../references/test-types.md | 35 ++ .../purview-buildsdk-project-layout/SKILL.md | 161 +++++++ .../SKILL.md | 111 +++++ .../SKILL.md | 166 +++++++ .../references/repository-root.md | 55 +++ .../purview-buildsdk-skill-tests/SKILL.md | 131 ++++++ .../references/authoring.md | 79 ++++ .../skills/purview-buildsdk-testing/SKILL.md | 121 ++++++ .../references/treenode-filter.md | 107 +++++ .../SKILL.md | 334 +++++++++++++++ .../references/legacy-emitter-migration.md | 58 +++ .../SKILL.md | 142 ++++++ .../SKILL.md | 245 +++++++++++ .../references/merge-internals.md | 139 ++++++ .../purview-sourcegenerator-pipeline/SKILL.md | 231 ++++++++++ .../purview-sourcegenerator-testing/SKILL.md | 404 ++++++++++++++++++ .../references/test-modernisation.md | 63 +++ .../SKILL.md | 230 ++++++++++ .../purview-zodsharp-aspnetcore/SKILL.md | 242 +++++++++++ .agents/skills/purview-zodsharp-core/SKILL.md | 176 ++++++++ .../purview-zodsharp-custom-rules/SKILL.md | 241 +++++++++++ .../purview-zodsharp-diagnostics/SKILL.md | 125 ++++++ .agents/skills/purview-zodsharp-json/SKILL.md | 223 ++++++++++ .../SKILL.md | 224 ++++++++++ .../references/dataannotations.md | 137 ++++++ .gitignore | 4 + skills-lock.json | 125 ++++++ src/Directory.Build.props | 5 + 34 files changed, 5056 insertions(+) create mode 100644 .agents/skills/purview-buildsdk-adoption/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-configuration/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-configuration/references/properties.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/references/blazor.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/references/detection.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/references/test-types.md create mode 100644 .agents/skills/purview-buildsdk-project-layout/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-repository-review/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-skill-installation/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-skill-installation/references/repository-root.md create mode 100644 .agents/skills/purview-buildsdk-skill-tests/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-skill-tests/references/authoring.md create mode 100644 .agents/skills/purview-buildsdk-testing/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-testing/references/treenode-filter.md create mode 100644 .agents/skills/purview-sourcegenerator-codewriter/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md create mode 100644 .agents/skills/purview-sourcegenerator-diagnostics/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-packaging/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md create mode 100644 .agents/skills/purview-sourcegenerator-pipeline/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-testing/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md create mode 100644 .agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md create mode 100644 .agents/skills/purview-zodsharp-aspnetcore/SKILL.md create mode 100644 .agents/skills/purview-zodsharp-core/SKILL.md create mode 100644 .agents/skills/purview-zodsharp-custom-rules/SKILL.md create mode 100644 .agents/skills/purview-zodsharp-diagnostics/SKILL.md create mode 100644 .agents/skills/purview-zodsharp-json/SKILL.md create mode 100644 .agents/skills/purview-zodsharp-source-generator/SKILL.md create mode 100644 .agents/skills/purview-zodsharp-source-generator/references/dataannotations.md create mode 100644 skills-lock.json diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md new file mode 100644 index 0000000..b1f222c --- /dev/null +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -0,0 +1,114 @@ +--- +name: purview-buildsdk-adoption +description: Adopt Purview.BuildSdk in a repository, or troubleshoot an adoption that is not behaving. Use when wiring the SDK into Directory.Build.props for the first time, when a property seems to have no effect, when the resolved version or namespace is wrong, or when a consuming repository builds but the SDK's automatic behaviour is not appearing. Covers the import chain, pre-import property ordering, and version detection from package.json. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Adopting Purview.BuildSdk + +The goal is a working adoption that does not break existing build behaviour. Prefer minimal, +targeted property changes over rewriting a repository's `Directory.Build.props`. + +For the full property list use `purview-buildsdk-configuration`; for auditing an existing +repository's shape, `purview-buildsdk-repository-review`. + +## 1. The import chain + +```xml + + + + + Acme.Sales + + + + +``` + +```xml + + + + +``` + +Both halves are required. A repository with only `Sdk.props` imported gets the evaluation-time +defaults — naming, identity, framework selection — but none of the targets, so validation, +packaging behaviour and the generated files never run. That combination usually presents as "the +properties look right but nothing happens". + +The SDK version comes from `global.json`, which the SDK itself bootstraps. + +## 2. Pre-import ordering is the first thing to check + +**A property that must affect `Sdk.props` evaluation has to be set before the import.** Setting it +in a `.csproj`, or below the import, is too late and silently does nothing — no error, no warning. +This is the single most common adoption failure. + +Set before the import: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `AutoIncludeUsings` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- any repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. + +When a property appears to have no effect, confirm what MSBuild actually resolved before changing +anything else: + +```bash +dotnet msbuild -getProperty:NamespacePrefix -getProperty:RootNamespace +``` + +## 3. Version detection + +Versions come from `package.json`. Resolution order: + +1. An explicit `RootPackageJson`. +2. CI workspace variables. +3. The `.git` root. +4. A nearby `package.json`. + +On success both `Version` and `PackageVersion` are set from the `version` field. + +If the version looks wrong, do not guess at the discovery — make it fail loudly instead: + +```xml +Strict +``` + +`Strict` fails the build when discovery or resolution cannot succeed, rather than silently skipping +it and leaving the default `1.0.0`. Set `VersionDetectionLogEnabled=true` to see what it found. + +## 4. Naming and placement + +Most of the SDK's value is inferred from project names and locations, so an adoption that keeps a +bespoke structure gets correspondingly little. Confirm the repository follows the conventions rather +than introducing new structure to work around them — see `purview-buildsdk-project-layout`, and +`purview-buildsdk-repository-review` for auditing an existing layout. + +The highest-value single check: every test project is named `.Tests`. **The dot is +required.** A project named plainly `UnitTests` is not detected as a test project at all, and +silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. + +## 5. Agent skills are a separate, explicit step + +The SDK does not install agent skills, and nothing it does during a build writes to an agent +directory. It reads which skills each referenced package ships and recommends the command that +installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. + +## Constraints + +- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. +- Do not disable `PurviewAutoSdkPack` unless the consumer explicitly asks to opt out. +- Do not restate an SDK default in a project file. Hand-configuration that merely repeats a default + is pure maintenance cost, and it hides the fact that the default later changed. +- Re-run the build — or the repository's canonical build command — after each change, and confirm + the fix before moving to the next one. diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md new file mode 100644 index 0000000..d82768b --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -0,0 +1,115 @@ +--- +name: purview-buildsdk-configuration +description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, tuning the repository bootstrap, making a library browser-safe for a Blazor WebAssembly client, configuring a BFF host, or working out whether a property must be set before the SDK import. Also use when the SDK "did something automatically" and you need the property that controls it. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Purview.BuildSdk configuration + +Use this skill when the question is "what property controls this, and where do I set it?" + +For *why* the SDK classified a project a particular way, use `purview-buildsdk-project-detection` +instead. For where projects and tests should live, use `purview-buildsdk-project-layout`. + +## First rule: know where a property must be set + +A property that changes `Sdk.props` evaluation must be set **before** the SDK import — in +`Directory.Build.props`, above the import. Setting it in a `.csproj` is too late and silently has no +effect. + +Pre-import properties include: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- `AutoIncludeUsings` +- `DisablePurviewBlazorWebAssemblyDefaults` +- repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. Prefer repo-wide defaults +in `Directory.Build.props` unless the setting is deliberately project-specific. + +## The properties you will reach for most + +| Property | Default | What it does | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity. Required unless `DisableNamespacePrefixCheck=true` | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | +| `TestDataFramework` | `Bogus` | `Bogus` or `None` | +| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `IsPackable` | `false` | Opts a project into the packable defaults | +| `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | +| `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | +| `DisableSourceLink` | `false` | Drops SourceLink from packable projects | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | +| `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | +| `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | + +The full set, grouped by area and with the behaviour each one changes, is in +[references/properties.md](references/properties.md). Read it rather than guessing a property name — +several of these have near-miss names that do nothing. + +## Derived properties to inspect, not re-derive + +When explaining what the SDK did, read the value it computed rather than reasoning from the folder +name: + +`PurviewLogicalProjectName`, `PurviewNamespacePrefix`, `PurviewProjectShortName`, `PurviewTestType`, +`RootNamespace`, `AssemblyName`, `PackageVersion`, `TestingType`, `TargetProjectName`, `RepoRoot`, +`RootPackageJson`, `IsBlazorHostProject`, `IsBffProject`, `PurviewBffExposesApi`, `IsAotCompatible`, +`WarningsAsErrors`. + +Ask MSBuild for one directly: + +```bash +dotnet msbuild -getProperty:RootNamespace +dotnet msbuild -getProperty:TestingType -getProperty:TargetProjectName +``` + +## Compiler-visible properties + +The SDK exports its properties to analyzers and generators as `build_property.`. When +authoring an analyzer, read the exported property instead of re-deriving SDK behaviour — the export +is the SDK's answer, and it stays correct when the rules change. The exported set is listed in +[references/properties.md](references/properties.md). + +## Version detection + +1. If `RootPackageJson` is set, that file is used. +2. Otherwise the repo root is discovered from CI variables, `.git`, or a nearby `package.json`. +3. On success, both `Version` and `PackageVersion` come from the `version` field. +4. `UsePackageJsonVersion=Strict` turns a failed discovery into a build failure instead of a silent + fallback. + +## Repository bootstrap + +The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. + +- `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` +- `DisableAutoCopySdkFiles` — the master off switch +- `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` + +Every project in a solution writes those same destinations, so the write is race-tolerant by +design: an existing file is never overwritten unless asked, each write is staged into a temporary +file and renamed into place, retries are quiet, and only a failure that survives every retry is +reported. + +**The SDK writes nothing into an agent directory.** It used to mirror a package's `.agents/**` into +the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPackDestinationFolder` +and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see +`purview-buildsdk-skill-installation`. + +## Guidance when changing SDK configuration + +1. Preserve existing defaults unless the task explicitly changes product behaviour. +2. Keep `README.md`, the property declaration, its validation and the shipped skills aligned — a + property documented in one place and absent from another is the failure mode this repository + keeps hitting. +3. A new property that affects import-time behaviour must say so in its documentation. +4. Add the property to the `CompilerVisibleProperty` list if an analyzer would benefit from it. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md new file mode 100644 index 0000000..1fb1f81 --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -0,0 +1,168 @@ +# Purview.BuildSdk property reference + +Every user-facing property, grouped by what it changes. Defaults are what the SDK applies when the +consuming project supplies nothing. + +## Identity and naming + +| Property | Default | Behaviour | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity source. Required unless the check is disabled. Set before the SDK import | +| `DisableNamespacePrefixCheck` | `false` | Stops the build failing when `NamespacePrefix` is unset | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName` and the default `PackageId` follow the fully evaluated `RootNamespace`, or the full logical project name when suffix-stripping removed a segment (`Shared`, `ServiceDefaults`). Set `false` before the SDK import for standard project-name behaviour | +| `AssemblyIdentifierClassName` | *(derived)* | Renames the generated marker class | +| `DisableGenerateAssemblyIdentifierClass` | `false` (forced `true` for test and shared-testing projects) | Drops the empty marker class (`Assembly`, global namespace) that architecture tests use to pin an assembly by `typeof` rather than by name | +| `DisableGenerateAssemblyInfoClass` | `false` | Drops the generated `AssemblyInfo` | +| `DisableAutoInternalsVisibleTo` | `false` | Drops automatic friend-assembly generation | +| `DisableProjectFileNamingConventionCheck` | `false` | Stops validating that the `.csproj` filename matches its directory | +| `AutoIncludeUsings` | `true` | Controls the SDK-added global usings. Set before the SDK import | + +## Target framework and language + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TargetFramework` | `net10.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | +| `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | +| `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | +| `PackProjectReferencedSourceGenerators` | `true` | Packable projects include analyzer `ProjectReference` outputs under `analyzers/dotnet/cs/`. Use `Pack="false"` on one reference to opt that one out | + +## Style policy + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisablePurviewStylePolicyValidation` | `false` | Stops the build failing (`PRSGD0006`–`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy — `dotnet_style_require_accessibility_modifiers` other than `omit_if_default`, hiding `IDE0040`/`IDE1006`, disabling the Style category in bulk, weakening the `_camelCase` private-instance-field rule, or adding the accessibility rules to `NoWarn`. Entries the SDK injects itself are ignored | +| `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | The production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`) but are context aware: public test classes, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Set before the SDK import | +| `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | +| `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | + +## Testing + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit`, `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute`, `None` | +| `TestDataFramework` | `Bogus` | `Bogus`, `None` | +| `CollectCoverage` | `true` for detected test projects | | +| `EnableStaticNativeInstrumentation` | `false` for test projects | | +| `EnableDynamicNativeInstrumentation` | `false` for test projects | | +| `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` | enabled for TUnit test projects | Microsoft.Testing.Platform wiring | +| `PurviewSharedTestingOutputType` | `Library` | Forced onto `IsSharedTestingProject` projects, with `IsTestProject`/`IsTestingPlatformApplication` cleared, because the test packages otherwise flip them into an executable test host. Set `Exe` before the SDK import to keep the package-driven shape | + +## Packaging + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsPackable` | `false` | | +| `PurviewAutoSdkPack` | `true` | Packs `Sdk/**/*` into the package at the right root-level paths | +| `PurviewSkillsSourceFolder` | `/skills` | Where authored skills live | + +Packable-project defaults, applied only when the project supplied nothing: +`GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, +`PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs ship +in the `.snupkg`; the `.nupkg` gets none unless the project opts in. Roslyn-component-only packages +instead default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/`. + +When the repository root is discoverable, the root `README.md` is packed automatically and +registered as `PackageReadmeFile`, unless one is configured already. + +Non-packable projects, including web apps, default `WarnOnPackingNonPackableProject=false` so a +solution-wide pack skips them silently. + +`ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never +forces SourceLink's CI-mode dirty-repository checks. + +## Telemetry and SourceLink + +| Property | Default | Behaviour | +| -- | -- | -- | +| `ExcludePurviewTelemetry` | `false` | Removes `Purview.Telemetry.SourceGenerator` | +| `ExcludeMSTelemetryExtension` | `false` | Removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — the whole group is skipped otherwise | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | | +| `DisableSourceLink` | `false` | | + +## Blazor WebAssembly, browser-safe libraries and BFF hosts + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsBffProject` | `true` for a web SDK project named `*Bff`, else `false` | Classifies a BFF host. `false` opts a `*Bff` project out; `true` opts another web project in | +| `IsBlazorHostProject` | *(computed)* | `true` for a web SDK project that references a WebAssembly client. Set `false` to opt out. Never implies `IsBffProject` | +| `PurviewAutoBlazorClientReference` | `true` | A host named `` references its `.Client` WebAssembly client automatically. Skipped when the reference is already declared | +| `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client: packable when asked, Dockerfile container detection, the automatic `Shared` reference. Set before the SDK import | +| `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`, each only where the project set nothing | +| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps the trim and AOT diagnostics as warnings; `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | +| `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids a browser-safe library adds to `WarningsAsErrors` | +| `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`, the check that every project a client (or browser-safe library) references sets `PurviewBrowserSafe` | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error | +| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, else `false` | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | + +`PurviewBrowserSafe="true"` is also accepted as `ProjectReference` metadata, recording that a +reference was judged browser-safe when it cannot say so itself. + +## Version detection + +| Property | Default | Behaviour | +| -- | -- | -- | +| `UsePackageJsonVersion` | `true` | `true`, `false`, `Strict` | +| `RootPackageJson` | *(discovered)* | Explicit path to the `package.json` to read | +| `EnableVersionDetectionCache` | `true` | | +| `VersionDetectionCacheFile` | *(derived)* | | +| `VersionDetectionLogEnabled` | `false` | Logs the detected version | + +## Repository bootstrap + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisableAutoCopySdkFiles` | `false` | Master switch for SDK-managed repository file writes | +| `PurviewRepoBootstrapMode` | `IfMissing` | Also `Always`, `WarnOnDrift`, `Never` | +| `BootstrapEditorConfigToRepoRoot` | `true` | | +| `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | +| `BootstrapGlobalJsonToRepoRoot` | `true` | | +| `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | +| `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | +| `PurviewRepoBootstrapCopyRetries` | `3` | | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | `false` downgrades a persistent write failure to a warning | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes the built-in copy task's `MSB3026` retry notice to a message | +| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers identifying a repository root, most authoritative first | + +There is no agent-folder mirror. `EnableAgentFolderInPackage`, `AgentPackDestinationFolder`, +`PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopy*`, `PurviewAgentSyncManifestPath` and +`DisableMirroredFileTrackingCheck` were removed: skills are installed by the skills CLI, and +nothing else was left for the mirror to deliver. + +## Skills + +Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, +`PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, +`PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, +`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. + +## Compiler-visible properties + +Exported to analyzers and generators as `build_property.`: + +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` +- `NamespacePrefix`, `DisableNamespacePrefixCheck` +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` +- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` +- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, + `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `TestingType`, `TargetProjectName` +- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, + `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject` +- `PurviewBrowserSafe`, `PurviewBrowserSafeWarningsAsErrors`, `DisablePurviewBrowserSafeReferenceCheck`, + `PurviewBrowserSafeReferenceCheckAsError`, `DisablePurviewBlazorWebAssemblyDefaults`, + `PurviewAutoBlazorClientReference`, `PurviewBffExposesApi` +- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` +- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, + `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewSuppressCopyRetryWarnings` +- `PurviewSkillsSourceFolder`, + `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, + `PurviewSkillsRunner` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, + `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md new file mode 100644 index 0000000..8573ca4 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -0,0 +1,157 @@ +--- +name: purview-buildsdk-project-detection +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Why Purview.BuildSdk did that + +Use this skill when the question is **why** the SDK applied something automatically. + +For where a project should live, use `purview-buildsdk-project-layout`. For the property that turns +a behaviour off, use `purview-buildsdk-configuration`. + +## Diagnose from computed properties, never from the folder name + +Read what the SDK actually computed: + +```bash +dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:RootNamespace \ + -getProperty:TestingType -getProperty:TargetProjectName -getProperty:IsTestProject +``` + +The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, +`PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, +`IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, +`IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, +`IsBlazorHostProject`, `IsBffProject`. + +## Detection rules + +### Test projects + +Name matches `^.*\.([\w|_]\w*)Tests?$` — a target name, a dot, then any word before `Test`/`Tests`. +The captured word becomes `TestingType`. + +This is a pattern, **not** a lookup. `MyProject.GoldenTests` is detected exactly as readily as +`MyProject.UnitTests`, with `TestingType=Golden` and `Category=Golden`, even though `Golden` is not a +name the SDK ships. + +**The dot is required.** A project named plainly `UnitTests` does not match, and silently gets no +runner, no coverage, no `InternalsVisibleTo` and no category. This is the single most common +surprise. + +Derived: `IsTestProject=true`, `TestingType=`, `PurviewTestType=Tests`, +`TargetProjectName=`. + +The SDK's explicit `TestType` item group governs **`InternalsVisibleTo` generation**, not detection: +a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each name in the group. A +suffix outside it still builds, runs and gets a category — it just cannot see `internal` members. +The 53 names are in [references/test-types.md](references/test-types.md). + +### Shared and shared-testing projects + +Recognised by **exact name**, never by substring. The two lists are in +[references/detection.md](references/detection.md). Derived: `IsSharedProject`, +`IsSharedTestingProject`. + +### SDK and content based + +| Signal | Result | +| -- | -- | +| the project's `Sdk="..."` declaration | `IsSdkProject`, `SdkProjectName` | +| `Microsoft.NET.Sdk.Web` | `IsWebSdkProject=true` | +| `Microsoft.NET.Sdk.Worker` | `IsWorkerSdkProject=true` | +| SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` | `IsAspireHostProject=true` | +| `Dockerfile`, `dockerfile` or `Dockerfile.dev` in the project directory | `IsContainerProject=true` | +| name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, `QuickStarts` | `IsCLIProject=true` | +| `Microsoft.NET.Sdk.BlazorWebAssembly` | `IsBlazorWebAssemblyProject=true` | +| `Microsoft.NET.Sdk.Razor` | `IsRazorClassLibraryProject=true` | +| a web SDK project whose name ends with `Bff` (case-sensitive, like `CLI`), or that sets `IsBffProject=true` | `IsBffProject=true` | +| a web SDK project that references an `IsBlazorWebAssemblyProject` project | `IsBlazorHostProject=true` | + +`IsBffProject=false` opts a `*Bff` project out. `IsBlazorHostProject` is **never** inferred into +`IsBffProject`: a host that keeps no tokens is not a BFF, and BFF rules do not describe it. + +`IsBlazorHostProject` is known at evaluation for the ``/`.Client` pairing. A host +referencing a client under any other name is resolved during the build, so ask for it with the +target that resolves it: + +```bash +dotnet msbuild -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHostProject +``` + +What each Blazor and BFF classification applies - and the browser-safe library check - is in +[references/blazor.md](references/blazor.md). + +## Namespace and identity + +1. `PurviewLogicalProjectName` is `NamespacePrefix` plus the project name, de-duplicated when the + project name already starts with the prefix's tail. +2. `RootNamespace` defaults to `PurviewLogicalProjectName`. +3. Known suffixes are stripped from `RootNamespace` — shared and shared-testing names, and segments + such as `Core`, `EF`, `Shared`, `ClientShared`, `ServiceDefaults`. +4. Test suffixes are stripped too, so `Acme.Api.UnitTests` maps back to `Acme.Api`. +5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — except where + stripping removed a segment, where they use the full `PurviewLogicalProjectName` so the artifact + stays distinct from its parent. Test and shared-testing projects keep their detected suffix. + Explicit `AssemblyName`/`PackageId`/`RootNamespace` always win. +6. The naming defaults are applied during `Sdk.props`, before the Microsoft SDK computes + `TargetName`, so the compiled output name always matches `AssemblyName`. + +## Automatic packages and references + +- **Non-test C# projects**: SourceLink (unless `DisableSourceLink`), the Purview telemetry packages + (unless `ExcludePurviewTelemetry`), `GenerateDocumentationFile=true`, and `InternalsVisibleTo` + generation (unless `DisableAutoInternalsVisibleTo`). +- **Test and shared-testing projects**: test-friendly `NoWarn` defaults, not packable or + publishable, and the packages selected by `TestingFramework`, `SubstituteFramework` and + `TestDataFramework`. TUnit test projects get Microsoft.Testing.Platform wiring automatically. + Shared-testing projects skip the runnable test package. +- **Packable projects**, **Roslyn components** and **special project types** (CLI, container, web, + Aspire host): see [references/detection.md](references/detection.md). +- **Blazor WebAssembly clients, Blazor hosts, BFFs and browser-safe libraries**: see + [references/blazor.md](references/blazor.md). + +Project-reference probing is layout-driven and covered by `purview-buildsdk-project-layout`. + +## The shipped analyzers + +The SDK adds `Purview.BuildSdk.Analyzers` as an `` item to every C# project, so its rules +surface in command-line builds and in Visual Studio. The code-fix assembly ships beside it. + +| Rule | What it enforces | +| -- | -- | +| `PDS0002` | Extensions classes live under `Extensions//` | +| `PDS0003` | Explicit types with target-typed `new()` | +| `PDS0004` | Correct acronym capitalisation | +| `PDS0008` | A BFF's authentication cookie is not set `HttpOnly = false` | +| `PDS0009` | A BFF's authentication cookie is not set `SameSite = None` | + +`PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Guid`, `Uuid`, +`Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, +`DbConnection` and `DbSet` are never flagged. A small set is still uppercased: `Api`, `Ai`, `Ui`, +`Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh`. Members mandated by a contract — interface +implementations and base-class overrides — are never renamed. + +Customise through `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), +`.acronym_map` (segment renames, e.g. `Sql:SQL`, or `Db:DB` to re-enable it) and +`.allowed_identifiers` (brand names, exact or word-boundary prefix, first match wins — `CosmosDb` +covers `CosmosDbServer`). All three merge with and override the shipped defaults. + +The shipped `.editorconfig` marks generated content (`Migrations/`, `*.g.cs`, `Generated/`, +`*.Designer.cs`, `obj/`, `bin/`) as `generated_code = true`, and suppresses namespace-conflict +diagnostics under `Extensions/`, so no `#pragma` suppressions are needed there. + +Two Visual Studio refactorings ship with them, both described in +[references/detection.md](references/detection.md). + +## When changing a detection rule + +1. Keep the `.csproj` filename equal to its directory name. +2. Update `README.md` and the shipped skills in the same change — a rule true in code and false in + prose is this repository's recurring failure. +3. Add or update the integration test in `src/tests/BuildSdk.IntegrationTests/` that pins it. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/blazor.md b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md new file mode 100644 index 0000000..c65d153 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md @@ -0,0 +1,105 @@ +# Blazor WebAssembly, browser-safe libraries and BFF hosts + +Every behaviour below applies only to the project type it names, and each one names the failure it +prevents. Nothing here changes a project that is not a WebAssembly client, a Blazor host, a BFF or a +`PurviewBrowserSafe` library. + +## Blazor WebAssembly client (`IsBlazorWebAssemblyProject`) + +| Applied | Failure it prevents | Opt out | +| -- | -- | -- | +| `IsPackable=false` | packing an app's `wwwroot` as a package nobody can consume | `IsPackable=true` in the project | +| `IsContainerProject=false`, so no `PublishAot`, `InvariantGlobalization` or Docker defaults | `PublishAot` on a client is a publish that fails, and the Blazor SDK owns globalisation | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| no automatic `../Shared*/Shared*.csproj` reference | a server-side library joining the browser download because of a folder name | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| the browser-safe reference check (`PRBLZ0001`) | a library joining the download without anyone deciding it was safe there | `DisablePurviewBrowserSafeReferenceCheck=true` | + +Globalisation, trimming mode and the Blazor runtime settings are deliberately left alone: the Blazor +SDK owns them, and a second default would compete with it. + +### The automatic packages + +The SDK adds `Purview.Telemetry.SourceGenerator` and `Microsoft.Extensions.Telemetry.Abstractions` +to every non-test project, a client included. Both were checked rather than assumed: + +- the generator is analyzer-only (`PrivateAssets=all`), so nothing of it is downloaded; +- its generated `ActivitySource`, `ILogger` and `Meter` code, and `Telemetry.Abstractions` itself, + publish with no trim warnings and run on the WebAssembly runtime + (`BlazorWebAppTests.AutomaticTelemetryPackages_RunInsideTheWebAssemblyRuntime` boots it); +- the SDK adds no exporter. An OTLP exporter cannot work from a browser, which is exactly why the SDK + never adds one - that is the application's choice. + +Nothing is therefore excluded from a client by default. `ExcludePurviewTelemetry` and +`ExcludeMSTelemetryExtension` still remove them. + +## Blazor host (`IsBlazorHostProject`) + +A web SDK project named `` beside a WebAssembly client named `.Client` is the Blazor Web +App pairing. The host gets an automatic `ProjectReference` to the client - probed the same way a test +project's target is, and skipped when the project already references it - so the client's framework +files are served. Opt out with `PurviewAutoBlazorClientReference=false`. + +`IsBlazorHostProject` says nothing about `IsBffProject`. In a Blazor Web App the host is the BFF only +if it keeps the tokens. + +## BFF host (`IsBffProject`) + +- **No packages are added.** YARP, OpenID Connect and any BFF library are the application's choice, + and Duende.BFF carries a commercial licence. +- The `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace every other web project gets is + removed, because a BFF that only forwards has no API document to generate. A BFF referencing + `Microsoft.AspNetCore.OpenApi` keeps it; `PurviewBffExposesApi` decides explicitly. +- `PDS0008` reports `HttpOnly = false` on the `CookieAuthenticationOptions` cookie (script could read + the session). `PDS0009` reports `SameSite = None` on it (cross-site requests would carry it). Only a + constant assigned to that cookie is reported - never the antiforgery cookie, which script may + legitimately read, nor the OpenID Connect correlation and nonce cookies, which need + `SameSite=None`. + +Deliberately **not** checked, because a build cannot check them precisely: + +- **a persisted Data Protection key ring.** The call that persists it (`PersistKeysTo*`) is routinely + made in a shared ServiceDefaults project, which the BFF's compilation cannot see, so the rule would + fire on correctly configured BFFs - and a noisy rule gets suppressed wholesale. +- **anti-forgery on forwarded routes.** It stays a review obligation. + +## Browser-safe library (`PurviewBrowserSafe=true`) + +Applies to class libraries only, and only what the project has not set: + +- `IsAotCompatible=true` (on a TFM that supports it), which turns on `IsTrimmable` and the trim, + single-file and AOT analyzers; +- ``, so `CA1416` reports an API the browser does not support; +- `WarningsAsErrors` for every trim (`IL2001`-`IL2128`), single-file (`IL3000`-`IL3005`) and AOT + (`IL3050`-`IL3059`) diagnostic and `CA1416`, listed in `PurviewBrowserSafeWarningCodes`. + `TreatWarningsAsErrors` is not touched. + +`CA1416` is `suggestion` in the shipped `.editorconfig`. The compiler's `/warnaserror+:CA1416` +outranks an `.editorconfig` severity, so the error still fires without the repository's setting +changing. The consequence of `PurviewBrowserSafeWarningsAsErrors=false` is that the trim and AOT +diagnostics stay warnings while `CA1416` falls back to the repository's configured severity. + +What it cannot catch: file and network I/O. `File` works against the browser's in-memory file system +and `HttpClient` works through `fetch`, so neither is annotated as unsupported, and no analyzer reports +them. Reviewing for them stays a human obligation. + +### The reference check (`PRBLZ0001`) + +A WebAssembly client asks each `ProjectReference` - through an MSBuild call on the referenced project, +never by guessing from its name - for its `PurviewBrowserSafe` value, and warns for each one that does +not set it. A browser-safe library runs the same check over its own references, so the guarantee +covers the whole download, not only the first level. + +- `PurviewBrowserSafe="true"` metadata on the `ProjectReference` records the decision for a project + that does not use this SDK. +- References with `ReferenceOutputAssembly="false"` or an `OutputItemType` are not downloaded and are + not checked. Package references are not checked either. +- `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error. + +### Purview packages a Contracts library builds on + +Checked against a `PurviewBrowserSafe` library, then run on the WebAssembly runtime: + +| Package | Result | +| -- | -- | +| `Purview.Results` 1.0.4 | clean | +| `Purview.ZodSharp` 2.4.2 | clean, including `[ZodSchema]` | +| `Purview.ValueObjects` 1.2.3 | the runtime assembly is AOT-compatible, but the JSON converter the generator emits for a scalar calls the reflection-based `JsonSerializer.Serialize`/`Deserialize(..., options)` overloads, which is `IL2026` and `IL3050` - errors in a browser-safe library. Set `GenerateJsonConverter = false` on the scalar until the generator writes the value directly. | diff --git a/.agents/skills/purview-buildsdk-project-detection/references/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md new file mode 100644 index 0000000..85fde09 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -0,0 +1,83 @@ +# Detection detail + +## Shared project names (exact) + +`Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` + +## Shared testing project names (exact) + +`SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, +`SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, +`SharedTestingHelpers` + +These are matched whole, not as substrings. `SharedModels` is not a shared project. + +A shared-testing project is forced to `PurviewSharedTestingOutputType` (default `Library`), with +`IsTestProject` and `IsTestingPlatformApplication` cleared, because the test packages otherwise flip +it into an executable test host. Set `PurviewSharedTestingOutputType=Exe` before the SDK import to +keep that package-driven shape. + +## Packable projects + +Defaults applied only where the project supplied nothing: `GenerateDocumentationFile`, +`IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, +`IncludeSource`, `DebugType=portable`. + +- Portable PDBs ship in the `.snupkg`. The `.nupkg` receives none unless the project opts in. +- The repository-root `README.md` is packed and registered as `PackageReadmeFile` when it exists and + nothing else is already being packed as one. +- Non-packable projects, web apps included, default `WarnOnPackingNonPackableProject=false`, so a + solution-wide `dotnet pack` skips them silently. +- `ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never + forces SourceLink's CI-mode dirty-repository checks. + +## Roslyn components + +A project that explicitly declares `IsRoslynComponent=true` gets: + +- a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, + `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules +- SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry excluded +- compiler-generated output under the framework-specific intermediate directory +- `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers`, so they + never leak into the packed nuspec + +`IsRoslynComponentOnly` defaults to `true`: `IncludeBuildOutput=false`, `IncludeSymbols=false`, no +`.symbols.nupkg` or `.snupkg`, and the analyzer PDB shipped inside the main `.nupkg` under +`analyzers/dotnet/cs/` beside the assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a +dual-role component that wants normal library symbol packaging. + +`ValidateRoslynComponentCompilerSettings` fails the pack of a packable Roslyn component missing +`LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules`. Opt out with +`DisableRoslynCompilerDefaultsValidation=true`. + +`PackProjectReferencedSourceGenerators` (default `true`) makes a packable project include analyzer +`ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set it +`false` globally, or `Pack="false"` on one analyzer reference. + +## Special project types + +| Type | Applied | +| -- | -- | +| CLI | `OutputType=Exe`, `appsettings*.json` included as content | +| Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | +| Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | +| Aspire host | `OutputType=Exe` | +| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | +| BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | +| `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | + +## The Visual Studio refactorings + +Two ship with the code-fix assembly, both serving the `PDS0002` convention: + +- **Split extensions class into one class per receiver type** — offered on a static extensions class + targeting several receiver types. It produces one `Extensions` class per receiver + (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes + `HostApplicationBuilderExtensions`) and places each under `Extensions//`. +- **Move extensions class to conventional location** — offered on a misplaced single-receiver + extensions class. It re-paths the file to `Extensions//`, fixes the namespace + to the receiver's, and updates `using` directives in other referencing documents so the move + compiles. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/test-types.md b/.agents/skills/purview-buildsdk-project-detection/references/test-types.md new file mode 100644 index 0000000..dace44f --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/test-types.md @@ -0,0 +1,35 @@ +# The shipped `TestType` group + +These 53 names drive **`InternalsVisibleTo` generation**, not test detection. A non-test project +grants friend access to `{AssemblyName}.{TestType}Tests` for each name here. + +Detection is the pattern `^.*\.([\w|_]\w*)Tests?$`, so a suffix outside this group is still detected, +still runs and still gets its category — it simply cannot reach the target project's `internal` +members. Prefer a name from this group unless the test type genuinely needs its own word. + +```text +Unit Integration E2E EndToEnd Acceptance +Functional Performance Load Smoke Stress +Regression Security Chaos Scenario System +Threat BlackBox WhiteBox GrayBox GreyBox +Accessibility Usability Visual Interactive Localization +Globalization Environment Configuration Deployment Architecture +Contract Component Sanity Property Fuzz +Mutation Snapshot Approval Benchmark Capacity +Concurrency Endurance Soak Spike Failover +Recovery Resilience Compatibility Interoperability Migration +Upgrade Penetration Compliance +``` + +## The five to reach for first + +`Unit`, `Integration`, `E2E`, `Functional`, `Contract`. Everything else is available, but a reader +has to ask what it means — use one only when the test type itself carries operational weight. + +## What a suffix buys you + +| | detected as a test project | gets a category | gets a runner and coverage | sees `internal` members | +| -- | -- | -- | -- | -- | +| `Api.UnitTests` | yes | `Unit` | yes | yes | +| `Api.GoldenTests` | yes | `Golden` | yes | **no** | +| `UnitTests` *(no dot)* | **no** | no | **no** | no | diff --git a/.agents/skills/purview-buildsdk-project-layout/SKILL.md b/.agents/skills/purview-buildsdk-project-layout/SKILL.md new file mode 100644 index 0000000..6c19c3d --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-layout/SKILL.md @@ -0,0 +1,161 @@ +--- +name: purview-buildsdk-project-layout +description: Decide where a project goes and what to call it in a repository that uses Purview.BuildSdk. Use when creating, moving, splitting or renaming a project or test project, choosing a test suffix, aligning namespaces and assembly identity with NamespacePrefix, laying out a Blazor Web App host, WebAssembly client and browser-safe Contracts library, or relying on the SDK's automatic project references. Also use when rationalising a repository's shape so the SDK's conventions work without overrides. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Project layout and naming for Purview.BuildSdk + +Use this skill when physically adding, moving, splitting or renaming a project. + +For *why* the SDK classified an existing project a particular way, use +`purview-buildsdk-project-detection`. For the property that changes a behaviour, use +`purview-buildsdk-configuration`. + +## First principle + +**Treat naming and placement as configuration, not decoration.** + +The SDK infers namespaces, assembly identity, test categories, package wiring and project references +from a small set of conventions. The closer a repository sits to them, the fewer overrides it needs. + +The corollary matters just as much: **preserve the host repository's existing layout first.** Only +introduce new structure when no established pattern exists. + +## Canonical layout + +```text +/ + package.json version source + .editorconfig bootstrapped by the SDK + skills/ authored agent skills, if any + src/ + .slnx + src//.csproj + tests/.Tests/.Tests.csproj +``` + +- The `.csproj` filename must equal its containing directory name, unless + `DisableProjectFileNamingConventionCheck=true`. +- Source projects under `src/`, test projects under `tests/`. +- Place a new project beside similar ones — same language, layer and test type. +- One test type per project by default. + +A repository that already uses a different shape keeps it; add to it rather than converting it. + +## Naming and identity + +`NamespacePrefix` is the root identity source. Keep project names short and let the SDK apply the +prefix: + +| `NamespacePrefix` | Project | `RootNamespace` | +| -- | -- | -- | +| `Aspire` | `Hosting` | `Aspire.Hosting` | +| `Acme.Sales.RegionalPipeline` | `Identity.API` | `Acme.Sales.RegionalPipeline.Identity.API` | +| `Acme.Sales.RegionalPipeline` | `Identity.Core` | `Acme.Sales.RegionalPipeline.Identity` | + +`AssemblyName` and `PackageId` follow the resolved `RootNamespace` — except where suffix-stripping +removed a segment (`Core`, `Shared`, `ServiceDefaults`), in which case they keep the fuller logical +name so the artifacts stay distinct from their parent. Explicit values always win, and +`EnableAssemblyNameGeneration=false` (before the SDK import) restores standard behaviour. + +Do not hand-author an alternate namespace scheme when one is already in use. + +## Test project naming + +Test detection is a **pattern**, not a list: `^.*\.([\w|_]\w*)Tests?$`. A target name, a dot, then +any word before `Test` or `Tests`. The captured word becomes `TestingType` and the baseline category. + +**The dot is required.** A project named plainly `UnitTests`, with no target in front of it, is not +detected as a test project at all — no runner, no coverage, no `InternalsVisibleTo`, no category. +Always name a test project `.Tests`. + +Prefer these by default: `UnitTests`, `IntegrationTests`, `E2ETests`, `FunctionalTests`, +`ContractTests`. Use another suffix only when the test type itself carries operational meaning. + +A suffix outside the SDK's shipped `TestType` group still builds, runs and gets its category — it +just cannot reach the target project's `internal` members, because that group drives +`InternalsVisibleTo` generation. The full list is in `purview-buildsdk-project-detection`. + +## Shared projects: exact names only + +The SDK recognises these names exactly — a near miss gets none of the behaviour. + +Shared: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers`. + +Shared testing: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, +`SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, +`SharedTestingHelpers`. + +## Automatic project references to preserve + +Layout is what makes these work. Move a project off the convention and you own the reference. + +Test projects probe for their target, in order: + +- `../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` + +Non-test projects pick up sibling shared projects via `../Shared*/Shared*.csproj`; test projects +pick up `../SharedTesting*/SharedTesting*.csproj`. A Blazor WebAssembly client is the exception: it +does not pick up `Shared*`, because everything it references is downloaded to the browser. + +## Blazor Web App: host and client + +```text +src/src/ + Shop/Shop.csproj Microsoft.NET.Sdk.Web - the host + Shop.Client/Shop.Client.csproj Microsoft.NET.Sdk.BlazorWebAssembly - the client + Shop.ClientShared/Shop.ClientShared.csproj a PurviewBrowserSafe library both use + Shop.Bff/Shop.Bff.csproj a BFF, when tokens are kept server-side in a separate host +src/tests/ + Shop.Client.UnitTests/ TargetProjectName = Shop.Client +``` + +- A web host named `` beside a WebAssembly client named `.Client` is the pairing the SDK + recognises: the host gets `IsBlazorHostProject=true` and a reference to the client automatically + (skipped when declared; `PurviewAutoBlazorClientReference=false` opts out). +- Identities with `NamespacePrefix=Acme`: `Shop.Client` is `Acme.Shop.Client`; `Shop.Bff` is + `Acme.Shop.Bff`; `ClientShared` is a stripped segment, so `Shop.ClientShared` has namespace + `Acme.Shop` and assembly `Acme.Shop.ClientShared`. +- Every library the client references must set `true`, or the + build reports `PRBLZ0001`. Keep server-only code (file, process, database access) out of anything + the client references. +- Name a BFF host `*Bff` (case-sensitive) for `IsBffProject=true`. Being the client's host does not + make a project a BFF. + +## Creating a new project + +1. Match the SDK and style of sibling projects. +2. Use Central Package Management if the repository has it. +3. Add only the dependencies the project's scope needs — the SDK supplies the testing stack, + SourceLink and telemetry by default. +4. Add the project to the solution. +5. Keep target frameworks, nullability, analyzers and warning settings consistent with neighbours. + +## Moving or splitting + +1. Create the destination using the established layout. +2. Move the files. +3. Update namespaces, usings and references for the destination. +4. Verify the destination still produces the intended `TestingType`, `TargetProjectName` and + `RootNamespace` — ask MSBuild rather than assuming: + `dotnet msbuild -getProperty:RootNamespace -getProperty:TargetProjectName`. +5. Remove dependencies the source no longer needs. +6. Update solution membership and project references. +7. Build, then run the affected tests. + +## Guardrails + +- Prefer minimal, targeted diffs; avoid cross-cutting renames unrelated to the move. +- Keep test intent unchanged while relocating. +- When the structure is ambiguous, infer from the nearest sibling projects and say which assumption + you made. +- When in doubt, preserve compatibility with the SDK's automatic naming, namespace and + project-reference behaviour. diff --git a/.agents/skills/purview-buildsdk-repository-review/SKILL.md b/.agents/skills/purview-buildsdk-repository-review/SKILL.md new file mode 100644 index 0000000..ce1fd29 --- /dev/null +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -0,0 +1,111 @@ +--- +name: purview-buildsdk-repository-review +description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Reviewing a repository against the SDK's conventions + +This is an audit, not a refactor. The deliverable is a prioritised list of findings and the cost of +each, so somebody else can decide what to change. + +For the conventions themselves use `purview-buildsdk-project-layout`; for why one project was +classified as it was, `purview-buildsdk-project-detection`. + +## The governing judgement + +**Deviation is only a finding when it costs the repository something.** The SDK infers behaviour +from naming and placement, so a deviation is interesting precisely to the extent that it turns an +automatic behaviour off. A layout that differs from the canonical one but still gets every automatic +behaviour is not debt, and reporting it as such wastes the reader's attention. + +Sort every finding into one of three buckets, and say which: + +| Bucket | Meaning | +| -- | -- | +| **Acceptable variance** | Differs from the canonical layout, costs nothing. Preserve it. | +| **Structural debt** | Blocks an automatic behaviour the repository would benefit from. | +| **Incremental opportunity** | Low-risk tidying worth doing when the area is touched anyway. | + +## 1. Read before concluding + +Inspect, in this order, before forming any opinion: + +- `Directory.Build.props` and `Directory.Build.targets` — where the SDK is imported, and what is set + before the import +- the solution entry point, for what is actually in the build +- `Directory.Packages.props`, for Central Package Management +- the project layout on disk + +## 2. Check the structural signals + +- Source projects under `src/`, test projects under `tests/`. +- `.csproj` filename equals its containing directory name — unless + `DisableProjectFileNamingConventionCheck=true`, which is itself a finding worth surfacing. +- Short project names, with `NamespacePrefix` carrying the repository identity. +- Test project names match `^.*\.([\w|_]\w*)Tests?$`. **The dot is required**; a project named + plainly `UnitTests` is not detected at all and silently gets no runner, no coverage, no + `InternalsVisibleTo` and no category. This is the single highest-value thing to check. +- Test suffixes come from the SDK's shipped `TestType` group where `internal` access is wanted — a + suffix outside it still runs, it just cannot see `internal` members. +- Shared and shared-testing projects use the **exact** recognised names. A near miss gets none of + the behaviour. + +## 3. Verify with MSBuild, do not infer from folder names + +A review that reasons from directory names will be wrong about at least one project. Ask: + +```bash +dotnet msbuild -getProperty:RootNamespace -getProperty:AssemblyName \ + -getProperty:IsTestProject -getProperty:TestingType -getProperty:TargetProjectName +``` + +Quote the computed values in the findings. "`Api.Tests` resolves `TestingType=` empty, so it has no +runner" is actionable; "`Api.Tests` looks like it might not be detected" is not. + +## 4. Review test readability + +- Subject-based suites: `{SubjectName}Tests`, with + `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` methods. +- Non-subject-based suites — build, generator, workflow, package, full-system — named clearly for + their broader role. A forced `{Subject}Tests` on one of these is worse, not better. +- TUnit categories and display names used where a suite is large enough to need navigating. + +See `purview-buildsdk-testing`. + +## 5. Check the test dependencies are earned + +The SDK already supplies `TUnit`, `TUnit.Mocks`, `Bogus` and the Microsoft.Testing.Platform wiring +to every detected test project. Anything beyond that was a deliberate choice, and deliberate choices +go stale: + +- `TUnit.Aspire` and `Purview.Containers` are **not** universal defaults. Confirm each reference is + genuinely needed by that project rather than copied from a sibling. +- Where a project references `Testcontainers` directly, check whether a `Purview.Containers` typed + module now covers it — the module set grows, and a direct reference that was once necessary often + is not any more. +- A package the SDK already adds, restated in a project file, is maintenance cost with no effect. + +## 6. Explain consequences, not just mismatches + +For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, +`AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the +sibling `Shared*` reference, `InternalsVisibleTo` generation, the test runner and coverage wiring. + +"Project is not under `src/`" is not a finding. "Project is not under `src/`, so the test project's +four probe paths all miss and the `ProjectReference` has to be maintained by hand" is. + +## Reporting the result + +1. One sentence on whether the repository broadly fits the SDK's conventions. +2. The findings, ordered by impact, each with its bucket and the behaviour it costs. +3. The low-risk rationalisation steps, as concrete renames or moves. +4. An explicit split of what is **already automatic** versus what this repository has configured by + hand — that list is usually the most useful part of the review, because hand-configuration that + merely restates a default is pure maintenance cost. + +Do not propose a cross-cutting rename as a first step. Name the one change with the best +cost-to-benefit ratio and say what it unlocks. diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md new file mode 100644 index 0000000..66c20a3 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -0,0 +1,166 @@ +--- +name: purview-buildsdk-skill-installation +description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Installing the skills a package ships + +For *authoring* skills, use `purview-buildsdk-skill-tests`. + +## The one thing to understand first + +**The SDK writes nothing into an agent directory.** A package *ships* `.agents/skills//`, but +as metadata — it is what tells the SDK which skills the package offers and at which commit. Nothing +copies it out. + +Skills are installed by the skills CLI and by nothing else. That is a deliberate single-writer rule. +Earlier versions of this SDK mirrored a package's whole `.agents/**` folder into the consuming +repository, and the CLI's own universal install location is `.agents/skills/` — the same paths. The +mirror would silently overwrite a pinned install on the next build, while `PRSKL0011` compared a +lockfile ref against content the mirror had already replaced. The mirror was removed outright rather +than fenced off, because the CLI covers the whole job. + +Installing is therefore a separate, deliberately **explicit** step. It is never a side effect of a +restore, a build, a design-time build or a test run. + +```bash +dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing +dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +``` + +Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their +own, something has been wired up that should not be. + +## How a skill is resolved, and why the version is always right + +There is deliberately **no custom manifest**. A package that ships skills already carries both +halves of the mapping, and `ResolvePurviewSkillSources` reads them: + +- `.agents/skills//` in the restored package says *which* skills it ships. +- The nuspec's ``, written by SourceLink for every package this + SDK builds, says which git repository and which **exact commit** produced them. + +So the resolved ref is the commit the *restored package version* was built from. An older +`PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility +is exact by construction rather than by convention. + +## Installing from an explicit source + +For a skill you want but have no `PackageReference` for: + +```bash +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk +``` + +The caller owns the ref here — append `#` to pin it. This path needs no restore, so +`PRSKL0001` does not apply. + +## Diagnostics + +| Code | Meaning | Fix | +| -- | -- | -- | +| `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | +| `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | +| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | +| `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx skills add ...` | +| `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | +| `PRSKL0012` | A skill cannot be verified | Message | +| `PRSKL0013` | An orphaned lockfile entry | Message | +| `PRSKL0014` | Recorded in the lockfile but never restored | Message | + +## Recommendations run during an ordinary build + +This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and +delegates to `RecommendPurviewSkills`, which can also be invoked by name: + +```bash +dotnet msbuild -t:RecommendPurviewSkills +``` + +`EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own +`skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. + +Four things keep it quiet, and all of them are load-bearing: + +- Design-time builds, CI builds, non-default TFM passes and + `EnablePurviewSkillRecommendations=false` are skipped outright. + `PurviewSkillRecommendationsInCI=true` re-enables CI. +- A fingerprint stamp at `/.purview/skill-recommendations.cache` means a given state is + reported once per repository, not once per project and again on every rebuild. The explicit target + ignores the stamp. +- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are + **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. +- A correctly installed set produces no output at all. + +Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so +only an entry under an owner the project references — but from a repository it no longer references +— is reported. A skill from any other owner is the developer's own, and is never mentioned. + +### Drift never recommends `npx skills update` + +`update` installs the *newest* published skill, which is only correct when the newest skill happens +to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in +a pinned `npx skills add` instead. + +## Package runners + +The CLI is published to npm, and every runner spells "run this npm package" differently. +`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. + +| Runner | Invocation | +| -- | -- | +| `npx` | `npx -y skills@1` | +| `bunx` | `bunx skills@1` | +| `bun` | `bun x skills@1` | +| `pnpm` | `pnpm dlx skills@1` | +| `yarn` | `yarn dlx skills@1` — Yarn 2+ only | +| `deno` | `deno run -A npm:skills@1` | + +An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. + +`DetectPurviewSkillsCli` probes `PATH` for `skills` first, then `$(PurviewSkillsRunner)`, then +`$(PurviewSkillsRunnerCandidates)`, honouring `PATHEXT` on Windows where these are `.cmd` shims. +`PATH` is read directly rather than by spawning a version check: deterministic, no process, and it +cannot hang on a cold download. **The runner actually found wins over the configured preference.** +`PurviewSkillsCommand` overrides the launcher entirely and skips detection. + +`ListPurviewSkills` reports runner availability but **never** errors on its absence — resolving and +reporting needs no Node tooling at all. + +## Always the repository root + +`skills-lock.json` and the agent directories belong to the repository, not to a project, so every +skill target runs from one resolved root: + +1. `PurviewSkillsInstallRoot` — an explicit override +2. `PurviewSkillsRepoRoot` — only when the **user** set it +3. the `PurviewRepositoryRootMarkers` walk +4. `PurviewSkillsRepoRoot` / `RepoRoot` as the SDK guessed them +5. CI workspace variables +6. SourceLink's git `SourceRoot` + +There is deliberately **no project-directory fallback**. That would scatter a lockfile and an agent +directory through the tree, one per project. An unresolved root is `PRSKL0003` on install, and +silence for recommendations — a build never fails over advice. + +The marker walk is described in [references/repository-root.md](references/repository-root.md); its +two-pass behaviour and its home-directory bound are both load-bearing. + +## Why the CLI rather than a reimplementation + +The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent +and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore +the same set later with `npx skills experimental_install`. + +`PurviewSkillsAgents` (default `*`) narrows the target agents. + +The lockfile is read **directly** rather than by parsing `npx skills list --json`, because that +listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs +no Node tooling, which is what keeps recommendations working on a machine that can build .NET but +has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md new file mode 100644 index 0000000..e90b695 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -0,0 +1,55 @@ +# The repository-root marker walk + +`FindPurviewRepositoryRoot` is an inline MSBuild task driven by `PurviewRepositoryRootMarkers`: + +```text +.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json +``` + +A trailing slash marks a directory. A directory marker still matches a **file** of that name, +because `.git` is a directory in a clone but a file in a worktree or a submodule, and both identify +a repository root. + +## Three things that are load-bearing + +### It cannot be an MSBuild intrinsic + +`GetDirectoryNameOfFileAbove` matches **files only** and returns nothing for `.git/`, `.github/`, +`.agents/` or `.claude/` — four of the nine markers. Verified, not assumed. + +### Two passes + +- **Pass 1** — the first marker in the list is *definitive* and is searched all the way up. `.git/` + *is* the repository root, so a monorepo sub-package with its own `package.json` does not win. +- **Pass 2** — every other marker resolves nearest-ancestor-first, with list order breaking ties + inside a single directory. Searching them all to the top would let a stray `.claude/` in a + developer's home directory outrank the real root. + +### The home directory is never a candidate + +The home directory and everything above it are excluded. Without that bound, a project outside any +repository resolves to `~` and installs into it — and a `.claude/` or a `.github/` very commonly +sits there. A repository *below* the home directory is unaffected, because it is below it. + +## Why the marker walk outranks the evaluation-time guess + +`PurviewSkillsRepoRoot` is computed during evaluation from `RepoRoot` and an `AGENTS.md` walk-up. +An `AGENTS.md` can legitimately sit in a subdirectory, and resolving to it would put a lockfile and +an agent directory somewhere that is not the repository. + +`_PurviewSkillsRepoRootWasSpecified` is captured in `Sdk.props` *before* defaulting, so a value the +**user** set still outranks the walk while a value the SDK guessed does not. + +## A target's Condition is evaluated before its DependsOnTargets + +A property a dependency computes is still empty in the depending target's `Condition`. That is why +the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on +`RecommendPurviewSkills` — and equally why the build hook's CI and design-time gating works at all. + +## The managed port + +`Purview.Skills.Testing` ships `SkillRepositoryRoot`, a managed port of the same walk with the same +default markers, so a test that validates a repository's skills resolves the same root the build +installs them into. The two are kept in step by `SkillRepositoryRootTests`, including an assertion +on the default marker list itself — both are defaults nobody sets, so nothing else would notice them +drifting apart. diff --git a/.agents/skills/purview-buildsdk-skill-tests/SKILL.md b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md new file mode 100644 index 0000000..4043ac4 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md @@ -0,0 +1,131 @@ +--- +name: purview-buildsdk-skill-tests +description: Author agent skills in a repository that uses Purview.BuildSdk, and validate them with Purview.Skills.Testing or the SDK's pack-time ValidatePurviewSkills target. Use when adding or renaming a skill, writing SKILL.md frontmatter, splitting content into references/ or scripts/, fixing a PRSKL0005 pack failure or a skill-name, skill-description or skill-bundled-resources test failure, or deciding where skills belong in a repository. +license: MIT +metadata: + package: Purview.Skills.Testing + repository: purview-dev/build-sdk +--- + +# Authoring and validating agent skills + +For installing skills a package ships, use `purview-buildsdk-skill-installation`. + +## Where skills live + +**At the repository root, in `skills//SKILL.md`.** Always. + +The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by +The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by +cloning it, so nothing else is visible to it. Nothing is packed into a NuGet package — a package +carries .NET artifacts, and skills are delivered by the CLI from git. + +```text +skills/ + my-skill/ + SKILL.md required + references/ loaded on demand + scripts/ executable, run on demand + assets/ templates and static resources +``` + +Link to a skill by its **authored** path, `./skills//SKILL.md`. A link into `.agents/skills/` +points at a gitignored file that does not exist in a fresh clone. + +## `SKILL.md` format + +```markdown +--- +name: my-skill +description: What it does, and when to use it. Include the words a task would contain. +license: MIT +metadata: + package: My.Package +--- + +# My skill + +Instructions. +``` + +| Field | Required | Constraint | +| -- | -- | -- | +| `name` | yes | 1–64 chars, lowercase `a-z0-9` and single hyphens, no leading, trailing or consecutive hyphen, **must equal the folder name** | +| `description` | yes | 1–1024 chars, non-empty | +| `license` | no | A licence name, or the name of a bundled licence file | +| `compatibility` | no | ≤500 chars, only when the skill has real environment requirements | +| `metadata` | no | A map of string to string. Anything not defined above belongs here | +| `allowed-tools` | no | Space-separated pre-approved tools. Experimental | + +**There are no other top-level fields.** `category`, `roles` and `tags` are common inventions and a +strict client rejects them; put them under `metadata`. + +The full specification is at . +[references/authoring.md](references/authoring.md) covers description writing, naming for a shared +namespace, and progressive disclosure. + +## Progressive disclosure + +An agent loads a skill in three stages: the `name` and `description` at startup, the whole +`SKILL.md` body once the skill activates, and `references/`, `scripts/` or `assets/` only when the +task reaches them. + +So: keep `SKILL.md` **under 500 lines**, and move detail into `references/`. The body is a cost paid +on every activation; a reference file is paid only when it is needed. + +Keep file references one level deep from `SKILL.md`, and relative to the skill root. A path that +climbs out of the skill folder is broken everywhere except the authoring repository, because only +the skill's own folder is installed. + +## Validating + +### In tests — `Purview.Skills.Testing` + +```csharp +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests; +``` + +**`[InheritsTests]` is load-bearing.** TUnit does not walk base types during discovery, so without +it the class compiles, the run is green and nothing is validated. TUnit reports `TUnit0030`; treat +it as an error. + +| Rule id | What it checks | +| -- | -- | +| `skills-discovery` | The folder, when present, holds at least one skill | +| `skill-structure` | Every folder has a `SKILL.md` whose frontmatter parses | +| `skill-name` | Spec-legal, unique, and equal to the folder name | +| `skill-description` | Present, non-empty, within 1024 characters | +| `skill-optional-fields` | Only spec-defined fields, each within its limit | +| `skill-bundled-resources` | Every linked file exists, inside the skill folder | +| `skill-body-size` | `SKILL.md` stays under the recommended 500 lines | + +A repository with **no** `skills/` folder passes every rule — authoring skills is optional. An +**empty** folder fails `skills-discovery`, because every other rule is vacuous against an empty +catalogue. + +Overrides: `RepositoryRoot` (default: the SDK's repository-root marker walk), +`SkillsFolderName` (default `skills`), and `SkipRule(string)`. + +The evaluator takes a `SkillCatalogue`, so a rule can be exercised against a fixture folder rather +than a real repository — the same three-layer shape this repository uses throughout: a reader, an +evaluator over an in-memory model, and a thin test base class. + +### At pack time — `ValidatePurviewSkills` + +It runs `BeforeTargets="Pack"`, so a malformed skill fails in the repository that owns it rather +than reaching the CLI, which would silently skip it. + +```bash +dotnet msbuild -t:ValidatePurviewSkills +``` + +Failures are reported as `PRSKL0005`, one per problem. Every authored skill is checked. + +The two checks overlap deliberately: the pack-time one runs without a test host, the test one +covers more of the specification. + +## What is deliberately not checked + +Prose quality, and whether an agent actually obeys the skill. A build can only check that a skill is +well formed. Treat a green run as "installable", not "good". diff --git a/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md new file mode 100644 index 0000000..69d4ee4 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md @@ -0,0 +1,79 @@ +# Authoring a skill that fires, and keeps firing + +## The description is the whole activation decision + +`name` and `description` are the only things loaded at startup. Everything else — the body, the +references, the scripts — is invisible until an agent has already decided to activate the skill on +the strength of those two fields. + +So write the description for a *matcher*, not for a reader: + +- Say **what it does** and **when to use it**, in that order. +- Include the words a real task would contain: the tool names, the file names, the error codes, the + property names, the phrases a person would type. +- Name the thing the skill is *not*, when a neighbouring skill could be confused with it. + +| | | +| -- | -- | +| Poor | `Helps with the build SDK.` | +| Better | `Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing framework, or working out whether a property must be set before the SDK import.` | + +The limit is 1024 characters. Most of that budget is better spent on trigger words than on prose. + +## Naming for a shared namespace + +Skills install into one flat directory per agent, shared with every other skill a developer has +installed from anywhere. A generic name is a collision waiting to happen, and the loser is whichever +one installed second. + +- Prefix with the vendor and the product: `purview-buildsdk-configuration`, not `sdk-configuration`. +- `sdk-*`, `project-*`, `test-*`, `build-*` are all too common to use unprefixed. +- The name must be 1–64 characters, lowercase `a-z0-9` with single hyphens, no leading, trailing or + consecutive hyphen — and it must equal the folder name, because the CLI installs by the + frontmatter name while a human reads the folder. + +Renaming a skill means renaming the folder and the frontmatter together, and re-running the install. + +## Progressive disclosure in practice + +| Stage | Loaded | Budget | +| -- | -- | -- | +| Metadata | `name`, `description` | ~100 tokens, always | +| Instructions | the whole `SKILL.md` body | under 5000 tokens recommended; keep the file under 500 lines | +| Resources | `references/`, `scripts/`, `assets/` | only when the task reaches them | + +What belongs where: + +- **`SKILL.md`** — the decisions, the rules, the things that are wrong by default, and a pointer to + everything else. If a reader needs it to make the *first* correct move, it goes here. +- **`references/`** — long tables, full option lists, grammars, enumerations. Keep each file + focused; an agent loads the whole file. +- **`scripts/`** — executable code. Self-contained, or documenting its dependencies clearly, with + helpful error messages. +- **`assets/`** — templates, schemas, images, lookup data. + +Keep references one level deep. Avoid a chain where one reference sends an agent to another. + +## Links + +Use relative paths from the skill root: + +```markdown +See [the reference](references/REFERENCE.md) for detail. +Run `scripts/extract.py`. +``` + +Only the skill's own folder is installed, so a link that climbs out of it (`../other-skill/...`) is +broken on every machine except the one that authored it. To point at another skill, name it in prose +and let the agent find it by name. + +## Keeping a skill true + +A skill that describes a product goes stale the moment the product changes, and nothing about a +stale skill looks broken — it just gives confidently wrong advice. + +- Change the skill in the **same commit** as the behaviour it documents. +- Prefer stating the property name, the diagnostic code or the target name over paraphrasing the + behaviour; those are greppable when the code changes. +- Where a number or a list is authoritative somewhere else — a default, an enumeration — say where + it comes from, so a reader can check it. diff --git a/.agents/skills/purview-buildsdk-testing/SKILL.md b/.agents/skills/purview-buildsdk-testing/SKILL.md new file mode 100644 index 0000000..5a5a7d5 --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/SKILL.md @@ -0,0 +1,121 @@ +--- +name: purview-buildsdk-testing +description: Write and run tests in a repository that uses Purview.BuildSdk. Use when authoring or refactoring TUnit tests (AAA structure, Subject_Scenario_Expectation naming, awaited assertions, CancellationToken), when choosing a test project's framework packages, and when running or filtering tests - the SDK wires Microsoft.Testing.Platform, so `dotnet test --filter` reports "Zero tests ran" and `--treenode-filter` is the one that works. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Testing in a Purview.BuildSdk repository + +Two things to know before anything else: + +1. A test project is detected by **name**, and detection is what wires the runner. See + `purview-buildsdk-project-layout` if the project is new or being moved. +2. The runner is **Microsoft.Testing.Platform (MTP)**, not VSTest. `--filter` does nothing useful. + +## What a test project gets automatically + +With the defaults (`TestingFramework=TUnit`, `SubstituteFramework=TUnitMocks`, +`TestDataFramework=Bogus`), a detected test project receives: + +- `TUnit`, `TUnit.Mocks`, `Bogus` +- Microsoft.Testing.Platform integration +- `CollectCoverage=true` +- test-friendly `NoWarn` defaults (`PurviewTestContextNoWarn`), while keeping the strict style + contract — `IDE0040`, field naming, formatting, `IDE1006` +- `IsPackable=false`, `IsPublishable=false` + +Add nothing else unless the project's purpose demands it. Two that stay explicit: + +- `TUnit.Aspire` — Aspire lifecycle and AppHost-backed integration tests +- `Purview.Containers` — container-backed integration tests. One API over Microsoft WSL Containers + (no Docker install needed on Windows) or Docker, with typed modules for SQL Server, PostgreSQL, + Redis, MySQL, RabbitMQ, Azurite and NATS. Experimental and prerelease, so pin the version. + Reference `Testcontainers` directly only where no typed module exists yet. + +## Authoring rules + +- Use TUnit (`TUnit.Core`, `TUnit.Assertions`). Do not introduce xUnit, NUnit, MSTest or + FluentAssertions unless explicitly asked. +- Every test method carries `[Test]`. +- **Every assertion is awaited**: `await Assert.That(actual).IsEqualTo(expected);`. An un-awaited + assertion silently passes. +- Arrange / Act / Assert, with the `// Arrange`, `// Act`, `// Assert` comments present. +- Test classes are `{SubjectName}Tests`, in the **same namespace** as the subject. +- Test methods are `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. Use `ctor` as the subject + for constructor tests. Methods, constructors, properties, operators, conversions and validation + hooks are all valid subjects. +- When any method called in the test body accepts a `CancellationToken`, take + `CancellationToken cancellationToken` as the **last parameter** and pass it through. + +```csharp +[Test] +public async Task ProcessAsync_GivenAnEmptyBatch_ReturnsZero(CancellationToken cancellationToken) +{ + // Arrange + var sut = new BatchProcessor(); + + // Act + var result = await sut.ProcessAsync([], cancellationToken); + + // Assert + await Assert.That(result).IsEqualTo(0); +} +``` + +For a suite that is not subject-based — a build, generator, workflow, package or full-system suite — +a broader, more truthful class name is better than a forced `{Subject}Tests`. Use TUnit display +names and categories to keep it navigable. + +When refactoring existing tests, bring them into compliance with all of the above while preserving +test intent. + +## Categories + +The detected test type becomes the baseline category automatically — `Api.IntegrationTests` yields +`Category=Integration`. Add further categories when they make a large suite easier to navigate; +they are what `--treenode-filter` property predicates select on. + +## Running tests + +```bash +dotnet test # the whole suite +dotnet test --treenode-filter "/*/*/LoginTests/*" # one class +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" +``` + +Prefer the repository's own task (`just test`, or equivalent) for a full run. + +The filter path is `////`, with `*` wildcards, `=`/`!=` property +predicates, `&`/`|` inside a single bracket group, and `**` only at the end. The full grammar, the +`--` separator rules across SDK versions, and the zero-tests checklist are in +[references/treenode-filter.md](references/treenode-filter.md). + +### "Zero tests ran" + +In order: + +1. Are you using `--treenode-filter` and not `--filter`? This is the cause most of the time. +2. Try `dotnet test --treenode-filter "..."`; on an older SDK use `dotnet test -- --treenode-filter "..."`. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Does the project actually contain `[Test]` methods? +5. Is the project **detected** as a test project? A name without the dot — `UnitTests` rather than + `Api.UnitTests` — gets no runner at all. Check with + `dotnet msbuild -getProperty:IsTestProject`. +6. Are all property predicates inside one bracket group, `[(A=1)&(B=2)]`? + +## Cross-platform requirement + +CI runs the full suite on Linux, so a test that only passes on Windows is a failing change. + +- Never hardcode a platform-specific path — no `C:\...`, no backslash-only separators, no + case-insensitive path assumptions. +- Build paths with `Path.Combine`, `Path.GetTempPath()`, `Path.DirectorySeparatorChar`, + `Path.GetFullPath()`. +- Feed production path-math code only native-format paths. Normalise a fixed Windows-style literal + to the current platform before passing it in. +- Do not rely on a case-insensitive filesystem, a `C:` drive, or trailing-separator behaviour. +- Keep `SyntaxTree` file paths and `build_property.*` values consistent and native when writing + analyzer tests. diff --git a/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md new file mode 100644 index 0000000..35d032b --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md @@ -0,0 +1,107 @@ +# `--treenode-filter` reference + +TUnit runs on Microsoft.Testing.Platform, not VSTest, so the VSTest `--filter` syntax does not +apply. Narrowing is done with `--treenode-filter`. + +## The path + +```text +//// +``` + +Each segment maps to one level of the tree: + +| Position | Matches | +| -- | -- | +| 1 | Assembly | +| 2 | Namespace | +| 3 | Class | +| 4 | Test name | + +```text +/*/*/LoginTests/* every test in LoginTests +/*/*/*/AcceptCookiesTest one test by name +/*/MyProject.Tests.Integration/*/* every test in a namespace +/*/*/*/* everything +``` + +## Operators + +### `*` — wildcard + +Matches a whole segment or part of one. + +```text +/*/*/LoginTests*/* +/*/*/MyProject.Tests.Api*/* +``` + +### `=` and `!=` — property predicates + +In the last segment, inside `[...]`. + +```text +/*/*/*/*[Category=Unit] +/*/*/*/*[Category!=Slow] +/*/*/*/*[Owner=*Team-Backend*] +``` + +### `&` and `|` — combining + +Inside a **single** bracket group, or a single parenthesised segment. + +```text +/*/*/*/*[(Category=Unit)&(Priority=High)] +/**[(Category=Unit)|(Priority=High)] +/*/*/(LoginTests)|(SignupTests)/* +``` + +### `**` — match all + +Any depth, but **only at the end** of the path. + +```text +/** +/MyAssembly/** +``` + +`/**/Path` is not valid. + +## Rules that catch people out + +- Only **one** property group `[...]` per path segment. `[Category=Smoke]|[Priority=High]` is not + valid; write `[(Category=Smoke)|(Priority=High)]`. +- `**` must be last. +- A segment left empty is not a wildcard — write `*`. + +## The `--` separator + +```bash +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" # preferred +dotnet test -- --treenode-filter "/*/*/*/*[Category=Unit]" # older SDKs +``` + +Newer .NET SDKs forward unrecognised arguments to the MTP runner directly. On an older SDK the `--` +separator is required, and omitting it produces an argument error rather than a filter. + +## Common selections + +| Intent | Filter | +| -- | -- | +| Everything | `/*/*/*/*` | +| All unit tests | `/*/*/*/*[Category=Unit]` | +| High-priority unit tests | `/*/*/*/*[(Category=Unit)&(Priority=High)]` | +| One class | `/*/*/MyFeatureTests/*` | +| A name pattern | `/*/*/*/MyScenario*` | +| Critical integration tests | `/*/MyProject.Tests.Integration/*/*[Priority=Critical]` | + +## Zero tests ran + +1. `--treenode-filter`, not `--filter`. +2. Try without the `--` separator first, then with it. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Confirm the project has `[Test]` methods. +5. Confirm the project is **detected** as a test project — + `dotnet msbuild -getProperty:IsTestProject`. A name without the dot, such as + `UnitTests` rather than `Api.UnitTests`, is not detected and gets no runner. +6. Confirm every property predicate sits in one bracket group. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md new file mode 100644 index 0000000..ee363c9 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md @@ -0,0 +1,334 @@ +--- +name: purview-sourcegenerator-codewriter +description: >- + Use when implementing, reviewing or refactoring C# source generators and analysers built on + Purview.SourceGeneratorFramework. Covers CodeWriter and XmlCommentWriter emission, scope safety, + incremental pipeline design, value equality, deterministic output, and migrating a legacy + string/StringBuilder emitter to CodeWriter. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Source generator CodeWriter modernization + +Use this skill for any work involving C# source generators, analysers, or generated output in `Purview.SourceGeneratorFramework`. It combines CodeWriter/XmlCommentWriter emission guidance with the incremental-source-generator and analyser best practices that ship with the framework. + +Refactoring an existing emitter off `string`/`StringBuilder`? The step-by-step playbook is in +[references/legacy-emitter-migration.md](references/legacy-emitter-migration.md). + +## Required implementation pattern + +When creating source output, favor this shape: + +1. Build immutable, value-equatable pipeline values first. +2. Register source output. +3. Inside the callback: `var writer = generationContext.CreateCodeWriter();` +4. Write header/usings/namespace. +5. Write structured types and members using declaration options records. +6. Add source once per output artifact. + +### CodeWriter lifetime: created in the output callback, never in pipeline state + +The line between "fine" and "forbidden" is the **incremental-cache boundary**, not the act of +creating a writer or handing it to a helper. + +**Fine — output-scoped use inside a `RegisterSourceOutput` callback.** Create the writer inside the +callback and pass it to any emitter/helper methods called from that same callback. It may be held in +local variables, passed as a parameter, or wrapped in a short-lived output context: + +```csharp +context.RegisterSourceOutput( + targets.CombineWithContext(contextProvider), + static (spc, pair) => + { + var (model, generationContext) = pair; + var writer = generationContext.CreateCodeWriter(); + EmitHeader(writer, model); + EmitType(writer, model); + spc.AddSource($"{model.Name}.g.cs", writer.ToString()); + } +); +``` + +**Forbidden — persisting the writer across the incremental-cache boundary.** Do not create it earlier +in the pipeline and pass it down, and do not store it anywhere Roslyn caches or another callback can +observe it: + +- Do not create a `CodeWriter` in a provider stage and carry it through the pipeline. +- Do not store a `CodeWriter` as a property or field on `GenerationContext` or a custom context. +- Do not return a `CodeWriter` (or an object holding one) from an incremental provider. +- Do not cache or reuse a writer for a later callback or a different output. + +A cached writer can retain previously written source, mix output from concurrently running +callbacks, and defeat scope tracking. A writer created in the callback and used only within that +callback is safe and expected. + +## Source generator & analyser best practices + +Apply the following rules to every generator, analyser, and refactor. + +### 1. Core principles + +- **Analyser for validation; generator for generation.** +- **Syntax for syntax, symbols for declarations, operations for executable semantics.** +- **Use `ForAttributeWithMetadataName` for attribute-driven generators.** +- **Remove Roslyn objects from the incremental pipeline as early as possible.** +- **Every value crossing a pipeline boundary must have meaningful value equality.** +- **Prefer many small incremental stages over one large transform.** +- **Keep broad inputs such as `Compilation` away from downstream generation.** +- **Generate deterministic output.** +- **Compile against the oldest Roslyn API version containing the functionality you need.** +- **Test caching, not just generated text.** + +The guiding principle for an incremental generator is: + +> Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model. + +### 2. Analyser vs source generator + +Use a `DiagnosticAnalyser` when the question is: + +> Is the source code valid according to this library's rules? + +Use an `IIncrementalGenerator` when the question is: + +> Given valid source code, what source should be generated? + +| Requirement | Prefer | +| --- | --- | +| Require a class to be `partial` | Analyser | +| Require an attribute on a declaration | Analyser | +| Validate a method signature | Analyser | +| Reject unsupported property types | Analyser | +| Detect invalid attribute arguments | Analyser | +| Detect unsupported API usage | Analyser | +| Offer an automatic fix | Analyser + `CodeFixProvider` | +| Generate members for a marked class | Incremental generator | +| Generate serializers/validators/mappers | Incremental generator | +| Generate a registry from discovered types | Incremental generator | +| Read a schema file and generate C# | Incremental generator | +| Internal generation failure | Generator diagnostic | + +### 3. Choosing an analyser action + +Use the narrowest API that represents the concept being analysed: + +- `RegisterSyntaxNodeAction` — exact source syntax (e.g., modifier presence). +- `RegisterSymbolAction` — declaration semantics (e.g., attributes, interfaces, accessibility). +- `RegisterOperationAction` — executable behaviour (e.g., invocation, assignment, object creation). +- `RegisterOperationBlockStart/EndAction` — stateful method analysis. +- `RegisterSymbolStart/EndAction` — type-wide analysis across members. +- `RegisterCompilationStartAction` — resolve known framework symbols once. +- `RegisterAdditionalFileAction` — analyse `AdditionalFiles`. +- Avoid `RegisterSyntaxTreeAction`, `RegisterSemanticModelAction`, and compilation-end actions unless genuinely necessary. + +### 4. Syntax vs symbol vs operation + +Decision tree: + +1. Does exact source spelling/structure matter? → **Syntax** +2. Otherwise, is it a declaration? → **Symbol** +3. Otherwise, is it executable behaviour? → **Operation** + +Use `SymbolEqualityComparer.Default.Equals(...)` when comparing symbols. + +### 5. Analyser best practices + +- Enable concurrent execution with `context.EnableConcurrentExecution()`. +- Explicitly configure generated-code analysis with `context.ConfigureGeneratedCodeAnalysis(...)`. +- Resolve known framework/library symbols once in a `RegisterCompilationStartAction`. +- Prefer narrow registrations over scanning entire syntax trees or compilations. +- Treat diagnostic IDs as public contracts and maintain release tracking files when publishing public diagnostics. + +### 6. Incremental generator golden rules + +Implement `IIncrementalGenerator`. Simply implementing it is not enough; the pipeline must be incremental. + +> **Pipeline values must be immutable and value-equatable.** + +Never keep these in persistent pipeline models: + +| Type | Verdict | +| --- | --- | +| `ISymbol` / `INamedTypeSymbol` / `IMethodSymbol` / `IPropertySymbol` | Never retain | +| `Compilation` | Do not propagate | +| `SemanticModel` | Do not propagate | +| `IOperation` | Do not propagate | +| `SyntaxTree` | Do not propagate | +| `SyntaxNode` | Remove ASAP | +| `Location` | Remove ASAP | +| `AdditionalText` | Project immediately | +| `T[]` / `List` | Avoid | +| `ImmutableArray` | Wrap with sequence equality | + +Use immutable records and `EquatableArray` (sequence equality) for collection members. + +### 7. Designing the pipeline + +Pipeline shape: + +```text +Roslyn Input + ↓ +Cheap discovery + ↓ +Semantic extraction + ↓ +Small equatable model + ↓ +Validation/transformation + ↓ +Generation model + ↓ +Source output +``` + +Guidelines: + +- Project the semantic transform as the boundary where Roslyn objects disappear. +- Prefer `static` callbacks to avoid capturing generator state. +- Honour cancellation tokens. +- Split transformations into many small stages. +- Keep syntax predicates cheap. +- Avoid indirect discovery (every interface implementation, every subclass, entire-compilation scans). + +### 8. Syntax discovery + +- Prefer `context.SyntaxProvider.ForAttributeWithMetadataName(...)` for attribute-driven generators. +- Use `context.SyntaxProvider.CreateSyntaxProvider(...)` only when syntax itself is the trigger and there is no marker attribute. +- The predicate must be cheap; do not walk the tree or do semantic work in it. + +### 9. `Collect`, `Combine`, and invalidation + +- `Collect()` turns per-item outputs into one aggregate. Changing any item invalidates the aggregate. +- Use `Collect()` only for genuinely global output: registries, lookups, duplicate detection, aggregate switches. +- Prefer per-item `RegisterSourceOutput`. +- `Combine()` is correct when the output depends on two providers. +- Avoid `models.Combine(context.CompilationProvider)` — project the compilation to a tiny capability fact first. +- Use `.WithComparer(...)` only when logical equality differs from the default. + +### 10. Diagnostics + +- Prefer a separate `DiagnosticAnalyser` for normal user validation. +- Use generator diagnostics only for malformed additional files, generator-only configuration, conflicting output, or failures that cannot be expressed by an analyser. +- Report diagnostics on the most useful user-authored `Location`. +- Do not keep `Location` in long-lived pipeline models. + +### 11. Output generation + +- Output must be deterministic: no timestamps, random GUIDs, process IDs, machine paths, culture-dependent output, or unordered dictionary output. +- Hint names must be deterministic, unique, and stable. +- Prefer text generation or `CodeWriter` over building Roslyn syntax trees just to stringify them. +- Use `RegisterPostInitializationOutput` for constant source such as marker attributes. +- Add generated source once per output artifact. + +### 12. Testing incrementally + +- Snapshot-testing generated source is not enough. +- Test first execution, cached second execution, unrelated changes remaining cached, per-target invalidation, deletion, renaming, global options, additional files, and global registry invalidation. +- Use `GeneratorDriverOptions` with `trackIncrementalGeneratorSteps: true` and inspect reasons: `New`, `Modified`, `Unchanged`, `Cached`, `Removed`. + +### 13. Roslyn version compatibility and packaging + +- The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. +- The consumer's `TargetFramework` does not determine analyser compatibility. +- Choose the oldest Roslyn version that contains the APIs you need. +- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. +- Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. +- Do not mistake multi-targeting for automatic analyser asset selection. +- Use `PrivateAssets="all"` for Roslyn development dependencies. +- Enable `EnforceExtendedAnalyzerRules` and investigate `RSxxxx` diagnostics before suppressing them. + +### 14. Recommended project configuration + +A generator project should normally include: + +```xml + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + +``` + +## Preferred API map for CodeWriter + +### File and namespace + +- `AutoGeneratedHeader(...)` +- `Using(...)` +- `FileScopedNamespace(...)` or `BlockNamespace(...)` +- `OpenPragmasScope(...)` for warning suppression scopes + +### Types and members + +- Types: `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum`, `Delegate` +- Members: `Method`, `MethodScope`, `Property`, `Field`, `Constructor` +- Attributes: `AttributeDeclarationOptions`, `AttributeArgumentOptions` +- Type syntax: `TypeReference` (nullable/generic/array/pointer-safe composition) + +### XML documentation + +Use `XmlCommentWriter` extension methods on `CodeWriter`: + +- `XmlSummary(...)`, `XmlParam(...)`, `XmlTypeParam(...)`, `XmlReturn(...)`, `XmlRemarks(...)`, `XmlExample(...)` +- `XmlCode(...)` / `XmlCodeBlock(...)` +- `XmlList(...)`, `XmlSeeAlso(...)`, `XmlException(...)` + +Static helpers: `CodeWriter.XmlInlineCode(...)`, `CodeWriter.XmlSee(...)`, `CodeWriter.XmlParamRef(...)`, `CodeWriter.XmlText(...)`. + +## Refactoring guide: string/StringBuilder -> CodeWriter + +Apply this checklist in order: + +1. **Move emission boundaries** — replace giant string assembly with phases: header, namespace, type, members. +2. **Replace manual braces/indentation** — use `using` scopes (`ClassScope`, `MethodScope`, `OpenBlockScope`, `IndentedScope`). +3. **Replace handwritten signatures** — use declaration option records. +4. **Replace raw XML lines** — use XML extension methods (`XmlSummary`, `XmlParam`, etc.). +5. **Normalize type strings** — use `TypeReference`. +6. **Preserve semantics and ordering** — generated members and diagnostics must remain equivalent. +7. **Validate scope safety** — keep or enable `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` for tests/dev. + +## Anti-patterns to remove during refactors + +- `StringBuilder.AppendLine("public class ...")` for declarations that can be structured. +- Manually writing `{` / `}` around methods and types where scope APIs exist. +- Hard-coded nullable type suffixes and generic syntax in arbitrary strings when `TypeReference` is available. +- Raw XML tag string composition when XML extension methods can enforce consistency. +- Creating a `CodeWriter` before the output callback and passing it through the pipeline. +- Storing a `CodeWriter` on `GenerationContext`, a custom context, or any incremental pipeline model. +- Sharing one `CodeWriter` across multiple generated outputs or callbacks. +- Keeping Roslyn objects, `CodeWriter`, or mutable state in incremental pipeline models. + +## Review checklist for pull requests + +- Generated declarations use structured APIs for types and members. +- XML docs use XML extension methods rather than raw `///` fragments. +- `CodeWriter` is created inside each output callback and never persists in pipeline state; passing it + to helper methods within that callback is expected. +- Header and generated attributes are deterministic and consistent. +- Existing diagnostics, generated member names, and public behavior are preserved. +- Roslyn objects are removed from pipeline models; `EquatableArray` is used for collections. +- Per-target output is preferred over collected global output unless global knowledge is required. +- `CompilationProvider` is not casually combined into output. +- Deterministic hint names and source text are used. +- Incremental caching behavior is tested, not just generated text. + +## See also + +- `agents/source-generator-framework-writer.agent.md` — specialist agent for `Purview.SourceGeneratorFramework` emitter authoring. +- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md b/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md new file mode 100644 index 0000000..54efd09 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md @@ -0,0 +1,58 @@ +# Migrating a legacy emitter to CodeWriter + +A playbook for moving a source generator's emission from manual `string` / `StringBuilder` +construction to `CodeWriter` plus the `XmlCommentWriter` documentation extensions, with behaviour +parity. + +## Before you start, collect + +- The emitter file(s) being changed. +- The generator type name, and the version string it stamps into generated attributes or headers. +- Whether the output must stay **byte-identical**. If it must, every whitespace decision below is a + constraint rather than a preference, and the diff of the generated snapshots is the acceptance + test. + +## Requirements + +1. Use the structured declaration APIs rather than hand-written declaration text: + `Class` / `Struct` / `RecordClass` / `Interface` / `Enum`, and `Method`, `Property`, `Field`, + `Constructor`. +2. Use the XML helper extensions instead of composing `///` lines: `XmlSummary`, `XmlParam`, + `XmlReturn`, `XmlRemarks`, `XmlCode` / `XmlCodeBlock`. +3. Use `TypeReference` once the type text gets complex — nullability, generics, arrays. +4. Keep the writer **output-scoped**: `generationContext.CreateCodeWriter()` *inside* the output + callback. Never create it earlier in the pipeline, and never store it in incremental provider + state or in a custom context. +5. Preserve behaviour, diagnostics and generated names. +6. Keep the change minimal. Do not reformat logic the migration does not touch. + +## Strategy + +Work phase by phase, because each phase has a different mechanical replacement: + +| Emitter phase | Replace with | +| --- | --- | +| header | the header helper, not a literal banner string | +| namespace | the namespace scope API | +| type declarations | declaration option records, not signature strings | +| member declarations | `Method` / `Property` / `Field` / `Constructor` | +| indentation and braces | scoped APIs — never manual `{`/`}` and `\t` | +| `///` comment composition | `XmlSummary`, `XmlParam`, `XmlReturn`, `XmlRemarks`, `XmlCode` | + +Call out any intentional semantic delta explicitly; a migration that quietly changes emitted names or +diagnostics is the failure mode this playbook exists to avoid. + +## Verification + +- Run the impacted tests. +- Confirm the generated files still compile. +- Confirm there are **no scope leaks**: `OpenScopeCount == 0` when the writer is materialized. Enable + CodeWriter scope validation in the test run. +- Where byte-identical output was required, diff the generated snapshots. + +## Report + +1. Files changed. +2. Why each change was necessary. +3. Risks and behaviour differences, if any. +4. The verification that actually ran. diff --git a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md new file mode 100644 index 0000000..21d9522 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -0,0 +1,142 @@ +--- +name: purview-sourcegenerator-diagnostics +description: >- + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD or TLB diagnostic, + when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix + provider is silently not running at all. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Purview.SourceGeneratorFramework diagnostics + +`Purview.SourceGeneratorFramework` ships `Purview.SourceGeneratorFramework.Analyzers` and +`Purview.SourceGeneratorFramework.CodeFixers` as analyzer assets inside the package, under +`analyzers/dotnet/cs/`. They are enabled automatically in any project that references the framework — +there is no separate package to add. + +Two compiler-analyzer families, plus a set of MSBuild-time diagnostics: + +- **Incremental generator best practice** — `PSGFR11`–`PSGFR33`: pipeline design, `CodeWriter` usage, + Roslyn component discovery, extension-class conventions. +- **C# 14 extension-member conventions** — `PSGFR34`–`PSGFR38`, with + `ReorganizeExtensionClassCodeFixProvider` and `ConvertToExtensionBlockCodeFixProvider`. +- **Merge and packaging** — `PSGFR39`–`PSGFR43`, where `PSGFR43` is the merge tool's blocked + public-surface code. + +## Start here when nothing runs + +A compiler host loads a generator, analyzer or code fix provider only when **three** conditions hold. +Missing any one means the component is **silently ignored** — no error, no output, a green build: + +1. **The type is public** (`PSGFR27`). +2. **The type is decorated** — `[Generator]` (`PSGFR26`), `[DiagnosticAnalyzer]` (`PSGFR25`), or + `[ExportCodeFixProvider]` (`PSGFR24`). +3. **The assembly is loaded as an analyzer** — packed under `analyzers/dotnet/cs/` in a package, or + referenced with `OutputItemType="Analyzer"` on a `ProjectReference`. + +A code fix provider additionally appears only when a diagnostic ID in its `FixableDiagnosticIds` is +actually produced by an analyzer loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix +providers when the analyzer set loads, so after adding or updating a fixer assembly you must restart +Visual Studio or reload the project before the fixes show up. + +## Build-time validation diagnostics + +These are MSBuild diagnostics, not compiler analyzers, so they are **not** tracked in +`AnalyzerReleases.*.md`: + +| Code | Raised by | Summary | +| --- | --- | --- | +| `PSGF0001` | `Purview.BuildSdk` | A Roslyn component did not produce (or declare) a source-generator analyzer file. | +| `PSGF0003` | `Purview.SourceGeneratorFramework` | A `PurviewGeneratorVisibleProperty` is not compiler-visible in the declaring project or its `Sdk/build`/`Sdk/buildTransitive` assets, so consumers cannot read `build_property.`. | +| `PRSGD0005` | `Purview.BuildSdk` | A file in the returned analyzer closure references an assembly that is neither in the closure nor a compiler-host assembly. | +| `PSGFR41` | merge tool | A component's public surface exposes framework types the merge internalizes. Reported by the analyzer at design time, and by the merge pass as `message` (default), `warning` or `error`. | +| `PSGFR43` | merge tool | The merged analyzer still exposes a public framework type, so it is not self-contained. Always an error; the merge fails with exit code 5. | + +Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD0005`) or +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false` (`PSGF0003`). + +## Incremental pipeline rules + +| Rule | Summary | +| --- | --- | +| `PSGFR11` | Prefer `SyntaxProvider.ForAttributeWithMetadataName` over `CreateSyntaxProvider` for attribute-based detection. | +| `PSGFR12` | Use `IIncrementalGenerator` / `RegisterSourceOutput` instead of `ISourceGenerator`. | +| `PSGFR14` | Avoid `RegisterImplementationSourceOutput` unless implementation-only output is required. | +| `PSGFR15` | Pipeline model collection members should use sequence equality (`EquatableArray`). | +| `PSGFR30` | Prefer `static` lambdas in pipeline methods, so the compiler never allocates a closure on the per-item hot path. | +| `PSGFR31` | Prefer `GeneratorAttributeSyntaxContext.TargetSymbol` over `SemanticModel.GetDeclaredSymbol(ctx.TargetNode)`. | +| `PSGFR33` | Pipeline models must not retain Roslyn objects (`ISymbol`, `SyntaxNode`, `Location`, …); extract the information into value types. | + +## CodeWriter rules + +| Rule | Summary | +| --- | --- | +| `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload, so annotations honour the target compilation. | +| `PSGFR17` | Consume scope-returning methods (`…Scope`, `IndentedScope`) with `using`. | +| `PSGFR18` | Prefer structured declaration APIs (`Class`, `Method`, `Property`, `Field`) over raw declaration text. | +| `PSGFR19` | Prefer structured statement APIs (`Return`, `MethodCall`, `Throw`, `Assignment`, `Using`, `Comment`) over raw statement text. | +| `PSGFR20` | Prefer the minimal `CodeWriter` overloads over hand-constructing `*DeclarationOptions` values. | +| `PSGFR21` | Prefer `HashDefines`/`HashDefinesScope` for `#if`/`#endif`. | +| `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning`. | +| `PSGFR23` | Prefer structured `IfBlock`/`ElseIf`/`Else` over raw `if` block text. | +| `PSGFR29` | Do not embed a `CodeWriter` in a string; use `XmlCommentWriter.XmlInlineCode`. | +| `PSGFR32` | Avoid `NormalizeWhitespace` when generating source; use an indented text writer such as `CodeWriter`. | + +## Component, extension and merge rules + +| Rule | Summary | +| --- | --- | +| `PSGFR24` | `CodeFixProvider` is not marked `[ExportCodeFixProvider]`; Visual Studio will never discover it. | +| `PSGFR25` | `DiagnosticAnalyzer` is not marked `[DiagnosticAnalyzer]`; it will never run. | +| `PSGFR26` | A generator type is not marked `[Generator]`; it will never run. | +| `PSGFR27` | A Roslyn component type is not public; the compiler host cannot instantiate it. | +| `PSGFR28` | `FixableDiagnosticIds` names a diagnostic no analyzer in the compilation produces; the fix will never be shown. | +| `PSGFR34` | Prefer C# 14 `extension(Receiver)` blocks over classic static `this`-parameter extension methods. | +| `PSGFR35` | Extension class name must match the extended type (`{Receiver}Extensions`). | +| `PSGFR36` | Extension classes belong in the extended type's namespace, under an `Extensions` folder. | +| `PSGFR37` | One extension class per receiver type; split a class that extends several. | +| `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. | +| `PSGFR39` | A non-packable Roslyn component opted out of the default self-contained analyzer output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false`) while embedding the framework; otherwise the package embeds the loose framework DLL under `analyzers/`. | +| `PSGFR40` | In a Roslyn component (`IsRoslynComponent=true`), reference SGF types as inline code (`Type`) rather than a `cref`: copied documentation must not depend on cref resolution. | +| `PSGFR41` | A public member (or generic constraint) whose signature references an SGF type in a component whose framework implementation is merged. The merge internalizes every SGF type, so the signature ends up referring to an internal type. Make the member, or its declaring type, non-public. | +| `PSGFR42` | A disposable Testing-harness result (`DriverRunResult`) is produced and never disposed, leaking the collectible `AssemblyLoadContext` and `MetadataLoadContext` of a compiled run. `warning` when the run syntactically compiles to an assembly (a `Compile` options member, or `CompileToAssembly = true`), otherwise `info`. Awaiting an assertion, or calling a helper handed the result — an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance back, so only a task-like await or a genuine producer is reported. | + +## Type-library and attribute-model diagnostics + +The bundled generators carry their own families: `TypeLibraryValidationAnalyzer` +(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model validation analyzers. They are +documented on their own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +## Code fixes + +Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: + +| Fix provider | Rule | +| --- | --- | +| `AddGeneratorAttributeCodeFixProvider` | `PSGFR26` | +| `AddDiagnosticAnalyzerAttributeCodeFixProvider` | `PSGFR25` | +| `AddExportCodeFixProviderAttributeCodeFixProvider` | `PSGFR24` | +| `MakeRoslynComponentPublicCodeFixProvider` | `PSGFR27` | +| `RemoveOrphanedFixableDiagnosticIdCodeFixProvider` | `PSGFR28` | +| `PreferTargetSymbolCodeFixProvider` | `PSGFR31` | +| `PreferStaticLambdaCodeFixProvider` | `PSGFR30` | +| `PreferNullableContextOverloadCodeFixProvider` (project-wide Fix All) | `PSGFR16` | +| `PipelineModelReferenceEqualityCollectionCodeFixProvider` | `PSGFR15` | +| `PreferStructuredCodeWriterIfBlockCodeFixProvider` | `PSGFR23` | +| `CodeWriterToStringCodeFixProvider` | `PSGFR29` | +| `PreferInlineCodeForFrameworkCrefCodeFixProvider` | `PSGFR40` | +| `MakeComponentSurfaceNonPublicCodeFixProvider` | `PSGFR41` | +| `DisposeTestResultCodeFixProvider` | `PSGFR42` | +| `ReorganizeExtensionClassCodeFixProvider` (renames, splits, moves, updates referencing files) | `PSGFR35`, `PSGFR36`, `PSGFR37` | +| `ConvertToExtensionBlockCodeFixProvider` | `PSGFR34` | +| `AddExtensionClassMetadataCodeFixProvider` | `PSGFR38` | +| `MakeTypeLibrarySpecNonPublicCodeFixProvider` | `TLB0021` | +| `AttributeDataModelSymbolPropertyCodeFixProvider` | attribute-data-model symbol properties | +| `TypeLibraryMemberAccessibilityCodeFixProvider`, `TypeLibraryMarkerDefaultInitializerCodeFixProvider`, `MakeTypeLibrarySpecPartialCodeFixProvider`, `RenameTypeLibrarySpecCodeFixProvider`, `TypeLibraryMemberTypeCodeFixProvider` | type-library rules | + +## Related skills + +`purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you +towards, and `purview-sourcegenerator-testing` for proving a pipeline actually caches. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md new file mode 100644 index 0000000..92350db --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -0,0 +1,245 @@ +--- +name: purview-sourcegenerator-packaging +description: >- + Use when packaging or referencing a source generator built on Purview.SourceGeneratorFramework - + the self-contained merged analyzer, GetSourceGeneratorAnalyzerFiles, embedding a generator in + another package, PurviewGeneratorVisibleProperty, analyzer-closure validation, Roslyn version + compatibility, and errors such as CS0433, CS8784 or MSB3073 from a loaded analyzer. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Packaging a source generator + +The rule everything else follows from: **a shipped Roslyn component carries its own copy of the +framework and references no loose `Purview.SourceGeneratorFramework.dll`.** Two generators built +against different framework versions must be able to load into one compiler host. A shared DLL under +`analyzers/` cannot give you that — whichever version loads first wins, and the other fails with +`MissingMethodException` or `CS0433`. + +The framework delivers this by IL-merging and internalizing its implementation into each component's +**intermediate** output, leaving the component's `bin` unmerged. + +## Referencing the framework from a component + +```xml + + true + + + + + +``` + +And from a consuming project, as an analyzer: + +```xml + +``` + +`Purview.BuildSdk` invokes `GetSourceGeneratorAnalyzerFiles` automatically — naming +`Targets="GetSourceGeneratorAnalyzerFiles"` explicitly still works but is not required. + +## The three consumption paths + +| Path | Trigger | What you get | +| --- | --- | --- | +| `GetSourceGeneratorAnalyzerFiles` (default) | a consuming project references the component as an analyzer | the **merged** component, from the content-addressed `purview-merged//`. `bin` stays unmerged, so an in-process test harness keeps shared framework type identity and `InternalsVisibleTo` access without `CS0433`. | +| `GetSourceGeneratorAnalyzerFiles` with `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` | opt-out | unmerged component **plus** the loose framework DLL from the package `lib/`. | +| `GetPurviewMergedAnalyzerFile` | the framework's own bundled-component pack, or a third-party package embedding the generator | the merged component; `bin` is never overwritten. | +| `GetPurviewMergedAnalyzerFileForPack` (pack-time, via `TargetsForTfmSpecificContentInPackage`) | packing a standalone packable generator | the merged DLL and PDB contributed straight to `analyzers/dotnet/cs`. `bin` is never mutated, so its shape does not depend on whether `build` or `pack` ran last. | + +Two properties exist and both should normally be left alone: + +- `PurviewEmbedSourceGeneratorFramework=false` — only for a project that *is* the framework + compile-time library. A published generator package must never disable embedding. +- `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` — only when the analyzer-files + consumer must keep the unmerged + loose-DLL shape (a generator shipped into one compiler process + alongside an incompatible framework version). + +`PSGFR39` errors when a **non-packable** component opts out while embedding the framework, because +that shape, packed through the GASF path, forces the loose DLL back under `analyzers/`. + +## A component that references another component + +A code-fix or analyzer component can reference the generator component normally (`ProjectReference`, +`ReferenceOutputAssembly` **not** `false`) when it needs the generator's internal diagnostic +identity. Both are `IsRoslynComponent`; only the generator references the framework. + +- The referenced component is returned as **its own merged assembly** and is never IL-merged into the + dependent one — that would duplicate its types, including `InternalsVisibleTo`-visible internals, + inside a second analyzer in the same host. +- The framework assembly is declared as copy-to-output content beside the generator's `bin`, and + content with `CopyToOutputDirectory` flows transitively through `ProjectReference`. That is what + lets Visual Studio load the dependent code-fix provider from its own `bin` without a + `FileNotFoundException` for the framework assembly. +- Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the + referenced project's **unmerged** `bin` assembly and adds it to the compiler's analyzers — the + command-line build does not. `Purview.BuildSdk` removes that item before adding the resolved + closure, so Roslyn only ever receives the merged artifact. Without that removal the unmerged copy + drags `Purview.SourceGeneratorFramework` into the compiler host + (`CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`) + and every generator registers twice. + +## Analyzer-closure validation + +`Purview.BuildSdk` validates the whole closure `GetSourceGeneratorAnalyzerFiles` returns: every +file's PE `AssemblyRef` must resolve to another file in the returned set, or to a compiler-host +assembly (`Microsoft.CodeAnalysis*`, `System.Composition.*`, `System.*`, `netstandard`, …). A missing +component artifact fails the build with `PRSGD0005`. + +Extend the permitted set with `` or the +`PurviewAnalyzerClosurePermittedReferences` property. Opt out with +`PurviewSourceGeneratorFrameworkAnalyzerValidation=false`. + +## Generator-read MSBuild properties + +A generator reading `build_property.` is only correct if `` is a +`CompilerVisibleProperty` **wherever the generator runs** — including in a consumer's project. +Declare what the component reads: + +```xml + + + +``` + +`PSGF0003` fails the build when a declared property is neither a `CompilerVisibleProperty` in the +project nor declared by the project's own `Sdk/build` or `Sdk/buildTransitive` assets — which is what +consumers actually receive. Opt out with +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false`. + +NuGet imports `buildTransitive` assets for **PackageReference** consumers only. Inside the package +repository every project uses `ProjectReference`, so `Purview.BuildSdk`'s +`ImportProjectReferencedBuildTransitiveAssets` registers the referenced project's +`Sdk/buildTransitive/*.props|*.targets` `CompilerVisibleProperty` items for in-repo consumers. Opt +out with `PurviewImportProjectReferenceBuildTransitive=false`. + +## Embedding a generator in another package + +Pack the **merged** assembly, so the outer package ships no loose framework DLL: + +```xml + + false + $(TargetsForTfmSpecificContentInPackage);PackMyGenerator + + + + + + + + + analyzers/dotnet/cs/ + + + +``` + +If the generator is also embedded at compile time for the outer package's consumers, the outer +package must make the framework's compiler-visible properties visible to them — build assets from +`Purview.SourceGeneratorFramework` are **not** copied into the outer package. Ship a `.props` that +declares the property and its `CompilerVisibleProperty` entry, packed under the outer package's own +ID so NuGet imports it: + +```xml + +``` + +## `IsExternalInit` + +The framework assembly defines `System.Runtime.CompilerServices.IsExternalInit` **publicly**, so the +bundled generators can emit `init`-based attribute types into any consumer compilation and the merge +has a single marker definition to internalize. + +**Do not declare your own.** A generator-local marker gives calls to the framework's `init` setters a +different required-custom-modifier identity from the setter definitions. The merge tool normalizes +those modifiers in a temporary copy for compatibility with legacy tooling, but removing the redundant +marker is the preferred configuration. + +## Roslyn version compatibility + +> The version of `Microsoft.CodeAnalysis.*` you compile the component against sets a **minimum +> compiler-host API requirement**. The consumer's `` has nothing to do with it — +> analyzer code runs inside the compiler/IDE host. + +| Roslyn package | Minimum Visual Studio | Language / .NET generation | +| ---: | --- | --- | +| 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | +| 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | +| 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | + +**This framework is built against Roslyn 5.0**, so a host loading it must be Roslyn 5.0 or later +(.NET 10 SDK / Visual Studio 2026 18.0). The testing packages multi-target `net8.0`–`net10.0`, and +Roslyn 5.x ships `net8.0`/`net9.0` assets, so those test targets still load the runner. + +## A generator project to start from + +```xml + + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + + + +``` + +`Purview.BuildSdk` already defaults most of this for a project it classifies as a Roslyn component, +so in an SDK repository the project file is usually far shorter. The exact Roslyn baseline is a +product-support decision. + +## Release gates + +Acceptance criteria for self-contained packaging — check metadata, never "the sample compiled": + +1. **No framework reference, no public framework types.** Every `analyzers/dotnet/cs/*.dll` has no + assembly reference to `Purview.SourceGeneratorFramework` and exposes no public type in a + framework-owned namespace (including the generated `Generators` attribute set and the + framework-emitted `Microsoft.CodeAnalysis.EmbeddedAttribute`). The one exception is the + component's own Roslyn entry points, which must stay public — Roslyn only instantiates public + components (`PSGFR27`). +2. **No loose framework DLL in any package**, and no `*.pdb` in the `.nupkg` — symbols ship only + through the `.snupkg`. +3. **Merged entry points survive**: each merged DLL still exposes its `IIncrementalGenerator`, + `DiagnosticAnalyzer` or `CodeFixProvider`. +4. **Installed-package consumer test**: a generator project referencing the framework by + `PackageReference` packs one self-contained DLL under `analyzers/dotnet/cs`, and a consumer that + installs it builds with the generator producing output. +5. **Two-version coexistence test**: build Generator A against the current framework and Generator B + against an intentionally binary-incompatible version, install both into one consumer, and build + with both package-reference orders. Both must run against their own embedded copy. + +## Going deeper + +The merge pass, the content-addressed artifact, framework type internalization and the properties +that tune it are in [references/merge-internals.md](references/merge-internals.md) — read it when a +merge fails, when a type leaks, or when you hit `MSB3073` / `CS0433` / `CS8784` from a loaded +analyzer. diff --git a/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md new file mode 100644 index 0000000..5d8b6c9 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md @@ -0,0 +1,139 @@ +# The merge pass and the self-contained analyzer + +## Where the merged artifact lives + +`_PurviewMergeSourceGeneratorFramework` only ever writes to the component's **intermediate** output. +`GetSourceGeneratorAnalyzerFiles` returns that result by substituting the merged path into +`TargetPathWithTargetPlatformMoniker` immediately before its body runs, leaving `GetTargetPath` — +which resolves assembly references — pointing at the unmerged `bin`. That split is what keeps the +in-repo test harness working while shipped assemblies stay self-contained. + +The artifact is **content-addressed**: + +```text +$(IntermediateOutputPath)purview-merged//$(TargetFileName) +``` + +`` is a SHA-256 over the component, the framework assembly and the merge tool (the tool +computes it in its `--tag` mode). The tag is in the **directory**, never the file name, because +ILRepack derives the merged assembly's simple name from the output file name and an analyzer must +keep `.dll`. + +Two properties follow, and both matter: + +- A rebuild with identical inputs reuses the existing file and **skips the merge**, so + `_PurviewMergeSourceGeneratorFramework` is idempotent across the per-consumer — and potentially + parallel — invocations of `GetSourceGeneratorAnalyzerFiles`. +- Changed inputs select a **new directory**, so the merge never overwrites an assembly a compiler + host (csc, `VBCSCompiler`, Visual Studio) already has loaded. Overwriting such a file fails on + Windows with a sharing violation, which surfaced as `MSB3073` (merge tool exit code + `-532462766`) and left consumers loading a stale or partially written analyzer — the root cause of + `CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`. + +The tool merges into a per-process `.staging-` directory and publishes it with a +non-overwriting directory rename. A concurrent invocation that loses the race simply observes the +published artifact rather than failing. + +Packaging needs the stable `.dll` name, so the pack paths +(`GetPurviewMergedAnalyzerFile`, `GetPurviewMergedAnalyzerFileForPack`) copy the content-addressed +artifact into `$(IntermediateOutputPath)purview-pack/` and pack that plainly named staging copy. + +## Framework type internalization + +ILRepack's `Internalize` is best-effort, and two gaps matter: + +- a framework type that reaches the merged component's public API surface stays public; and +- the types the framework's own generators emit **into** the component — the + `Purview.SourceGeneratorFramework.Generators` attribute set, generated type libraries, marker + attributes — are not part of the merged framework assembly at all, so ILRepack never sees them. + +Either gap leaks framework types out of what must be a self-contained analyzer, and any project +loading that analyzer alongside the real framework assembly fails with `CS0433` ambiguity for every +leaked type. + +So the merge tool runs a deterministic internalization pass (`FrameworkTypeInternalizer`) after +ILRepack finishes: + +- Every type in a framework-owned namespace (`Purview.SourceGeneratorFramework` and its children, + including the generated `Generators` attribute set) becomes non-public. Ownership is evaluated + through the **declaring chain**, because Mono.Cecil reports an empty namespace for a nested type: + the framework's nested containers — generated type-library namespace classes, nested operator/enum + groups, `CodeWriter` scopes — are only reachable through their declaring type. +- Types the framework's generators emitted into the component, recognized by the tool name on their + `System.CodeDom.Compiler.GeneratedCodeAttribute` (`TypeLibraryGenerator`, + `AttributeDataModelGenerator`), become non-public too, so a generated type library can never leak a + `TypeIdentity`/`TypeReference` member as public API. +- `Microsoft.CodeAnalysis.EmbeddedAttribute`, the framework-emitted marker, becomes non-public. +- **Roslyn component entry points are never internalized.** A generator, analyzer, code fix provider + or refactoring provider reachable from the framework namespace stays public, because Roslyn only + instantiates public components (`PSGFR27`). This is what keeps the framework's own bundled + analyzers working after the pass. +- The pass reports the result. Leftover public framework types fail the merge with **exit code 5** + (`PSGFR43`). Public component members whose signature exposes a framework type are logged + (`PSGFR41`) so the author can make them, or their declaring type, non-public. Visibility is + followed through the declaring chain here too: a public nested type inside an internal type — for + example the compiler-synthesised `$`/`$` extension containers emitted for an extension class + — is not reachable from outside the component, so it is not part of the public surface and is not + reported. + +Ownership is seeded with the framework assembly's own type identities, so a framework type living +*outside* the framework namespace (`Microsoft.CodeAnalysis.*Extensions`, `System.StringExtensions`) +is internalized as well. The artifact's assembly-level `InternalsVisibleTo` / +`IgnoresAccessChecksTo` grants are removed, because a shipped analyzer is not the component's own +assembly. + +## Tuning the pass + +| Property | Default | Effect | +| --- | --- | --- | +| `PurviewMergeOwnedNamespaces` | empty | Extra namespace prefixes (semicolon-separated) treated as framework-owned. | +| `PurviewMergeOwnedTypeFullNames` | empty | Extra type full names (semicolon-separated) treated as framework-owned. | +| `PurviewMergePublicSurfaceSeverity` | `message` | How a finding is reported: `message` (plain log text), `warning`/`error` (MSBuild diagnostics with code `PSGFR41`), or `none`. | +| `PurviewMergePublicSurfaceValidation` | `true` | `false` sets the severity to `none`. | +| `PurviewMergePublicSurfaceOrigin` | the merged component assembly | The origin reported with an MSBuild finding; point it at a source or project path for IDE navigation. | + +All five participate in the content tag, so changing one re-runs the merge — and re-emits its +findings — rather than reusing a cached artifact. The compiler analyzer with the same id (`PSGFR41`) +reports the same condition while you edit, so the surface is usually fixed before the merge runs. + +## Generated accessibility: bin versus merged artifact + +The component's own generated types — the type library, the attribute data models — keep their +generated accessibility in the component's **bin output**. `TLB0015` requires a hand-written partial +to be `public static partial` so it can merge with the generated library, and in-repo consumers such +as code fixers and sibling assemblies compile against it. + +Only the **merged analyzer artifact** internalizes them. Self-containment is therefore enforced at +the merge boundary without rewriting the accessibility an in-repo consumer compiles against, and that +split is safe precisely because a merged artifact is never referenced as a compile-time dependency. + +> A merged component is an analyzer artifact. Never reference it as a compile-time dependency. Tests +> that need to run a *packaged* generator load it out of band. + +## How the framework package itself is assembled + +`Purview.SourceGeneratorFramework` is dual-role: the framework assembly ships in `lib/` so generator +projects compile against it, and `analyzers/` carries self-contained generator, analyzer and +code-fixer assemblies. The bundled projects are: + +- `SourceGeneratorFramework.Generators` — `AttributeDataModelGenerator`, `TypeLibraryGenerator`; +- `SourceGeneratorFramework.Analyzers` — the `PSGFR*` and `TLB*` analyzers; +- `SourceGeneratorFramework.CodeFixers` — the code fix providers. + +Each has the framework implementation merged and internalized into it. The package deliberately does +**not** put `Purview.SourceGeneratorFramework.dll` under `analyzers/dotnet/cs`, so generators built +against different framework versions never ask Roslyn to load competing versions of a same-named +dependency. + +The shared models and helpers that once shipped as a separate +`Purview.SourceGeneratorFramework.Shared.dll` are compiled directly into the framework assembly +(`SourceGeneratorShared.Link.targets` links the sources). Consumers get one +`Purview.SourceGeneratorFramework.dll`, which removes the version-skew hazard a separately bundled +Shared assembly caused — generator packages loading a different Shared version in-process failed with +binary-incompatibility errors, such as removed `PurviewTypeLibrary` fields. + +Those projects are `IsRoslynComponent = true` and are **not** packable on their own. The +`SourceGeneratorFramework` project builds them via `GetPurviewMergedAnalyzerFile` and packs them +under `analyzers/dotnet/cs/` in `BuildAndPackBundledAnalyzerAssemblies`. They were previously +consumed as analyzer project references, but they now reference the framework assembly for the shared +types, which would form a project-reference cycle. diff --git a/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md b/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md new file mode 100644 index 0000000..373942f --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md @@ -0,0 +1,231 @@ +--- +name: purview-sourcegenerator-pipeline +description: >- + Use when composing an incremental pipeline with Purview.SourceGeneratorFramework - + IncrementalPipeline helpers, GenerationContext and custom contexts, CreateCodeWriter lifetime, + GeneratorResult and blocking versus non-blocking diagnostics, disabling a generator from MSBuild, + scope validation, test logging, and stage tracking names. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Incremental pipeline composition + +`IncrementalPipeline` composes `IncrementalValueProvider` and `IncrementalValuesProvider`: +attribute-based discovery, generation-context creation, disable-property checks, and thin +source-output registration. + +Everything here follows from one rule: **pipeline values must be immutable and value-equatable.** +Roslyn caches the complete value a provider publishes, and offers no way to exclude one property of +it from caching. + +## `GenerationContext` + +A base execution-services context carrying the Roslyn `Compilation`, immutable `GenerationSettings`, +an optional `ISourceGenLogger`, and a factory for independently owned `CodeWriter` instances. + +```csharp +var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context); +``` + +### Custom contexts + +A custom context does not need to accept or read build properties itself: + +```csharp +public sealed class MyGenerationContext : GenerationContext +{ + public MyGenerationContext(Compilation compilation, GenerationSettings settings, ISourceGenLogger? logger) + : base(compilation, settings, logger) + { + } +} + +var contextProvider = IncrementalPipeline.GenerationContextValueProvider( + context, + nameof(MyGenerator), + "1.0.0", + factory: static (compilation, settings, logger, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return new MyGenerationContext(compilation, settings, logger); + }, + disablePropertyName: "MyGenerator_Disable" +); +``` + +The provider resolves scope validation, generator disabling and test logging from analyzer-config +properties *before* invoking the factory. The logger is created internally only when logging is +enabled and a sink is registered for that run. + +## `CodeWriter` lifetime — the rule that bites + +Treat `GenerationContext` as **cached incremental state** and each `CodeWriter` as **mutable, +output-scoped execution state**. Create the writer inside the registered source-output callback, +after the incremental cache boundary: + +```csharp +IncrementalPipeline.RegisterSourceOutput( + context, + targets, + contextProvider, + static (spc, target, generationContext) => + { + var writer = generationContext.CreateCodeWriter(); + EmitTarget(generationContext, writer, target); + spc.AddSource($"{target.Name}.g.cs", writer.ToString()); + } +); +``` + +Creating it in the callback and passing it to emitter helpers called from that same callback is the +intended pattern. The only forbidden thing is **persisting the writer in pipeline state**. + +Why, concretely: + +- Roslyn caches the whole published value; you cannot exempt a field. +- `CodeWriter` is mutable, so a cached one retains previously written source when the context is + reused for another output or run. +- Source-output callbacks may process independent targets **concurrently**; a shared writer mixes + their output and races. +- A fresh writer gives each generated source independent scope tracking and deterministic ownership. + +`CreateCodeWriter()` returns a new, independently owned instance every call. + +**Never add a `CodeWriter` property or field to a class derived from `GenerationContext`.** A custom +context is still produced by an incremental provider and cached as one complete value. Store only +compilation-derived services and immutable configuration there. + +When an emitter needs both services and writing, pass the context and the writer separately, or +compose a short-lived wrapper created inside the callback — which must never be returned from a +provider: + +```csharp +public sealed class GenerationOutputContext : ISourceGenLogger + where TContext : GenerationContext +{ + public GenerationOutputContext(TContext generation) + { + Generation = generation; + Writer = generation.CreateCodeWriter(); + } + + public TContext Generation { get; } + + public CodeWriter Writer { get; } + + public void Log(SourceGenLogLevel level, int indentation, string message, params object[] args) => + Generation.Log(level, indentation, message, args); +} +``` + +## `GeneratorResult` and diagnostics that do not stop generation + +`RegisterSourceOutput` combines targets with the generation context, reports diagnostics, and runs +the callback only for successful results: + +```csharp +var targets = IncrementalPipeline.ForAttributeWithMetadataName( + context, + AttributeType, + static (ctx, ct) => + { + var symbol = ctx.TargetSymbol; + + return symbol is null + ? GeneratorResult.Empty + : GeneratorResult.Create(symbol.Name); + } +); +``` + +The callback runs only when `GeneratorResult.ShouldProcess` is `true` — the result carries a value +**and** none of its diagnostics are blocking. + +`ReportableDiagnostic.IsBlocking` is an explicit per-diagnostic decision, **independent of +severity**. An `Error`-severity diagnostic can still allow generation to continue, which is what you +want when the generated code is what helps the developer fix the problem — a generator emitting an +abstract base class can report an error per missing override while still emitting the base class, so +the user can see exactly what to implement: + +```csharp +static readonly DiagnosticDescriptor MissingOverride = new( + "MYGEN001", + "Missing override", + "Type '{0}' must override '{1}'", + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true +); + +// IsBlocking is false, so ShouldProcess stays true and the base class is still emitted. +var diagnostic = ReportableDiagnostic.Create(MissingOverride, isBlocking: false, symbol, symbol.Name, "Execute"); + +return GeneratorResult.Create(model, diagnostic); +``` + +`isBlocking: true` stops generation for that target while still reporting. +`HasBlockingDiagnostics` reports whether any carried diagnostic blocked; +`HasErrorDiagnostics` reports the severity-based view. + +## Disabling a generator at build time + +Pass the compiler-visible disable property to the context provider; its resolved value lands in +`GenerationSettings` automatically: + +```xml + + true + +``` + +```csharp +var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider( + context, + nameof(MyGenerator), + "1.0.0", + disablePropertyName: "MyGenerator_Disable" +); + +// In the output stage: +if (generationContext.Settings.IsSourceGeneratorDisabled) + return; +``` + +`IsDisabledValueProvider` is still there for when expensive upstream transforms must be filtered +before being combined with the generation context. + +Declare the property with `PurviewGeneratorVisibleProperty` so it is compiler-visible wherever the +generator runs — `PSGF0003` otherwise fails the build. See the `purview-sourcegenerator-packaging` +skill. + +## Scope validation + +The default context provider reads +`PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` and threads it into +`GenerationSettings.ValidateCodeWriterScopes`. When enabled, `ToString()` throws +`CodeWriterScopeValidationException` if `OpenScopeCount` is not zero. + +## Test logging + +Framework logging is off in ordinary compiler runs. The testing integration enables it by registering +an isolated sink and supplying a per-run session ID through analyzer config. Context providers create +the internal logger automatically — generators implement no logging interface and no logging-support +source is generated. + +The sink registry stores **callbacks only**; it never buffers entries. With logging disabled, a +missing session ID, or no matching sink, the provider supplies no logger and log calls are discarded. +Test sinks own whatever they capture and are removed when the run completes. + +## Tracking names + +The pipeline helpers assign a tracking name to every stage, so a cache test can assert which stages +were recomputed. The `purview-sourcegenerator-testing` skill covers the stage matrix and the four +scenarios a cache test should assert. + +## Related skills + +`purview-sourcegenerator-codewriter` for emission, `purview-sourcegenerator-diagnostics` for the +`PSGFR` rules that enforce most of the above, and `purview-sourcegenerator-testing` for proving the +pipeline caches. diff --git a/.agents/skills/purview-sourcegenerator-testing/SKILL.md b/.agents/skills/purview-sourcegenerator-testing/SKILL.md new file mode 100644 index 0000000..aaeff09 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -0,0 +1,404 @@ +--- +name: purview-sourcegenerator-testing +description: >- + Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class, + configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, and asserting + incremental pipeline caching stage by stage. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Testing source generators, analyzers, code fixes and refactorings + +Use this skill whenever a task involves authoring, fixing, or modernising tests for Roslyn components +(generators, diagnostic analyzers, code fix providers, refactoring providers) built with +`Purview.SourceGeneratorFramework`. It covers the framework-agnostic test runner layer and the +`CodeQuery` syntax-lookup API. For the TUnit base classes and assertion extensions, also load the +`purview-sourcegenerator-tunit-tests` skill. + +Modernising an existing suite onto `CodeQuery` and adding a stage-by-stage cache test? The playbook +is in [references/test-modernisation.md](references/test-modernisation.md). + +## Picking the right runner + +| Roslyn type | Runner | +|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `SourceGeneratorTestRunner` | +| `DiagnosticAnalyzer` | `DiagnosticAnalyzerTestRunner` | +| `CodeFixProvider` | `CodeFixTestRunner` (single) | +| — | `CodeFixTestRunner.RunFixAllAsync` (project-wide) | +| `CodeRefactoringProvider` | `RefactoringTestRunner` | + +TUnit projects should prefer the matching base class instead (see `purview-sourcegenerator-tunit-tests`): +`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, +`TUnitRefactoringTestBase`. + +## Result types + +- `DriverRunResult` (generator) — `DriverResult`, `AllSyntaxTrees`/`PrimarySyntaxTrees`, + `CompilationResult.Compilation`, `GetGeneratedTree`, `GetSource`, `GetTypeByMetadataName`, `LogEntries`. +- `AnalyzerTestResult` — `Diagnostics`, `Compilation`. +- `CodeFixTestResult` — `Diagnostics`, `CodeActions`, `FixedSource`, `Compilation`, `ChangedSolution`. +- `CodeFixFixAllResult` — `Diagnostics`, `CodeActions`, `FixedSources`, `ChangedSolution`. +- `RefactorTestResult` — `CodeActions`, `FixedSources`, `ChangedSolution`, `Compilation`. + +Use `DriverRunResultExtensions` (`AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, +`AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, …) for quick checks, but prefer +`CodeQuery` for structural assertions. + +## Querying produced code with `CodeQuery` + +Every result exposes a `CodeQuery` via extensions in `CodeQueryResultExtensions`: + +```csharp +result.Generated() // DriverRunResult: generated trees (default, generated-first) +result.Output() // DriverRunResult: entire output compilation (user + generated) +analyzerResult.Code() // AnalyzerTestResult: input compilation +codeFixResult.Code() // CodeFixTestResult: input compilation +codeFixResult.FixedCode() // CodeFixTestResult: parsed fixed source (or post-fix solution) +fixAllResult.FixedCode() // CodeFixFixAllResult: post-fix documents +refactorResult.FixedCode() // RefactorTestResult: post-refactor documents +``` + +Every `Get` has an accompanying `Has` (bool) and `TryGet` (out): `GetMethod`/`HasMethod`/`TryGetMethod`, +`GetClass`, `GetStruct`, `GetInterface`, `GetEnum`, `GetDelegate`, `GetRecord`, `GetProperty`, +`GetField`, `GetConstructor`, `GetNamespace`, `GetTypeDeclaration`, plus generic `Get`/`Has` +and `GetSyntaxTree`/`HasSyntaxTree`. `Get` throws `SyntaxNotFoundException` when nothing matches. + +Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` — and the +`TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so +`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the +non-generic `ResourceDefinition`. + +Every `Get` returns a `CodeQueryResult` — the matched syntax node (`Node`) plus a query scoped to it +(`Query`), with implicit conversions to both the node and the scoped query. Use `.Node` for direct syntax +access, or chain member queries (the originating query is carried by the result, so it is not passed again). + +Types can be matched against `TypeReference`/`TypeIdentity`, resolved through the compilation's semantic +model (nullable value types are significant, so `int?` never matches `int`): + +```csharp +result.Generated().HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType); +result.Generated().HasReturnType("Compute", TypeReference.Create()); +result.Generated().GetMethod("Format").HasParameters(TypeReference.Create(), objectReference); +``` + +When a test expects a nullable annotation, prefer the test-only `query.MakeNullable(type)` extension (on a +`CodeQuery`, accepting a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's +compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the +`PSGFR16` context-overload suggestion (tests have no generation context to pass). + +Member chaining from a type declaration (`MemberQueryExtensions`): + +```csharp +var service = result.Generated().GetClass("ServiceCollectionExtensions"); // or GetClass(name, "Namespace") +service.HasProperty("Count", intType); +service.HasIndexer(stringType, intType); +service.HasMethod("Add", intType, complexType); +service.HasMethodReturnType("Add", stringType); +service.HasConstructor(stringType); +service.HasAttribute("SomeAttribute"); +``` + +Node-inspection checks on scoped results: + +```csharp +result.Generated().GetClass("Service").HasAccessibility(Accessibility.Public); // resolves C# defaults +result.Generated().GetClass("Service").GetProperty("Name").HasSetterAccessibility(Accessibility.Private); +result.Generated().GetClass("ResourceDefinition", 1).HasGenericTypeParameters("TResourceType"); +result.Generated().GetClass("ResourceDefinition", 1).HasBaseType(new TypeIdentity("ResourceDefinition", ns)); +result.Generated().GetClass("Service").HasNestedType("Builder"); // GetNestedType(...) to fetch +result.Generated().GetClass("Service").IsInNamespace("Example.Models"); // IsInGlobalNamespace() for no namespace +result.Generated().IsInNamespace(cls.Node, "Example.Models"); // or on the query directly +``` + +## Configuring options and a reusable starting point + +`SourceGeneratorTestOptions` is the base record. Common knobs: + +- `AdditionalNamespaces` / `IncludeDefaultNamespaces` — namespaces prepended to test source. +- `AdditionalAssemblyTypes` / `AdditionalReferences` — assemblies referenced by the test compilation + (use `AdditionalAssemblyTypes = [typeof(SomeType)]` to pull in a whole assembly). +- `AdditionalSources` — extra source files added to every run. +- `AnalyzerConfigOptions` — `build_property.*` values; keys without the prefix are also exposed as MSBuild + properties. +- `DisableSourceGeneratorPropertyName` / `DisableSourceGeneratorValue` — generator disable toggle. +- `NullableContextOptions`, `OutputKind`, `LanguageVersion`, `CompileToAssembly`. +- `ValidateCodeWriterScopes`, `EnableLogging`. +- `ExcludeGeneratedSourceHintNames` — hides generated marker trees from `PrimarySyntaxTrees`. + +**Easy starting point recipe.** Derive an options record that seeds the namespaces and assemblies your +generator needs, so every test gets a working compilation with no boilerplate: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // the framework's Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableMyGenerator; + } +} +``` + +`Compile()` returns a copy with `CompileToAssembly = true`, preserving the derived options type. Use the +base class hooks `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` to mutate sources/options per run (for +example to append a marker attribute source via `WithAdditionalSources`). + +When `CompileToAssembly` is enabled, emission is fully in-memory. On .NET 8+ the assembly loads into a +collectible `AssemblyLoadContext`, so the result is `IDisposable` — `using var result = ...` unloads it and +keeps repeated runs from polluting the default context. `result.CompilationResult.Assembly` is the runnable +assembly (generated code can execute); `result.CompilationResult.Metadata` / `.MetadataAssembly` give a +metadata-only reflection view (types, members, attributes) over the emitted assembly without executing code. + +**Always write `using var result = ...`.** `PSGFR42` ("Dispose test harness results with a using +declaration") flags a `DriverRunResult` that is produced and never disposed — including the indirect case +where a helper creates a result, projects a value out of it and returns only the projection. It is a +`warning` when the run syntactically compiles to an assembly (`.Compile()`, a `Compile` options member, or +`CompileToAssembly = true`) and `info` otherwise; the `DisposeTestResultCodeFixProvider` fix inserts the +`using`. A result that is returned to the caller, or disposed explicitly, is not flagged. Neither is awaiting +an assertion, nor calling a helper that is handed the result — an extension on it, or a method taking it as a +parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and +`result.AssertNoGenerationExceptions()` are not flagged. + +## Best practices + +- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with + `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. +- **Generator references**: to use a generated type in the test project AND pass the generator type to a + runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal + reference. +- **Multi-target**: build generators against the Roslyn version that supports the test matrix; the + framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep + `System.Collections.Immutable` version pinned to the shared one. +- **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes + undisposed `CodeWriter` scopes fail tests. +- **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). +- **Dispose results**: `using var result = ...`, enforced by `PSGFR42`. +- **Cap parallelism**: the harness shares one `MetadataReference` set per process, so memory is largely flat + in the degree of parallelism, but the platform's auto-detected default is both slower and heavier than an + explicit ceiling. Wall clock saturates around four concurrent tests; prefer + `--maximum-parallel-tests 8` (or a `TestingPlatformCommandLineArguments` equivalent) for a project that + multi-targets and runs several TFM executables at once. + +## Incremental cache testing (`RunIncrementalAsync`) + +To prove the pipeline caches correctly stage-by-stage, use `SourceGeneratorTestRunner.RunIncrementalAsync` +(or `GenerateIncrementalAsync` on the TUnit base). It runs a sequence of source sets over a **single shared +`GeneratorDriver`** and captures each run's `TrackedSteps`, keyed by tracking name. The canonical reference +implementation is `IncrementalPipelineCacheTests` in `SourceGeneratorShared.UnitTests`; the end-to-end +generator variant is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`. Copy the pattern into your own test project — do +not expect the source repo's files locally. + +### What is being asserted and why + +Roslyn reports one `IncrementalStepRunReason` per step output on each run: + +- `New` — the step ran for the first time. +- `Modified` — the step ran and produced a different value than the previous run. +- `Unchanged` — the step ran but produced the same value. +- `Cached` — the step was skipped and its previous result reused from the incremental cache. + +A pipeline is "caching correctly" when an unchanged input keeps every stage `Cached`/`Unchanged`, and a +targeted change marks **only** the stages whose inputs actually changed `Modified` while unrelated stages +stay `Cached`. If a generator accidentally leaks `Compilation`, `SemanticModel`, `ISymbol`, +`SyntaxNode`, or `Location` into a pipeline model, unrelated stages will report `Modified`/`New` on rerun — +these tests fail the build and catch the regression. + +### The four scenarios every cache test should cover + +1. **First run → all `New`.** Nothing can be cached on the first run; this confirms every stage is tracked + under the expected name. +2. **Identical rerun → all `Cached`/`Unchanged`.** `RunIncrementalAsync(sources, ...)` runs the same source + set twice for exactly this case. This is the strongest "it caches" proof. +3. **Source-only change → only the source/attribute stage `Modified`.** Changing an attributed class must + mark `ForAttribute_*` (and downstream output) `Modified` while property/config stages stay `Cached`. +4. **Property-only change → only the property/configuration stage `Modified`.** Toggling an MSBuild + property (via `IncrementalRunInput.AnalyzerConfig`) must mark `GetMSBuildPropertyValue_*` / + `GetGenerationConfiguration` / `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. + +### How the runner makes this possible + +`RunIncrementalAsync` creates **one** driver, enables incremental step tracking, and **reuses the same +`Compilation` instance for identical source sets** (keyed by prepared source text). Without that reuse, +Roslyn would see a fresh compilation on the second run and report stages `Modified`/`New` even though the +sources are byte-identical — the "cached" assertion would fail. + +### Log entries per run + +`IncrementalCacheRun.LogEntries` carries the framework log entries that run wrote, in order. The sink is +registered for the whole sequence and drained between runs, so entries never accumulate. A rerun whose +output step is cached logs nothing, which makes it a second signal alongside the step reasons: + +```csharp +await Assert.That(result.Runs[0].LogEntries).IsNotEmpty(); +await Assert.That(result.Runs[1].LogEntries).IsEmpty(); +``` + +`DriverRunResult.LogEntries` (the `RunAsync` path) is likewise in write order. Both are empty when +`EnableLogging` is disabled. + +### The `StepReasons` helper + +`IncrementalCacheRun.Steps` is `ImmutableDictionary>` +keyed by tracking name. Flatten each step's `Outputs` into the reasons list so assertions read cleanly: + +```csharp +static ImmutableDictionary> StepReasons(IncrementalCacheRun run) +{ + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + return builder.ToImmutable(); +} +``` + +### Framework pipeline stage names + +- `GetMSBuildPropertyValue_{Property}` — `IncrementalPipeline.PropertyValueProvider`. +- `GetGenerationConfiguration` — `IncrementalPipeline.GenerationContextValueProvider`. +- `GetGenerationContext_{Capabilities}` — e.g. `GetGenerationContext_EmptyCapabilities`. +- `ForAttribute_{AttributeType}` — `IncrementalPipeline.ForAttributeWithMetadataName`. + +The framework reference (`IncrementalPipelineCacheTests`, framework-agnostic runner) shows all four +scenarios against `TestGenerator`/`DiagnosticTestGenerator`: + +```csharp +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.TestGenerators; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +public class IncrementalPipelineCacheTests +{ + const string AttributedSource = """ + [TestAttribute] + public partial class MyClass { } + """; + const string ChangedAttributedSource = """ + [TestAttribute] + public partial class AnotherClass { } + """; + const string TestAttributeSource = """ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class TestAttribute : System.Attribute { } + """; + + static SourceGeneratorTestOptions CreateOptions() => + new SourceGeneratorTestOptions() + .WithAdditionalSources(TestAttributeSource) + .WithExcludeGeneratedSourceHintNames("TestAttribute"); + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) { /* as above */ } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource])], + CreateOptions(), + cancellationToken); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert.That(reasons.Values.SelectMany(r => r).All(r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner() + .RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second.Values.SelectMany(r => r).All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(r => r == StepReason.Cached)).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [ + new IncrementalRunInput([AttributedSource]), + new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]), + ], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_TestAttribute"].All(r => r == StepReason.Cached)).IsTrue(); + } +} +``` + +### End-to-end generator variant (`GenerateIncrementalAsync`) + +TUnit tests derive from the base class and call `GenerateIncrementalAsync` (which wires the +`OnBeforeRun`/`OnBeforeRunAsync` hooks and the derived options). `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests` is the reference: + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } +} +``` + +**Why the example filters to `frameworkStages`:** `ServiceRegistrationGenerator` emits its own +`GenerateServiceAttribute` via post-initialization output, which is regenerated as a new `SyntaxTree` each +run. That makes Roslyn's *internal* `ForAttributeWithMetadataName` `Compilation` step legitimately report +`Modified` on an identical rerun. Asserting on the framework-named stages (which stay `Cached`/`Unchanged`) +is the meaningful check. If your generator does not depend on its own post-init output, the stricter +"every tracked step is `Cached`/`Unchanged`" assertion (as in the framework `IncrementalPipelineCacheTests`) +is correct. + +Per-run MSBuild-property changes are supplied with `new IncrementalRunInput(sources, [("build_property.X", "value")])`. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md new file mode 100644 index 0000000..6db4780 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md @@ -0,0 +1,63 @@ +# Modernising a Roslyn test suite + +A playbook for moving an existing test suite onto `CodeQuery` plus the TUnit assertion extensions, +and adding a stage-by-stage incremental cache test. + +## Before you start, collect + +- The test file(s) being changed. +- The Roslyn component under test — generator, analyzer, code fix or refactor (infer it from the + base class if the task does not say). +- The component's type name. + +## Requirements + +### 1. The right base class and method + +| Component | Base class | Method | +| --- | --- | --- | +| Generator | `TUnitSourceGeneratorTestBase` | `GenerateAsync` | +| Analyzer | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync` | +| Code fix | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync` / `ApplyFixAllAsync` | +| Refactor | `TUnitRefactoringTestBase` | `RefactorAsync` | + +### 2. `CodeQuery` instead of string matching + +Replace `GetGeneratedTree(...)` + `string.Contains(...)` with `CodeQuery` +(`result.Generated().Get/Has/TryGet…`) and the terminal assertion extensions — +`await Assert.That(result).HasGeneratedMethod/Class/Property/Field/SyntaxTree(…)` — which return the +node so a follow-up assertion has something to work with. + +### 3. `TypeReference` instead of signature strings + +Match parameter and return types structurally. A signature string breaks on a whitespace or +nullability change that means nothing. + +### 4. Options in one place + +Derive a `SourceGeneratorTestOptions` record that seeds the required namespaces and additional +assemblies, and remove the per-test duplication it replaces. + +### 5. A stage-by-stage cache test + +Use `RunIncrementalAsync`, or `GenerateIncrementalAsync` on the TUnit base, and assert the four +scenarios. `ServiceRegistrationCacheTests` and `IncrementalPipelineCacheTests` are the reference +pattern. + +| Scenario | Expected | +| --- | --- | +| First run | every framework stage `New` | +| Identical rerun (`RunIncrementalAsync(sources, …)` runs the same source twice) | framework stages `Cached`/`Unchanged` | +| Source-only change | `ForAttribute_*` `Modified`; property/config stages stay `Cached` | +| Property-only change (`new IncrementalRunInput(sources, [("build_property.X", "value")])`) | `GetMSBuildPropertyValue_*`, `GetGenerationConfiguration`, `GetGenerationContext_*` `Modified`; `ForAttribute_*` stays `Cached` | + +Use the `StepReasons(IncrementalCacheRun)` flattening helper. If the generator depends on its own +post-init output, assert on the framework-named stages rather than on every tracked step. + +### 6. Minimal change + +Keep behaviour equivalent, and do not reformat unrelated tests. + +## Verification + +Build the test project and run its suite before finishing. diff --git a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md new file mode 100644 index 0000000..bc711c5 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -0,0 +1,230 @@ +--- +name: purview-sourcegenerator-tunit-tests +description: >- + Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and + method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions, and + modernising existing tests. +metadata: + product: Purview.SourceGeneratorFramework.Testing.TUnit + repository: purview-dev/sourcegenerator-framework +--- + +# TUnit test authoring for Roslyn components + +Use this skill whenever a task involves authoring, fixing, or modernising **TUnit** tests for Roslyn +components built with `Purview.SourceGeneratorFramework`. It tells you which base class to derive from, +which method to call, how to customise options with an easy starting point, and how to use the TUnit +assertion extensions. For the framework-agnostic runner layer and the `CodeQuery` API, also load the +`purview-sourcegenerator-testing` skill. + +## Base class → method matrix + +| Roslyn type | Base class | Method to call | +|---|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | +| `DiagnosticAnalyzer` | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | +| `CodeFixProvider` (single fix) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | +| `CodeFixProvider` (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | +| `CodeRefactoringProvider` | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | + +For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. + +Framework-agnostic equivalents (no TUnit): `SourceGeneratorTestRunner`, `DiagnosticAnalyzerTestRunner`, +`CodeFixTestRunner`, `RefactoringTestRunner`. + +## Easy starting point: derive your options record + +Create a test-options record that seeds the namespaces and assemblies your component needs, then pass it +to every test via `new MyTestOptions()`: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // framework Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = "DisableMyGenerator"; + } +} + +public class MyGeneratorTests : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GeneratesExpectedSource(CancellationToken ct) => + await GenerateAsync("...source...", ct); +} +``` + +Use the base hooks to customise per-run: `OnBeforeRun`/`OnBeforeRunAsync` (mutate sources/options, e.g. +`options.WithAdditionalSources(markerAttributeSource)`) and `OnAfterRun`/`OnAfterRunAsync`. Use +`options.Compile()` to opt into `CompileToAssembly` while preserving the derived options type. + +Write `using var result = await GenerateAsync(...)`: `DriverRunResult` is `IDisposable`, and `PSGFR42` +flags a result that is produced and never disposed (a `warning` once the run compiles to an assembly, +`info` otherwise). It also catches the indirect case — a helper that creates a result, projects a value out +of it and returns only the projection. Awaiting an assertion, or calling a helper that is handed the result — +an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance +back, so neither is flagged. + +For code fixes/refactorings, select a specific registered action via `CodeFixTestOptions.EquivalenceKey` +or `CodeActionIndex`, and `RefactorTestOptions.Span`/`NodeSelector` (e.g. +`NodeSelector = query => query.GetMethod("M")`). + +## TUnit assertion extensions + +All assertion extensions live under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` +(globally imported by the package's props). `Assert.That(...)` calls are terminal and **return the value** +when awaited. + +- **`CodeQueryAssertions`** — return `CodeQueryResult` (the matched node via `.Node`, plus a query + scoped to it via `.Query`): `HasGeneratedMethod` (optionally with `TypeReference[]` parameter types), + `HasGeneratedMethodReturnType`, `HasGeneratedClass` (by name or `TypeReference`/`TypeIdentity` identity), + `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`; `HasFixedMethod` for code-fix and + refactor results. The assertions operate on a `CodeQuery` directly, so pass any query — `result.Generated()` + (generated trees), `result.Output()` (whole compilation), or `result.FixedCode()` — or use the convenience + overloads on the test result types, which query the relevant code for you. +- **Scoped member chaining** — `HasPropertyOfType(name, type)`, `HasFieldOfType(name, type)`, + `HasMethodOfType(name, TypeReference[])`, `HasConstructorOfType(TypeReference[])`, and + `HasAttributeOfType(name)` chain from a scoped `CodeQueryResult` (e.g. the result of `HasGeneratedClass`) + and return the matched member. They are named to avoid colliding with the bool `Has*` predicates in + `MemberQueryExtensions`. +- **Fluent `.And` chains** — the node-producing assertions (`HasGeneratedClass`, `HasPropertyOfType`, + `HasMethodOfType`, `HasNestedType`) move the chain onto the matched node, so further assertions can be + appended with `.And`. Node-inspection assertions — `WithAccessibility`, `WithGetterAccessibility`, + `WithSetterAccessibility`, `WithBaseType`, `WithGenericTypeParameter(s)`, `IsInNamespace`, + `IsInGlobalNamespace` — keep the node on the chain: + ```csharp + var method = await Assert.That(query) + .HasGeneratedClass("Service") + .And.HasNestedType("Builder") + .And.WithAccessibility(Accessibility.Private) + .And.HasMethodOfType("Build", []); + ``` + Generic types are matched by arity — `HasGeneratedClass(name, arity)` or a `TypeIdentity` with arity — + so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` + without matching the non-generic `ResourceDefinition`. +- **Nullable expected types in tests** — use the test-only `query.MakeNullable(type)` extension (on a + `CodeQuery`). It resolves the annotation against the query's compilation and, unlike + `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trip the `PSGFR16` context-overload + suggestion, since tests have no generation context to pass. + ```csharp + CodeQueryResult method = await Assert.That(result.Generated()).HasGeneratedMethod("DoWork", [intType, nullableInt]); + CodeQueryResult cls = await Assert.That(result.Generated()).HasGeneratedClass("Service"); + await Assert.That(cls.HasProperty("Count", intType)).IsTrue(); // chained member query + await Assert.That(cls.Node.Identifier.ValueText).IsEqualTo("Service"); // direct syntax access + await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results + + var query = result.Generated(); + var attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute); + await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); + ``` +- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, + `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` + (whitespace-flattened string comparison). + +For structural assertions (members, signatures, namespaces) prefer `result.Generated()` + +`Get/Has/TryGet` from `CodeQuery` (see `purview-sourcegenerator-testing`). + +## Incremental cache tests (`GenerateIncrementalAsync`) + +`TUnitSourceGeneratorTestBase` exposes `GenerateIncrementalAsync`, which mirrors `RunIncrementalAsync` but +also wires the base class hooks (`OnBeforeRun`/`OnBeforeRunAsync`) and your derived options record. Use it to +prove the pipeline caches stage-by-stage. The reference is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`; the framework-agnostic twin with a full walkthrough is +in the `purview-sourcegenerator-testing` skill. + +Why the tests look the way they do: + +- `GenerateIncrementalAsync([Source])` runs the **same source twice** on a single shared driver. The first + run reports every stage `New`; the second must report `Cached`/`Unchanged` for unchanged stages — that is + the core "it caches" proof. +- `GenerateIncrementalAsync([new IncrementalRunInput([Source]), new IncrementalRunInput([changed])])` runs two + **different** source sets, so a source-only change must mark `ForAttribute_*` `Modified` while + property/configuration stages stay `Cached`. +- `new IncrementalRunInput([Source], [("build_property.X", "value")])` toggles an MSBuild property for one + run only, so a property-only change must mark `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/ + `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. +- `result.Runs[n].LogEntries` carries the framework log entries *that run* wrote, in order, so a rerun whose + output step is cached can be asserted to have logged nothing. Empty when `EnableLogging` is disabled. +- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a + `ImmutableDictionary>` so assertions can address a stage + by name (see `purview-sourcegenerator-testing` for the helper body). +- If the generator's pipeline depends on its own post-initialization output (a self-referencing generated + attribute), Roslyn's internal `ForAttributeWithMetadataName` `Compilation` step is legitimately `Modified` + on rerun; assert on the framework-named stages (e.g. `ForAttribute_GenerateServiceAttribute`, + `GetGenerationConfiguration`, `GetGenerationContext_EmptyCapabilities`) rather than every tracked step. + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync( + [ + new IncrementalRunInput([Source]), + new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]), + ], + cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_GenerateServiceAttribute"].All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } +} +``` + +Use `using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;` and your own stage names. + +## Modernising existing tests + +When converting legacy tests that do `result.GetGeneratedTree(...)` + `string.Contains(...)`: + +1. Replace tree-lookup + string matching with `result.Generated().GetClass/GetMethod/GetProperty(...)` and + the `Has*`/`TryGet*` family. +2. Replace signature string checks with `TypeReference` parameter/return-type matching. +3. Replace `Assert.That(text).Contains("...")` with the terminal assertion extensions that return nodes. +4. Verify options use a derived record (namespaces + assemblies) rather than repeating `AdditionalNamespaces` + per test. +5. Add a stage-by-stage cache test if the component has an incremental pipeline — first run `New`, + identical rerun `Cached`/`Unchanged`, and targeted changes mark only the affected stage `Modified`. + Use the inlined examples in this skill and in `purview-sourcegenerator-testing`'s "Incremental cache testing" + section, swapping in your own generator and stage names. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-zodsharp-aspnetcore/SKILL.md b/.agents/skills/purview-zodsharp-aspnetcore/SKILL.md new file mode 100644 index 0000000..a11c258 --- /dev/null +++ b/.agents/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/.agents/skills/purview-zodsharp-core/SKILL.md b/.agents/skills/purview-zodsharp-core/SKILL.md new file mode 100644 index 0000000..98280dd --- /dev/null +++ b/.agents/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/.agents/skills/purview-zodsharp-custom-rules/SKILL.md b/.agents/skills/purview-zodsharp-custom-rules/SKILL.md new file mode 100644 index 0000000..06215cc --- /dev/null +++ b/.agents/skills/purview-zodsharp-custom-rules/SKILL.md @@ -0,0 +1,241 @@ +--- +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. Its JSON Schema mapping is exact for `DateOnly`, `TimeOnly`, deterministic +`DateTime` forms, and `DateTimeOffset`; the optional UTC opt-in only narrows the accepted offset (see the JSON +skill). + +```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/.agents/skills/purview-zodsharp-diagnostics/SKILL.md b/.agents/skills/purview-zodsharp-diagnostics/SKILL.md new file mode 100644 index 0000000..101e872 --- /dev/null +++ b/.agents/skills/purview-zodsharp-diagnostics/SKILL.md @@ -0,0 +1,125 @@ +--- +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. | + +## Collections + +| ID | Meaning | +| --- | --- | +| `ZODSGEN046` | *(warning)* `[CollectionBehavior]` is applied to a property that is not a collection of nullable elements or values (a scalar, a collection whose element cannot be `null`, or a dictionary whose value cannot be `null`), so the setting can never take effect. | + +## 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/.agents/skills/purview-zodsharp-json/SKILL.md b/.agents/skills/purview-zodsharp-json/SKILL.md new file mode 100644 index 0000000..adaedda --- /dev/null +++ b/.agents/skills/purview-zodsharp-json/SKILL.md @@ -0,0 +1,223 @@ +--- +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; +- `[NonSentinel]` exports exact patterns for `DateOnly`, `TimeOnly`, `DateTime`, and `DateTimeOffset`. The + default `DateTimeOffset` pattern is offset-aware and needs no converter. An optional narrower UTC contract + uses `ZodSharpDateTimeOffsetSerialization=Utc` or + `[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)]`, paired + with the integration package's `UtcDateTimeOffsetConverter` (which rejects non-UTC input on read). Override + per type/property with `[DateTimeOffsetBehavior(...)]`. + +### 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. +- **Collection items** follow the null-items setting: `T[]`/`List` maps to `z.array(T)` and + `T?[]`/`List` (or a property allowing nulls via `[CollectionBehavior(AllowNulls = true)]`) to + `z.array(T.nullable())`. A collection whose elements may be null exports + `items.anyOf: [ …, { "type": "null" } ]`; by default the generated validator rejects a null element with + `invalid_type` at the element's path, matching `z.array(T)`. +- **Dictionaries map to `z.record(z.string(), V)`.** A property implementing `IDictionary<,>` or + `IReadOnlyDictionary<,>` exports `{ "type": "object", "additionalProperties": }` (a nullable value + wraps that value schema in a `null` branch), and its size constraints use `minProperties`/`maxProperties`. + `Z.Record(schema)` exports the same shape, and `Z.FromJsonSchema` reads an object with + `additionalProperties` back as a record in both JSON integrations. + +## 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` (a nullable item + schema — `anyOf` with a `{"type":"null"}` branch — imports as a nullable element). + +### 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/.agents/skills/purview-zodsharp-source-generator/SKILL.md b/.agents/skills/purview-zodsharp-source-generator/SKILL.md new file mode 100644 index 0000000..31d140e --- /dev/null +++ b/.agents/skills/purview-zodsharp-source-generator/SKILL.md @@ -0,0 +1,224 @@ +--- +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`. | +| `AllowNulls` | unset | Whether this type's collection properties may contain `null` elements. Unset defers to the assembly default. | + +`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`. +- Collection elements reject `null` by default: the error is `invalid_type` at the element's own path + (`["Items", 0]`). Control it with `[CollectionBehavior(AllowNulls = ...)]` on the property, a `T?` + element annotation (implies allow), `[ZodSchema(AllowNulls = ...)]` on the containing type, + `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]`, then the `ZodSharpAllowNulls` MSBuild + property. The most specific statement wins; an unset level defers to the next. An explicit + `AllowNulls = true` exports a `null` branch on the collection's `items` schema. +- **Dictionaries are maps, not arrays.** A property whose type implements `IDictionary` + or `IReadOnlyDictionary` (for example `Dictionary`, `SortedDictionary`, + `ConcurrentDictionary`, `ImmutableDictionary`) validates every **value** against the value type's + nested schema and reports a failure at `["Items", "key"]`. Keys are used only in error paths, and + `AllowNulls` governs a null value (`Dictionary` rejects it by default; `Child?` or + `[CollectionBehavior(AllowNulls = true)]` accepts it). A dictionary exports as + `{ "type": "object", "additionalProperties": … }` and `[Length]`/`[MinLength]`/`[MaxLength]` map to + `minProperties`/`maxProperties` — never `z.array`/`items`. +- 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 | +| `ZodSharpAllowNulls` | `false` | assembly-wide default for whether collections may contain `null` elements; explicit `true` accepts them | +| `ZodSharpDateTimeOffsetSerialization` | `PreserveOffset` | `Utc` narrows `[NonSentinel] DateTimeOffset` to a zero-offset wire contract; pair with the integration package's `UtcDateTimeOffsetConverter`. Both modes export an exact sentinel pattern | + +`DateTimeOffset` precedence: `[DateTimeOffsetBehavior]` on the property/parameter, then on the type, then +`[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = ...)]`, then the MSBuild property, then +`PreserveOffset`. The attribute shapes the schema only; `Utc` requires a converter that also rejects non-UTC +input on read. + +## 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/.agents/skills/purview-zodsharp-source-generator/references/dataannotations.md b/.agents/skills/purview-zodsharp-source-generator/references/dataannotations.md new file mode 100644 index 0000000..30e92ec --- /dev/null +++ b/.agents/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/.gitignore b/.gitignore index 8e47abe..2306968 100644 --- a/.gitignore +++ b/.gitignore @@ -594,3 +594,7 @@ coverage/ !assets/packages/* .tools/ + +# Agent skills are installed into the universal .agents/skills/ folder only, and that folder and +# skills-lock.json are committed. Per-agent folders (and Claude Code's local settings) stay out. +/.claude/ diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..5b46810 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,125 @@ +{ + "version": 1, + "skills": { + "purview-buildsdk-adoption": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", + "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + }, + "purview-buildsdk-configuration": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", + "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + }, + "purview-buildsdk-project-detection": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", + "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + }, + "purview-buildsdk-project-layout": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", + "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + }, + "purview-buildsdk-repository-review": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", + "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + }, + "purview-buildsdk-skill-installation": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", + "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + }, + "purview-buildsdk-skill-tests": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", + "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + }, + "purview-buildsdk-testing": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-testing/SKILL.md", + "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + }, + "purview-sourcegenerator-codewriter": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", + "computedHash": "f24ae680340784e62236d044cbc3658d5ca7bdc3cea3fc44f2bb5cf75a84f5e4" + }, + "purview-sourcegenerator-diagnostics": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", + "computedHash": "c3c7920701e0e688f34ddece9e448472e010e69767e8c08914638128d34a8f17" + }, + "purview-sourcegenerator-packaging": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", + "computedHash": "e0e6372e92aefc8df026a73010d9e7436b0a8c2378a96fdebd8b4de9e025d4a0" + }, + "purview-sourcegenerator-pipeline": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-pipeline/SKILL.md", + "computedHash": "7d48394d7e3890f288949c79f040646207f34af9ae85827a63d056bae1b1b985" + }, + "purview-sourcegenerator-testing": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", + "computedHash": "d88bf2353073013ff9ddf9d3fe1a2e9415eb60d319bbd053baa105f50fa3f889" + }, + "purview-sourcegenerator-tunit-tests": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", + "computedHash": "14809d3727643e9a779d590b6627b3283af7b36c835e902cb80162552cb45b4f" + }, + "purview-zodsharp-aspnetcore": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-aspnetcore/SKILL.md", + "computedHash": "a1c1f9fc008be609cb12622ca08022965497fe306cc1b4203333440a0edb4206" + }, + "purview-zodsharp-core": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-core/SKILL.md", + "computedHash": "e89c1000bb305420b85c1b93f3f77d9b500fc6b7d52b7016f670430fab1659b0" + }, + "purview-zodsharp-custom-rules": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-custom-rules/SKILL.md", + "computedHash": "3726a476b75b842daf67898e19b197331a1195061053183d66e9e05a66294266" + }, + "purview-zodsharp-diagnostics": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-diagnostics/SKILL.md", + "computedHash": "19e480b1eca26e417642b8c55f689580eb7b741f02e78387041ffebba1216ddb" + }, + "purview-zodsharp-json": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-json/SKILL.md", + "computedHash": "82da551adff5506232d5419d925ed198d71844f7901ad13ef7a3a0bead4a826f" + }, + "purview-zodsharp-source-generator": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-source-generator/SKILL.md", + "computedHash": "7172450983d6130c3335276fdcec80ca87b473ab7fbb43dfd08304b55c1632b0" + } + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7b698eb..ae66414 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -8,6 +8,11 @@ itself, and anything carrying a single Entity Framework Core 10 pin could not restore for net8.0/net9.0. Roslyn components are excluded by the SDK, which targets them at netstandard2.0. --> All + + universal From 849bac8ecda760d8d0bf7720ab7b7a1d07111615 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:21:53 +0000 Subject: [PATCH 12/23] test(repository): guard the installed skills and the agents-only convention InstalledSkillTests checks that every skills-lock.json entry is installed under .agents/skills/, that every installed folder is recorded in the lockfile, that no entry is one of this repository's own skills, and that neither a .claude/skills/ copy nor a CLAUDE.md exists. --- .../InstalledSkillTests.cs | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/tests/Repository.UnitTests/InstalledSkillTests.cs diff --git a/src/tests/Repository.UnitTests/InstalledSkillTests.cs b/src/tests/Repository.UnitTests/InstalledSkillTests.cs new file mode 100644 index 0000000..1d8b32b --- /dev/null +++ b/src/tests/Repository.UnitTests/InstalledSkillTests.cs @@ -0,0 +1,161 @@ +using System.Text.Json; + +namespace Purview.ValueObjects.Repository; + +/// +/// Guards the skills installed from other repositories. They are installed by the skills CLI into the +/// universal .agents/skills/ folder only, and that folder and the CLI's skills-lock.json +/// are committed, so the two must agree and no per-agent copy or Claude-specific instruction file may +/// appear beside them. +/// +/// +/// SkillTests validates this repository's own skills in skills/; these are someone else's, +/// so their content is not checked here, only that the installed set is the recorded set. +/// +public sealed class InstalledSkillTests +{ + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + [Test] + public async Task Lockfile_GivenEveryEntry_HasItsSkillInstalledUnderAgents(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var missing = recorded + .Where(static name => !File.Exists(Path.Combine(AgentsSkillsFolder(), name, "SKILL.md"))) + .ToArray(); + + // Assert + await Assert.That(recorded).IsNotEmpty().Because("skills-lock.json records no installed skills"); + await Assert + .That(missing) + .IsEmpty() + .Because( + "skills-lock.json records skills that are not installed in .agents/skills/. Restore them with " + + $"`npx -y skills@1 experimental_install`: {string.Join(", ", missing)}" + ); + } + + [Test] + public async Task AgentsSkills_GivenEveryInstalledSkill_IsRecordedInTheLockfile(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var unrecorded = Directory + .GetDirectories(AgentsSkillsFolder()) + .Select(static path => Path.GetFileName(path)) + .Where(name => !recorded.Contains(name, StringComparer.Ordinal)) + .ToArray(); + + // Assert + await Assert + .That(unrecorded) + .IsEmpty() + .Because( + "every folder in .agents/skills/ must be written by the skills CLI, which records it in " + + $"skills-lock.json: {string.Join(", ", unrecorded)}" + ); + } + + [Test] + public async Task Lockfile_GivenEveryEntry_IsFromAnotherRepository(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var authoredHere = recorded + .Where(static name => Directory.Exists(Path.Combine(RepositoryRoot.Value, "skills", name))) + .ToArray(); + + // Assert + await Assert + .That(authoredHere) + .IsEmpty() + .Because( + "this repository's own skills are read at skills//SKILL.md and never installed into " + + $".agents/skills/, where a second copy would drift: {string.Join(", ", authoredHere)}" + ); + } + + [Test] + public async Task ClaudeFolder_GivenTheInstalledSkills_HoldsNoCopy(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var claudeSkills = Path.Combine(RepositoryRoot.Value, ".claude", "skills"); + + // Act + var exists = Directory.Exists(claudeSkills); + + // Assert + await Assert + .That(exists) + .IsFalse() + .Because( + "skills are installed into .agents/skills/ only. A .claude/skills/ folder means the CLI ran without " + + "`-a universal`; delete it and reinstall with `-a universal`" + ); + } + + [Test] + public async Task RepositoryRoot_GivenTheAgentConvention_HasNoClaudeInstructionFile( + CancellationToken cancellationToken + ) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var claudeMd = Path.Combine(RepositoryRoot.Value, "CLAUDE.md"); + + // Act + var exists = File.Exists(claudeMd); + + // Assert + await Assert + .That(exists) + .IsFalse() + .Because("AGENTS.md is the only agent instruction file; a CLAUDE.md would be a second, drifting copy"); + } + + static string AgentsSkillsFolder() => Path.Combine(RepositoryRoot.Value, ".agents", "skills"); + + static async Task ReadLockfileSkillsAsync(CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(Path.Combine(RepositoryRoot.Value, "skills-lock.json")); + using var lockfile = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + return + [ + .. lockfile + .RootElement.GetProperty("skills") + .EnumerateObject() + .Select(static property => property.Name) + .Order(StringComparer.Ordinal), + ]; + } + + 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, "skills-lock.json")) + && Directory.Exists(Path.Combine(directory.FullName, "src", "src")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries AGENTS.md, skills-lock.json and src/src." + ); + } +} From d512a75c301d5b539a59a3f6f1fe0c0b6a7c3d56 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:21:54 +0000 Subject: [PATCH 13/23] docs(agents): document the installed skills and the agents-only convention AGENTS.md gains an "Installed skills (.agents/skills/)" section: the installed set and its sources, the -a universal install and restore commands, why nothing goes into .claude/ and no CLAUDE.md is added, and the test that guards it. The authored-skills section no longer says .agents/skills/ is gitignored, and the layout table lists both skill folders. --- AGENTS.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 915e2b7..fac308f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,8 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar | `global.json` | Required .NET SDK and Microsoft.Testing.Platform selection | | `package.json` | Authoritative repository/package version | | `Justfile` | Supported local workflow commands | +| `skills` | Authored agent skills, installable with the skills CLI (see [Agent skills](#agent-skills)) | +| `.agents/skills`, `skills-lock.json` | Skills installed from other repositories, committed (see [Installed skills](#installed-skills-agentsskills)) | ## Standard workflow @@ -181,17 +183,61 @@ Before handing work back: - Update all affected docs, samples, analyzer metadata, and release notes when applicable. - Run appropriate build, test, formatting, and pack checks in proportion to risk. - State exactly what validation ran. If a check was skipped or blocked, give the concrete reason and remaining risk. + +## Installed skills (`.agents/skills/`) + +Specialist guidance this repository depends on - `Purview.BuildSdk` configuration, the +`Purview.SourceGeneratorFramework` generator and test APIs, and the ZodSharp integration - is published by the +repositories that own it, and installed here by the skills CLI. The installed skills are **committed**: +`.agents/skills/**` and the CLI's `skills-lock.json` (source, skill path and content hash of every entry) are both +in source, so a clone has them without running anything. + +| Skill | Source | Load before | +| --- | --- | --- | +| `purview-buildsdk-*` (8 skills) | `purview-dev/build-sdk` | build/SDK configuration, project layout and detection, testing, skill installation and skill tests | +| `purview-sourcegenerator-codewriter` | `purview-dev/sourcegenerator-framework` | writing or reviewing emitter code (`CodeWriter`, scopes) | +| `purview-sourcegenerator-pipeline` | `purview-dev/sourcegenerator-framework` | changing the incremental pipeline or its value-equatable models | +| `purview-sourcegenerator-diagnostics` | `purview-dev/sourcegenerator-framework` | adding or changing a `VO1xxx` diagnostic or the analyzer | +| `purview-sourcegenerator-packaging` | `purview-dev/sourcegenerator-framework` | changing how the generator is packed or merged | +| `purview-sourcegenerator-testing` | `purview-dev/sourcegenerator-framework` | writing generator, analyzer or code-fix tests (`CodeQuery`) | +| `purview-sourcegenerator-tunit-tests` | `purview-dev/sourcegenerator-framework` | the TUnit base classes and assertion extensions | +| `purview-zodsharp-*` (6 skills) | `purview-dev/zodsharp` | the `[ZodSchema]` integration, custom rules, JSON, ASP.NET Core and `ZODSGEN` diagnostics | + +Skills are installed into the universal `.agents/skills/` location **only**. Never install copies into `.claude/` +or any other per-agent folder, and never add a `CLAUDE.md`: `AGENTS.md` is the one instruction file, and +`/.claude/` is gitignored. Pass `-a universal`, which writes `.agents/skills/` and nothing else: + +```bash +# add or update every skill from a source +npx -y skills@1 add purview-dev/build-sdk -s '*' -a universal -y +npx -y skills@1 add purview-dev/sourcegenerator-framework -s '*' -a universal -y +npx -y skills@1 add purview-dev/zodsharp -s '*' -a universal -y + +# restore the committed set from skills-lock.json +npx -y skills@1 experimental_install +``` + +Commit the changed `.agents/skills/**` and `skills-lock.json` together, in their own `chore` commit. Do not edit +anything under `.agents/skills/` by hand - the CLI is its only writer, and a hand edit no longer matches the +lockfile's hash. `Purview.BuildSdk`'s `InstallPurviewSkills` target delegates to the same CLI; +`src/Directory.Build.props` sets `PurviewSkillsAgents=universal` so it is restricted the same way rather than +defaulting to every agent (`*`). + +`src/tests/Repository.UnitTests/InstalledSkillTests.cs` keeps this honest: every `skills-lock.json` entry has its +`.agents/skills//SKILL.md`, every installed folder is in the lockfile, no entry is one of this repository's +own skills, and neither a `.claude/skills/` copy nor a `CLAUDE.md` exists. + ## Agent skills Authored skills live in the repository-root `skills/` folder. Nothing packs them: `Purview.BuildSdk` delivers skills through the skills CLI, which clones the *git* repository and only discovers a root `skills/` folder. -`.agents/skills/` is the CLI's install location for skills from **other** repositories, and is -gitignored. This repository's own skills are never installed into it — that would leave two copies -to drift apart. Read them at their authored path, `skills//SKILL.md`. +`.agents/skills/` holds the skills installed from **other** repositories (above). This repository's own skills are +never installed into it — that would leave two copies to drift apart. Read them at their authored path, +`skills//SKILL.md`. -Install these skills into your agent's directories with: +Consumers install these skills with: ```bash npx skills add purview-dev/value-objects From 4ff55403e62fb5e1ce8185a700de29951512f51f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:45:36 +0000 Subject: [PATCH 14/23] chore(skills): install the purview engineering playbook under .agents Every purview repository installs purview-dev/engineering-playbook beside purview-dev/build-sdk. Installed the same way as the others, into the universal location only, and nothing was written outside .agents/ and skills-lock.json: npx -y skills@1 add purview-dev/engineering-playbook -s '*' -a universal -y That adds purview-architecture, purview-architecture-testing, purview-engineering-principles, purview-persistence, purview-telemetry and purview-testing-conventions. --- .../purview-architecture-testing/SKILL.md | 183 +++++++++ .agents/skills/purview-architecture/SKILL.md | 176 +++++++++ .../references/contract-strategy.md | 236 ++++++++++++ .../references/project-context-discovery.md | 115 ++++++ .../references/project-responsibilities.md | 119 ++++++ .../references/proportional-architecture.md | 92 +++++ .../purview-engineering-principles/SKILL.md | 171 +++++++++ .agents/skills/purview-persistence/SKILL.md | 106 ++++++ .../references/guid-generation.md | 93 +++++ .../references/postgresql.md | 98 +++++ .../references/sqlserver.md | 101 +++++ .agents/skills/purview-telemetry/SKILL.md | 123 ++++++ .../references/observability-levels.md | 93 +++++ .../purview-testing-conventions/SKILL.md | 357 ++++++++++++++++++ .../acceptance-and-container-tests.md | 117 ++++++ skills-lock.json | 36 ++ 16 files changed, 2216 insertions(+) create mode 100644 .agents/skills/purview-architecture-testing/SKILL.md create mode 100644 .agents/skills/purview-architecture/SKILL.md create mode 100644 .agents/skills/purview-architecture/references/contract-strategy.md create mode 100644 .agents/skills/purview-architecture/references/project-context-discovery.md create mode 100644 .agents/skills/purview-architecture/references/project-responsibilities.md create mode 100644 .agents/skills/purview-architecture/references/proportional-architecture.md create mode 100644 .agents/skills/purview-engineering-principles/SKILL.md create mode 100644 .agents/skills/purview-persistence/SKILL.md create mode 100644 .agents/skills/purview-persistence/references/guid-generation.md create mode 100644 .agents/skills/purview-persistence/references/postgresql.md create mode 100644 .agents/skills/purview-persistence/references/sqlserver.md create mode 100644 .agents/skills/purview-telemetry/SKILL.md create mode 100644 .agents/skills/purview-telemetry/references/observability-levels.md create mode 100644 .agents/skills/purview-testing-conventions/SKILL.md create mode 100644 .agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md new file mode 100644 index 0000000..0619225 --- /dev/null +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -0,0 +1,183 @@ +--- +name: purview-architecture-testing +description: >- + Use when adding, changing or debugging architecture tests in a .NET solution - dependency-direction + rules, forbidden framework references, project naming checks, cycle detection - or when a boundary + keeps being broken in review and should fail the build instead. Adopts Purview.Architecture.Testing + and a single policy file rather than hand-maintained lists. +license: MIT +metadata: + author: Purview-Dev + version: "2.0" +--- + +# Purview architecture testing + +A boundary that only review enforces is a boundary that erodes. The job of this skill is to turn a +rule a reviewer keeps repeating into a test that fails the build, **without** producing a wall of +brittle assertions that everyone disables six months later. + +Prerequisite: know what the projects are *for*. See `purview-architecture`. + +## 1. Only test a rule that is real + +Before writing anything, answer: **has this rule been broken, or is it certain to be?** + +A test for a dependency nobody would plausibly add is cost with no benefit. Good candidates: + +- A direction that has already been violated once. +- A boundary that is load-bearing - Core staying free of EF Core, Composition being referenced by + nothing. +- A rule a newcomer cannot infer from the code. + +Bad candidates: anything the compiler already enforces, and style preferences dressed up as +architecture. + +## 2. Adopt the package; define the policy once + +The failure mode of architecture tests is a dependency matrix written twice - once in a wiki table and +once in assertions - which then disagree. The prose is the copy that drifts, because nothing fails +when it is wrong. + +So do not write the rule engine. `Purview.Architecture.Testing` ships it, so the matrix, naming, +sibling-independence, composition, forbidden-package, cycle, test-reference and waiver rules are +defined once and shared. Adoption is two files. + +**A policy** at `.config/architecture-policy.json`, pointing at the published schema so an editor +validates it as you type: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 2, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "aliases": ["Domain"], + "category": "domain", + "purpose": "The domain model, its invariants and its rules.", + "mayDependOn": ["Shared"], + "mustNotDependOn": ["Application", "Persistence", "Composition"], + "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore"] + } + ], + "waivers": [] +} +``` + +**One derived test class:** + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +That is the whole of the adoption. Each rule is a separate test, so a failure names the rule rather +than collapsing the set into one assertion, and the package validates the policy against its schema +first - a policy that does not match the schema may be silently missing a rule. + +The build-sdk ships a skill for the package itself, `purview-buildsdk-architecture-tests`, which is +authoritative on its API and rule ids. Read it rather than this page when you are extending or +debugging the package. + +Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable +too. Full worked example: +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). + +## 3. Read project files, and assemblies + +Two sources, because they catch different things: + +| Source | Catches | Misses | +| -- | -- | -- | +| `.csproj` `ProjectReference` | The reference a reviewer actually added | A dependency arriving transitively | +| Compiled assembly references | A dependency that arrived indirectly | Intent — it cannot tell direct from transitive | + +The package's rules read the **project files**, because a declared `ProjectReference` is what a +reviewer adds and what the policy governs. Add a small hand-written test that reads the **compiled +assemblies** for the leak a project file cannot show - a namespace from the wrong layer reaching a +public type. + +Classify a project by the **first segment** of its name, so `Persistence.SqlServer` and `Persistence` +are both Persistence - qualification is a naming choice, never a different rule. The package does +this for you; a hand-written assembly test must do it too. + +## 4. The rules worth having + +| Rule | What it catches | +| -- | -- | +| Dependency matrix | Core referencing Persistence; Application referencing a concrete provider | +| Nothing references Composition | A circular implementation choice | +| Sibling independence | `Providers.GitHub` reaching into `Providers.AzureDevOps` | +| Forbidden package prefixes | EF Core in Core; ASP.NET hosting in a domain project | +| Cycle detection | A graph that MSBuild rejects with an unreadable message | +| Catch-all names | `Infrastructure`, `Services`, `Helpers` appearing as project names | +| No production → test reference | A test helper leaking into shipped code | + +The package provides all of these. Do not re-implement one; change the policy instead. + +## 5. Write failures someone can act on + +This is what decides whether the suite survives. A failure must name: + +1. The offending project. +2. The rule violated. +3. The specific reference that broke it. +4. The direction the dependency *should* run. +5. The likely fix. + +```text +'Application' (Application) references 'Persistence.SqlServer' (Persistence), but Application +must not depend on Persistence. Invert it: Persistence may depend on Application, or introduce +an abstraction that Application owns and Persistence implements. +``` + +Not `Expected collection to be empty`. Collect every violation and report them as a numbered list - +one test run should show all of them, not the first. The package already reports this way; keep the +same standard in any rule you add. + +## 6. Waivers, not suppressions + +A repository that predates the policy will violate it. Deleting the test or adding a blanket +`#pragma` makes the policy a lie. Record the departure in the policy instead: + +```json +{ + "project": "Infrastructure", + "mapsTo": "Adapters", + "rules": ["naming"], + "reason": "Holds one adapter, predates the catalogue; renaming churns the wiki and docs for no gain.", + "reviewedOn": "2026-10-15" +} +``` + +The package tests the waivers themselves: each must name a project that still exists, a rule, and a +substantive reason. A waiver for a deleted project is dead weight that hides the next one. + +Critically, a waiver excuses **the narrowest thing possible** - usually the name. `mapsTo` keeps the +project classified so every dependency rule still applies to it. Prefer a waiver to +`SkipRule`, which removes a rule for the whole repository with no record. + +## 7. Conventions + +- TUnit, matching the repository's other tests, adopted through `PurviewArchitectureTests` with + `[InheritsTests]`. +- Arrange/Act/Assert, one behaviour per test. +- Let the package find the repository root - it probes upward for the policy and stops at the `.git` + boundary. Override `RepositoryRoot` only when the policy does not sit above the test binary. +- Keep the policy file free of anything a rule cannot read; a field no rule consults is a rule that + looks enforced and is not. + +## Checks before you answer + +- Is every rule you added one that has been, or will be, broken? +- Is the matrix defined once, in the policy, with the documentation checked against it? +- Does each failure name the fix, not just the fact? +- Did you record a waiver rather than weaken a rule for existing code? + +## See also + +- `purview-architecture` — what the projects are for, and how they are named +- `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md new file mode 100644 index 0000000..c04f3d6 --- /dev/null +++ b/.agents/skills/purview-architecture/SKILL.md @@ -0,0 +1,176 @@ +--- +name: purview-architecture +description: >- + Use when deciding the shape of a .NET solution that uses Purview libraries — how many projects, + whether to add a repository or CQRS layer, where validation lives, how an API and its clients share + a contract (Contracts, OpenAPI or Hybrid) and when a BFF is earned — or before generating + substantial implementation code. Establishes project context first, then applies DRY, KISS and + architecture proportional to the actual requirements rather than to an enterprise reference diagram. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview architecture + +The job of this skill is to stop two failures that cost far more than they look: + +- **Architecture bought in advance.** Four projects, a repository interface and a mapping layer for an + application that has one table and one caller. +- **A recommendation made without context.** Advice about database-side GUID generation given to a + stateless CLI, or a Clean Architecture split proposed for a 300-line utility. + +So: establish context, then choose proportionally. Both steps are mandatory, and the first one is the +one that gets skipped. + +## 1. Establish context before deciding + +Before recommending a structure, adding a project, or generating substantial code, you must know four +things. **Read them out of the repository first** — asking a user a question the code already answers +is the most common way this skill is applied badly. + +| Dimension | Read it from | Ask only if | +| -- | -- | -- | +| Complexity (small / medium / large) | Number of functional areas, integrations and external consumers — not file count | The requirements are genuinely not written down anywhere | +| Project type | SDK attribute, `OutputType`, `Microsoft.NET.Sdk.Web`, an AppHost project, a `Main` | The repository is empty | +| Persistence | `PackageReference` to an EF Core provider, `DbContext`, a connection string, a `Purview.Containers` resource | Persistence is being introduced now | +| Telemetry | `Purview.Telemetry.SourceGenerator`, `ILogger` injection, `ActivitySource`, an OTLP exporter | Nothing is configured and you are about to add it | + +Identifier generation is a **conditional** dimension: resolve it only when persistence exists *and* +GUID keys are in play. See `purview-persistence`. + +Judge complexity on requirements, not size. A 2,000-line CLI with one output format is small. A +900-line service that reconciles three upstream systems and owns money movement is not. + +**Do not re-ask.** A dimension answered by repository configuration, existing source, a project file, +an earlier turn in the conversation, or a recorded architectural decision is answered. Ask concise, +targeted questions only for the unknowns that change your recommendation — and if nothing changes, +ask nothing. + +Full decision procedure: `references/project-context-discovery.md`. + +## 2. Choose proportionally + +| Scale | Shape | What you still do not add | +| -- | -- | -- | +| Small | One project, or two when a test project needs a non-trivial subject. Direct implementations. | Repository over `DbContext`, CQRS, a mapping layer, speculative interfaces | +| Medium | Explicit boundaries where complexity already hurts — usually a domain project and a host | Event sourcing, a generic infrastructure framework, a second infrastructure implementation nobody asked for | +| Large | Modularity, independent infrastructure implementations, enforceable boundaries (see the playbook's Architecture-As-Tests page) | Patterns adopted for symmetry rather than for a named problem | + +Architecture is allowed to arrive late. A boundary added when the second caller appears is cheap; a +boundary added in anticipation of a second caller that never appears is permanent. + +**Never add a project for symmetry.** "Domain, Application, Infrastructure, API" is a response to a +problem, not a starting position. If you cannot name the failure a layer prevents in this codebase, +do not add the layer. + +Full guidance and the per-pattern justification tests: `references/proportional-architecture.md`. + +## 3. Name what you do create + +A project name states a **responsibility**, optionally qualified by an implementation: + +```text +[.] +``` + +The responsibility decides the rules; the qualifier never changes them. Three rules people usually +get wrong: + +- **Do not qualify a single implementation.** `Persistence` is correct even when it uses SQL Server. + Rename when a second provider arrives, not in anticipation. +- **Do not create an empty parent.** `Persistence.SqlServer` + `Persistence.PostgreSql` with nothing + shared need no `Persistence` project. +- **Multiple implementations do not require multiple assemblies.** Two small adapters with the same + dependencies belong in one project. + +Never use a *category* as a project name — `Infrastructure`, `Services`, `Managers`, `Models`, +`Helpers`, `Common`. Nothing can be excluded from a category, so it grows without limit. And never +rename an established project just to match canonical terminology; `Domain`, `UseCases` and +`Integrations` are recognised aliases. + +Responsibility catalogue, dependency matrix and the naming decision tree: +`references/project-responsibilities.md`. + +## 4. Check the direction + +`A → B` means **A depends on B at compile time** — not a runtime call, a DI registration, an HTTP +request or an Aspire orchestration edge. Confusing those is the most common source of a wrong answer +about whether a dependency is allowed. + +The rules that matter most: + +- Core depends on nothing but `Shared`. +- Application never references a concrete `Persistence`, `Providers` or `Adapters` project. +- Composition references implementations; **nothing references Composition** except a host. +- Sibling implementations (`Providers.GitHub`, `Providers.AzureDevOps`) stay independent. +- Persistence owns migration *definitions*; `Migrator` owns their *execution*. Production API startup + does not apply schema migrations. + +When a boundary is worth having, make it fail the build rather than the review — see +`purview-architecture-testing`. + +## 5. Choose how an API and its clients share a contract + +When there is an HTTP API and at least one client, choose **Contracts**, **OpenAPI** or **Hybrid** +from the clients that **exist today**. A planned client is noted; it does not count. + +| Clients today | Strategy | Failure it prevents | +| -- | -- | -- | +| All .NET, same solution, shipped in lockstep | **Contracts** — a library of request/response types on the domain's value objects, referenced by the server and every .NET client | Rules drifting, and a weaker second copy of them in the UI | +| At least one non-.NET client, or one that ships independently | **Hybrid** — Contracts is the source; the OpenAPI document and its schemas are generated from it | Non-.NET clients hand-writing shapes, and .NET clients losing their value objects to a generated client | +| No .NET client — partners or the public only | **OpenAPI**, generated from the server's types; no Contracts project | A project with no consumer but the server | + +The rules that do not bend: + +- **OpenAPI is an output, never an input**, while Contracts exists. Never regenerate a C# client from + your own API's document when that client is in the same solution. +- **Never hand-maintain a parallel set of models**, and **the server always validates** — a shared + validator is for user experience, not trust. +- **Contracts is browser-safe** when a WebAssembly client references it: architecture-policy bans, + the trim and AOT analyzers and the browser platform analyzer check it, not a reviewer. +- **A BFF is earned by keeping tokens out of the browser.** It holds the tokens, gives the browser an + `HttpOnly` cookie, forwards with the user's token, and requires an anti-forgery header on every + forwarded call. In Blazor it is the host that serves the WebAssembly client, and a BFF that only + forwards does not reference Contracts. The policy does not define a BFF yet, so these are review + obligations until a future Build SDK release adds Blazor and BFF support. + +Full procedure, version-skew rules, BFF guidance and worked examples: `references/contract-strategy.md`. + +## 6. DRY, applied where it pays + +Reuse the Purview libraries when they remove a real source of truth, not for coverage: + +| Concern | Library | Use it when | +| -- | -- | -- | +| Operation outcomes without exceptions for control flow | `Purview.Results` | Failures are expected and the caller must handle them | +| Primitive obsession, validated identity types | `Purview.ValueObjects` | A value has rules that would otherwise be re-checked at each boundary | +| Schema shared with a TypeScript client | `Purview.ZodSharp` | A contract is duplicated between C# and TypeScript today | +| Local dependencies for dev and integration tests | `Purview.Containers` | Tests need a real database, queue or emulator | +| Logging, tracing and metrics from one declaration | `Purview.Telemetry.SourceGenerator` | Always — see `purview-telemetry` | + +Duplication that matters: validation repeated per entry point, a domain rule restated in the UI, +configuration bound in two places, two types that both claim to be the truth. Duplication that does +not: two similar-looking methods that answer to different owners. + +## 7. Telemetry is not an advanced topic + +Recommend instrumentation from the first commit, at every project size. A small application gets +small telemetry — it does not get none. See `purview-telemetry`. + +## Checks before you answer + +- Did you read the four context dimensions rather than ask for them? +- Can you name, for each project and each abstraction you are proposing, the failure it prevents here? +- Is anything in your proposal there only because a reference architecture has it? +- If there is an API with clients, did you choose its contract strategy from the clients that exist + today? +- Did you include telemetry? + +## See also + +- `purview-telemetry` — instrumentation at every scale +- `purview-persistence` — storage and GUID-key guidance, only when persistence is real +- [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md new file mode 100644 index 0000000..e281a41 --- /dev/null +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -0,0 +1,236 @@ +# Contract strategy: Contracts, OpenAPI or Hybrid + +When a solution has an HTTP API and one or more clients, something has to define the shapes that cross +between them. There are three ways to do it, and the wrong one fails in one of two directions: + +- **Rules drift.** The client holds a second, weaker copy of the domain rules, and a request the UI + accepted is rejected by the server — or a break only surfaces at runtime. +- **Decoupling bought in advance.** A .NET client in the same solution regenerates its models from + the API's own OpenAPI document, and throws away every value object and rule to buy an independence + the solution does not have. + +The strategy is chosen from the clients that **exist today**. A planned client is noted; it does not +count. "Later" and "in case" do not justify a strategy any more than they justify a project — see +`proportional-architecture.md`. + +## The three strategies + +### Contracts + +A .NET class library holding the request and response types, built on the domain's value objects and +carrying `[ZodSchema]`. The server references it, and so does every .NET client — including a Blazor +WebAssembly client. + +There is one copy of every rule, error code and normalisation, and both sides run the same generated +validators. + +### OpenAPI + +The server publishes an OpenAPI document, and clients generate their models and calls from it. It is +language-neutral and versions independently. + +Generated models are primitives. Value objects, custom ZodSharp rules, error codes, and trimming and +casing are lost; only what JSON Schema can express survives. + +### Hybrid + +Contracts is the **single source of truth**. The OpenAPI document is **generated from it**, with its +component schemas taken from ZodSharp's export — the generated `{T}Schema.BuildJsonSchema()` is built +from the same metadata as the validator, so the document and the validators cannot drift. + +.NET clients reference Contracts. Non-.NET clients generate from the document and take their +validation rules from the exported JSON Schema — for TypeScript, `z.fromJSONSchema` (Zod 4) reads it +back as Zod schemas. The `purview-zodsharp-json` and `purview-zodsharp-source-generator` skills are +authoritative on the export. + +Three facts about the export that a Hybrid design has to plan for: + +- **ZodSharp ships no OpenAPI integration.** It produces JSON Schema; putting that into the OpenAPI + document — for example with a schema transformer in `Microsoft.AspNetCore.OpenApi` — is the API + host's job. Without it, ASP.NET Core describes a value object from its CLR shape, not from its rules, + and the document and the validators drift after all. +- **A non-.NET client still gets only what JSON Schema can express.** A value object exports as its + underlying value, and a rule the exporter cannot represent is listed under `x-zodsharp-unsupported` + rather than dropped. The contract test should fail when that list grows. +- **Exported objects set `additionalProperties: false`.** A client that validates a *response* against + that schema rejects a field the server has added — so "additive changes only" is not safe until + clients tolerate unknown response fields. Validate requests strictly and responses leniently, or the + versioning rule below protects nobody. (The reverse also holds: System.Text.Json ignores unknown + members, so the server accepts a request with an extra key that the schema rejects.) + +## Decision procedure + +1. **List the clients that exist today.** For each, record: + - its language and runtime; + - who owns it; + - whether it lives in the same repository and solution; + - whether it ships **in lockstep** with the server or on its own cadence. + + A planned client is noted, but it does not count yet. +2. **Every client is .NET, in the same solution, and ships in lockstep → Contracts.** This prevents: + - rules drifting between client and server; + - a second, weaker copy of the domain rules in the UI; + - contract breaks that only surface at runtime. + + Publish OpenAPI anyway if documentation or tooling wants it, but generate it from Contracts. It is + an output, never an input. +3. **At least one client is not .NET, or ships independently → Hybrid.** This prevents: + - non-.NET clients hand-writing shapes and rules; + - the .NET clients losing their value objects and rules to a generated client. + + It requires the independent-versioning rules under [Version skew](#version-skew). +4. **No .NET client exists, or the API is a surface whose clients the team does not own (public or + partner) → OpenAPI**, still generated from the server's types. A separate Contracts project is not + earned when the server is its only consumer. +5. **A .NET client in another repository with its own release cadence** prefers Contracts shipped as + a package, if that team accepts the dependency. Otherwise treat it as step 3. + +```text +flowchart TD + A["List the clients that exist today"] --> C{"Every client .NET,
same solution, lockstep?"} + C -- yes --> K["Contracts
OpenAPI only as an output"] + C -- no --> B{"Any .NET client
at all?"} + B -- no --> O["OpenAPI
generated from the server's types"] + B -- yes --> D{"The only other clients are .NET
in another repository,
and they accept a package?"} + D -- yes --> P["Contracts, shipped as a package"] + D -- no --> H["Hybrid
Contracts is the source, OpenAPI generated from it"] +``` + +Steps 3 and 4 do not overlap: a .NET client in the solution alongside a partner or non-.NET client is +step 3, because the .NET client still has value objects and rules to lose. + +## Rules that follow + +- **Never regenerate a C# client from your own API's OpenAPI document when that client is in the same + solution.** It throws away value objects and rules to buy decoupling the solution does not have. +- **Never hand-maintain a parallel set of models** on either side. Two definitions of one wire shape + disagree eventually, and nothing fails when they start to. +- **The server always validates.** Sharing validators with a client is for user experience and + consistency. It is never a substitute for validating on the server, because the client is untrusted. +- **Moving from Contracts to Hybrid must be additive.** Because Contracts is already the source, adding + a non-.NET client means turning on generation of the OpenAPI document and a contract test. Nothing in + Contracts changes. Design for that, but do not build it until the client exists. + +## Contracts project constraints + +- **References only the domain library (value objects) and the runtime Purview packages** — + `Purview.ValueObjects`, `Purview.ZodSharp`, `Purview.Results`. Never the server, persistence, HTTP + clients or hosting, and never an ASP.NET Core bridge such as `Purview.ZodSharp.AspNetCore`; those + belong to the API host. A Contracts project that references the server is a server that every client + now ships. +- **Browser-safe when a WebAssembly client references it.** No file, network or process I/O, safe to + trim, and no reflection-dependent behaviour. The same applies transitively to the domain library, + because the client downloads it too. Make the build enforce it, not a reviewer — three mechanisms, + each catching what the others cannot: + - **Your repository's architecture policy** forbids `Microsoft.AspNetCore` packages + (`forbiddenPackagePrefixes`) and the `Microsoft.AspNetCore.App` framework reference + (`forbiddenFrameworkReferences`) in `Contracts` and `Core`. The playbook's own policy forbids the + packages in `Core` only; the rest arrives with the Build SDK's planned Blazor support, so add it + yourself until then. See `purview-architecture-testing`. + - **`true`** on both projects turns on the trim and AOT analyzers, + so reflection a trimmed client would break on is a warning at build time. `Purview.ValueObjects` + and `Purview.ZodSharp` already set it. + - **``** makes the platform-compatibility analyzer (`CA1416`) + flag an API that is unsupported in the browser, such as starting a process. + + The policy rules fail a test run. The two analyzers produce warnings, and the SDK does not turn on + `TreatWarningsAsErrors` for an ordinary project — set it on `Contracts` and `Core`, or the "clean + build has no warnings" rule is the only thing standing behind them. No analyzer proves the absence of + every kind of I/O; what they miss is a review obligation, and the client's own tests running in the + browser are the last check. +- **Deserialisation mode is a deliberate choice per direction.** The server reads requests through + `Create` (strict), because the client is untrusted. Record what the client uses for responses, and + why — strict catches a server that has drifted, and `Hydrate` trusts a server the client cannot + verify. + +## Version skew + +| Shipping | Strategy | Failure it prevents | What to do | +| -- | -- | -- | -- | +| Lockstep | Contracts, internal apps | A silent failure that looks like a bug, when an old cached client talks to a new server | The server stamps its build on responses; the client prompts the user to reload when the stamps differ | +| Independent | Hybrid, OpenAPI | A deployed client breaking without warning | Additive changes only; a breaking change gets a new route version; a contract test compares the generated document against the last published one and fails on a breaking difference | + +## Backend-for-Frontend + +A BFF is a host, not a layer, and it is held to the same justification test as anything else. +Microsoft's [Blazor Web App with OIDC and the BFF pattern](https://learn.microsoft.com/aspnet/core/blazor/security/blazor-web-app-with-oidc?pivots=with-bff-pattern) +is the authority on the mechanics; what follows is the rules and the failures behind them. + +### What earns it + +- **Keeping tokens out of the browser.** The BFF signs the user in (OpenID Connect), holds the access + and refresh tokens, and gives the browser only an authentication cookie. Script in the page — a + compromised dependency, an XSS hole — cannot read a token that never reached it. This prevents tokens + being stolen from browser storage. +- **It forwards with the user's access token, never its own identity.** The API validates that token + exactly as it would any other, so authorisation and the audit trail stay per-user. A BFF calling with + a client-credentials token turns every action into the BFF's and moves authorisation into the BFF. + +### In a Blazor solution, the BFF is the host + +The project that serves the WebAssembly client is the natural BFF: it already runs on the server, at +the client's origin. Do not add a separate BFF in front of it. + +- **It references the client project** (`Bff → WebApp`) because it serves the client's assets. The + client never references it; they talk over HTTP with the cookie. +- **Same origin is the point.** The client calls the BFF's own routes, the BFF forwards them to the API + (with YARP's `MapForwarder`, for example), and there is no CORS configuration and no third-party + cookie. +- **The client shares Contracts with the API directly.** The WebAssembly client references Contracts, + sends Contracts types, and runs the generated validators. The BFF in the middle passes the bytes + through untouched. + +### Rules that come with a cookie + +Moving the credential into a cookie trades token theft for a different attack, and the BFF has to +close it: + +- **Every forwarded call requires an anti-forgery signal.** The browser attaches the cookie to any + request to the BFF's origin, including one a hostile page triggers. Require a custom header the + client always sends, which a cross-site form cannot set, and reject calls without it — Duende.BFF's + `X-CSRF: 1` is the established form. `SameSite` narrows the attack; it does not replace the check. + This prevents cross-site request forgery. +- **The cookie is `HttpOnly`, `Secure` and `SameSite=Strict` or `Lax`.** `HttpOnly` is what keeps + script from reading it; without it the BFF protects nothing. +- **Data Protection keys are persisted and shared** when the BFF runs on more than one instance or + survives a restart. The cookie is encrypted with them, so per-instance keys sign users out at random. +- **Refresh happens on the server.** The BFF refreshes the access token before it expires and signs + the user out when refresh fails. The client never sees either token. +- **A large session moves to a server-side store.** Tokens kept inside the cookie make it large enough + to be split across several cookies, and the session cannot be revoked server-side. Use a ticket store + when either matters. + +### What it may reference + +- **A BFF that only forwards does not reference Contracts** and never reads payloads. Reading a payload + it does not own makes it a second consumer that has to change in lockstep for no benefit. +- **Prerendering is the common exception.** If the BFF prerenders client components that load data, + it must run those data calls on the server, and it references Contracts to do so. That is a real + reason; turning prerendering off for those components is the alternative, and the choice should be + written down. +- **A BFF that combines several API calls into one response owns its own contract** for those + responses, chosen with this same procedure. That is a separate justification. The forwarding-only + test — "a service layer that forwards" is not earned — still applies to everything else it does. +- **One BFF per client kind, not one per screen.** Web and mobile may each get one when their sign-in + or response needs differ. + +**None of this is enforced yet.** The architecture policy has no BFF responsibility; Blazor and BFF +support is planned for a future Build SDK release. Until then every rule in this section is a review +obligation, and a repository that adds a BFF project records it in its own policy — see +`project-responsibilities.md`. Even once it is in the policy, "Contracts only when it prerenders or +combines" stays a review obligation, because a dependency matrix cannot express a condition. + +## Worked examples + +| Clients today | Strategy | +| -- | -- | +| Blazor WebAssembly app, same solution, lockstep, signed in through a BFF | Contracts, referenced by the API and the WebAssembly client. The BFF is the host that serves the client, holds the tokens and forwards with the user's token; it references Contracts only if it prerenders data. OpenAPI generated for documentation. | +| Blazor WebAssembly app plus a React Native mobile app on its own release cadence | Hybrid. Mobile generates from the document and takes Zod rules from the exported schema. Additive-only versioning. A mobile BFF if sign-in differs. | +| Partner integrations only | OpenAPI, generated from the server's types. No Contracts project. | + +## See also + +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) + — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape +- [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema + export diff --git a/.agents/skills/purview-architecture/references/project-context-discovery.md b/.agents/skills/purview-architecture/references/project-context-discovery.md new file mode 100644 index 0000000..d40569a --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-context-discovery.md @@ -0,0 +1,115 @@ +# Project context discovery + +The procedure for establishing enough context to make an architectural recommendation, and — just as +importantly — for knowing when to stop asking. + +## The principle + +Every question you ask a user is a question they have usually already answered in the repository. +Reading is free and silent; asking is slow and, when the answer was already in a project file, it +reads as not having looked. So the order is fixed: + +1. Read the repository. +2. Read the conversation so far. +3. Ask only about what remains *and* changes your answer. + +A dimension whose answer would not change your recommendation is not worth a question. If a project +is a stateless CLI, the database GUID strategy is not an unknown — it is irrelevant, and raising it +is noise. + +## Dimension 1 — complexity + +Assess from requirements, never from line or project count. + +| Signal | Suggests | +| -- | -- | +| One functional area, one consumer, no integrations | Small | +| A handful of features, one or two integrations, a single team | Medium | +| Several bounded areas, multiple services or teams, operational obligations (SLOs, auditability, money) | Large | + +Where to look: `README.md`, the wiki, issue or ticket references, the solution file's project list, +`PackageReference` entries naming external systems, the number of distinct hosts. + +Traps: + +- **A big repository is not a complex one.** A monorepo of ten tiny tools is ten small projects. +- **A small repository can be complex.** Few files, hard invariants. +- **Generated code inflates everything.** Discount it. + +## Dimension 2 — project type + +Read it; do not ask it. + +| Evidence | Type | +| -- | -- | +| `Microsoft.NET.Sdk.Web` | Web application or HTTP API — distinguish by whether views/pages or endpoints dominate | +| `OutputType` of `Exe` plus an argument parser | CLI | +| `IsPackable`, no entry point | Reusable library | +| `IHostedService` / `BackgroundService`, `Worker` SDK | Background worker | +| An AppHost project, `Aspire.Hosting` | Distributed application | +| WPF/WinForms/Avalonia/MAUI SDK or `UseWPF` | Desktop application | + +A reusable library carries the strongest constraint on the rest of your advice: it must not configure +global telemetry providers, must not read configuration from the ambient environment, and must not +decide a persistence technology for its consumer. + +## Dimension 3 — persistence + +| Question | Read it from | +| -- | -- | +| Is persistence required at all? | Any data-access `PackageReference`, a `DbContext`, a connection string, a repository type, an Aspire resource | +| Which technology? | The EF Core provider package (`Microsoft.EntityFrameworkCore.SqlServer`, `Npgsql.EntityFrameworkCore.PostgreSQL`), the client package, or the `Purview.Containers` resource in the AppHost | +| Is more than one implementation required? | Two providers referenced, a provider abstraction already present, or an explicitly stated requirement | +| EF Core or something else? | Presence of `Microsoft.EntityFrameworkCore` versus Dapper, a raw client, or a document store SDK | + +"No persistence" is a complete and common answer. When it is the answer, every storage-shaped +recommendation — repositories, migrations, GUID strategies, connection resilience — is out of scope. + +## Dimension 4 — telemetry + +| Question | Read it from | +| -- | -- | +| Is telemetry configured? | `ILogger` injection, `ActivitySource`/`Meter` construction, `AddOpenTelemetry`, a `ServiceDefaults` project | +| Is Purview Telemetry available? | A `Purview.Telemetry.SourceGenerator` `PackageReference`, or interfaces attributed `[Logger]`, `[ActivitySource]`, `[Meter]` | +| Which signals are relevant? | Logs always; traces once a call crosses a process or a dependency; metrics once someone would alert on a rate or a duration | +| Is there an exporter? | An OTLP endpoint in configuration, a console exporter, an Aspire dashboard, an APM package | + +Existing instrumentation is a constraint, not a starting point to replace. Preserve it, extend it, +and never register the same source twice. + +## Conditional dimension — identifier generation + +Resolve **only** when persistence exists and GUIDs are, or are about to be, primary keys. + +- Are GUIDs primary keys, or just surface identifiers? +- Is the value generated in the application or by the database? +- Which provider and version? (This changes the answer — see `purview-persistence`.) +- Does index locality matter at the expected write volume? +- Must the identifier exist before the entity is persisted (outbox, correlation, client-side graph)? + +The last question usually decides it: if the application needs the value before `SaveChanges`, the +database cannot be the generator. + +## Asking well + +When you must ask, ask few and ask concretely. Batch the unknowns into one short set, offer the +likely answers, and state what you will do if the user does not care. + +Good: + +> Two things I can't tell from the repository: is this service expected to own its own database, or +> read from the existing one? And should failures be returned to the caller or thrown? I'll assume +> its own database and `Purview.Results` for expected failures unless you say otherwise. + +Bad: + +> What is the project complexity? What is the project type? Is persistence required? Which database? +> Is telemetry configured? Do you use GUID primary keys? + +The second asks six questions, five of which the repository answers, and one of which is irrelevant. + +## Recording the answer + +When a dimension is resolved by conversation rather than by the repository, that is a decision the +next agent will not be able to read. Write it down — in `AGENTS.md`, a wiki page, or an ADR — so the +question is asked once and never again. diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md new file mode 100644 index 0000000..d74d2cf --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -0,0 +1,119 @@ +# Project responsibilities, naming and dependency direction + +The catalogue in brief. The authoritative definition is +[`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), +and the full page — with diagrams — is +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +Do not restate either here or in another skill; link to them. + +## The catalogue + +`A → B` means A depends on B **at compile time**. + +| Responsibility | Also known as | Owns | May depend on | +| -- | -- | -- | -- | +| `Core` | `Domain` | Entities, value objects, invariants, domain events and errors | Shared | +| `Abstractions` | — | Cross-boundary capability contracts | Core, Shared | +| `Application` | `UseCases` | Use cases, workflows, orchestration | Core, Abstractions, Contracts, Shared | +| `Contracts` | — | Externally exposed request/response types; browser-safe when a WebAssembly client references it | Core, Shared | +| `Persistence` | — | DbContext, mappings, migration *definitions* | Core, Abstractions, Application, Shared | +| `Providers` | `Integrations` | Integration with a *named external platform* | Core, Abstractions, Application, Shared | +| `Adapters` | — | An *application-defined capability* via one technology | Core, Abstractions, Application, Shared | +| `Composition` | — | DI registration and implementation selection | Everything above | +| `API` | — | HTTP host | Composition, Contracts, ServiceDefaults, … | +| `Worker` | — | Background-processing host | Composition, ServiceDefaults, … | +| `Migrator` | — | *Executes* migrations and controlled seeding | Persistence, Composition, ServiceDefaults | +| `AppHost` | — | Aspire orchestration | The hosts it orchestrates | +| `ServiceDefaults` | — | Telemetry, health, resilience, discovery defaults | Shared | +| `WebApp` | — | Frontend, including a Blazor WebAssembly client that references Contracts | Contracts, ServiceDefaults, Shared | +| `Shared` | — | Small domain-independent primitives | *nothing* | + +**None of these is mandatory.** A small application with `Core` and one host has a complete and +correct architecture. The catalogue names responsibilities so you can tell when splitting one out +buys something — it is not a list of projects to create. + +### Backend-for-Frontend: guidance, not yet policy + +A BFF is a host responsibility that the policy does **not** define yet; Blazor and BFF support is +planned for a future Build SDK release. Until then it is guidance, and every rule below is a review +obligation: + +| Owns | May depend on | Split it out when | Leave it combined when | +| -- | -- | -- | -- | +| Sign-in and token custody for a browser or mobile client; forwards with the user's token. In Blazor, the host that serves the WebAssembly client | The WebAssembly client project, ServiceDefaults, Shared — and Contracts only when it prerenders data or combines responses | Tokens must be kept out of the browser, or several API calls must become one response | It would only forward calls that the client could make with a token it already holds safely | + +A repository that adds a BFF project today needs its own responsibility entry or a waiver in its +policy, or the architecture tests reject the project as unclassified. See `contract-strategy.md`. + +## When to split + +| Responsibility | Split it out when | Leave it combined when | +| -- | -- | -- | +| `Core` | A domain model exists | The app is a thin utility | +| `Abstractions` | Two implementations, or the consumer must not see the implementation | One implementation — keep the interface beside it | +| `Application` | Workflows are reused by more than one host | It would only forward CRUD | +| `Contracts` | The contract strategy is Contracts or Hybrid — see `contract-strategy.md` | The API host is the only consumer, which is the OpenAPI strategy || `Persistence` | Persistence exists at all | A single-project utility writing a file | +| `Providers` | Two platforms with different SDKs or lifecycles | One platform | +| `Adapters` | One capability, several technologies | One capability, one technology | +| `Composition` | Two hosts compose the same implementations | One host — compose in the host | +| `Migrator` | Schema changes are a deliberate deployment step | Development-only startup migration | +| `Shared` | A primitive is used by two or more responsibilities | Almost always | + +## Naming + +```text +[.] +``` + +| Situation | Name | +| -- | -- | +| One implementation | `Persistence` — do **not** qualify it just because it is SQL Server | +| Several, same dependencies and lifecycle | `Persistence` — one project, several classes | +| Several, genuinely different | `Persistence.SqlServer`, `Persistence.PostgreSql` | +| Several, with shared code | Add `Persistence` alongside the qualified projects | +| Several, nothing shared | Qualified projects only — no empty parent | + +Discouraged as project names, because each names a category rather than a responsibility: +`Infrastructure`, `Services`, `Managers`, `Models`, `Helpers`, `Common`, `Utils`, `Utilities`. + +`Infrastructure` is the common case. It is a useful *category* — covering Persistence, Providers and +Adapters — and a poor *project*, because every technical class qualifies for it. + +**Do not rename an established project** merely to match canonical terminology. A rename costs more +than the consistency is worth, and `Domain`/`UseCases`/`Integrations` are recognised aliases. + +## Providers versus Adapters + +Not a vendor distinction: + +- **Provider** — the requirement names the platform: "sync issues from GitHub". Swapping it changes + the product. +- **Adapter** — the requirement names the capability: "send an email". Swapping the technology + changes nothing the user sees. + +Azure Blob behind `IFileStore` is an Adapter. The Azure DevOps work-item API in a product that +integrates Azure DevOps is a Provider. + +## Dependency rules that catch people out + +1. **Composition is referenced by nothing** except a host. It selects implementations, so it depends + on them; the reverse would make the choice circular. +2. **Application never references a concrete infrastructure project.** It depends on an abstraction + and receives an implementation it cannot name. +3. **Sibling implementations stay independent.** If `Providers.GitHub` needs something from + `Providers.AzureDevOps`, that shared thing belongs in the unqualified project or an abstraction. +4. **Persistence owns migration definitions; Migrator executes them.** Production API startup does + not mutate a schema. +5. **Runtime is not compile time.** DI registration, an HTTP call and Aspire orchestration are not + assembly references. A Worker and an API can share Application without referencing each other. + +## Recommending this to a repository that predates it + +Do not propose a mass rename. Suggest, in order: + +1. Fix dependency *directions* that are actually wrong — those are bugs. +2. Split a project only where a named problem justifies it. +3. Record a waiver for an established name rather than renaming it, so the rule stays enforceable + without churn. + +A waiver excuses the name only; the project still classifies, so every dependency rule still applies. diff --git a/.agents/skills/purview-architecture/references/proportional-architecture.md b/.agents/skills/purview-architecture/references/proportional-architecture.md new file mode 100644 index 0000000..3ed7f55 --- /dev/null +++ b/.agents/skills/purview-architecture/references/proportional-architecture.md @@ -0,0 +1,92 @@ +# Proportional architecture + +Structure is a cost paid now against a failure expected later. Proportional architecture is the +practice of only paying when the failure is named, present, and more expensive than the structure. + +## The justification test + +Before adding a project, an interface, a layer or a pattern, answer in one sentence: + +> **What failure does this prevent in *this* codebase, and has it happened or is it certain to?** + +If the sentence needs the words "in case", "later", "when we scale", or "it's good practice", the +structure is not yet justified. Write the simpler thing. The simpler thing is also the thing that is +cheapest to change when the failure finally does arrive. + +This is not an argument against structure. It is an argument against *unfalsifiable* structure. + +## By scale + +### Small — a utility, CLI, prototype, simple API, limited-scope application + +- One project. A second only when there is a test project, or when a genuinely reusable library + falls out. +- Call the database, the API or the file system directly from the code that needs it. +- Validate at the edge, once. +- Logging from the first commit; traces and metrics when there is something to correlate. + +Do not add: a repository interface over `DbContext`, a service layer that forwards, a mapping layer +between two near-identical shapes, an interface with one implementation and no test double need +(`Purview.Containers` and TUnit.Mocks remove most of that need), a `Domain`/`Application`/ +`Infrastructure` split. + +### Medium — several functional areas, some integrations, moderate domain complexity + +- Introduce a boundary where you can name what it separates. Usually that is domain rules from the + host, so a domain project and an application host. +- An abstraction over an external dependency earns its place when the dependency is genuinely + swappable, or when testing against it is otherwise painful. "Swappable in principle" is not + swappable. +- Dependency instrumentation, trace propagation across the integrations, metrics on the operations + someone would be paged about. + +Do not add: CQRS unless reads and writes have actually diverged in shape, lifetime or scaling; +event sourcing unless the history *is* the product; a second infrastructure implementation that has +no second consumer. + +### Large — complex domain, multiple services or teams, substantial operational requirements + +- Modularity along ownership lines, because the real constraint is people, not code. +- Independent infrastructure implementations where they are genuinely independent. +- Boundaries made enforceable rather than documented — a dependency-direction rule that a wrong + `ProjectReference` fails the build. See the playbook's Architecture-As-Tests page. +- End-to-end tracing, standardised resource attributes, SLIs, retention and sampling policy. + +Even here, the justification test holds per decision. "Large" licenses the *option* to add +structure, not the obligation to add all of it on day one. + +## Patterns and the conditions that earn them + +| Pattern | Earned when | Not earned by | +| -- | -- | -- | +| Repository over EF Core | You must swap the store, or the `DbContext` API genuinely leaks into domain decisions | Testability — EF Core's in-memory and container-backed testing already covers it | +| CQRS | Read and write models have diverged in shape or scaling needs | A desire for symmetry with a reference architecture | +| Event sourcing | The sequence of changes is the product, or audit is a hard requirement | "We might want history" | +| Mediator / in-process bus | Many handlers, genuinely decoupled registration | Three call sites that could be method calls | +| Mapping layer | Two shapes that must diverge and are owned separately | Two shapes that are identical today | +| Extra DI indirection | A real second implementation, or a composition root that must vary | Inversion for its own sake | +| Generic infrastructure framework | Three or more concrete uses exist now | One use and an expectation of more | + +## Evolving incrementally + +The honest sequence is: write the direct implementation; let the second caller arrive; extract the +boundary the second caller revealed. Each step is small, reversible and informed. + +The dishonest sequence is: predict the second caller; build the boundary; never get the second +caller; maintain the boundary forever. The cost is invisible because it is spread across every +future change. + +When you do extract, extract along the seam the code showed you — not the seam the diagram +predicted. + +## What this does not license + +Proportional is not an excuse for: + +- Skipping tests, or skipping telemetry. +- Leaving validation duplicated across entry points. +- Letting domain rules live in the UI or in the database. +- Two sources of truth for the same fact. + +Those are not structure bought in advance; they are defects. Simplicity means fewer moving parts, +not fewer guarantees. diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md new file mode 100644 index 0000000..ba4dd9b --- /dev/null +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -0,0 +1,171 @@ +--- +name: purview-engineering-principles +description: >- + Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering + conventions - convention over configuration, derived identity, architecture as tests, warnings as + errors, proving consumer-facing claims, documentation ownership, test data builders, agent + legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a + build or a reviewer enforces it. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Purview engineering principles + +Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +that matters throughout: **a convention a build can check is checked by the build; a convention only +a reviewer can check is labelled a review obligation and never described as enforced.** A rule that +claims enforcement it does not have is worse than no rule, because people stop looking. + +## 1. Convention over configuration + +A repository declares as little as possible. One MSBuild SDK supplies the defaults, and a project +file is short because the SDK already knows what a project of that shape needs. + +Configuration copied between repositories diverges. When the rule lives in one package, fixing it +once fixes it everywhere; when it lives in eleven project files, it is fixed eleven times — or, more +often, once. + +*Enforced by:* `` in a single +`Directory.Build.props`. The SDK detects project type from the filename and the `Sdk` attribute. + +*At your scale:* a single-project repository still benefits. The import is two lines and removes the +target framework, language version, nullable context, analyzer set and package metadata. + +## 2. Identity is derived, never repeated + +`RootNamespace` comes from `$(NamespacePrefix).$(ProjectName)`, and `AssemblyName` follows the +resolved `RootNamespace`. A name appears once — in the folder and file name — and everything else is +computed. + +A hand-typed namespace drifts from the assembly it lives in, and the drift is invisible until +something reflects over types: an architecture test, a serializer, a DI scan. + +*Enforced by:* the SDK during `.props` evaluation, plus a build-time check +(`PurviewProjectFileNameMismatch`) when a `.csproj` filename does not match its parent directory. + +*At your scale:* always. It costs nothing and removes a class of bug. + +## 3. Architecture is a test, not a diagram + +The dependencies a layer may have are asserted by tests that read the compiled assemblies. A +`ProjectReference` pointing the wrong way fails the build. + +A diagram is a claim about the past. Dependency direction erodes one convenient reference at a time, +and every such reference looks reasonable in isolation. + +*Enforced by:* a test project that references every layer and inspects their metadata references. + +*At your scale:* small repositories can skip it. Adopt it the moment a **second layer** exists — +that is when the wrong direction first becomes possible. + +## 4. The build is the only authority + +Style is enforced by analyzers and `EnforceCodeStyleInBuild`, and the SDK's own style policy is +re-validated on every compile, so a repository cannot quietly downgrade a rule. + +*At your scale:* always. If a rule matters and cannot be checked, say so plainly. + +## 5. Warnings are not acceptable output + +A clean build has no warnings. One that will never be fixed is either fixed, suppressed *where it +occurs* with a stated justification, or turned off deliberately. + +Warnings are a budget. A build that emits twenty is a build where the twenty-first is invisible — and +the twenty-first is the one that mattered. + +*Enforced by:* severity, partly. The SDK's shipped `.editorconfig` sets many rules to `error`. The +rest is review, backed by the pipeline running `dotnet build` with the repository's configuration. + +*At your scale:* a small repository can tolerate `warning`; a large one cannot. Decide deliberately +rather than by accumulation. + +## 6. Every claim about a consumer is proved by a consumer + +When a package documents how it must be used — a target framework, an asset, a guard, a workaround — +something in the repository builds that documented consumer and asserts the documented outcome. + +Consumer-facing documentation is the part of a library least likely to be exercised by its own tests, +because **the library's own build is not a consumer**. It drifts silently and fails in somebody +else's repository. + +*Enforced by:* a verification script that packs, then builds throwaway consumer projects against the +produced packages, asserting the happy path *and* the documented guard errors. `purview-dev/containers` +is the worked example (`just verify-consumers`), deliberately excluded from the default unit filter +because it restores from nuget.org. + +*At your scale:* only once a package is consumed outside its own repository. Before that, the +repository's own tests already are the consumer. + +## 7. Documentation is a build artefact of the repository + +The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its +`mkdocs.yml`, its navigation and its `edit_uri`. + +Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is +wrong within one release. + +*Enforced by:* review, plus the navigation file — a page absent from `_Sidebar.md` is not published. + +*At your scale:* always. Even a repository with one page should own that page. + +## 8. Everything a test needs is built by one builder + +Domain content in a test comes from `TestBuilder`. A test never constructs a domain object inline, +and it overrides **only** the value it is proving. + +Inline construction encodes a type's shape into every test that touches it, so one constructor change +becomes a repository-wide edit. It also hides which values the test actually depends on, because +every value looks deliberate. + +*Enforced by:* review. + +*At your scale:* adopt from the first test. Retrofitting is a mechanical edit across every test file, +and exactly the kind of edit that is postponed forever. + +## 9. An agent is a reader with no memory + +A repository states its purpose, its layout and its conventions in `AGENTS.md`. **Nothing important +is discoverable only by reading the code.** + +A coding agent has the repository and nothing else — no colleague to ask, no institutional memory, no +intuition about which of two plausible patterns is intended. It will confidently generalise from +whatever it finds first. That is also every new-joiner failure mode; the agent just makes the cost +visible sooner, because an agent does not ask. + +*Enforced by:* review, plus publishing skills so agent-facing guidance tracks the library version +rather than a repository's memory of it. + +*At your scale:* always. `AGENTS.md` is cheaper than the questions it prevents. + +## 10. A change is verified in proportion to its risk + +Checks are tiered, and a repository declares which tiers its pipeline runs. A pull request runs the +fast tier; the slow tier runs deliberately, locally or on a dedicated runner. + +A pipeline that runs everything on every pull request gets made optional. One that runs too little +gets ignored. Tiering is what keeps the gate credible. + +*Enforced by:* the pipeline's `TestFilter` and the test project's own **name** — the SDK categorises +a `*UnitTests` project as `Unit`, so the tier is a property of the project rather than of a +hand-written attribute. + +*At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by +attribute. + +## Applying these in review + +Three questions catch most of it: + +1. **Is this configuration, or is it a default the SDK should own?** If two repositories would copy + it, it belongs in the SDK. +2. **Is this rule enforced, or merely stated?** If stated, say so where it is stated. +3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in + `AGENTS.md` or the wiki. + +## Related skills + +`purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a +boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern +guidance. diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md new file mode 100644 index 0000000..4667fa2 --- /dev/null +++ b/.agents/skills/purview-persistence/SKILL.md @@ -0,0 +1,106 @@ +--- +name: purview-persistence +description: >- + Use when a .NET project actually persists data and you are choosing a storage approach, deciding + whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific + guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for + projects that do not persist anything. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview persistence + +**Activation condition:** this skill applies only when persistence is real. If the project is a +stateless CLI, a pure library, or a service that calls an API and stores nothing, stop here — none of +this is relevant, and raising a database GUID strategy with that project is noise. + +Confirm persistence from the repository before proceeding (see `purview-architecture`): a data-access +`PackageReference`, a `DbContext`, a connection string, or an explicit requirement to store state. + +## 1. Shape the data access proportionally + +| Scale | Shape | +| -- | -- | +| Small | `DbContext` used directly from the code that needs it. No repository, no unit-of-work wrapper — `DbContext` already is both. | +| Medium | A boundary when domain rules must not know about EF Core, or when a query is complex enough to deserve a name. Often that is a few focused query types, not a generic `IRepository`. | +| Large | Independent infrastructure implementations where there genuinely are several, with the dependency direction enforced by a test. | + +**Do not wrap EF Core in a repository for testability.** `Purview.Containers` gives you a real +database in tests, which is both more faithful and less code than an abstraction built to be mocked. +A repository earns its place when you must swap the store or when `DbContext` is genuinely leaking +into domain decisions — not before. + +## 2. Local dependencies + +`Purview.Containers` provides Aspire resource integrations for the dependencies you need in +development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `Redis`, `Nats`, +`RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory +provider: the in-memory provider does not enforce the constraints that break you in production. + +## 3. GUID keys — only if GUIDs are keys + +Resolve these before recommending anything; most are answered by the repository: + +1. Are GUIDs primary keys, or merely external identifiers over an integer key? +2. Is the value generated by the application or by the database? +3. Which provider, and which version? +4. Does index locality matter at the expected write volume? +5. Must the identifier exist before the row is persisted? + +Question 5 usually decides it. If the application needs the identifier before `SaveChanges` — for an +outbox row, a correlation value, or to build an object graph client-side — the database cannot be the +generator, and the remaining questions are about *which* client-side strategy. + +### The rule that matters most + +**SQL Server and PostgreSQL do not benefit from the same sequential GUID implementation.** Their +index structures and their GUID comparison semantics differ, and a "sequential GUID" that helps one +can be no better than random on the other. Never carry a strategy from one to the other without +re-deriving it. + +- SQL Server specifics: `references/sqlserver.md` +- PostgreSQL specifics: `references/postgresql.md` +- Choosing between them, and when a custom generator is justified: `references/guid-generation.md` + +### Prefer built-in capability + +Use the framework or database capability when it is sufficient — `Guid.CreateVersion7()` on .NET 9+, +`NEWSEQUENTIALID()`, EF Core's own value generators, a provider's native UUIDv7 function. A custom +generator is a maintenance obligation and a correctness risk; introduce one only against a measured +requirement. + +**There is no Purview GUID generation package.** Do not reference one, and do not create one without +first establishing a concrete, measured need that the built-in options cannot meet. If such a +capability is added later, this skill is where the guidance for recommending it belongs. + +## 4. Value objects as keys + +`Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a +`CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather +than per property. + +This is about type safety at the boundary, not about the generation strategy — the two decisions are +independent. + +## 5. Migrations and schema + +- Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — + not by the application at startup in production. +- Expect the schema to be ahead of or behind the code during a deployment; design changes to be + compatible in both directions. +- Keep the connection string out of source. Keep it out of telemetry too. + +## Checks before you answer + +- Have you confirmed persistence is actually required? +- If you are recommending a GUID strategy, do you know the provider *and* its version? +- Are you proposing an abstraction you can justify by something other than testability? +- Have you preferred a built-in capability over a custom generator? + +## See also + +- `purview-architecture` — context discovery and proportional structure +- `purview-telemetry` — instrument the data access too; it is where the latency is diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md new file mode 100644 index 0000000..432053a --- /dev/null +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -0,0 +1,93 @@ +# Choosing a GUID generation strategy + +The cross-provider decision procedure. Provider detail lives in `sqlserver.md` and `postgresql.md`. + +## Step 0 — is this question in scope? + +Ask nothing and recommend nothing unless **both** are true: + +- The project persists data, and +- GUIDs are (or are about to be) primary keys. + +A stateless CLI, a pure library, a service with an integer key, or a project using GUIDs only as +external correlation identifiers does not need this decision. Raising it anyway is the failure this +page is written to prevent. + +## Step 1 — must the identifier exist before persistence? + +This question decides more than any other. + +**Yes** — the application needs the value before `SaveChanges` for an outbox row, a correlation +identifier, an object graph built client-side, or an identifier returned before commit. The database +cannot be the generator. Go to step 2 with client-side options only. + +**No** — the database may generate it, and database generation is available to you. + +## Step 2 — which provider, and which version? + +Do not skip the version. It changes the available options materially: + +| Provider | Version matters because | +| -- | -- | +| SQL Server | `NEWSEQUENTIALID()` is long-standing; the comparison semantics that make byte-ordered UUIDs useless have never changed | +| PostgreSQL | Native `uuidv7()` exists only from 18; `gen_random_uuid()` is built in from 13 | +| .NET | `Guid.CreateVersion7()` exists from .NET 9 | + +**The two databases do not share an answer.** SQL Server compares `uniqueidentifier` with the last +six bytes most significant, so a byte-ordered UUIDv7 is effectively random to its clustered index. +PostgreSQL compares `uuid` in byte order, so UUIDv7 is genuinely sequential there. A strategy that is +optimal on one is unremarkable or wrong on the other. + +## Step 3 — does locality actually matter? + +Sequential keys exist to reduce index fragmentation and page splits under write pressure. If the +table takes tens of inserts a minute, this is not your problem and a random GUID is the simpler, +more private choice. + +Reach for a sequential strategy when you can point at write volume, measured fragmentation, page +splits, or buffer-pool pressure. "It's best practice" is not a measurement. + +Note also that on SQL Server the problem is often better solved by making the clustered index +something other than the GUID key — see `sqlserver.md`. + +## Step 4 — is unpredictability required? + +Sequential and time-ordered identifiers are guessable, and UUIDv7 discloses creation time to the +millisecond. Where an identifier appears in a URL, is handed to a third party, or acts as a +capability, use a random UUID — or keep the ordered value internal and expose a random surrogate. + +Never use `NEWSEQUENTIALID()` output as a secret. + +## Decision summary + +| Provider | Needs value before insert | Locality matters | Recommendation | +| -- | -- | -- | -- | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | Either | No | `Guid.NewGuid()` | +| PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | +| PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | +| PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| Any | Either | Either, but must be unpredictable | Random v4 | + +## Prefer built-in capability + +In order of preference: + +1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. +2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. +3. A reviewed, tested implementation of a published standard, when neither of the above is available + on your target versions. +4. A bespoke generator — only against a measured requirement the first three cannot meet. + +A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, +counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode +is duplicate or badly-ordered keys found in production. + +## There is no Purview GUID package + +No Purview library provides GUID generation today. Do not reference one and do not invent one. + +If a concrete need emerges that the built-in options cannot meet — established by measurement, not by +anticipation — that is the point at which creating one is worth discussing. Until then this guidance +is the deliverable, and this page is where a recommendation for such a package would be documented. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md new file mode 100644 index 0000000..cd557ec --- /dev/null +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -0,0 +1,98 @@ +# PostgreSQL UUID keys + +Read this only when the provider is PostgreSQL and UUIDs are primary keys. The SQL Server answer is +different — see `sqlserver.md`. + +## Why PostgreSQL differs from SQL Server + +Two differences drive everything on this page: + +1. **PostgreSQL has no clustered index.** Tables are heaps; the primary key is a separate B-tree. A + random UUID therefore does not cause row-level page splits the way it does on a clustered SQL + Server table. The cost is in the index: random inserts touch scattered index pages, which hurts + cache locality and WAL volume, but it is a smaller and differently-shaped problem. +2. **`uuid` compares in byte order.** Unlike SQL Server, PostgreSQL orders UUIDs by their bytes. A + UUID that is sequential in byte layout *is* sequential to the index. + +The consequence: **UUIDv7 works here.** It is the natural answer on PostgreSQL and the wrong answer +on SQL Server. Never carry a sequential-GUID choice across the two. + +## The options + +### `Guid.CreateVersion7()` — .NET 9+, application-generated + +The default recommendation for new PostgreSQL schemas with UUID keys. + +```csharp +public sealed class Order +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); +} +``` + +- Time-ordered in byte layout, so index locality is good. +- The value exists before persistence, so outbox rows, correlation and client-side graphs all work. +- Standard (RFC 9562), so it is portable and readable by other systems. +- Embeds a millisecond timestamp — which is also its disclosure caveat. + +Tell EF Core the application supplies it: + +```csharp +builder.Property(o => o.Id).ValueGeneratedNever(); +``` + +Without that, EF Core may treat the key as store-generated and skip your value. + +### Database-generated UUIDv7 — PostgreSQL 18+ + +PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have +`gen_random_uuid()` (v4, from the built-in pgcrypto-derived function) and nothing native for v7. + +**Check the server version before recommending `uuidv7()`.** It is the single most common way this +guidance goes wrong — the function simply does not exist on the version most deployments are running. + +```sql +-- PostgreSQL 18 and later only +ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7(); +``` + +Prefer application-side generation anyway unless rows are inserted by paths that bypass the +application, because the value-before-persistence property is worth more than the marginal +difference. + +### `gen_random_uuid()` — database-generated v4 + +Available without an extension from PostgreSQL 13. Random, so index locality is poor, but +unpredictable and simple. + +### `Guid.NewGuid()` — application-generated v4 + +Random and unpredictable. Fine at low to moderate write volume, which covers most applications. + +## Npgsql and EF Core notes + +- `Guid` maps to `uuid` natively. Do not store UUIDs as `text` or `varchar` — it costs space, loses + index efficiency and defeats comparison. +- Npgsql's `uuid` mapping is byte-order faithful, so a v7 value sorts in the index as you expect. +- If the application generates the key, set `ValueGeneratedNever()` so EF Core does not fetch a + store-generated value that does not exist. +- A legacy database may have UUIDs in `text` columns. Migrating to `uuid` is usually worth it, but it + is a data migration, not a model change. + +## Choosing + +| Situation | Use | +| -- | -- | +| New schema, .NET 9+, UUID keys | `Guid.CreateVersion7()`, `ValueGeneratedNever()` | +| .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | +| Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | +| Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | +| Low or moderate volume | v4 — the locality difference will not be your bottleneck | + +## The timestamp caveat + +A UUIDv7 exposes when the row was created, to the millisecond. Where identifiers are public, that can +leak signup times, order rates or activity patterns — and it makes identifiers partially guessable by +time window. Where that matters, use a random UUID for the public identifier and keep the v7 value +internal, or use a surrogate. diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md new file mode 100644 index 0000000..c728295 --- /dev/null +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -0,0 +1,101 @@ +# SQL Server GUID keys + +Read this only when the provider is SQL Server and GUIDs are primary keys. The PostgreSQL answer is +different — see `postgresql.md`. + +## Why SQL Server is a special case + +A clustered index determines physical row order. A `uniqueidentifier` primary key is clustered by +default, so random GUIDs insert into the middle of the index, causing page splits, fragmentation and +a larger buffer-pool working set. On a write-heavy table this is measurable. + +The complication is that **SQL Server does not compare `uniqueidentifier` values the way the bytes +are laid out.** Its ordering weights the last six bytes (node field) most significantly, then earlier +groups. So a GUID that is sequential in big-endian byte order — including a standard UUIDv7 — is +**not** sequential under SQL Server's comparison. This is precisely why the SQL Server and PostgreSQL +answers diverge. + +## The options + +### `NEWSEQUENTIALID()` — database-generated + +A column default that produces values ascending in SQL Server's own ordering. Best index locality +available. + +```sql +ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] + DEFAULT NEWSEQUENTIALID() FOR [Id]; +``` + +EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to +`NEWSEQUENTIALID()` through the default value-generation strategy. + +Trade-offs: + +- The value does not exist until the row is inserted. EF Core retrieves it, but you cannot build an + object graph around the key beforehand. +- Values are **not** unpredictable: the sequence restarts on service restart but is otherwise + guessable. Never use one as a security token or capability URL. +- It reveals rough insert ordering, which can be an information-disclosure concern on a public + surface. + +### EF Core's `SequentialGuidValueGenerator` — client-generated + +EF Core ships a value generator that produces GUIDs ordered for SQL Server's comparison semantics, +generated in the application. + +```csharp +builder.Property(o => o.Id) + .HasValueGenerator() + .ValueGeneratedOnAdd(); +``` + +Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality +while letting the application own the value. + +### `Guid.NewGuid()` — random + +Correct, portable and unpredictable; poor index locality. + +Appropriate when write volume is low (the fragmentation never matters), when the key is +non-clustered, or when unpredictability is a requirement. Do not dismiss it by default — for most +applications it is fine, and "most applications" includes more than people expect. + +### `Guid.CreateVersion7()` — .NET 9+ + +Ordered in byte layout, but **not** ordered under SQL Server's comparison. On SQL Server it behaves +roughly like a random GUID for clustered-index purposes. It is the right answer on PostgreSQL and the +wrong answer here, which is the trap this page exists to prevent. + +Use it on SQL Server only when you want the embedded timestamp for application reasons and accept +random-like insert locality. + +## Decoupling the key from the index + +Often the best answer is not a different GUID but a different index. Keep the GUID as the primary key +and make the clustered index something naturally ascending: + +```csharp +builder.HasKey(o => o.Id).IsClustered(false); +builder.HasIndex(o => o.CreatedAt).IsClustered(); +``` + +This removes the page-split problem entirely and leaves the key unpredictable. Consider it before +reaching for a sequential generator. + +## Choosing + +| Situation | Use | +| -- | -- | +| High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | +| Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | +| You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | + +## Measure before optimising + +Index fragmentation from random GUIDs is real but frequently irrelevant. Before changing a key +strategy, confirm it matters: check page splits, fragmentation and buffer-pool pressure on the actual +table at the actual volume. Changing a primary key strategy on a live table is expensive; do it +against evidence. diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md new file mode 100644 index 0000000..6d83173 --- /dev/null +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -0,0 +1,123 @@ +--- +name: purview-telemetry +description: >- + Use when adding, reviewing or scaling observability in a .NET project — logging, tracing, metrics, + OpenTelemetry wiring — or when deciding whether a project is "too small for telemetry". Recommends + Purview.Telemetry.SourceGenerator at every project size, keeps libraries free of global provider + configuration, and scales the export infrastructure rather than the instrumentation. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview telemetry + +**Telemetry is a baseline, not an advanced topic.** Instrument from the first commit at every project +size. What changes with scale is the *export and operations* story — collectors, dashboards, sampling +— not whether the code emits anything. + +A project that defers telemetry until it becomes distributed acquires its observability at exactly +the moment it can least afford the refactor. + +## Instrumentation versus export + +Keep these separate; conflating them is what produces "we're too small for telemetry". + +| | What it is | Who owns it | +| -- | -- | -- | +| **Instrumentation** | The log, span and measurement the code emits | Every project, every size, from day one | +| **Export** | Where signals go: console, OTLP, a backend, a dashboard | The application host, sized to the operation | + +A small CLI is fully instrumented and exports to the console. That is not a reduced form of +telemetry; it is the correctly sized export for a CLI. + +## Use the generator + +`Purview.Telemetry.SourceGenerator` turns an attributed interface into logging, tracing and metrics +implementations plus a DI registration extension. One declaration, three signals, no hand-written +`ILogger` message templates or `ActivitySource` plumbing. + +Do **not** reinvent its API here. The three shipped skills are authoritative and travel with the +package: + +- `telemetry-sourcegenerator-quickstart` — adding the package, the first interface, DI registration, + and publishing meter/activity-source names to the host via `TelemetryNames` +- `telemetry-sourcegenerator-design` — designing telemetry interfaces well +- `telemetry-sourcegenerator-migration` — moving existing `ILogger`/`ActivitySource`/metrics code onto + the generator + +Recommend the generator whenever the project targets a compatible TFM and the team is willing to take +an analyzer-only package reference. Where it is not compatible, fall back to the standard .NET +abstractions below — never to a bespoke logging wrapper. + +## Libraries versus hosts + +This is the rule most often broken, and it is not negotiable. + +**A reusable library:** + +- Emits through `ILogger`, `ActivitySource` and `Meter` — or through generated telemetry + interfaces, which produce exactly those. +- Publishes its activity-source and meter names so a host can subscribe. The generator does this for + you through assembly metadata. +- **Never** calls `AddOpenTelemetry`, configures an exporter, sets a sampler, or requires a specific + backend. +- Never reads an OTLP endpoint from configuration itself. + +**The application host:** + +- Owns provider configuration, exporters, sampling and resource attributes. +- Subscribes to the sources its libraries publish — with the generator, by opting into the + `TelemetryNames` class and passing its arrays to the OpenTelemetry builder. + +A library that configures a global provider breaks every consumer that already configured one, and +silently wins or loses depending on initialisation order. + +## Sizing the export + +| Scale | Export and operations | +| -- | -- | +| **Small** | Console or a single OTLP endpoint. Structured logs, exceptions recorded on the span. No collector, no dashboards, no custom exporter, no distributed tracing infrastructure. | +| **Medium** | OTLP export, dependency instrumentation (HTTP, EF Core), trace propagation across the integrations, application metrics, health checks, a sampling decision made deliberately. | +| **Large** | End-to-end distributed tracing, standardised resource attributes, operational metrics, dashboards, alerting, SLIs, documented retention and sampling policy. | + +Full detail per level: `references/observability-levels.md`. + +Instrumentation is identical across all three rows. Only the right-hand column grows. + +## Baseline for any application + +- Structured logging — parameters as fields, never interpolated into the message. +- Errors and exceptions recorded as telemetry, with the exception attached to the span. +- Operation correlation, so one unit of work is one trace. +- OpenTelemetry-compatible instrumentation, so the backend stays a choice. +- Log level configurable at runtime. +- Export behaviour configurable per environment. +- Sensitive values — credentials, tokens, connection strings, personal data — never logged or placed + on span attributes or baggage. + +## Before you change anything + +1. **Inspect what exists.** Find current `ILogger` use, `ActivitySource`/`Meter` instances, any + `AddOpenTelemetry` call, a `ServiceDefaults` project. +2. **Preserve it.** Extend existing instrumentation; do not replace working telemetry to adopt a + library. +3. **Do not duplicate registrations.** One registration per source; adding a second is a silent + double-count on every metric. +4. **Size the configuration to the project**, using the table above. +5. **Check for leaking secrets** in anything you add. +6. **Keep instrumentation and export distinct** in what you recommend. + +## Do not + +- Omit telemetry because the application is small. +- Invent Purview telemetry APIs — read the three shipped skills instead. +- Configure a global provider from a library. +- Recommend a collector, dashboard stack or custom exporter for a small project. +- Put a user identifier, token or connection string into a log field, span attribute or baggage item. + +## See also + +- `purview-architecture` — establishing project context and scale before deciding +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) diff --git a/.agents/skills/purview-telemetry/references/observability-levels.md b/.agents/skills/purview-telemetry/references/observability-levels.md new file mode 100644 index 0000000..df97d78 --- /dev/null +++ b/.agents/skills/purview-telemetry/references/observability-levels.md @@ -0,0 +1,93 @@ +# Observability levels + +Instrumentation is constant across these levels. Export, infrastructure and operational practice are +what scale. Read this after establishing project complexity — see `purview-architecture`. + +## Small + +*A utility, CLI, prototype, simple API or limited-scope application.* + +**Do:** + +- Structured logging with parameters as fields. +- Exceptions recorded with enough context to reproduce. +- An `ActivitySource` around the unit of work, so a failure has a shape and a duration. +- Console export, or a single OTLP endpoint when one already exists. +- Log level from configuration, so a user can raise it without a rebuild. + +**Do not:** + +- Stand up a collector. +- Adopt a centralised monitoring platform. +- Build distributed tracing infrastructure for a single process. +- Create dashboards nobody reads. +- Write a custom exporter. + +The test: a user reports a failure and you can ask for one log output that tells you what happened. + +## Medium + +*Several functional areas, integrations, moderate domain complexity.* + +Everything above, plus: + +- **Dependency instrumentation** — HTTP clients, EF Core, messaging. These are where latency and + failure actually live, and the instrumentation is a package reference, not code. +- **Trace propagation** across every integration boundary, so a trace survives the process hop. W3C + `traceparent` by default; do not invent a correlation header. +- **Application metrics** for the operations someone would notice: request rate, error rate, duration, + queue depth. Prefer histograms over averages. +- **OTLP export** to a real backend. +- **Health checks**, distinguishing liveness from readiness. +- **A deliberate sampling decision.** Head sampling at a fixed ratio is fine; the point is that it was + chosen rather than defaulted into. + +Common mistake at this level: adding metrics for everything measurable instead of the few that drive +a decision. A metric nobody alerts or decides on is cost without signal. + +## Large + +*Complex domain, multiple services or teams, substantial operational requirements.* + +Everything above, plus: + +- **End-to-end distributed tracing**, including asynchronous hops — message producers and consumers + linked, not orphaned. +- **Standardised resource attributes** — `service.name`, `service.version`, `deployment.environment` + — applied uniformly so cross-service queries work at all. Follow OpenTelemetry semantic conventions + rather than inventing names. +- **Operational metrics** beyond application ones: saturation, dependency health, cost drivers. +- **Dashboards** owned by the team that gets paged. +- **Alerting** on symptoms users feel, not on causes. Alert on error rate and latency; investigate + with CPU and queue depth. +- **Service-level indicators** derived from the telemetry, with objectives agreed with whoever cares. +- **Retention and sampling policy**, documented and costed. Tail sampling if error traces must always + survive. + +## Signal selection + +| Signal | Add when | Warning | +| -- | -- | -- | +| Logs | Always | Unstructured logs are not telemetry; they are strings | +| Traces | Work crosses a process, a dependency or an async boundary | A span per method produces noise, not insight | +| Metrics | Someone would alert on, or decide from, a rate or duration | High-cardinality attributes (user id, request id) are a cost incident | + +Put high-cardinality context on spans and logs, never on metric dimensions. + +## Sensitive information + +At every level, and without exception: + +- No credentials, tokens, keys or connection strings in messages, attributes or baggage. +- No personal data beyond what the system is entitled to process — and where an identifier is + necessary, prefer a pseudonymous one. +- Remember that **baggage propagates across service boundaries**; treat it as the most public place + in your telemetry. +- Redact at the point of emission. A redaction step in the pipeline is a backstop, not a control. + +## Export configuration + +The host owns this. Keep the endpoint, protocol, sampling ratio and resource attributes in +configuration, not in code, so the same build runs in every environment. + +Libraries contribute names and signals; they never contribute configuration. diff --git a/.agents/skills/purview-testing-conventions/SKILL.md b/.agents/skills/purview-testing-conventions/SKILL.md new file mode 100644 index 0000000..1fe3232 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/SKILL.md @@ -0,0 +1,357 @@ +--- +name: purview-testing-conventions +description: >- + Use when writing or reviewing tests in a .NET repository following the Purview conventions - TUnit + and TUnit.Mocks, the AAA structure and naming, placing a test in the namespace of the class under + test, CreateXXX construction helpers, building domain content through TestBuilder with Bogus, the + assertion surface, parameterised tests, which tier a test project belongs to, BDTest for + acceptance tests, and Purview.Containers for a suite that needs a real database or queue. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Testing conventions + +| Concern | The rule | +| --- | --- | +| Framework | **TUnit**. Not xUnit, NUnit or MSTest. | +| Substitutes | **TUnit.Mocks**. Not NSubstitute or Moq. | +| Test data | **Bogus**, reached through `TestBuilder`. | +| Acceptance tests | **BDTest** over TUnit, via `BDTest.TUnit`. Not Gherkin, SpecFlow or Reqnroll. | +| A real dependency | **`Purview.Containers`**. Not Testcontainers directly, never a shared instance. | +| Structure | Explicit `// Arrange`, `// Act`, `// Assert` — Given/When/Then in an acceptance test. | +| Naming | `{SubjectUnderTest}_{Scenario}_{Expectation}`, and `async Task`. | +| Placement | One `{Class}Tests` class per class under test, **in the namespace of the class under test**. | +| Construction | Dependencies and the system under test come from `CreateXXX` helpers with optional parameters. | +| Async | The async overload wherever the API offers one — in the test **and in every helper it calls**. Convert a sync helper, or state why it cannot be. | +| Cancellation | If any method the test calls accepts a `CancellationToken`, the test's **last parameter** is one, forwarded to every call that accepts one. | +| Domain content | Built through `TestBuilder`. Never inline. | + +## Why these, specifically + +**One framework.** The SDK wires TUnit into any project whose name ends in a test suffix — that +wiring is why a test project file is empty. A second framework means every project file grows a +conditional and the pipeline grows a second discovery path. That is a rule about the **runner**, not +the style of a test — `BDTest.TUnit` is an adapter *over* TUnit, so an acceptance suite does not break +it. + +**Same namespace as the subject.** The test reads as part of the thing it describes, needs no `using` +to reach the type, and **cannot silently test a different type with the same name**. + +**`CreateXXX` helpers.** A test body that constructs its own dependencies buries the assertion under +setup. A helper with optional parameters says what the test changed and defaults everything else: + +```csharp +static ConventionCatalogue CreateCatalogue(IConventionRepository? repository = null) => + new(repository ?? CreateRepository().Object); +``` + +**`TestBuilder`.** Inline construction encodes a domain type's shape into every test that touches it, +so one constructor change becomes a repository-wide edit. It also destroys the reader's signal about +which values the test depends on, because every literal looks deliberate. + +## A test + +```csharp +using Acme.Builders; + +namespace Acme.Domain; + +public sealed class CustomerIdTests +{ + [Test] + public async Task Create_GivenLowercaseSlug_ReturnsTheValue() + { + // Arrange + var id = TestBuilder.CustomerId("build-enforced"); + + // Act + var value = id.Value; + + // Assert + await Assert.That(value).IsEqualTo("build-enforced"); + } +} +``` + +Two deliberate details: + +- **The empty `// Arrange` block stays.** The comments are what makes a three-line test readable next + to a thirty-line one, and a convention applied "when needed" is applied inconsistently. +- **Assertions are awaited.** `Assert.That(...)` is terminal and returns a value, so + `await Assert.That(result).IsEqualTo(expected)` is the form. **An un-awaited assertion is a test + that cannot fail.** + +## Assertions + +| Form | Use | +| --- | --- | +| `await Assert.That(x).IsEqualTo(y)` / `.IsNotEqualTo(y)` | value equality | +| `await Assert.That(x).IsTrue()` / `.IsFalse()` | booleans | +| `await Assert.That(x).IsEmpty()` | collections — the workhorse of architecture rules | +| `await Assert.That(x).Count().IsEqualTo(2)` | collection size | +| `await Assert.That(x).Contains(y)` / `.DoesNotContain(y)` | membership | +| `await Assert.That(act).Throws()` | a synchronous delegate that should throw | +| `await Assert.That(async () => await f()).Throws()` | an async one | +| `await Assert.That(x).IsTypeOf()` | exact runtime type | +| `await Assert.That(x).IsEquivalentTo(sequence)` | order-independent sequence equality | + +For an exception test the two shapes are `Action` and `Func`. The compiler will not infer +either, so the explicit type is required and is **not** a style violation: + +```csharp +Action act = () => CustomerId.Create(""); + +await Assert.That(act).Throws(); +``` + +## Parameterised tests + +`[Arguments]` puts the interesting values in the attribute rather than a loop, so a failure names the +case: + +```csharp +[Test] +[Arguments("")] +[Arguments(" ")] +[Arguments("Has Spaces")] +[Arguments("has_underscore")] +public async Task Create_GivenSlugThatIsNotALowercaseSlug_Throws(string value) +{ + // Arrange + // Act + Action act = () => CustomerId.Create(value); + + // Assert + await Assert.That(act).Throws(); +} +``` + +## Async all the way down + +**Where an API offers an async overload, use it** — `File.ReadAllTextAsync`, not `File.ReadAllText`. +**And the rule reaches through test helpers:** a helper calling such an API is itself converted. Being +a helper is not an exemption. + +The build enforces the first half and cannot enforce the second, which is why the second needs saying: + +- `VSTHRD103` is an **`error`** and flags a sync call with an `Async`-suffixed equivalent **when the + call sits inside a `Task`-returning method**. In an `async Task` test, `File.ReadAllText` fails the + build. +- A **sync helper is not `Task`-returning**, so the identical call inside it is invisible to the + analyzer. Pushing the call one level down turns a build error into nothing. + +It also compounds with the cancellation rule, which keys off whether anything in the chain accepts a +`CancellationToken`: a sync helper accepts none, so it **silently removes the trigger** and the test is +uncancellable while looking compliant. (TUnit also runs tests in parallel, so a blocking read holds a +thread-pool thread an async read would release.) + +Converting — three things change together, and the first two are not optional: + +```csharp +// Before — invisible to VSTHRD103, and the chain accepts no token +static string Documentation => File.ReadAllText(DocumentationPath); + +// After +static async Task ReadDocumentationAsync(CancellationToken cancellationToken) => + await File.ReadAllTextAsync(DocumentationPath, cancellationToken); +``` + +- It returns a `Task`, bringing every call inside it back under `VSTHRD103`. +- It gains the **`Async` suffix** — `VSTHRD200` is an `error`, so the rename is enforced. +- It takes a trailing `CancellationToken`, because the chain now accepts one; its callers declare and + forward one. + +**A property cannot be `async`, so it becomes a method** — that is the conversion, not an exemption. +A read-only property hiding IO is the most common form of this violation. + +It genuinely cannot be converted when **no async overload exists** (`File.Exists`, `File.Delete`, +`Directory.EnumerateFiles`; likewise `ArchitecturePolicy.Load()` and `PolicySchemaValidator.Validate()`, +which `Purview.Architecture.Testing` ships synchronous-only) or when **a base type or interface fixes +the signature**. State the reason at the call. Never fake it: `Task.Run` around a blocking call, +`Task.FromResult` around a sync result, or `.Result` / `.GetAwaiter().GetResult()` to flatten a task — +the last two are `VSTHRD002`, an `error`. + +## Cancellation + +Three parts, triggered by the **call chain** rather than by the test: + +1. **If any method the test calls accepts a `CancellationToken`, the test declares one.** Not only the + method under test — anything in the chain, including a `File.ReadAllTextAsync` or the delegate handed + to `Assert.That`. +2. **It is the last parameter**, and TUnit supplies the value. With `[Arguments]`, the data binds to the + leading parameters and the token is supplied on top, so the attribute's positional order still matches + the signature: + + ```csharp + [Test] + [Arguments("build-enforced", true)] + public async Task ListEnforcedByBuildAsync_GivenAConvention_FiltersIt( + string id, + bool enforcedByBuild, + CancellationToken cancellationToken + ) + ``` + +3. **It is forwarded to every call that accepts one.** Never `CancellationToken.None`, never `default`, + never a token the test creates. TUnit's token is the *test's* token — cancelled on timeout or on an + aborted run — so a call given `None` cannot be stopped, and **one hop that drops it makes the whole + chain uncancellable**, which is why partial forwarding is a failure and not a lesser version of the + rule. It is also the only place that proves the production code threads the token through at all. + +A test whose chain accepts no token takes no parameter; an unused token is noise, not compliance. + +## Substitutes + +TUnit.Mocks is globally imported by the SDK, so no `using` is needed: + +```csharp +[Test] +public async Task RegisterAsync_GivenAnAlreadyRegisteredConvention_DoesNotStoreIt(CancellationToken cancellationToken) +{ + // Arrange + var repository = CreateRepository(); + repository.ExistsAsync(Any(), Any()).Returns(true); + var catalogue = CreateCatalogue(repository.Object); + + // Act + await Assert + .That(async () => await catalogue.RegisterAsync(TestBuilder.EnforcedConvention(), cancellationToken)) + .Throws(); + + // Assert + repository.AddAsync(Any(), Any()).WasNeverCalled(); +} +``` + +- `Mock.Of()` produces a `Mock`; `.Object` is the instance you pass in. +- `Any()` matches any argument, `Is(value)` a specific one, `AnyArgs()` the whole call. +- `.Returns(value)` sets the result. For a `Task`-returning method, return the `T` — the task is + unwrapped for you. +- `.WasCalled(Times.Once)` and `.WasNeverCalled()` are the assertions. + +The `// Assert` after a `Throws` is not a mistake: that test's point is that the exception was thrown +**and** that nothing was written, and both are assertions about the same act. + +## `TestBuilder` + +It lives in the shared testing project and is the single means by which a test creates domain +content: + +```csharp +public static class TestBuilder +{ + public const int DefaultCount = 3; + + internal static Faker Faker { get; } = new(); + + public static Domain.ConventionId ConventionId(string? value = null) => + Domain.ConventionId.Create(value ?? $"{Slug()}-{Faker.Random.String2(6, LowercaseAlphabet)}"); + + public static Domain.Convention Convention( + Domain.ConventionId? id = null, + string? title = null, + VerificationTier? tier = null, + bool? enforcedByBuild = null) => + Domain.Convention.Create( + id ?? ConventionId(), + title ?? Faker.Lorem.Sentence(4), + tier ?? Faker.Random.Enum(), + enforcedByBuild ?? Faker.Random.Bool()); +} +``` + +Four rules make it work: + +- **Omitted arguments are generated; supplied arguments are used exactly.** A test overrides only the + value it is proving, and everything else is realistic without the test saying so. +- **A collection factory comes with a companion.** `Conventions(int count = DefaultCount)` generates a + distinct set, so a test needing "some" gets "some" without its own loop. +- **Bogus is a global `using`.** `Faker` is available with no import — which is also why an explicit + `using Bogus;` in a shared testing project is reported as unnecessary. +- **A value-object test is the one exception.** A test proving what `Create` and `Hydrate` accept and + reject has to name the rejected input itself, so it constructs the invalid value directly. + Everything valid still goes through the builder. + +## Two constraints that shaped the builder + +Both are consequences of SDK rules, not preferences, and both were found by building it. + +**The factories are flat, not grouped.** The natural shape is a nested class per feature area +(`TestBuilder.Catalogue.Convention(...)`), and it cannot compile: the SDK ships +`dotnet_diagnostic.CA1034.severity = error`, so a publicly visible nested type fails the build, and +`PRSGD0008` refuses to let a repository add `CA1034` to `NoWarn`. Making the group `internal` does +not help either — the SDK's `InternalsVisibleTo` target runs on non-test projects and grants access +*to* test and shared-testing projects rather than *from* them, so a shared testing project's +internals are not visible to the tests beside it. An area that outgrows a flat builder gets its own +top-level builder class. + +**`var` is the house style, with one carve-out.** `csharp_style_var_elsewhere = true:warning` makes +an explicit type on a declaration initialised from a method call a warning (`IDE0007`). The carve-out +is object creation: the `PDS0003` analyzer prefers an explicit type with target-typed `new()`: + +```csharp +var id = TestBuilder.ConventionId("build-enforced"); // method call -> var +string title = new('a', Convention.MaxTitleLength); // new() -> explicit type +``` + +## What is enforced, and what is review + +| Rule | Enforced by | +| --- | --- | +| TUnit is the only framework | the build — the SDK adds it; a second has to be added by hand | +| Test projects are discovered and categorised | the build (name-suffix detection) | +| A shared testing project is referenced, not executed | the build (`IsSharedTestingProject`) | +| Style, including the `var` carve-out | the build (analyzers, `warning` or above) | +| An acceptance suite is categorised `Acceptance` and kept out of the fast tier | the build (name-suffix detection) and the pipeline's `TestFilter` | +| A container-backed suite fails rather than passes when its backend is missing in CI | the pipeline, when the backend is pinned with `PURVIEW_CONTAINERS_BACKEND` | +| AAA structure, naming, `CreateXXX` helpers, `TestBuilder` usage | **review** | +| Given/When/Then chains end in `.BDTestAsync()` | **review** | +| A real dependency comes from `Purview.Containers`, not a shared instance | **review** | +| An async overload is used inside an `async Task` test | the build — `VSTHRD103` is `error` | +| A converted helper carries the `Async` suffix | the build — `VSTHRD200` is `error` | +| A **sync helper** calling an API with an async overload is converted | **review** — a non-`Task`-returning method is outside `VSTHRD103`'s scope | +| A declared `CancellationToken` is the last parameter | the build — `CA1068` is `warning`, and a clean build has no warnings | +| A test whose chain accepts a token declares one and forwards it everywhere | **review** | + +## Acceptance tests and container-backed tests + +An acceptance test — a behaviour somebody outside the team asked for, in their words — is written with +**BDTest over TUnit** (`BDTest.TUnit`) in a `.AcceptanceTests` project, where Given/When/Then +replaces the AAA comments. A suite that needs a real database, queue or emulator gets a throwaway +container from **`Purview.Containers`** in its own `.IntegrationTests` project, skipping +locally when no backend is usable and failing in CI. + +Read `references/acceptance-and-container-tests.md` before writing either: it carries the scenario and +container shapes, and the two traps — a chain that never calls `.BDTestAsync()` runs no steps and still +passes, and a suite that skips in CI turns a missing runtime into a green job. + +## Tiers + +A test project's **name** carries its tier — the SDK categorises `*UnitTests` as `Unit`, and takes the +word before `Tests` as a pattern, so `*AcceptanceTests` gives `Acceptance` with nothing added to the +pipeline. The `TestFilter` then selects a tier without a hand-written attribute. Split a slow suite +into its own project rather than tagging individual tests. + +Filtering uses TUnit's `--treenode-filter`, not `--filter`: these repositories run on +Microsoft.Testing.Platform, where `dotnet test --filter` reports "Zero tests ran". + +## At your scale + +- **One test project.** TUnit, AAA, the naming convention, `TestBuilder`. That is the whole of it. +- **A shared fixture.** Move it into `SharedTestingInfra` and make the class public — a shared + testing project is a library, so its fixtures are legitimately public API and `CA1515` does not + apply. +- **A database or a container.** One `.IntegrationTests` project and one `Purview.Containers` + module. Do not build a fixture framework around it until a second suite needs the same container. +- **A behaviour somebody outside the team asked for.** That, and not before, is when BDTest earns its + place: one `.AcceptanceTests` project with one story. Reach for the HTML report only once + somebody will read it. +- **A package with a documented contract.** Add a consumer-verification script; see + `purview-engineering-principles`. + +## Related skills + +`purview-engineering-principles` for the rules this implements, and `purview-architecture-testing` +for turning an architectural boundary into a failing build. diff --git a/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md new file mode 100644 index 0000000..e8b9110 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md @@ -0,0 +1,117 @@ +# Acceptance tests and container-backed tests + +Two shapes that sit outside the unit tier. Both keep TUnit as the runner, and both live in their own +test project so the tier comes from the project name rather than an attribute. + +## Acceptance tests: BDTest + +An acceptance test states a behaviour somebody outside the team asked for, in their words. Use +**BDTest over TUnit through the `BDTest.TUnit` adapter**, in a `.AcceptanceTests` project +(the SDK emits `Category=Acceptance` for it). + +Not Gherkin, SpecFlow or Reqnroll, for two reasons: + +- **BDTest is code-only.** No `.feature` file and no generated test body, so there is no second + artefact that can drift from the step implementing it. That drift is the failure that makes a BDD + suite stop being trusted. +- **One runner stays one runner.** `BDTest.TUnit` is an adapter over TUnit, so the SDK's wiring, the + emitted category and `--treenode-filter` are all unchanged. A BDD framework with its own runner is + exactly what the one-framework rule refuses. + +```csharp +[Story(AsA = "account holder", IWant = "to withdraw money", SoThat = "I can use my savings")] +public class WithdrawalTests : TUnitBDTestBase +{ + AccountSteps Account => new(Context); + + [Test] + [ScenarioText("Withdrawing money reduces the balance")] + public async Task WithdrawMoney() => + await Given(() => Account.CreateAnAccount(100m)) + .When(() => Account.Withdraw(25m)) + .Then(() => Account.TheBalanceIs(75m)) + .BDTestAsync(); +} + +public class AccountSteps(AccountContext context) +{ + [StepText("I have an account with a balance of {0}")] + public void CreateAnAccount(decimal balance) => context.Balance = balance; + + [StepText("I withdraw {0}")] + public void Withdraw(decimal amount) => context.Balance -= amount; + + [StepText("the account balance is {0}")] + public async Task TheBalanceIs(decimal expected) => await Assert.That(context.Balance).IsEqualTo(expected); +} +``` + +Five rules, and the first is the one that bites: + +- **`.BDTestAsync()` runs the scenario.** A chain stopping at `.Then(...)` compiles, passes, and + executes none of its steps — the same failure mode as an un-awaited `Assert.That`. +- **Given/When/Then replaces Arrange/Act/Assert, and only here.** The chain already names the three + phases. Everywhere else the AAA comments stay. +- **`Context` is the only state.** Steps take it through the constructor and hold no fields of their + own; BDTest gives each test its own context, which is what keeps parallel and repeated runs honest. +- **`[StepText]` is written for the report's reader.** `{0}` is replaced with the argument's actual + value at run time, so the report states what the scenario did, not what it was called. +- **Domain content still comes from `TestBuilder`.** The vocabulary changes at the step; the test-data + rule does not. + +Assertions inside a step are ordinary awaited TUnit assertions. `BDTest.Reporting` produces the +single-file HTML report — publish it as a build artifact from the job that ran the suite. + +## A test that needs a real dependency: `Purview.Containers` + +A database, queue, blob store or emulator comes from `Purview.Containers`: a throwaway Linux container +behind one API over Microsoft WSL Containers or Docker, with typed modules for SQL Server, PostgreSQL, +Redis, MySQL, RabbitMQ, Azurite and NATS. + +The two refused alternatives: + +- **A shared instance** (a local SQL Server, a team database) makes tests order-dependent and failures + unreproducible — the state a test found was left by whatever ran before it, on whatever machine. +- **Testcontainers directly** ties the suite to a Docker installation, which on Windows is an install + and a licensing question per developer and per runner. The WSL backend removes it, and the same test + code runs on either. + +```csharp +await using var postgres = new PostgreSqlBuilder() + .WithDatabase("tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + +await postgres.StartAsync(); + +await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); +await connection.OpenAsync(); +``` + +`Build()` validates the configuration, `StartAsync()` returns only once every wait strategy is +satisfied — there is no sleep to tune — and `await using` disposes the container at end of scope. + +Three rules: + +- **Its own `.IntegrationTests` project**, so the tier is in the name and the suite stays out + of the pull-request filter without anybody remembering an attribute. +- **Skip locally when no backend is usable; fail in CI.** Two different situations, so never one + outcome. Locally, probe and skip. In CI, pin the backend with `PURVIEW_CONTAINERS_BACKEND` — a named + backend never falls back, so a missing runtime fails loudly instead of letting a green job mean + nothing ran: + + ```csharp + [Before(Class)] + public static async Task EnsureABackendIsAvailable() + { + var backends = await ContainerBackends.ProbeAllAsync(); + + Skip.Unless(backends.Any(backend => backend.IsUsable), "No container backend is available."); + } + ``` + + Left unpinned, automatic selection probes in priority order and prefers WSL Containers over Docker. +- **Pin the version exactly.** `Purview.Containers` is **experimental and prerelease** — API, defaults + and packaging rules can change between releases. The packages are a family, so one property in + `Directory.Packages.props` governs all of them and a bump cannot be applied to half. diff --git a/skills-lock.json b/skills-lock.json index 5b46810..90f6427 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,18 @@ { "version": 1, "skills": { + "purview-architecture": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture/SKILL.md", + "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" + }, + "purview-architecture-testing": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture-testing/SKILL.md", + "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + }, "purview-buildsdk-adoption": { "source": "purview-dev/build-sdk", "sourceType": "github", @@ -49,6 +61,18 @@ "skillPath": "skills/purview-buildsdk-testing/SKILL.md", "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" }, + "purview-engineering-principles": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-engineering-principles/SKILL.md", + "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + }, + "purview-persistence": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-persistence/SKILL.md", + "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + }, "purview-sourcegenerator-codewriter": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", @@ -85,6 +109,18 @@ "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", "computedHash": "14809d3727643e9a779d590b6627b3283af7b36c835e902cb80162552cb45b4f" }, + "purview-telemetry": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-telemetry/SKILL.md", + "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + }, + "purview-testing-conventions": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-testing-conventions/SKILL.md", + "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + }, "purview-zodsharp-aspnetcore": { "source": "purview-dev/zodsharp", "sourceType": "github", From 9ddf4b9fd95944faf2211aaa717e955f823fc024 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:45:36 +0000 Subject: [PATCH 15/23] test(repository): check authored skills are never copied into .agents InstalledSkillTests now mirrors ZodSharp's five checks. A skill authored in skills/ must not appear in .agents/skills/, whether or not the lockfile records it, and the CLAUDE.md check also covers .github/CLAUDE.md. --- .../InstalledSkillTests.cs | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/tests/Repository.UnitTests/InstalledSkillTests.cs b/src/tests/Repository.UnitTests/InstalledSkillTests.cs index 1d8b32b..99d11c6 100644 --- a/src/tests/Repository.UnitTests/InstalledSkillTests.cs +++ b/src/tests/Repository.UnitTests/InstalledSkillTests.cs @@ -5,8 +5,8 @@ namespace Purview.ValueObjects.Repository; /// /// Guards the skills installed from other repositories. They are installed by the skills CLI into the /// universal .agents/skills/ folder only, and that folder and the CLI's skills-lock.json -/// are committed, so the two must agree and no per-agent copy or Claude-specific instruction file may -/// appear beside them. +/// are committed, so the two must agree and no per-agent copy or instruction file may appear beside +/// them. /// /// /// SkillTests validates this repository's own skills in skills/; these are someone else's, @@ -62,23 +62,27 @@ await Assert } [Test] - public async Task Lockfile_GivenEveryEntry_IsFromAnotherRepository(CancellationToken cancellationToken) + public async Task AgentsSkills_GivenTheAuthoredSkills_HoldsNoCopyOfThem(CancellationToken cancellationToken) { // Arrange - var recorded = await ReadLockfileSkillsAsync(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + var authored = Directory + .GetDirectories(Path.Combine(RepositoryRoot.Value, "skills")) + .Select(static path => Path.GetFileName(path)) + .ToArray(); // Act - var authoredHere = recorded - .Where(static name => Directory.Exists(Path.Combine(RepositoryRoot.Value, "skills", name))) + var copied = authored + .Where(static name => Directory.Exists(Path.Combine(AgentsSkillsFolder(), name))) .ToArray(); // Assert await Assert - .That(authoredHere) + .That(copied) .IsEmpty() .Because( - "this repository's own skills are read at skills//SKILL.md and never installed into " - + $".agents/skills/, where a second copy would drift: {string.Join(", ", authoredHere)}" + "this repository's own skills live in skills/ only; a copy in .agents/skills/ would drift from " + + $"the authored one: {string.Join(", ", copied)}" ); } @@ -103,22 +107,27 @@ await Assert } [Test] - public async Task RepositoryRoot_GivenTheAgentConvention_HasNoClaudeInstructionFile( - CancellationToken cancellationToken - ) + public async Task Repository_GivenAgentInstructions_HasNoClaudeMd(CancellationToken cancellationToken) { // Arrange cancellationToken.ThrowIfCancellationRequested(); - var claudeMd = Path.Combine(RepositoryRoot.Value, "CLAUDE.md"); + string[] candidates = + [ + Path.Combine(RepositoryRoot.Value, "CLAUDE.md"), + Path.Combine(RepositoryRoot.Value, ".github", "CLAUDE.md"), + ]; // Act - var exists = File.Exists(claudeMd); + var present = candidates.Where(File.Exists).ToArray(); // Assert await Assert - .That(exists) - .IsFalse() - .Because("AGENTS.md is the only agent instruction file; a CLAUDE.md would be a second, drifting copy"); + .That(present) + .IsEmpty() + .Because( + "this repository is not agent-specific: AGENTS.md is its one instruction file, so a CLAUDE.md " + + $"must not be added: {string.Join(", ", present)}" + ); } static string AgentsSkillsFolder() => Path.Combine(RepositoryRoot.Value, ".agents", "skills"); From a6138f3efae52112b9e64d19c3dff397252d752f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:45:37 +0000 Subject: [PATCH 16/23] docs(agents): list the engineering playbook skills The installed-skills table and install commands include purview-dev/engineering-playbook, and the description of InstalledSkillTests matches its checks. --- AGENTS.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fac308f..86b07fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,14 +186,20 @@ Before handing work back: ## Installed skills (`.agents/skills/`) -Specialist guidance this repository depends on - `Purview.BuildSdk` configuration, the -`Purview.SourceGeneratorFramework` generator and test APIs, and the ZodSharp integration - is published by the -repositories that own it, and installed here by the skills CLI. The installed skills are **committed**: +Specialist guidance this repository depends on - the Purview engineering playbook, `Purview.BuildSdk` +configuration, the `Purview.SourceGeneratorFramework` generator and test APIs, and the ZodSharp integration - is +published by the repositories that own it, and installed here by the skills CLI. The installed skills are **committed**: `.agents/skills/**` and the CLI's `skills-lock.json` (source, skill path and content hash of every entry) are both in source, so a clone has them without running anything. | Skill | Source | Load before | | --- | --- | --- | +| `purview-engineering-principles` | `purview-dev/engineering-playbook` | setting up, reviewing or rationalising the repository against the Purview conventions | +| `purview-architecture` | `purview-dev/engineering-playbook` | deciding project shape or generating substantial implementation code | +| `purview-architecture-testing` | `purview-dev/engineering-playbook` | adding or changing architecture tests | +| `purview-testing-conventions` | `purview-dev/engineering-playbook` | writing or reviewing tests (TUnit, AAA, naming, builders) | +| `purview-persistence` | `purview-dev/engineering-playbook` | choosing a storage approach, abstracting EF Core, or a GUID primary-key strategy | +| `purview-telemetry` | `purview-dev/engineering-playbook` | adding or reviewing observability | | `purview-buildsdk-*` (8 skills) | `purview-dev/build-sdk` | build/SDK configuration, project layout and detection, testing, skill installation and skill tests | | `purview-sourcegenerator-codewriter` | `purview-dev/sourcegenerator-framework` | writing or reviewing emitter code (`CodeWriter`, scopes) | | `purview-sourcegenerator-pipeline` | `purview-dev/sourcegenerator-framework` | changing the incremental pipeline or its value-equatable models | @@ -209,6 +215,7 @@ or any other per-agent folder, and never add a `CLAUDE.md`: `AGENTS.md` is the o ```bash # add or update every skill from a source +npx -y skills@1 add purview-dev/engineering-playbook -s '*' -a universal -y npx -y skills@1 add purview-dev/build-sdk -s '*' -a universal -y npx -y skills@1 add purview-dev/sourcegenerator-framework -s '*' -a universal -y npx -y skills@1 add purview-dev/zodsharp -s '*' -a universal -y @@ -224,8 +231,9 @@ lockfile's hash. `Purview.BuildSdk`'s `InstallPurviewSkills` target delegates to defaulting to every agent (`*`). `src/tests/Repository.UnitTests/InstalledSkillTests.cs` keeps this honest: every `skills-lock.json` entry has its -`.agents/skills//SKILL.md`, every installed folder is in the lockfile, no entry is one of this repository's -own skills, and neither a `.claude/skills/` copy nor a `CLAUDE.md` exists. +`.agents/skills//SKILL.md`, every installed folder is in the lockfile, no skill authored in `skills/` is +copied into `.agents/skills/`, no `.claude/skills/` copy exists, and there is no `CLAUDE.md` (at the root or in +`.github/`). ## Agent skills From 3ab629cd800e0def930534db18c82d4b29405b08 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 17:14:22 +0000 Subject: [PATCH 17/23] build: bumped shared build --- .config/dotnet-tools.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f8a4785..3fc1c2c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,11 +17,11 @@ "rollForward": false }, "purview.build": { - "version": "0.7.1", + "version": "0.8.0", "commands": [ "purview-build" ], "rollForward": false } } -} \ No newline at end of file +} From f8999fa18389a269151ebe78bff6ad5bffe20db3 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 29 Oct 2026 22:24:15 +0000 Subject: [PATCH 18/23] chore: cleared old docs --- docs/index.md | 16 ---------------- mkdocs.yml | 21 --------------------- 2 files changed, 37 deletions(-) delete mode 100644 docs/index.md delete mode 100644 mkdocs.yml diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 23e5e04..0000000 --- a/docs/index.md +++ /dev/null @@ -1,16 +0,0 @@ -# Purview Value Objects - -Strongly typed value objects with Entity Framework and ZodSharp validation integrations. - -[Get started](Getting-Started.md){ .md-button .md-button--primary } - -## Guides - -- [Value object design](Value-Object-Design.md) -- [Entity Framework integration](Entity-Framework.md) -- [ZodSharp validation](ZodSharp-Validation.md) -- [Trimming, Native AOT and Blazor](Trimming-AOT-and-Blazor.md) - -## Reference - -- [Diagnostics](Diagnostics.md) — every `VO1xxx` rule, what triggers it, and how to fix it diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 112ecd8..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,21 +0,0 @@ -site_name: Purview Value Objects -site_description: Developer documentation for Purview Value Objects -repo_url: https://github.com/purview-dev/value-objects -edit_uri: edit/main/docs/ -docs_dir: docs - -theme: - name: material - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: indigo - accent: cyan - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: indigo - accent: cyan - features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy] - -plugins: [search, techdocs-core] -markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences] From 4f27fcd890a42793b941d8be8a9c6e205b659c4c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 2 Nov 2026 19:10:46 +0000 Subject: [PATCH 19/23] fix: better integration for strict vs. non-strict in aot --- AGENTS.md | 6 +- CHANGELOG.md | 65 +++++++++ Directory.Packages.props | 2 +- README.md | 39 ++--- docs/Entity-Framework.md | 9 +- docs/Getting-Started.md | 21 ++- docs/Trimming-AOT-and-Blazor.md | 40 ++++++ docs/ZodSharp-Validation.md | 114 ++++++++++++--- package.json | 2 +- skills/purview-value-objects-efcore/SKILL.md | 11 +- .../purview-value-objects-zodsharp/SKILL.md | 50 +++++-- .../ComplexValueObjectEmitter.EF.cs | 10 +- ...ComplexValueObjectEmitter.Serialization.cs | 9 +- .../ComplexValueObjectModelBuilder.cs | 54 +++++-- .../Models/ComplexValueObjectModel.cs | 5 +- .../Models/EFValueObjectDescriptors.cs | 7 +- .../ReferencedEFValueObjectDiscovery.cs | 4 +- .../ScalarValueObjectEmitter.Equality.cs | 11 +- .../ValueObjectEFConverterEmitter.cs | 20 +++ .../ValueObjectEFRegistryEmitter.cs | 10 +- .../ValueObject/ValueObjectEmitterHelpers.cs | 28 ++++ src/src/ValueObjects/Sdk/README.md | 25 +++- .../ScalarJsonConverterFactory.cs | 32 ++--- .../Serialization/ValueObjectJson.cs | 125 ++++++++++++++++ src/src/ValueObjects/ValueObjects.csproj | 8 ++ src/src/ZodSharp.AspNetCoreSample/Models.cs | 18 +-- src/src/ZodSharp.AspNetCoreSample/Program.cs | 37 +++-- src/src/ZodSharp.AspNetCoreSample/README.md | 19 +-- .../ValueObjectEFSourceGeneratorTests.cs | 80 ++++++++++- .../ScalarAutomaticPropertyTests.cs | 47 +++++- .../StrictDeserializationTests.cs | 134 ++++++++++++++++++ .../SourceGeneratedJsonContextTests.cs | 40 ++++++ 32 files changed, 931 insertions(+), 151 deletions(-) create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/StrictDeserializationTests.cs diff --git a/AGENTS.md b/AGENTS.md index 86b07fc..d002539 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,11 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar `scripts/test-packed-consumers.ps1`): it publishes a Native AOT console and a Blazor WebAssembly app (trimmed and `RunAOTCompilation`) from the packed package with `-warnaserror`, runs them, and leak-scans every output. - Generated JSON converters read and write through `ValueObjectJson` (runtime), never through the - `JsonSerializer` overloads that take only `JsonSerializerOptions`. + `JsonSerializer` overloads that take only `JsonSerializerOptions`. The generated Entity Framework Core + JSON-column converters likewise go through `ValueObjectJson.Serialize`/`Deserialize` with the type's converter; + only a value object with no usable converter (`VO1017`) keeps `JsonSerializer`. +- The runtime declares ``, so the platform compatibility analyzer runs for + Blazor WebAssembly. A new CA1416 warning is a defect, like a trim/AOT warning. ## Testing conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e207fe..a4dc66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,71 @@ Released versions correspond to `v` GitHub releases. `package.json` is newest heading below is the version in development. Earlier releases are listed under [releases](https://github.com/purview-dev/value-objects/releases). +## 1.3.0-prerelease.2 + +A prerelease of fixes and guidance: no target framework is dropped and the public API only grows. + +### Fixed + +- **The generated Entity Framework Core JSON-column converters are trim- and Native AOT-safe.** A complex value + object with `EFMapping = EntityFrameworkMapping.Json` (its own `EF.Converter`, or the inline converter a + consumer's registry emits for a value object from an assembly without EF Core) converted through + `JsonSerializer.Serialize(vo)`/`JsonSerializer.Deserialize(v)`, so every trimmed or AOT build got IL2026/IL3050 + from generated code. The converter now writes and reads through the type's JSON converter (its own + `[JsonConverter]`, otherwise the generated one) with `ValueObjectJson.Serialize`/`Deserialize`, and the column + content is unchanged. A value object with `GenerateJsonConverter = false` (`VO1017`), or whose `[JsonConverter]` + names a factory, keeps the `JsonSerializer` call. +- **ZodSharp issue paths for a request of value objects.** A strict value object that failed its ZodSharp schema + while a request was deserialized was reported at its own path (`Value`, `Field 'Value' ...`): a converter cannot + know where in the document it sits. The generated strict converters now throw the validator's exception inside a + `JsonException`, so System.Text.Json records the member (`$.customerEmail`), and Purview.ZodSharp + 3.0.0-prerelease.2 re-roots the errors there: `CustomerEmail.Value`, `Field 'CustomerEmail' ...`, + `Copies[1].Value`. Verified under the JIT on .NET 10 and 11 and under Native AOT; on .NET 10 it needs + `AddZodSharpProblemDetails(o => o.HandleBindingFailures = true)`. +- **ZodSharp Problem Details guidance.** The docs, the ZodSharp skill and the ASP.NET Core sample registered + `ScalarJsonConverterFactory` for strict value objects. Under Native AOT every request then returned `500`, and with + earlier ZodSharp builds the response never reached `ZodExceptionHandler` (a bare `400`, or a `500` in Development). + The guidance, skill and sample now validate the bound request with the default `Hydrate` mode, + `WithZodSharpValidation()` and the generated `AddZodSharp{Assembly}Schemas()` registration. That reports **every** + invalid member at its full path; `Strict` stops at the first. + +### Added + +- `ValueObjectJson.Serialize`/`Deserialize`, the reflection-free string helpers the + Entity Framework Core JSON-column converters call, and `ValueObjectJson.IsValidationFailure`/`ValidationFailed`, + the rule and exception the strict converters (generated and `ScalarJsonConverterFactory`) share. +- The runtime declares `browser` as a supported platform (``), so the + platform compatibility analyzer (CA1416) reports any API it calls that Blazor WebAssembly does not support. +- Guidance for `Purview.ZodSharp.Http` with value objects, and an account of each ZodSharp package under trimming + and Native AOT, in [ZodSharp Validation](docs/ZodSharp-Validation.md#typed-http-clients) and + [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md#zodsharp-packages). + +### Changed + +- **A generated strict converter throws `JsonException` for a value that fails validation**, with the validator's + exception (`ArgumentException` by default, `ZodException` with ZodSharp) as its `InnerException` and the + member's location in `Path`. It used to let the validator's exception escape unwrapped. This is what + `ScalarJsonConverterFactory` already did, so the two paths now agree, and ASP.NET Core answers the failure as bad + input. `Hydrate` (the default) is unchanged: it never validates. +- Built and tested against Purview.ZodSharp 3.0.0-prerelease.2. The ASP.NET Core sample uses the + `ToHttpValidationProblemDetails(ZodProblemDetailsOptions, HttpContext)` overload that replaces the registry-only + one this release made obsolete. + +### Upgrade notes + +- **Strict deserialization failures are `JsonException`s.** Code that deserializes a strict value object and catches + `ArgumentException` or `ZodException` should catch `JsonException` and read its `InnerException`; with ZodSharp, + `ZodJsonExceptions.TryGetErrors(exception, typeInfo, out var errors)` returns the errors under the member. +- **ZodSharp Problem Details:** take Purview.ZodSharp 3.0.0-prerelease.2 or later. On .NET 10 with strict value + objects, set `AddZodSharpProblemDetails(o => o.HandleBindingFailures = true)`. Remove any + `ScalarJsonConverterFactory` registration. To report every invalid member rather than the first, validate the + bound request instead: drop `Strict`, mark the request `[ZodSchema]`, register `AddZodSharp{Assembly}Schemas()` + and add `.WithZodSharpValidation()` (or, on `net11.0`, `AddZodSharpValidation()`). Either way, error keys change + from `Value` to the member path (`CustomerEmail.Value`). +- **`Purview.ZodSharp.Http` and a same-project `JsonSerializerContext`.** Pass a `JsonTypeInfo` built from options + that carry the generated converters, not the context's own property, which writes each value object as + `{"value": ...}`. + ## 1.3.0-prerelease.1 A minor prerelease: no target framework is dropped and the public API only grows. `Purview.ValueObjects` still diff --git a/Directory.Packages.props b/Directory.Packages.props index 5d25f2f..36fed52 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ 5.9.0 1.76.0 1.2.2 - 3.0.0-prerelease.1 + 3.0.0-prerelease.2 diff --git a/README.md b/README.md index f285299..dcc05cf 100644 --- a/README.md +++ b/README.md @@ -63,29 +63,29 @@ expresses a nullable scalar). See ## JSON serialization -Scalar value objects serialize as their underlying value. Register the converter factory on your -`JsonSerializerOptions`: +Scalar value objects serialize as their underlying value; complex value objects serialize as an object of their +members. The generator emits a `[JsonConverter]` per value object, so no registration is needed: ```csharp -var options = new JsonSerializerOptions(); -options.Converters.Add(new ScalarJsonConverterFactory()); +var json = JsonSerializer.Serialize(EmailAddress.Create("demo@example.com")); // "demo@example.com" ``` -The generator also emits a `[JsonConverter]` per value object, so scalar/complex value objects serialize correctly -even when the factory is not registered. The generated converters are trim- and Native AOT-safe; the factory uses -reflection and is annotated `[RequiresUnreferencedCode]`, so a trimmed or AOT application should rely on the -generated converters instead (see [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md), which also -covers source-generated `JsonSerializerContext`s). - -Use the same options for Entity Framework JSON columns: +The generated converters are reflection-free, so they are trim- and Native AOT-safe. `ScalarJsonConverterFactory` +covers a hand-written scalar that has no generated converter: ```csharp -modelBuilder - .Entity() - .Property(c => c.Email) - .HasColumnType("jsonb"); +var options = new JsonSerializerOptions(); +options.Converters.Add(new ScalarJsonConverterFactory()); ``` +It uses reflection and is annotated `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`, so a trimmed or AOT +application should rely on the generated converters instead (see +[Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md), which also covers source-generated +`JsonSerializerContext`s). + +A complex value object mapped to an Entity Framework JSON column (`EFMapping = EntityFrameworkMapping.Json`) is +stored through the same generated converter. + ## Entity Framework Core When your project references `Microsoft.EntityFrameworkCore`, the generator emits an `EF` nested class per value @@ -170,7 +170,8 @@ var result = EmailAddressSchema.Validate(EmailAddress.Create("demo@example.com") ``` See [ZodSharp Validation](docs/ZodSharp-Validation.md), the `src/src/ZodSharpSample` project, and -the `src/src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details for strict deserialization failures). +the `src/src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details for a request of value objects, each +invalid member reported under its own path). Purview.ZodSharp 3 targets `net10.0`/`net11.0` (this package still ships `net8.0`-`net11.0` and does not depend on it); `Purview.ZodSharp.Blazor` validates Blazor forms over value objects - see [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md#blazor). @@ -180,7 +181,11 @@ it); `Purview.ZodSharp.Blazor` validates Blazor forms over value objects - see Generated code uses no reflection, so trimmed, Native AOT and Blazor WebAssembly applications get no IL2xxx/IL3xxx warning from it. `just smoke-packed-consumers` proves it against the packed package: a Native AOT console, a Blazor WebAssembly app (trimmed and `RunAOTCompilation`, run in a headless browser), a Blazor Server app and a `net8.0` -console, all built with warnings as errors and scanned for compiler-host assemblies. See +console, all built with warnings as errors and scanned for compiler-host assemblies. + +The generated Entity Framework Core mapping, JSON-column converters included, adds no warning either, though EF Core +itself is not fully trim- or AOT-compatible. The runtime declares `browser` as a supported platform, so the CA1416 +analyzer reports any API it calls that Blazor WebAssembly does not support. See [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md). ## How it works diff --git a/docs/Entity-Framework.md b/docs/Entity-Framework.md index 7c94c2e..be4b3a0 100644 --- a/docs/Entity-Framework.md +++ b/docs/Entity-Framework.md @@ -138,7 +138,10 @@ What the mapping does: - **Complex value objects** (`[ValueObject]`) map as **EF Core complex types** (EF Core 8+) by default, producing a column per member — including nested scalar value objects (e.g. `Money.Currency` converts to its primitive). - Complex value objects with `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` map to a single JSON column using the - generated JSON converter. + generated JSON converter (or the type's own `[JsonConverter]`), through `ValueObjectJson` rather than the + reflection-based `JsonSerializer` overloads, so the column converter adds no trim or Native AOT warning. The + column content is what `JsonSerializer.Serialize(value)` would write. Only with `GenerateJsonConverter = false` + (`VO1017`), or a `[JsonConverter]` naming a factory, does it fall back to `JsonSerializer`. ## Nullable scalars and nullable columns @@ -570,6 +573,10 @@ value-object provider assemblies referenced by EF consumers: `src/tests/ValueObjects.EFCompatibility.IntegrationTests`, so version-specific API shifts (for example `ValueGeneratorFactory.Create`'s second parameter changing to `ITypeBase`) are caught by the build rather than by a consumer. +- **Trimming and Native AOT.** The generated mapping code raises no IL2xxx/IL3xxx warning of its own, but EF Core + itself is not fully trim- or AOT-compatible: its `DbContext` constructor and migrations APIs carry + `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`, and a trimmed or AOT application warns at those calls. + Follow EF Core's own guidance (compiled models and precompiled queries) for that part. - Value objects are immutable; EF tracks them by value like any struct/record. The generator emits a parameterless constructor for `[ValueObject]` types to support EF Core materialization. - **Materialization is a replay path.** Entity Framework Core rebuilds a value object with `Hydrate(...)`, diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index dc1698c..5337c88 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -293,18 +293,23 @@ public readonly partial record struct OrderStatus ## 5. JSON serialization Scalar value objects serialize as their underlying value. The generator emits a `[JsonConverter]` per value object, -so they round-trip with default options. For a shared options instance, register -`ScalarJsonConverterFactory`: +so they round-trip with any options and need no registration. The generated converters use no reflection, so they +are trim- and Native AOT-safe. `ScalarJsonConverterFactory` is only for a hand-written scalar that has no generated +converter, in a host that is neither trimmed nor AOT-compiled: ```csharp var options = new JsonSerializerOptions(); options.Converters.Add(new ScalarJsonConverterFactory()); ``` +A source-generated `JsonSerializerContext` in the project that declares the value objects is the exception: see +[Trimming, Native AOT and Blazor](Trimming-AOT-and-Blazor.md#systemtextjson-source-generation). + `ValueObjectDeserializationMode` controls which factory deserialization uses: - `Hydrate` (default) – reconstructs via `Hydrate`, skipping validation. -- `Strict` – reconstructs via `Create`, re-running validation. +- `Strict` – reconstructs via `Create`, re-running validation. A value that fails is reported as a `JsonException` + whose `InnerException` is the validator's exception and whose `Path` is where the value sat (`$.customerEmail`). ```csharp [Scalar(DeserializationMode = ValueObjectDeserializationMode.Strict)] @@ -418,10 +423,12 @@ through `Create` unchanged. See [Custom rules on scalars](ZodSharp-Validation.md#custom-rules-on-scalars) and [Reusing a normal rule for a scalar](ZodSharp-Validation.md#reusing-a-normal-rule-for-a-scalar). -In ASP.NET Core, `Purview.ZodSharp.AspNetCore` converts those `ZodException`s into standard Problem -Details responses — combine `ValueObjectDeserializationMode.Strict` with -`AddZodSharpProblemDetails()` + `UseExceptionHandler()` so invalid request bodies return -`HttpValidationProblemDetails` automatically. See the `src/src/ZodSharp.AspNetCoreSample` project. +In ASP.NET Core, `Purview.ZodSharp.AspNetCore` turns validation failures into standard Problem Details +responses. Mark the request model `[ZodSchema]`, bind it with the default `Hydrate` mode, and add +`.WithZodSharpValidation()` to the endpoint: every invalid value object member is reported under its own path +(`CustomerEmail.Value`). See +[ASP.NET Core Problem Details](ZodSharp-Validation.md#aspnet-core-problem-details) and the +`src/src/ZodSharp.AspNetCoreSample` project. See `ZodSharp-Validation.md` and the `src/src/ZodSharpSample` project. diff --git a/docs/Trimming-AOT-and-Blazor.md b/docs/Trimming-AOT-and-Blazor.md index 2f9b6a7..50a5121 100644 --- a/docs/Trimming-AOT-and-Blazor.md +++ b/docs/Trimming-AOT-and-Blazor.md @@ -21,6 +21,9 @@ it, every one with warnings as errors, and scans every output for compiler-host `Purview.ValueObjects` has no dependency on ZodSharp. A project that adds the ZodSharp integration needs the target frameworks ZodSharp supports; a project that does not keeps the full range. +The runtime assembly declares `browser` as a supported platform, so the platform compatibility analyzer (CA1416) +reports any API it calls that is unsupported in Blazor WebAssembly. + ## The one reflection-based component `ScalarJsonConverterFactory` builds converters with reflection and expression trees, and is annotated @@ -68,6 +71,43 @@ Inside a generated converter: - anything else - an enum, or a custom underlying type - needs metadata, so add `[JsonSerializable(typeof(T))]` to the context. The `NotSupportedException` thrown otherwise names the type. +## ZodSharp packages + +A Native AOT minimal API with `[Scalar]` + `[ZodSchema]` value objects in `Strict` mode, built against ZodSharp +3.0.0-prerelease.2, raises no trim or AOT warning from any ZodSharp API it uses, under the build analyzers or ILC's +whole-program analysis: + +| Package | Under trimming and Native AOT | +| --- | --- | +| `Purview.ZodSharp` | Generated schemas use typed code, no reflection. Its reflection fallbacks sit behind the `ZodSharp.Reflection.IsSupported` feature switch, which is off when trimming or compiling ahead of time. | +| `Purview.ZodSharp.AspNetCore` | `AddZodSharpProblemDetails`, `ZodExceptionHandler` and `WithZodSharpValidation()` are safe; the first adds ZodSharp's ProblemDetails JSON metadata itself. Assembly scanning (`AddZodSharp(o => o.ScanAssemblies...)`) is not trim-safe: register the generated `AddZodSharp{Assembly}Schemas()` instead. Validating a bound request this way reports each value object member under its full path (`CustomerEmail.Value`), under Native AOT as under the JIT. | +| `Purview.ZodSharp.Http` | Safe, and runs in Blazor WebAssembly: every API takes a `JsonTypeInfo`. | +| `Purview.ZodSharp.Blazor`, `.Extensions.Validation` | Per their own guidance, safe with the generated `AddZodSharp{Assembly}Schemas()` registration; the model-walking fallback is annotated `[RequiresUnreferencedCode]`. The packed-consumer gate runs `Purview.ZodSharp.Blazor` under WebAssembly AOT. | + +A strict value object's failure is re-rooted under the request member by `ZodExceptionHandler` under Native AOT too +(on .NET 10 with `HandleBindingFailures = true`; see +[ZodSharp Validation](ZodSharp-Validation.md#strict-deserialization)). + +What does not work is `ScalarJsonConverterFactory`. Under Native AOT it cannot build its converters, and every request +it handles returns `500`. The generated converters need no registration. + +For `Purview.ZodSharp.Http` and for an ASP.NET Core app's own `JsonSerializerContext`, register the generated +converters when the value objects are declared in the same project, as for any +[source-generated context](#systemtextjson-source-generation). + +## Entity Framework Core + +The Entity Framework Core mapping the generator emits - converters, comparers, the `ConfigureValueObjects` +registry and the key value generators - raises no trim or AOT warning of its own. A JSON-mapped complex value object +(`EFMapping = EntityFrameworkMapping.Json`) is written to its column through its generated converter and +`ValueObjectJson`, not through `JsonSerializer`. Without the reflection-based metadata a trimmed or AOT application +turns off, a member of a JSON-mapped value object is written through its value object's converter or the built-in +converter for a primitive; an enum member, or a custom one, throws `NotSupportedException` naming the type. + +EF Core itself is not fully trim- or AOT-compatible: its `DbContext` constructor and migrations APIs are annotated +`[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`, so a trimmed or AOT application warns at those calls. See EF +Core's guidance on compiled models and precompiled queries. + ## Blazor ### Binding a value object in a form diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md index 1e0e807..80f9ee5 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -501,41 +501,117 @@ result. ### ASP.NET Core Problem Details -In ASP.NET Core, `Purview.ZodSharp.AspNetCore` maps thrown `ZodException`s to standard -`HttpValidationProblemDetails` responses. This covers strict deserialization of value objects -(`ValueObjectDeserializationMode.Strict`) and any `Create`/`Parse` failure that bubbles up as a -`ZodException`. - -Wire the handler into the pipeline: +In ASP.NET Core, `Purview.ZodSharp.AspNetCore` turns validation failures into standard `HttpValidationProblemDetails` +responses. For a request body of value objects, bind it with the default `Hydrate` mode and validate the bound +request. The request's generated schema validates each value object member through that value object's own schema, so +every invalid member is reported under its own path: ```csharp +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [Required, EmailAddress] + public string Value { get; } +} + +[ZodSchema] +public sealed partial record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); + +builder.Services.AddZodSharpMyAppSchemas(); // generated: AddZodSharp{Assembly}Schemas(), trim- and AOT-safe builder.Services.AddZodSharpProblemDetails(); builder.Services.AddProblemDetails(); var app = builder.Build(); app.UseExceptionHandler(); + +app.MapPost("/orders", (PlaceOrderRequest request) => TypedResults.Ok()) + .WithZodSharpValidation(); ``` -Mark the value object for strict deserialization and ZodSharp validation so invalid request bodies throw -during model binding: +`{"customerEmail":"not-an-email","quantity":500}` returns `400 application/problem+json`: -```csharp -[Scalar( - ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, - DeserializationMode = ValueObjectDeserializationMode.Strict)] -[ZodSchema] -public readonly partial record struct EmailAddress +```json { - [Required, EmailAddress] - public string Value { get; } + "errors": { + "CustomerEmail.Value": ["Field 'CustomerEmail' must be a valid email address."], + "Quantity.Value": ["Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100."] + }, + "issues": [{ "path": "CustomerEmail.Value", "segments": ["CustomerEmail", "Value"], "code": "invalid_string", ... }] } +``` + +On `net11.0`, `AddZodSharpValidation()` from `Purview.ZodSharp.Extensions.Validation` does the same for every endpoint +through ASP.NET Core's built-in validation pipeline. Both work under Native AOT with the generated registration. For +Native AOT (`WebApplication.CreateSlimBuilder` and a source-generated `JsonSerializerContext`), also register the +generated converters on the options, because a context in the project that declares the value objects cannot see +their `[JsonConverter]` attributes: +```csharp builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())); +{ + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default); + options.SerializerOptions.Converters.Add(new EmailAddress.EmailAddressJsonConverter()); +}); +``` + +#### Strict deserialization + +With `ValueObjectDeserializationMode.Strict` the value object validates while it is deserialized. The generated +converter reports a failure as a `JsonException` whose inner exception is the validator's (`ZodException` here), so +System.Text.Json records where the value sat (`$.customerEmail`). `ZodExceptionHandler` joins the two, so the +response is the same contract as above, located under the member: `errors["CustomerEmail.Value"]`, +`Field 'CustomerEmail' ...`, and `Copies[1].Value` for a collection element. + +```csharp +// .NET 10: binding answers a body it cannot deserialize itself (a bare 400) unless it is told to throw. +builder.Services.AddZodSharpProblemDetails(options => options.HandleBindingFailures = true); + +// .NET 11: binding hands the exception to the ProblemDetails service, which ZodSharp rewrites; nothing to set. +builder.Services.AddZodSharpProblemDetails(); +``` + +`HandleBindingFailures` sets `RouteHandlerOptions.ThrowOnBadRequest` for the whole application, so every minimal-API +binding failure travels through `UseExceptionHandler()`. Malformed JSON is still a standard `400` ProblemDetails. This +needs Purview.ZodSharp 3.0.0-prerelease.2 or later, and works under Native AOT. + +One limit remains: binding stops at the first invalid member. The bound-request validation above reports every one, +so prefer it for request bodies, and keep `Strict` for payloads that are not validated any other way. + +`ScalarJsonConverterFactory` throws the same exception, but it uses reflection: under Native AOT it cannot build its +converters and every request returns `500`. The generated converters need no registration. + +### Typed HTTP clients + +`Purview.ZodSharp.Http` (`net10.0`/`net11.0`, no ASP.NET Core dependency, so it runs in Blazor WebAssembly) validates +on the client side of the wire. `PostAsJsonValidatedAsync`, `PutAsJsonValidatedAsync` and `PatchAsJsonValidatedAsync` +validate a body before sending it, so an invalid one is never sent. `ReadFromJsonAndValidateAsync` validates a +response, and `TryReadZodValidationProblemAsync` turns the server's ProblemDetails back into ZodSharp errors. Every +API takes a `JsonTypeInfo`, so the package is trim-, AOT- and WebAssembly-safe. + +When the value objects are declared in the same project as the `JsonSerializerContext`, do not pass the context's own +property (`AppJsonContext.Default.PlaceOrderRequest`). That context cannot see the generated converters and writes +`{"customerEmail":{"value":"a@example.com"}}`, which the server rejects. Build the type info from options that carry +the converters: + +```csharp +JsonSerializerOptions options = new(AppJsonContext.Default.Options) +{ + Converters = { new EmailAddress.EmailAddressJsonConverter(), new OrderQuantity.OrderQuantityJsonConverter() }, +}; +var requestInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(PlaceOrderRequest)); + +var sent = await http.PostAsJsonValidatedAsync( + "orders", request, new PlaceOrderRequestSchemaValidator(), requestInfo); +if (!sent.IsSuccess) + return sent.Errors; // e.g. CustomerEmail.Value: invalid_string - nothing was sent + +using var response = sent.Value!; +if (await response.TryReadZodValidationProblemAsync() is { } problem) + return problem.ToValidationErrors(); // rejected by the server ``` -A `POST` body with an invalid email now returns `400 application/problem+json` with the structured issues -in the `issues` extension. +A value object from a referenced library needs none of this, and the context's own property works. Map error codes to HTTP statuses and formatted messages with `ErrorType` + `ErrorTypeRegistry`. Mark a static partial class with `[ErrorType]` on a `static readonly ErrorType` field and the bundled diff --git a/package.json b/package.json index 33a658b..eb3ced0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "value-objects", - "version": "1.3.0-prerelease.1", + "version": "1.3.0-prerelease.2", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/skills/purview-value-objects-efcore/SKILL.md b/skills/purview-value-objects-efcore/SKILL.md index 8800c71..d1ce2ae 100644 --- a/skills/purview-value-objects-efcore/SKILL.md +++ b/skills/purview-value-objects-efcore/SKILL.md @@ -54,7 +54,7 @@ generated `ModelCustomizer` that runs it after `OnModelCreating`, and works for | --- | --- | --- | | `[Scalar]` | one column of the underlying primitive | via `{Type}.EF.Converter` + `ValueComparer`; `EmailAddress` is a `TEXT` column | | `[ValueObject]` (default) | an EF Core complex type — one column per member | EF Core 8+ only; nested scalars convert too | -| `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` | a single JSON column | a value converter, so it works on every EF Core version | +| `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` | a single JSON column | a value converter, so it works on every EF Core version; writes through the generated JSON converter, with no reflection | The provider-to-model direction always goes through `Hydrate(...)`. **Materialization is a replay path**: neither `OnValidate` nor a ZodSharp schema runs when a row is read. That matches @@ -314,6 +314,15 @@ is left unmapped — use `EFMapping = Json` or configure the property manually. (converters, the key value generator, the convention, `ValueObjectSequentialGuid`) works on EF Core 7 and later. +## Trimming and Native AOT + +The generated mapping raises no IL2xxx/IL3xxx warning of its own. A JSON column goes through +`ValueObjectJson` and the type's generated converter, not `JsonSerializer`, unless the type turned +its converter off (`VO1017`). EF Core itself is not fully trim- or AOT-compatible — its `DbContext` +constructor and migrations APIs are annotated — so a trimmed or AOT app warns there; that is EF's +surface, not the generator's. Without reflection-based JSON metadata, an enum or custom member of a +JSON-mapped value object throws `NotSupportedException`. + ## Diagnostics `VO1009` (EF option set without an EF reference), `VO1010` (underlying type not mappable), `VO1017` diff --git a/skills/purview-value-objects-zodsharp/SKILL.md b/skills/purview-value-objects-zodsharp/SKILL.md index 7e79542..3a28585 100644 --- a/skills/purview-value-objects-zodsharp/SKILL.md +++ b/skills/purview-value-objects-zodsharp/SKILL.md @@ -265,35 +265,57 @@ reconstructs codes or messages — it forwards `result.Errors` into a `ZodExcept ### ASP.NET Core Problem Details -`Purview.ZodSharp.AspNetCore` maps a thrown `ZodException` to `HttpValidationProblemDetails`: +Bind request bodies with the default `Hydrate` mode and validate the **bound request**: its generated +schema validates each value object member through that value object's own schema, so every invalid member +is reported under its own path (`CustomerEmail.Value`, message `Field 'CustomerEmail' ...`). ```csharp +[ZodSchema] +public sealed partial record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); + +builder.Services.AddZodSharpMyAppSchemas(); // generated AddZodSharp{Assembly}Schemas(), AOT-safe builder.Services.AddZodSharpProblemDetails(); builder.Services.AddProblemDetails(); var app = builder.Build(); app.UseExceptionHandler(); +app.MapPost("/orders", (PlaceOrderRequest request) => TypedResults.Ok()).WithZodSharpValidation(); ``` -Make the value object validate during model binding: +An invalid body returns `400 application/problem+json` with `errors` and the structured `issues`, under +the JIT and Native AOT. On `net11.0`, `AddZodSharpValidation()` (`Purview.ZodSharp.Extensions.Validation`) +does it for every endpoint. Under Native AOT (`CreateSlimBuilder` and a source-generated context), also +register the generated converters (`new EmailAddress.EmailAddressJsonConverter()`) on the HTTP JSON options. + +`ValueObjectDeserializationMode.Strict` gives the same contract at the same path: the generated converter +throws `JsonException(inner: ZodException)`, System.Text.Json records `$.customerEmail`, and +`ZodExceptionHandler` re-roots the errors (`CustomerEmail.Value`, `Copies[1].Value`). On .NET 10 set +`AddZodSharpProblemDetails(o => o.HandleBindingFailures = true)`, or binding answers a bare `400`; .NET 11 +needs nothing. Requires Purview.ZodSharp 3.0.0-prerelease.2+. It still stops at the first invalid member, so +prefer bound-request validation for request bodies. Don't register `ScalarJsonConverterFactory`: under Native +AOT every request fails. + +### Typed HTTP clients (`Purview.ZodSharp.Http`) + +`PostAsJsonValidatedAsync`/`PutAsJsonValidatedAsync`/`PatchAsJsonValidatedAsync` validate a body before +sending (an invalid one is never sent), `ReadFromJsonAndValidateAsync` validates a response, and +`TryReadZodValidationProblemAsync` turns the server's ProblemDetails back into ZodSharp errors. Every API +takes a `JsonTypeInfo`, so it is trim-, AOT- and WebAssembly-safe. **Do not pass the context's own +property** (`AppJsonContext.Default.Order`) when the value objects are declared in the same project: that +context cannot see their converters and writes `{"customerEmail":{"value":"..."}}`, which the server rejects. +Build the type info from options that carry the generated converters: ```csharp -[Scalar( - ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, - DeserializationMode = ValueObjectDeserializationMode.Strict)] -[ZodSchema] -public readonly partial record struct EmailAddress +JsonSerializerOptions options = new(AppJsonContext.Default.Options) { - [Required, EmailAddress] - public string Value { get; } -} + Converters = { new EmailAddress.EmailAddressJsonConverter() }, +}; +var orderInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(PlaceOrderRequest)); -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())); +var sent = await http.PostAsJsonValidatedAsync("orders", request, new PlaceOrderRequestSchemaValidator(), orderInfo); ``` -An invalid body now returns `400 application/problem+json` with the structured issues in the `issues` -extension. +Value objects from a referenced library need none of this; the context's own property works. Map error codes to HTTP statuses with `[ErrorType]` on a `static readonly ErrorType` field; the bundled `ErrorTypeGenerator` emits `Create{Field}(...)` and a `[DoesNotReturn]` `Throw{Field}(...)`. diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs index c291ef7..5902c5f 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs @@ -20,8 +20,14 @@ static void EmitEF(CodeWriter writer, ComplexValueObjectModel model, bool emitEF ValueObjectEmitterHelpers.EFConverterBaseType(model.TypeModel.FullyQualifiedName, "global::System.String"), model.TypeModel.FullyQualifiedName, "global::System.String", - "vo => global::System.Text.Json.JsonSerializer.Serialize(vo)", - $"v => global::System.Text.Json.JsonSerializer.Deserialize<{model.TypeModel.FullyQualifiedName}>(v)!", + ValueObjectEFConverterEmitter.ToJsonProviderExpression( + model.TypeModel.FullyQualifiedName, + model.EFJsonConverterTypeName + ), + ValueObjectEFConverterEmitter.FromJsonProviderExpression( + model.TypeModel.FullyQualifiedName, + model.EFJsonConverterTypeName + ), ValueObjectEmitterHelpers.EFJsonReaderWriterType("global::System.String"), model.IsEF8Referenced ); diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs index e1f37aa..7670768 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs @@ -82,7 +82,14 @@ static void EmitJsonConverter(CodeWriter writer, ComplexValueObjectModel model) { loop.IfBlock( "reader.TokenType == global::System.Text.Json.JsonTokenType.EndObject", - ifBody => ifBody.Return($"{model.HydrateFactoryName}({hydrateArgs})") + ifBody => + ValueObjectEmitterHelpers.EmitJsonFactoryReturn( + ifBody, + $"{model.HydrateFactoryName}({hydrateArgs})", + model.Options.DeserializationMode + == ValueObjectSymbolInspector.StrictModeName, + model.TypeModel.FullyQualifiedName + ) ); loop.Line("var jsonName = reader.GetString();"); diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index 8f8a083..60e0557 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs @@ -238,7 +238,15 @@ out var efCtorArgs zodSchema.HasSchema, zodSchema.SchemaClassName, isEFReferenced, - ValueObjectSymbolInspector.IsEF8Referenced(compilation) + ValueObjectSymbolInspector.IsEF8Referenced(compilation), + isEFReferenced + ? ResolveEFJsonConverterTypeName( + typeSymbol, + typeModel.Value.FullyQualifiedName, + hasJsonConverterAttribute, + valueObjectOptions.GenerateJsonConverter + ) + : null ); return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); @@ -264,16 +272,42 @@ ValueObjectDefaultsAttributeData assemblyDefaults if (type is not INamedTypeSymbol { IsGenericType: false } named) return null; - var converter = - ResolveDeclaredJsonConverter(named) - ?? ResolveGeneratedJsonConverter(named, compilationAssembly, assemblyDefaults); - if (converter is null) + var converterTypeName = + ResolveDeclaredJsonConverterTypeName(named) + ?? ResolveGeneratedJsonConverterTypeName(named, compilationAssembly, assemblyDefaults); + if (converterTypeName is null) return null; + var converter = $"new {converterTypeName}()"; return isNullableValueType ? $"{TypeLibrary.ValueObjectJsonGlobalName}.Nullable({converter})" : converter; } - static string? ResolveDeclaredJsonConverter(INamedTypeSymbol type) + /// + /// The converter an Entity Framework Core JSON column of this value object serializes through, or + /// to keep the reflection-based JsonSerializer call. It is the converter + /// JsonSerializer.Serialize(value) would pick: the author's own [JsonConverter] when there is one + /// (and nothing when that is a factory or otherwise cannot be constructed here), otherwise the generated + /// {Name}JsonConverter. + /// + static string? ResolveEFJsonConverterTypeName( + INamedTypeSymbol typeSymbol, + string fullyQualifiedName, + bool hasJsonConverterAttribute, + bool generateJsonConverter + ) + { + if (hasJsonConverterAttribute) + return ResolveDeclaredJsonConverterTypeName(typeSymbol); + + return generateJsonConverter ? $"{fullyQualifiedName}.{typeSymbol.Name}JsonConverter" : null; + } + + /// + /// The fully qualified name of the concrete JsonConverter<T> a type's own + /// [JsonConverter(typeof(C))] names, or when it has none, or names a factory or a + /// converter that cannot be constructed without arguments. + /// + internal static string? ResolveDeclaredJsonConverterTypeName(INamedTypeSymbol type) { foreach (var attribute in type.GetAttributes()) { @@ -295,7 +329,7 @@ ValueObjectDefaultsAttributeData assemblyDefaults ctor.Parameters.Length == 0 && ctor.DeclaredAccessibility == Accessibility.Public ); if (constructible && IsJsonConverterOf(converterType, type)) - return $"new {ValueObjectSymbolInspector.ToTypeName(converterType)}()"; + return ValueObjectSymbolInspector.ToTypeName(converterType); return null; } @@ -303,7 +337,7 @@ ValueObjectDefaultsAttributeData assemblyDefaults return null; } - static string? ResolveGeneratedJsonConverter( + static string? ResolveGeneratedJsonConverterTypeName( INamedTypeSymbol type, IAssemblySymbol compilationAssembly, ValueObjectDefaultsAttributeData assemblyDefaults @@ -338,9 +372,7 @@ ValueObjectDefaultsAttributeData assemblyDefaults return null; } - return generateJsonConverter - ? $"new {ValueObjectSymbolInspector.ToTypeName(type)}.{type.Name}JsonConverter()" - : null; + return generateJsonConverter ? $"{ValueObjectSymbolInspector.ToTypeName(type)}.{type.Name}JsonConverter" : null; } static bool IsJsonConverterOf(INamedTypeSymbol converterType, INamedTypeSymbol valueType) diff --git a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs index 9804a21..17fac32 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs @@ -36,7 +36,10 @@ sealed record class ComplexValueObjectModel( bool HasZodSchemaValidation, string? ZodSchemaClassName, bool IsEFReferenced, - bool IsEF8Referenced + bool IsEF8Referenced, + // The JsonConverter an Entity Framework Core JSON column of this value object serializes through, or null + // when there is none to call without reflection. + string? EFJsonConverterTypeName ); /// A member of a complex value object. diff --git a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs index 3e09d3b..6508597 100644 --- a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs +++ b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs @@ -35,7 +35,9 @@ readonly record struct EFScalarDescriptor( /// Compile-time description of an Entity Framework Core-enabled complex value object, used to emit the /// assembly-level ValueObjectEFExtensions registry. When the declaring assembly emitted an /// EF nested class (), the registry references {TypeName}.EF.Converter -/// for JSON columns; otherwise the JSON converter is emitted inline. +/// for JSON columns; otherwise the JSON converter is emitted inline, serializing through +/// when the type's [JsonConverter] names one that can be called without +/// reflection. ///
readonly record struct EFComplexDescriptor( string TypeName, @@ -43,5 +45,6 @@ readonly record struct EFComplexDescriptor( bool IsEF8Referenced, bool HasComparer, bool HasJsonConverter, - bool HasEFMembers + bool HasEFMembers, + string? JsonConverterTypeName = null ); diff --git a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs index 6375496..33cc6c7 100644 --- a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs +++ b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs @@ -222,7 +222,9 @@ efMapping is not null isEF8Referenced, options.GenerateEFComparer, efMapping is not null && ValueObjectSymbolInspector.IsEFMappingJson(efMapping), - HasEFMembers: false + HasEFMembers: false, + // The compiled type carries the [JsonConverter] its own generator emitted (or its author's). + JsonConverterTypeName: ComplexValueObjectModelBuilder.ResolveDeclaredJsonConverterTypeName(type) ) ); return; diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs index 096ce02..06be68f 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs @@ -287,8 +287,8 @@ static void EmitJsonConverter(CodeWriter writer, ScalarValueObjectModel model) return; var valueObjectType = ValueObjectType(model); - var factoryMethod = - model.Options.DeserializationMode == ValueObjectSymbolInspector.StrictModeName ? "Create" : "Hydrate"; + var isStrict = model.Options.DeserializationMode == ValueObjectSymbolInspector.StrictModeName; + var factoryMethod = isStrict ? "Create" : "Hydrate"; writer.Class( new($"{model.TypeModel.Name}JsonConverter") @@ -343,7 +343,12 @@ [new MethodCallArgumentOptions("reader", ParameterModifier.Ref), "options"], ); } - methodBody.Return($"{factoryMethod}(value)"); + ValueObjectEmitterHelpers.EmitJsonFactoryReturn( + methodBody, + $"{factoryMethod}(value)", + isStrict, + model.TypeModel.FullyQualifiedName + ); } ); diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs index 20ae341..328e3ac 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs @@ -53,6 +53,26 @@ hydrateCastTypeName is null ? $"v => {valueObjectTypeName}.Hydrate(v)" : $"v => {valueObjectTypeName}.Hydrate(({hydrateCastTypeName})v)"; + /// + /// Builds the model-to-provider lambda of a JSON column. With it writes + /// through that converter with the runtime's ValueObjectJson, which is trim- and Native AOT-safe; + /// without one (the value object opted out of its generated converter, or names a factory) it falls back to the + /// reflection-based JsonSerializer.Serialize. + /// + public static string ToJsonProviderExpression(string valueObjectTypeName, string? jsonConverterTypeName) => + jsonConverterTypeName is null + ? "vo => global::System.Text.Json.JsonSerializer.Serialize(vo)" + : $"vo => {TypeLibrary.ValueObjectJsonGlobalName}.Serialize<{valueObjectTypeName}, {jsonConverterTypeName}>(vo)"; + + /// + /// Builds the provider-to-model lambda of a JSON column; the counterpart of + /// . + /// + public static string FromJsonProviderExpression(string valueObjectTypeName, string? jsonConverterTypeName) => + jsonConverterTypeName is null + ? $"v => global::System.Text.Json.JsonSerializer.Deserialize<{valueObjectTypeName}>(v)!" + : $"v => {TypeLibrary.ValueObjectJsonGlobalName}.Deserialize<{valueObjectTypeName}, {jsonConverterTypeName}>(v)!"; + public static void EmitConverterClass(CodeWriter writer, EFConverterDefinition definition) { var nullableObject = PurviewTypeLibrary.System.Object.MakeNullable(writer); diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index 24d3655..0b33939 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -745,8 +745,14 @@ Dictionary Fields ValueObjectEmitterHelpers.EFConverterBaseType(descriptor.TypeName, "global::System.String"), descriptor.TypeName, "global::System.String", - "vo => global::System.Text.Json.JsonSerializer.Serialize(vo)", - $"v => global::System.Text.Json.JsonSerializer.Deserialize<{descriptor.TypeName}>(v)!" + ValueObjectEFConverterEmitter.ToJsonProviderExpression( + descriptor.TypeName, + descriptor.JsonConverterTypeName + ), + ValueObjectEFConverterEmitter.FromJsonProviderExpression( + descriptor.TypeName, + descriptor.JsonConverterTypeName + ) ) ); fields[descriptor] = fieldName; diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs index d779681..f106949 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs @@ -47,6 +47,34 @@ public static string EFJsonReaderWriterType(string providerTypeName) return $"global::Microsoft.EntityFrameworkCore.Storage.Json.{singleton}"; } + /// + /// Emits a generated JSON converter's return {factoryCall};. Under strict deserialization the factory is + /// Create, and a validation failure leaves the converter as the JsonException + /// ValueObjectJson.ValidationFailed builds, with the validator's exception inside: System.Text.Json then + /// records where the value sat (JsonException.Path), which Purview.ZodSharp uses to report the errors under + /// that member, and hosts answer it as bad input. ScalarJsonConverterFactory throws the same exception. + /// + public static void EmitJsonFactoryReturn( + CodeWriter body, + string factoryCall, + bool isStrict, + string valueObjectTypeName + ) + { + if (!isStrict) + { + body.Return(factoryCall); + return; + } + + var helper = TypeLibrary.ValueObjectJsonGlobalName; + body.Block("try", tryBlock => tryBlock.Return(factoryCall)); + body.Block( + $"catch (global::System.Exception exception) when ({helper}.IsValidationFailure(exception))", + catchBlock => catchBlock.Throw($"{helper}.ValidationFailed(typeof({valueObjectTypeName}), exception)") + ); + } + /// /// ZodSharp refinement emission shared by the scalar and complex value object emitters. /// diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index 3e7d85e..88a00ae 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -103,18 +103,21 @@ intentionally not mirrored. ## JSON serialization -Scalar value objects serialize as their underlying value. Register the converter factory on your -`JsonSerializerOptions`: +Scalar value objects serialize as their underlying value; complex value objects serialize as an object of their +members. The generator emits a `[JsonConverter]` per value object, so no registration is needed. That generated +converter is reflection-free and trim- and Native AOT-safe. + +`ScalarJsonConverterFactory` covers a hand-written scalar that has no generated converter. It uses reflection, so +register it only in a host that is neither trimmed nor AOT-compiled: ```csharp var options = new JsonSerializerOptions(); options.Converters.Add(new ScalarJsonConverterFactory()); ``` -The generator also emits a `[JsonConverter]` per value object, so scalar/complex value objects serialize -correctly even when the factory is not registered. That generated converter is reflection-free and trim- and -Native AOT-safe; register `ScalarJsonConverterFactory` only for a hand-written scalar, in a host that is neither -trimmed nor AOT-compiled. +A source-generated `JsonSerializerContext` in the project that declares the value objects cannot see the generated +`[JsonConverter]` attributes, so register the generated converters on its options +(`new EmailAddress.EmailAddressJsonConverter()`); a value object from a referenced library needs nothing. ## Entity Framework Core @@ -135,6 +138,16 @@ Queries compare the value object type directly — no `.Value` required — or t (`c.Email == "..."`, `m.Id == guid`). See the Entity Framework guide for automatic + manual mapping, keys and indexes, generated key values, query filters, schema/migrations, assembly defaults, and the opt-out levels. +## Trimming, Native AOT and Blazor + +Generated code uses no reflection, so trimmed, Native AOT and Blazor WebAssembly applications get no +IL2xxx/IL3xxx warning from it, including the Entity Framework Core JSON-column converters (EF Core itself is +not fully trim- or AOT-compatible). The runtime declares `browser` as a supported platform. The package targets +`net8.0`-`net11.0`; the optional Purview.ZodSharp 3 integration needs `net10.0`+, and `Purview.ZodSharp.Blazor` +validates Blazor forms over value objects. See the +[Trimming, Native AOT and Blazor](https://github.com/purview-dev/value-objects/blob/main/docs/Trimming-AOT-and-Blazor.md) +guide. + ## Validation with ZodSharp Validate value objects with [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp), a C# port of Zod: diff --git a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs index 2095c06..8bfef15 100644 --- a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs +++ b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs @@ -37,15 +37,12 @@ namespace Purview.ValueObjects.Serialization; /// for a hand-written scalar that has no generated converter, and only in a host that is neither trimmed /// nor AOT-compiled. /// -/// -/// -/// Creates the factory. -/// -/// -/// The trimming and Native AOT requirement is declared here because this is where a host opts in, and -/// because an override of cannot carry the attributes -/// itself (IL2046/IL3051 require them to match the unannotated base). Registering this factory in a -/// trimmed or AOT-compiled host will warn at the construction site instead of failing at runtime. +/// +/// The trimming and Native AOT requirement is declared on the constructor because that is where a host opts +/// in, and because an override of cannot carry the +/// attributes itself (IL2046/IL3051 require them to match the unannotated base). Registering this factory in +/// a trimmed or AOT-compiled host will warn at the construction site instead of failing at runtime. +/// /// [method: RequiresUnreferencedCode( "ScalarJsonConverterFactory reads ScalarAttribute, resolves the scalar member and the " @@ -158,29 +155,18 @@ public override TScalarObject Read(ref Utf8JsonReader reader, Type typeToConvert { return _create(scalar!); } - catch (Exception exception) when (IsValidationFailure(exception)) + catch (Exception exception) when (ValueObjectJson.IsValidationFailure(exception)) { // Surfaced as JsonException so System.Text.Json attaches Path and LineNumber, and so hosts // treat it as bad input. Under Strict the factory is Create, which throws the validation // exception of whatever validator the value object uses - ArgumentException by default, or // ZodException with ZodSharp validation. Letting those escape unwrapped meant ASP.NET Core // saw an unhandled exception and answered 500 instead of 400, with no indication of which - // property failed. - throw new JsonException( - $"Cannot deserialize {typeof(TScalarObject).Name}: the value failed validation.", - exception - ); + // property failed. The generated converters throw the same exception. + throw ValueObjectJson.ValidationFailed(typeof(TScalarObject), exception); } } - /// - /// Whether is a validation failure from the value object's factory - /// rather than something that must not be reported as malformed input. - /// - static bool IsValidationFailure(Exception exception) => - exception - is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or JsonException); - public override void Write(Utf8JsonWriter writer, TScalarObject value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, _getScalar(value), options); diff --git a/src/src/ValueObjects/Serialization/ValueObjectJson.cs b/src/src/ValueObjects/Serialization/ValueObjectJson.cs index b6ddec7..b58839a 100644 --- a/src/src/ValueObjects/Serialization/ValueObjectJson.cs +++ b/src/src/ValueObjects/Serialization/ValueObjectJson.cs @@ -1,4 +1,6 @@ +using System.Buffers; using System.ComponentModel; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -126,6 +128,61 @@ public static void Write( converter.Write(writer, value, options); } + /// + /// Serializes to a JSON string through : the trim- and + /// Native AOT-safe equivalent of JsonSerializer.Serialize(value) for a type whose + /// [JsonConverter] is . The generated Entity Framework Core JSON-column + /// converters use it. + /// + /// + /// While reflection-based serialization is enabled the options are , + /// so the output matches JsonSerializer.Serialize(value) exactly. Trimmed and Native AOT applications turn + /// it off, and then a member is written through its value object's converter or the built-in converter for a + /// primitive; any other member type (an enum, for example) throws . + /// + /// The type to write. + /// The converter that writes . + /// The value to write. + /// The JSON text. + public static string Serialize(T value) + where TConverter : JsonConverter, new() + { + ArrayBufferWriter buffer = new(); + using (Utf8JsonWriter writer = new(buffer)) + Write(writer, value, StringOptions.Instance, ConverterCache.Instance); + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + /// + /// Deserializes through : the trim- and Native AOT-safe + /// equivalent of JsonSerializer.Deserialize<T>(json) for a type whose [JsonConverter] is + /// . The generated Entity Framework Core JSON-column converters use it. + /// + /// The options are chosen as for . + /// The type to read. + /// The converter that reads . + /// The JSON text. + /// The value read. + /// is not a single JSON value. + public static T? Deserialize(string json) + where TConverter : JsonConverter, new() + { + ArgumentNullException.ThrowIfNull(json); + + Utf8JsonReader reader = new(Encoding.UTF8.GetBytes(json)); + if (!reader.Read()) + throw new JsonException($"The JSON value could not be converted to {typeof(T)}: the input is empty."); + + var value = Read(ref reader, StringOptions.Instance, ConverterCache.Instance); + + // The reader rejects a second top-level value itself; this only guards a converter that stopped short. + if (reader.Read()) + throw new JsonException($"The JSON value could not be converted to {typeof(T)}: unexpected trailing data."); + + return value; + } + /// /// Writes a member of a complex value object: its name (with the options' naming policy applied) and its value, /// honouring . @@ -194,6 +251,38 @@ public static bool IsMember(string? jsonName, string memberName, JsonSerializerO ); } + /// + /// Whether , thrown by a value object's factory during strict deserialization, is a + /// validation failure - and so is reported as - rather than something that must + /// not be reported as bad input. + /// + /// The exception the factory threw. + /// for a validation failure. + public static bool IsValidationFailure(Exception exception) => + exception + is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or JsonException); + + /// + /// The exception a strict converter throws when the value it read fails validation: a + /// whose is the validator's exception (ArgumentException by default, + /// ZodException with ZodSharp validation). + /// + /// + /// System.Text.Json fills in ($.customerEmail) as the exception leaves the + /// converter, which is the only way the member that held the value can be known; Purview.ZodSharp reads it to + /// report a ZodException's errors under that member (CustomerEmail.Value). Hosts treat a + /// as bad input, so ASP.NET Core answers it with 400 rather than 500. + /// + /// The value object being read. + /// The validation failure. + /// The exception to throw. + public static JsonException ValidationFailed(Type valueObjectType, Exception innerException) + { + ArgumentNullException.ThrowIfNull(valueObjectType); + + return new($"Cannot deserialize {valueObjectType.Name}: the value failed validation.", innerException); + } + /// Adapts a converter for to . /// The underlying value type. /// The converter for the underlying type. @@ -239,6 +328,42 @@ static NotSupportedException CreateNotSupported(Type type) => + $"add [JsonSerializable(typeof({type}))] to that context." ); + static class ConverterCache + where TConverter : JsonConverter, new() + { + public static readonly TConverter Instance = new(); + } + + /// The options and use. + static class StringOptions + { + public static readonly JsonSerializerOptions Instance = Create(); + + // JsonSerializerOptions.Default resolves metadata through reflection. It is only read while reflection-based + // serialization is enabled, which trimmed and Native AOT applications turn off by default + // (JsonSerializerIsReflectionEnabledByDefault), and the trimmer then removes the branch - the same rule + // TryGetTypeInfo follows for the options a converter is called with. + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Only reached while JsonSerializer.IsReflectionEnabledByDefault is true." + )] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "Only reached while JsonSerializer.IsReflectionEnabledByDefault is true." + )] + static JsonSerializerOptions Create() + { + if (JsonSerializer.IsReflectionEnabledByDefault) + return JsonSerializerOptions.Default; + + JsonSerializerOptions options = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; + options.MakeReadOnly(); + return options; + } + } + sealed class NullableConverter(JsonConverter converter) : JsonConverter where T : struct { diff --git a/src/src/ValueObjects/ValueObjects.csproj b/src/src/ValueObjects/ValueObjects.csproj index 0840f96..6dd13b7 100644 --- a/src/src/ValueObjects/ValueObjects.csproj +++ b/src/src/ValueObjects/ValueObjects.csproj @@ -26,6 +26,14 @@ true + + + + + -/// A scalar value object validated by the ZodSharp-generated schema and deserialized in -/// mode, so invalid JSON input throws a -/// ZodException that the registered exception handler converts to Problem Details. +/// A scalar value object validated by the ZodSharp-generated schema. It deserializes with the default +/// , so binding never throws; the endpoint filter validates the +/// bound request, and an invalid email is reported at CustomerEmail.Value. /// -[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)] +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)] [ZodSchema] readonly partial record struct EmailAddress { @@ -18,9 +18,9 @@ readonly partial record struct EmailAddress } /// -/// A scalar value object validated by the ZodSharp-generated schema and deserialized strictly. +/// A scalar value object validated by the ZodSharp-generated schema. /// -[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)] +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)] [ZodSchema] readonly partial record struct OrderQuantity { @@ -29,6 +29,8 @@ readonly partial record struct OrderQuantity } /// -/// The request DTO bound by POST /orders. Its value object members are deserialized strictly. +/// The request DTO bound by POST /orders. Its generated schema validates each value object member through +/// that value object's own schema, so every invalid member is reported, under its own path. /// -sealed record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); +[ZodSchema] +sealed partial record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); diff --git a/src/src/ZodSharp.AspNetCoreSample/Program.cs b/src/src/ZodSharp.AspNetCoreSample/Program.cs index 3f1809c..a1fa481 100644 --- a/src/src/ZodSharp.AspNetCoreSample/Program.cs +++ b/src/src/ZodSharp.AspNetCoreSample/Program.cs @@ -1,15 +1,16 @@ -using Purview.ValueObjects.Serialization; using ZodSharp; using ZodSharp.AspNetCore; using ZodSharp.Core; var builder = WebApplication.CreateBuilder(args); -// Register the ScalarJsonConverterFactory so value objects serialize as their scalar value and -// deserialize through the configured ValueObjectDeserializationMode (Strict here). -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory()) -); +// No JSON registration: each value object carries a generated [JsonConverter] that serializes it as its +// scalar value and, in the default Hydrate mode, binds it without validating. Do not register +// ScalarJsonConverterFactory: it adds reflection and is not trimming- or Native AOT-compatible. + +// Registers the ZodSharp validators generated for this assembly, trim- and Native AOT-safe (assembly +// scanning is not). WithZodSharpValidation() below resolves the request's validator from them. +builder.Services.AddZodSharpPurviewValueObjectsZodSharpAspNetCoreSampleSchemas(); // Registers ZodExceptionHandler as an IExceptionHandler. Requires UseExceptionHandler() in the // pipeline (added below), otherwise the handler is never invoked. @@ -26,14 +27,17 @@ app.UseExceptionHandler(); -// Binds a request containing strict scalar value objects. Invalid JSON (for example an invalid -// email or a quantity outside 1-100) throws a ZodException during deserialization, which the -// exception handler turns into a 400 HttpValidationProblemDetails response. +// Binds a request of value objects, then validates it with PlaceOrderRequestSchema, which validates each +// member through that value object's own schema. An invalid email or a quantity outside 1-100 is a 400 +// HttpValidationProblemDetails response listing every invalid member under its own path +// (CustomerEmail.Value, Quantity.Value). Strict deserialization reports the same path (on .NET 10 with +// AddZodSharpProblemDetails(o => o.HandleBindingFailures = true)), but stops at the first invalid member. app.MapPost( - "/orders", - (PlaceOrderRequest request) => - Results.Ok(new { Email = request.CustomerEmail.Value, Quantity = request.Quantity.Value }) -); + "/orders", + (PlaceOrderRequest request) => + Results.Ok(new { Email = request.CustomerEmail.Value, Quantity = request.Quantity.Value }) + ) + .WithZodSharpValidation(); // Throws a ZodException carrying a registered error code. The handler resolves the ErrorType from // the registry and returns a 409 Conflict response whose message is formatted from the error's @@ -47,7 +51,7 @@ // with ErrorType resolution, without relying on the exception-handling middleware. app.MapGet( "/orders/{orderId}/email", - (string orderId, string email) => + (string orderId, string email, HttpContext httpContext) => { try { @@ -56,7 +60,10 @@ } catch (ZodException ex) { - var problem = ex.ToHttpValidationProblemDetails(ErrorTypeRegistry.Default); + var problem = ex.ToHttpValidationProblemDetails( + new ZodProblemDetailsOptions { Registry = ErrorTypeRegistry.Default }, + httpContext + ); return Results.Problem( problem.Detail, statusCode: problem.Status, diff --git a/src/src/ZodSharp.AspNetCoreSample/README.md b/src/src/ZodSharp.AspNetCoreSample/README.md index 3fb0cee..292ebe2 100644 --- a/src/src/ZodSharp.AspNetCoreSample/README.md +++ b/src/src/ZodSharp.AspNetCoreSample/README.md @@ -12,12 +12,15 @@ dotnet run --project src/src/ZodSharp.AspNetCoreSample ## What it shows -- **Strict deserialization of value objects** — `EmailAddress` and `OrderQuantity` are annotated with - `[Scalar]` + `[ZodSchema]` and `ValueObjectDeserializationMode.Strict`. Their generated `Create` runs - the ZodSharp-generated schema, so deserializing an invalid value throws a `ZodException`. +- **Validating a request of value objects** — `EmailAddress` and `OrderQuantity` are `[Scalar]` + + `[ZodSchema]`, and `PlaceOrderRequest` is `[ZodSchema]`. The request binds with the default `Hydrate` mode, + and `.WithZodSharpValidation()` validates it with `PlaceOrderRequestSchema`, which validates each member + through that value object's own schema. Every invalid member is reported under its own path + (`CustomerEmail.Value`, `Quantity.Value`) in an `HttpValidationProblemDetails` response with the structured + `issues` extension. The validators are registered with the generated, trim-safe + `AddZodSharpPurviewValueObjectsZodSharpAspNetCoreSampleSchemas()`; nothing is registered on the JSON options. - **Automatic exception handling** — `AddZodSharpProblemDetails()` registers `ZodExceptionHandler` and - `app.UseExceptionHandler()` catches the thrown `ZodException`, converting it to a - `HttpValidationProblemDetails` response with the structured issues in the `issues` extension. + `app.UseExceptionHandler()` catches a thrown `ZodException` (below), converting it to the same contract. - **Mapping error types to status codes** — `ErrorTypes.SaveFailed` (a `[ErrorType]`-generated partial) is registered in `ErrorTypeRegistry.Default`; `ErrorTypes.ThrowSaveFailed(...)` throws a `ZodException` carrying the `aggregate_save_failed` code, which is returned as a `409 Conflict` with a message @@ -37,13 +40,13 @@ curl -i -X POST http://localhost:5000/orders \ -d '{"customerEmail":"demo@example.com","quantity":2}' ``` -Send an invalid email — the strict value object throws and the handler returns a 400 Problem Details -payload: +Send an invalid email and quantity — the filter returns a 400 Problem Details payload with +`errors["CustomerEmail.Value"]` and `errors["Quantity.Value"]`: ```bash curl -i -X POST http://localhost:5000/orders \ -H "Content-Type: application/json" \ - -d '{"customerEmail":"not-an-email","quantity":2}' + -d '{"customerEmail":"not-an-email","quantity":500}' ``` Simulate an optimistic-concurrency failure (returns `409 Conflict` with a formatted message): diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs index bfb93c4..a41f3fc 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs @@ -409,10 +409,83 @@ public readonly partial record struct Money await Assert.That(converterType.TypeArguments[1].SpecialType).IsEqualTo(SpecialType.System_String); var moneyRecord = Normalize(result.Generated().GetRecord("Money", "Testing").Node.ToString()); - await Assert.That(moneyRecord).Contains("JsonSerializer.Serialize(vo)"); + await Assert + .That(moneyRecord) + .Contains("ValueObjectJson.Serialize(vo)"); + await Assert + .That(moneyRecord) + .Contains("ValueObjectJson.Deserialize(v)"); + await Assert.That(moneyRecord).DoesNotContain("JsonSerializer.Serialize("); + await Assert.That(moneyRecord).DoesNotContain("JsonSerializer.Deserialize<"); await Assert.That(moneyRecord).Contains("JsonStringReaderWriter.Instance"); } + [Test] + public async Task ComplexEFGeneration_JsonMappingWithDeclaredConverter_SerializesThroughDeclaredConverter( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json)] + [System.Text.Json.Serialization.JsonConverter(typeof(MoneyConverter))] + public readonly partial record struct Money + { + public decimal Amount { get; } + } + + public sealed class MoneyConverter : System.Text.Json.Serialization.JsonConverter + { + public override Money Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options) => + Money.Hydrate(reader.GetDecimal()); + + public override void Write(System.Text.Json.Utf8JsonWriter writer, Money value, System.Text.Json.JsonSerializerOptions options) => + writer.WriteNumberValue(value.Amount); + } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsEFGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var moneyRecord = Normalize(result.Generated().GetRecord("Money", "Testing").Node.ToString()); + await Assert + .That(moneyRecord) + .Contains("ValueObjectJson.Serialize(vo)"); + await Assert.That(moneyRecord).DoesNotContain("JsonSerializer.Serialize("); + } + + [Test] + public async Task ComplexEFGeneration_JsonMappingWithoutGeneratedConverter_FallsBackToJsonSerializer( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json, GenerateJsonConverter = false)] + public readonly partial record struct Money + { + public decimal Amount { get; } + } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsEFGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var moneyRecord = Normalize(result.Generated().GetRecord("Money", "Testing").Node.ToString()); + await Assert.That(moneyRecord).Contains("global::System.Text.Json.JsonSerializer.Serialize(vo)"); + await Assert.That(moneyRecord).DoesNotContain("ValueObjectJson.Serialize<"); + } + [Test] public async Task ComplexEFGeneration_NoneMapping_EmitsNoEFMembers(CancellationToken cancellationToken) { @@ -988,7 +1061,10 @@ public readonly partial record struct LocalId await Assert.That(registry).Contains("[typeof(global::Shared.Audit)]"); await Assert.That(registry).Contains("ValueConverter"); await Assert.That(registry).Contains("JsonStringReaderWriter.Instance"); - await Assert.That(registry).Contains("JsonSerializer.Serialize(vo)"); + await Assert + .That(registry) + .Contains("ValueObjectJson.Serialize(vo)"); + await Assert.That(registry).DoesNotContain("JsonSerializer.Serialize("); await Assert.That(registry).DoesNotContain("global::Shared.Audit.EF.Converter"); var compilationErrors = result diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs index e0f4ac3..01003c7 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs @@ -477,17 +477,21 @@ static partial void OnValidate(System.Guid value) public static class Harness { + public sealed record Holder(StrictId Id); + public static bool RejectsEmpty() { try { - System.Text.Json.JsonSerializer.Deserialize( - "\"00000000-0000-0000-0000-000000000000\""); + System.Text.Json.JsonSerializer.Deserialize( + "{\"Id\":\"00000000-0000-0000-0000-000000000000\"}"); return false; } - catch (System.ArgumentException) + catch (System.Text.Json.JsonException ex) + when (ex.InnerException is System.ArgumentException && ex.Path == "$.Id") { - // Strict re-validates through Create, which throws the OnValidate exception. + // Strict re-validates through Create. Its OnValidate exception leaves the converter inside a + // JsonException, so System.Text.Json records where the value sat. return true; } } @@ -508,6 +512,41 @@ public static bool RejectsEmpty() await Assert.That(rejects).IsTrue(); } + [Test] + public async Task ScalarGeneration_GivenDeserializationMode_WrapsOnlyStrictValidationFailures( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar( + DeserializationMode = Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict + )] + public readonly partial record struct StrictId { } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct HydratedId { } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var strict = result.Generated().GetRecord("StrictId", "Testing").Node.ToString(); + var hydrated = result.Generated().GetRecord("HydratedId", "Testing").Node.ToString(); + + await Assert.That(strict).Contains("ValueObjectJson.IsValidationFailure(exception)"); + await Assert + .That(strict) + .Contains("ValueObjectJson.ValidationFailed(typeof(global::Testing.StrictId), exception)"); + await Assert.That(hydrated).DoesNotContain("ValidationFailed"); + await Assert.That(hydrated).Contains("return Hydrate(value);"); + } + [Test] public async Task ScalarGeneration_GivenAssemblyDefaults_AppliesAndTypeOverrideWins( CancellationToken cancellationToken diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/StrictDeserializationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/StrictDeserializationTests.cs new file mode 100644 index 0000000..3016bde --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/StrictDeserializationTests.cs @@ -0,0 +1,134 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using ZodSharp; +using ZodSharp.Core; + +namespace Purview.ValueObjects.Serialization; + +/// +/// A strict value object's generated converter reports a validation failure as a whose +/// inner exception is the validator's, so System.Text.Json records where the value sat. Purview.ZodSharp joins the +/// two (), so a 's errors are located under the member that +/// held the value - exactly as validating the deserialized model would report them. +/// +public sealed class StrictDeserializationTests +{ + [Test] + public async Task Deserialize_GivenInvalidStrictZodMember_ReportsErrorsUnderTheMember() + { + // Arrange + const string json = """{"CustomerEmail":"not-an-email","Copies":[]}"""; + + // Act + var exception = CaptureJsonException(json); + + // Assert + await Assert.That(exception.Path).IsEqualTo("$.CustomerEmail"); + await Assert.That(exception.InnerException).IsTypeOf(); + + var found = ZodJsonExceptions.TryGetErrors(exception, RequestTypeInfo(), out var errors); + await Assert.That(found).IsTrue(); + await Assert + .That(errors.Select(static e => e.FullPath.ToDottedString())) + .IsEquivalentTo(["CustomerEmail.Value"]); + } + + [Test] + public async Task Deserialize_GivenInvalidStrictZodElement_ReportsErrorsUnderTheElement() + { + // Arrange + const string json = """{"CustomerEmail":"a@example.com","Copies":["b@example.com","not-an-email"]}"""; + + // Act + var exception = CaptureJsonException(json); + + // Assert + ZodJsonExceptions.TryGetErrors(exception, RequestTypeInfo(), out var errors); + await Assert.That(errors.Select(static e => e.FullPath.ToDottedString())).IsEquivalentTo(["Copies[1].Value"]); + } + + [Test] + public async Task Deserialize_GivenInvalidStrictComplexValueObject_WrapsTheValidatorException() + { + // Arrange + const string json = """{"Price":{"Amount":-1,"Currency":"USD"}}"""; + + // Act + var exception = CaptureJsonException(json); + + // Assert + await Assert.That(exception.Path).IsEqualTo("$.Price"); + await Assert.That(exception.InnerException).IsAssignableTo(); + await Assert.That(exception.Message).Contains(nameof(StrictPrice)); + } + + [Test] + public async Task Deserialize_GivenValidStrictValues_RoundTrips() + { + // Arrange + const string json = """{"CustomerEmail":"a@example.com","Copies":["b@example.com"]}"""; + + // Act + var request = JsonSerializer.Deserialize(json); + + // Assert + await Assert.That(request!.CustomerEmail.Value).IsEqualTo("a@example.com"); + await Assert.That(request.Copies.Single().Value).IsEqualTo("b@example.com"); + } + + [Test] + public async Task Deserialize_GivenHydrateMode_DoesNotValidate() + { + // Act - the default mode is the replay path: an invalid value is hydrated, never rejected. + var email = JsonSerializer.Deserialize("\"not-an-email\""); + + // Assert + await Assert.That(email.Value).IsEqualTo("not-an-email"); + } + + static JsonException CaptureJsonException(string json) + { + try + { + JsonSerializer.Deserialize(json); + } + catch (JsonException ex) + { + return ex; + } + + throw new InvalidOperationException("Expected a JsonException."); + } + + // The root contract maps the JSON names in JsonException.Path back to member names - from a source-generated + // context, as a trimmed or Native AOT application would supply it. + static System.Text.Json.Serialization.Metadata.JsonTypeInfo RequestTypeInfo() => + StrictDeserializationJsonContext.Default.StrictOrderRequest; +} + +/// A strict scalar validated by its ZodSharp schema. +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)] +[ZodSchema] +public readonly partial record struct StrictZodEmail +{ + [Required, EmailAddress] + public string Value { get; } +} + +public sealed record StrictOrderRequest(StrictZodEmail CustomerEmail, IReadOnlyList Copies); + +/// A strict complex value object validated by its OnValidate hook. +[ValueObject(DeserializationMode = ValueObjectDeserializationMode.Strict)] +public readonly partial record struct StrictPrice(decimal Amount, string Currency) +{ + partial void OnValidate(decimal amount, string currency) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount), "The amount must not be negative."); + } +} + +public sealed record StrictPriceHolder(StrictPrice Price); + +[System.Text.Json.Serialization.JsonSerializable(typeof(StrictOrderRequest))] +sealed partial class StrictDeserializationJsonContext : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs b/src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs index 21037dc..99c2aff 100644 --- a/src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs +++ b/src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs @@ -165,6 +165,46 @@ public async Task Read_GivenResolverWithoutMetadataForCustomType_ThrowsNotSuppor await Assert.That(exception).IsNotNull(); await Assert.That(exception!.Message).Contains(typeof(ContextOrder).FullName!); } + + [Test] + public async Task Serialize_GivenComplexValueObject_MatchesJsonSerializerAndRoundTrips() + { + // Arrange - the string helpers back the generated Entity Framework Core JSON-column converters, which used to + // call JsonSerializer.Serialize(value)/Deserialize(json); the column content must not change. + var money = ContextMoney.Create(19.99m, ContextCurrencyCode.Create("usd"), ContextCurrencyCode.Create("eur")); + var expected = JsonSerializer.Serialize(money); + + // Act + var json = ValueObjectJson.Serialize(money); + var roundTripped = ValueObjectJson.Deserialize(json); + + // Assert + await Assert.That(json).IsEqualTo(expected); + await Assert.That(json).IsEqualTo("""{"Amount":19.99,"Currency":"USD","Fallback":"EUR"}"""); + await Assert.That(roundTripped).IsEqualTo(money); + } + + [Test] + public async Task Deserialize_GivenEmptyInput_ThrowsJsonException() + { + // Act & Assert + await Assert + .That(() => ValueObjectJson.Deserialize(" ")) + .Throws(); + } + + [Test] + public async Task Deserialize_GivenTrailingValue_ThrowsJsonException() + { + // Act & Assert + await Assert + .That(() => + ValueObjectJson.Deserialize( + """{"Amount":1,"Currency":"USD"} 2""" + ) + ) + .Throws(); + } } [Scalar] From 417d13fb1f1deef8338c328063a6f45457b76e34 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 2 Nov 2026 23:20:29 +0000 Subject: [PATCH 20/23] chore: bumped nuget versions --- .config/dotnet-tools.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3fc1c2c..4bb5cb6 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,11 +17,11 @@ "rollForward": false }, "purview.build": { - "version": "0.8.0", + "version": "0.8.1", "commands": [ "purview-build" ], "rollForward": false } } -} +} \ No newline at end of file From 56c4dab67fa906207c7d309c9147abc8e03b547c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 2 Nov 2026 23:33:25 +0000 Subject: [PATCH 21/23] fix(packed): commit the WebAssembly consumer's index.html The packed-consumer gate's Blazor WebAssembly consumer had no wwwroot/index.html in the repository: the web section of .gitignore ignores *.html at any depth, so the page existed only on the machine that wrote it. A fresh CI checkout therefore published an app whose wwwroot held _framework but no index.html, the static file host's MapFallback threw FileNotFoundException sending it, and the gate failed with "The WebAssembly host answered 500 for /". Add the page, un-ignore the fixtures' wwwroot folders, and assert index.html in Assert-WasmOutput so a future loss is reported as a missing file rather than as a 500 from the host. --- .gitignore | 6 +++++ .../BlazorWasm/wwwroot/index.html | 22 +++++++++++++++++++ scripts/test-packed-consumers.ps1 | 6 ++++- 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 scripts/packed-consumers/BlazorWasm/wwwroot/index.html diff --git a/.gitignore b/.gitignore index 2306968..ca3988f 100644 --- a/.gitignore +++ b/.gitignore @@ -593,6 +593,12 @@ coverage/ !src/src/EventSourcing/build !assets/packages/* +# The packed-consumer fixtures under scripts/packed-consumers are real, hand-written Blazor apps, not build output. +# The web section above ignores *.html/*.css/*.js at any depth, which silently kept the WebAssembly consumer's +# wwwroot/index.html out of the repository: a fresh clone then published an app with no index.html and the gate's +# static file host answered 500 for /. +!scripts/packed-consumers/**/wwwroot/** + .tools/ # Agent skills are installed into the universal .agents/skills/ folder only, and that folder and diff --git a/scripts/packed-consumers/BlazorWasm/wwwroot/index.html b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html new file mode 100644 index 0000000..ddf8e0f --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html @@ -0,0 +1,22 @@ + + + + + + + Packed Purview.ValueObjects Blazor WebAssembly consumer + + + + + +
Loading...
+ + + + diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 index de39a25..5b28f2b 100644 --- a/scripts/test-packed-consumers.ps1 +++ b/scripts/test-packed-consumers.ps1 @@ -560,7 +560,11 @@ try { } function Assert-WasmOutput([string] $publish) { - $framework = Join-Path $publish 'wwwroot/_framework' + $root = Join-Path $publish 'wwwroot' + $framework = Join-Path $root '_framework' + # Without it the static file host has nothing to serve and answers 500 for /, so name it here rather than in the + # browser step. The repository ignores *.html (see .gitignore), so a consumer's index.html must stay committed. + Assert-Contains $root '^index\.html$' 'the app''s index.html' Assert-Contains $framework '^Purview\.ValueObjects\.[a-z0-9]+\.wasm$' 'Purview.ValueObjects' Assert-Contains $framework '^Purview\.ZodSharp\.[a-z0-9]+\.wasm$' 'Purview.ZodSharp' Assert-Contains $framework '^Purview\.ZodSharp\.Blazor\.[a-z0-9]+\.wasm$' 'Purview.ZodSharp.Blazor' From 3a237fc50f5bb5abf8cd2136535b13a312ac98b1 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 2 Nov 2026 23:49:50 +0000 Subject: [PATCH 22/23] fix(ci): retry a cold-cache dotnet tool restore The pack-validation step failed with exit 1 because dotnet tool restore reported The command "purview-build" specified in the tool manifest file is not contained in the package with Package Id purview.build. The commands contained in the package are dotnet-inspect. although Purview.Build 0.8.1 declares and restores correctly on its own. Reproduced on a cold package cache with both 0.8.1 and 0.7.1: the .NET 11 rc.3 CLI attributes the command of the RID-specific dotnet-inspect tool to the manifest entry beside it. The failed attempt has downloaded and installed every tool by then, so the next attempt succeeds. scripts/restore-dotnet-tools.ps1 retries a bounded number of times. It is quiet on success and prints every attempt's output when all fail, so the retry cannot hide a genuine failure. It is now the only place local tools are restored: `just` (ensure-pipeline-tool), the CI pack-validation step, and the scratch repository that test-pack-validation-rules.ps1 builds for each of its 27 runs. Also give pr.yml its final newline. --- .github/workflows/pr.yml | 4 +- AGENTS.md | 5 +- Justfile | 2 +- scripts/restore-dotnet-tools.ps1 | 77 ++++++++++++++++++++++++++ scripts/test-pack-validation-rules.ps1 | 4 +- 5 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 scripts/restore-dotnet-tools.ps1 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5d1ba03..dbd9ee1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -69,5 +69,5 @@ jobs: - name: Prove every pack-validation forbidden-content rule rejects a package shell: pwsh run: | - dotnet tool restore - ./scripts/test-pack-validation-rules.ps1 -PackagesFolder artifacts/packed \ No newline at end of file + ./scripts/restore-dotnet-tools.ps1 + ./scripts/test-pack-validation-rules.ps1 -PackagesFolder artifacts/packed diff --git a/AGENTS.md b/AGENTS.md index d002539..40b6cf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,7 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar Prefer the `Justfile` recipes or their equivalent commands: ```text -dotnet tool restore +./scripts/restore-dotnet-tools.ps1 just restore just build just test @@ -151,7 +151,8 @@ headless browser, Blazor Server, net8.0, compiler-host leak scan). It needs the `LOCAL_NUGET_FEED_PATH`. Run it when generated code, the runtime, packaging or a dependency version changes. The repository uses the shared `Purview.Build` pipeline (`purview-build.json` at the root). `just pipeline-pr` -runs `dotnet tool restore`, which installs the pinned `Purview.Build` from `.config/dotnet-tools.json`. +restores the local tools with `scripts/restore-dotnet-tools.ps1`, which installs the pinned `Purview.Build` from +`.config/dotnet-tools.json` and retries a cold-cache `dotnet tool restore` failure (see that script's help). Local CI-equivalent validation: diff --git a/Justfile b/Justfile index 3636b57..aa723e1 100644 --- a/Justfile +++ b/Justfile @@ -18,7 +18,7 @@ default: # Restore the repository's .NET local tools (.config/dotnet-tools.json) [private] ensure-pipeline-tool: - dotnet tool restore + pwsh -NoProfile -File scripts/restore-dotnet-tools.ps1 # Run the PR pipeline (restore, build, lint, tests, pack, validate) [group('Pipeline')] diff --git a/scripts/restore-dotnet-tools.ps1 b/scripts/restore-dotnet-tools.ps1 new file mode 100644 index 0000000..c26aaca --- /dev/null +++ b/scripts/restore-dotnet-tools.ps1 @@ -0,0 +1,77 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Restores the repository's .NET local tools (.config/dotnet-tools.json), retrying a failed attempt. + +.DESCRIPTION + `dotnet tool restore` can fail on a cold package cache, exit 1, and still leave every tool installed. The + .NET 11 SDK (rc.3) mis-attributes the command of a RID-specific tool to the entry beside it in the manifest: + dotnet-inspect 0.26.0 declares DotNetCliTool version 2 with RuntimeIdentifierPackages, and the purview.build + entry after it is then rejected with + + The command "purview-build" specified in the tool manifest file is not contained in the package with + Package Id purview.build. The commands contained in the package are dotnet-inspect. + + although Purview.Build 0.8.1 declares and restores correctly on its own. The + failed attempt has downloaded and extracted the packages by then, so the next attempt succeeds - the bug is + an ordering/state artefact of a cold cache, not a wrong manifest. + + Retrying is bounded and never hides a genuine failure: every attempt must fail before this script fails, and a + failed attempt's output is printed when none succeeds. It is quiet on success, because + scripts/test-pack-validation-rules.ps1 restores once per validation run. This is the only place the repository + restores its local tools, so `just` and CI behave the same way. + +.PARAMETER Attempts + Total attempts, including the first. Defaults to 3. + +.PARAMETER DelaySeconds + Seconds to wait between attempts. Defaults to 2. + +.PARAMETER ToolManifest + Manifest to restore. Defaults to the repository's .config/dotnet-tools.json. A caller that made its own copy - + scripts/test-pack-validation-rules.ps1 builds a scratch repository - passes that copy instead. + +.EXAMPLE + ./scripts/restore-dotnet-tools.ps1 + +.EXAMPLE + ./scripts/restore-dotnet-tools.ps1 -Attempts 5 -DelaySeconds 10 +#> +[CmdletBinding()] +param ( + [ValidateRange(1, 10)] + [int] $Attempts = 3, + [ValidateRange(0, 60)] + [int] $DelaySeconds = 2, + [string] $ToolManifest +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$manifest = $ToolManifest ? $ToolManifest : (Join-Path $repositoryRoot '.config/dotnet-tools.json') +if (-not (Test-Path $manifest)) { throw "No local tool manifest at '$manifest'." } + +# Each failed attempt's output is buffered, not streamed: the retry is expected once on a cold cache, and a caller +# such as scripts/test-pack-validation-rules.ps1 restores 27 times. A genuine failure still prints every attempt. +$failures = [System.Collections.Generic.List[string]]::new() +for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + $output = & dotnet tool restore --tool-manifest $manifest 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -eq 0) { + Write-Host 'Local tools restored.' -ForegroundColor Green + return + } + + $failures.Add(($output -join "`n")) + if ($attempt -lt $Attempts) { + Write-Host ( + "dotnet tool restore failed (attempt $attempt of $Attempts); retrying in $DelaySeconds s. " + + "The attempt still populates the package cache, which is what the next one needs - see this script's help." + ) -ForegroundColor Yellow + Start-Sleep -Seconds $DelaySeconds + } +} + +$failures | ForEach-Object { Write-Host $_ } +throw "dotnet tool restore failed after $Attempts attempt(s)." diff --git a/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 index b54459f..50982b6 100644 --- a/scripts/test-pack-validation-rules.ps1 +++ b/scripts/test-pack-validation-rules.ps1 @@ -94,7 +94,9 @@ function Invoke-Validation([string] $name, [System.IO.FileInfo[]] $nupkgs, [stri Push-Location $root try { - & dotnet tool restore | Out-Null + # The scratch repository holds a copy of the manifest; the retry is scripts/restore-dotnet-tools.ps1's, so a + # cold package cache behaves the same here as in `just` and CI. + & (Join-Path $PSScriptRoot 'restore-dotnet-tools.ps1') -ToolManifest (Join-Path $root '.config/dotnet-tools.json') $output = & dotnet purview-build ` --Build:Solution=empty.slnx ` --Build:ArtifactsFolder=artifacts ` From e263358a40b212649daf2af4cd6d33005846d76b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 3 Nov 2026 00:00:02 +0000 Subject: [PATCH 23/23] fix(ci): leave the pack-validation step a zero exit code GitHub's pwsh shell appends `if ((Test-Path variable:\LASTEXITCODE)) { exit $LASTEXITCODE }` to the step, so the step's result is whatever the last native command left behind - and scripts/test-pack-validation-rules.ps1 ends with 27 `dotnet purview-build` runs that must fail, so the step reported exit 1 after printing "Every ForbiddenContent rule (25) rejected its file". Exit 0 explicitly once every rule has passed, and clear $LASTEXITCODE when a retried dotnet tool restore succeeds, so the retry cannot poison a step that only restores tools. Reproduced with the runner's wrapper locally: cold cache, attempt 1 fails, attempt 2 restores, 27 validation runs pass, step exit 0. --- scripts/restore-dotnet-tools.ps1 | 2 ++ scripts/test-pack-validation-rules.ps1 | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/scripts/restore-dotnet-tools.ps1 b/scripts/restore-dotnet-tools.ps1 index c26aaca..5bbc78e 100644 --- a/scripts/restore-dotnet-tools.ps1 +++ b/scripts/restore-dotnet-tools.ps1 @@ -59,6 +59,8 @@ $failures = [System.Collections.Generic.List[string]]::new() for ($attempt = 1; $attempt -le $Attempts; $attempt++) { $output = & dotnet tool restore --tool-manifest $manifest 2>&1 | ForEach-Object { "$_" } if ($LASTEXITCODE -eq 0) { + # A retried attempt leaves the failed attempt's 1 in $LASTEXITCODE; a caller run as a CI step exits with it. + $global:LASTEXITCODE = 0 Write-Host 'Local tools restored.' -ForegroundColor Green return } diff --git a/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 index 50982b6..ea57e04 100644 --- a/scripts/test-pack-validation-rules.ps1 +++ b/scripts/test-pack-validation-rules.ps1 @@ -180,3 +180,7 @@ if ($failures.Count -gt 0) { } Write-Host "Every ForbiddenContent rule ($($cases.Count)) rejected its file, in every package ($($packages.Count))." -ForegroundColor Green + +# The 27 runs validate by expecting purview-build to fail, so $LASTEXITCODE is 1 here; GitHub's pwsh step appends +# `exit $LASTEXITCODE`, which would fail the step despite every rule passing. +exit 0