From 2b01903fbe0a764d63dc8d2b360aa3fbbcc3ff61 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 8 Oct 2026 17:19:45 +0100 Subject: [PATCH 1/3] feat: expanding support for scalar --- AGENTS.md | 16 + CHANGELOG.md | 37 +- Directory.Packages.props | 2 +- README.md | 5 +- docs/Diagnostics.md | 1 + docs/Getting-Started.md | 52 ++ docs/ZodSharp-Validation.md | 12 + package.json | 2 +- src/Directory.Build.props | 5 + src/src/Sample/Models.cs | 15 + .../AnalyzerReleases.Shipped.md | 8 + .../AnalyzerReleases.Unshipped.md | 4 +- .../ValueObjectDiagnosticAnalyzer.cs | 20 +- .../Common/DiagnosticLibrary.cs | 10 + .../Common/TypeLibrary.Constants.cs | 3 + .../Generators/ValueObjectSourceGenerator.cs | 55 +- .../ComplexValueObjectModelBuilder.cs | 7 +- .../Models/EFValueObjectDescriptors.cs | 6 +- .../Models/ScalarValueObjectModel.cs | 3 + .../ReferencedEFValueObjectDiscovery.cs | 18 +- .../ValueObject/ScalarAttributeParser.cs | 77 +++ .../ScalarValueObjectEmitter.Comparison.cs | 14 +- .../ScalarValueObjectEmitter.EF.cs | 3 +- .../ScalarValueObjectEmitter.Equality.cs | 15 +- .../ValueObject/ScalarValueObjectEmitter.cs | 47 ++ .../ScalarValueObjectModelBuilder.cs | 322 ++++++++---- .../ValueObject/ValueObjectDefaultsHelper.cs | 22 +- .../ValueObjectEFConverterEmitter.cs | 12 +- .../ValueObjectEFRegistryEmitter.cs | 3 +- .../ValueObject/ValueObjectSymbolInspector.cs | 15 +- src/src/ValueObjects/Sdk/README.md | 4 + .../Serialization/ScalarAttribute.cs | 131 ++--- .../Serialization/ScalarAttributeOfT.cs | 45 ++ .../ScalarJsonConverterFactory.cs | 26 +- .../Serialization/ScalarOptionsAttribute.cs | 139 +++++ .../ValueObjectDiagnosticAnalyzerTests.cs | 67 +++ .../ScalarAutomaticPropertyTests.cs | 492 ++++++++++++++++++ .../ScalarCustomPropertyNameTests.cs | 2 +- .../CompatibilityModels.cs | 10 + .../EntityFrameworkCompatibilityTests.cs | 28 + .../ZodSchemaAutomaticScalarModels.cs | 105 ++++ .../ZodSchemaIntegrationTests.cs | 154 ++++++ .../Serialization/Models.cs | 48 ++ .../ScalarJsonConverterFactoryTests.cs | 32 ++ 44 files changed, 1819 insertions(+), 275 deletions(-) create mode 100644 src/src/SourceGenerator/ValueObject/ScalarAttributeParser.cs create mode 100644 src/src/ValueObjects/Serialization/ScalarAttributeOfT.cs create mode 100644 src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs create mode 100644 src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaAutomaticScalarModels.cs diff --git a/AGENTS.md b/AGENTS.md index 2f692a6..4c75d39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,20 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar keep it free of event-sourcing dependencies. - Keep the runtime package dependency-free (System.Text.Json is in-box for the supported TFMs). +## Trimming and Native AOT (non-negotiable) + +- The generated, default path for every value object must stay trimming- and Native AOT-safe: no reflection, no + `Type.MakeGenericType`, no expression-tree compilation, no reflection-based `JsonSerializer` overloads, and no + unannotated `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` call sites. +- The runtime package sets `IsAotCompatible`, so the trim and AOT analyzers run as part of every build. A new + trim/AOT warning is a defect, not noise. +- `ScalarJsonConverterFactory` is the only permitted reflection-based component. It must keep its + `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` annotations at the opt-in construction point and its + 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. + ## Testing conventions - This repository uses TUnit on Microsoft.Testing.Platform, selected in `global.json`. Do not add xUnit, NUnit, @@ -146,6 +160,8 @@ Before handing work back: - Confirm the requested behavior and scope are satisfied. - Confirm only intended files changed. - Review public API, serialization, and package-content implications. +- Confirm trimming/Native AOT safety: the generated path adds no reflection and no new + `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` call sites, and the `IsAotCompatible` build is clean. - Add or update focused tests for code changes. - Update all affected docs, samples, analyzer metadata, and release notes when applicable. - Run appropriate build, test, formatting, and pack checks in proportion to risk. diff --git a/CHANGELOG.md b/CHANGELOG.md index fa8d002..bae4630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ been published to NuGet. No stable release has been cut yet; the published line - [Diagnostics](docs/Diagnostics.md): a consolidated reference for all 17 `VO1xxx` rules. `VO1002`–`VO1008` previously appeared only in the analyzer release table, and the Entity Framework and ZodSharp rules were split across two guides. +- **Automatic scalar declaration.** `[Scalar]` and `[Scalar(typeof(TValue))]` let the generator + declare the underlying property (`public TValue Value { get; init; }`); `[Scalar]` remains the manual form + where the author declares it. The forms are mutually exclusive: declaring the generator-owned property is + reported as the new error `VO1022`. `Nullable = true` (reference types) declares a nullable reference + scalar; nullable value types are written directly (`[Scalar]` / `[Scalar(typeof(int?))]`). A nullable + reference type cannot be a generic attribute argument or a `typeof` operand (`CS8970`/`CS8639`). +- **Nullable scalars round-trip JSON `null`.** The generated converter and `ScalarJsonConverterFactory` + accept `null` for a nullable scalar (a `T?` reference or value type) and still reject it for a non-nullable + reference scalar. +- **ZodSharp integration for the automatic forms.** With `Purview.ZodSharp 2.0.2`, `[Scalar]` / + `[Scalar(typeof(T))]` combined with `[ZodSchema]` generate a `{Type}Schema` from the attribute (the + underlying type and property name) and validate through `Create`, even though the property is not visible + to the ZodSharp generator. ZodSharp 2.0.2 also ships `[NullOrNonWhiteSpace]` for the nullable + "null or non-whitespace" rule. ### Fixed @@ -37,6 +51,10 @@ been published to NuGet. No stable release has been cut yet; the published line setter-less. A get-only **auto**-property is still mapped — it has a compiler-generated backing field, which Entity Framework Core maps — so the check is for that field rather than merely for a missing setter. `[NotMapped]` and `.Ignore(` previously appeared nowhere in the test suite. +- **A nullable value type scalar emitted a `CS8607` warning in its generated `GetHashCode`.** The generated + override suppressed the BCL `[DisallowNull]` annotation on `EqualityComparer.GetHashCode` only for a + nullable *reference* scalar; a nullable *value* type scalar (`[Scalar]`) now suppresses it too — both + hash `null` as 0 at runtime — so the generated code is warning-free. - **A strict-mode deserialization failure returned a 500 instead of a 400.** With `ValueObjectDeserializationMode.Strict`, the validation exception from `Create` — `ArgumentException` by default, `ZodException` with ZodSharp validation — escaped `JsonSerializer` unwrapped. ASP.NET Core treats @@ -76,6 +94,11 @@ been published to NuGet. No stable release has been cut yet; the published line advertised interface contract is honoured. Covered by a **compiling** regression test; the previous coverage was an incremental-cache test that never compiled its output, which is why this was missed. - The scalar ZodSharp adapter. +- **Nullable reference scalars now compile cleanly.** The Entity Framework converter emitted + `typeof(string?)` (`CS8639`), `is` patterns used a nullable reference type (`CS8116`), the primitive + `CompareTo`/`Equals` overload was ambiguous for a nullable value type, and `GetHashCode`/`ToString` + produced nullable warnings. The generated code now strips the annotation where it is not representable and + handles null in those members. ### Changed — trimming and Native AOT @@ -91,6 +114,9 @@ been published to NuGet. No stable release has been cut yet; the published line `[JsonConverter(typeof(JsonConverter))]` pointing at a generated, reflection-free converter, and that path is trim- and AOT-safe. Register the factory only for a hand-written scalar, in a host that is neither trimmed nor AOT-compiled. +- `AGENTS.md` now records trimming and Native AOT as a **non-negotiable**: the generated path stays + reflection-free and an `IsAotCompatible` warning is a defect. The nullable-scalar support adds no reflection + to the generated converter; the opt-in `ScalarJsonConverterFactory` remains the only reflection component. ### Changed @@ -121,12 +147,15 @@ been published to NuGet. No stable release has been cut yet; the published line constructor is not an error condition. When a `[Scalar]` type declares no constructor matching its scalar value, `ScalarValueObjectEmitter.EmitConstructor` **emits a private one**, which is the documented and tested behaviour. The rule as written contradicted the generator. The id now sits with `VO1011`, `VO1012`, - `VO1014` and `VO1020` as retired and never reused, leaving 16 live rules. + `VO1014` and `VO1020` as retired and never reused, leaving 17 live rules. +- **`VO1022` ships in a new `## Release 1.0.1` block** in `AnalyzerReleases.Shipped.md`, for the automatic + scalar forms that declare the underlying property themselves. -### Still outstanding for a stable 1.0 +### Versioning and package lineage -- `Purview.ZodSharp 2.0.1-prerelease.1` is resolvable only from the local feed, not nuget.org. A stable - `Purview.ValueObjects` cannot be published until a stable `Purview.ZodSharp` is on nuget.org. +- `Purview.ZodSharp` continues the [`guinhx/ZodSharp`](https://github.com/guinhx/ZodSharp) project (its `v1` + line). The package was moved to `Purview.*` and restarted at `v2` so existing `v1` consumers can migrate; + `Purview.ValueObjects` integrates with `Purview.ZodSharp 2.0.2`. ## 1.0.0-prerelease.11 diff --git a/Directory.Packages.props b/Directory.Packages.props index 4e1caca..5f4c39e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ 5.9.0 1.68.17 1.0.0 - 2.0.1 + 2.0.2 diff --git a/README.md b/README.md index c0b272e..d9ffad0 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,10 @@ var email = EmailAddress.Create("Demo@Example.com"); // email.Value == "demo@example.com" ``` -`[Scalar]` wraps a single primitive; `[ValueObject]` wraps multiple members. +`[Scalar]` wraps a single primitive; `[ValueObject]` wraps multiple members. The manual `[Scalar]` form +declares the underlying property; the automatic `[Scalar]` / `[Scalar(typeof(T))]` forms have the +generator declare it (and `[Scalar(Nullable = true)]` expresses a nullable reference scalar). See +[Getting Started](docs/Getting-Started.md#automatic-vs-manual-underlying-property). ## JSON serialization diff --git a/docs/Diagnostics.md b/docs/Diagnostics.md index 7b36722..867c2b9 100644 --- a/docs/Diagnostics.md +++ b/docs/Diagnostics.md @@ -21,6 +21,7 @@ These govern the declaration shape a value object must have for the generator to | `VO1007` | Warning | `ValueObjectDeserializationMode.Strict` is set but no `Create` overload exists to re-validate through. | Add the `Create` overload, or use the default `Hydrate` mode. | | `VO1008` | Error | `[Scalar]` and `[ValueObject]` are both applied to the same type. | Pick one. A scalar wraps a single primitive; a value object composes members. | | `VO1016` | Warning | A value object member is mutable. | Make the member `readonly`/`init`-only. A mutable member breaks immutability and makes equality unstable. | +| `VO1022` | Error | A `[Scalar]` type declares a member with the name of the property the generator owns. | Remove the declaration, or use `[Scalar]` (without a type argument) to own the property yourself. | ## ZodSharp validation rules diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index df7afd5..3a052f5 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -55,6 +55,58 @@ string json = System.Text.Json.JsonSerializer.Serialize(email); // json == "\"demo@example.com\"" ``` +### Automatic vs manual underlying property + +`[Scalar]` is the **manual** form: you declare the property. `[Scalar]` and `[Scalar(typeof(T))]` are +the **automatic** form: the generator declares it. They are mutually exclusive for a given type — +declaring the property with an automatic form is an error (`VO1022`). + +```csharp +// Automatic: the generator emits `public Guid Value { get; init; }`. +[Scalar] // or [Scalar(typeof(Guid))] +public readonly partial record struct InstallationId { } + +// Manual: you declare the property. +[Scalar] +public readonly partial record struct InstallationId +{ + public Guid Value { get; init; } +} +``` + +Use `Nullable` when the automatic property should be a nullable reference type. Nullable reference types +cannot be generic attribute arguments (`[Scalar]` does not compile — `CS8970`) or `typeof` +operands (`typeof(string?)` does not compile — `CS8639`), so express nullability on the attribute: + +```csharp +[Scalar(Nullable = true)] // emits `public string? Value { get; init; }` +[Scalar(typeof(string), Nullable = true)] // same +public readonly partial record struct Handle { } +``` + +Nullable value types are written directly, for example `[Scalar]` or `[Scalar(typeof(int?))]`. + +A nullable scalar round-trips JSON `null`: `[Scalar(Nullable = true)]` and `[Scalar]` +deserialize `null` and serialize back to `null`. A non-nullable reference scalar still rejects `null` with +a `JsonException`. Validation is yours to write, so make it null-tolerant — for a value that must be +`null` or non-whitespace: + +```csharp +[Scalar(Nullable = true)] +public readonly partial record struct Nickname +{ + static partial void OnValidate(string? value) + { + if (value is not null && string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Value must be null or non-whitespace.", nameof(value)); + } +} +``` + +The manual form is the more explicit option when you prefer the type and nullability to appear in a +property declaration. Both forms are supported, and neither is reported as a diagnostic. Other properties +are allowed in either form; only the scalar property (and the interface's `Value` forwarding) is reserved. + ## 3. Complex value objects A complex value object wraps multiple members and validates them together. diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md index 175a7a8..a62c3db 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -114,6 +114,18 @@ public readonly partial record struct PhoneNumber } ``` +> **Automatic scalar form.** The examples above use the manual `[Scalar]` form, where the property and its +> DataAnnotations are declared by you. The automatic `[Scalar]` / `[Scalar(typeof(T))]` forms have the +> value-object generator declare the underlying property, so a property-attached DataAnnotation has no host. +> For those types, use Zod-native **type-level** rules (`[RequiredZod]`, `[ZodRule(typeof(...))]`), which +> ZodSharp reads from the type and adapts to the scalar's underlying value, or keep the manual form when +> property-level DataAnnotations are required. Purview.ZodSharp 2.0.2+ generates the `{Type}Schema` from the +> attribute itself (the underlying type and property name), so the automatic form validates through `Create` +> exactly like the manual form. A nullable scalar (`Nullable = true` or a `T?` value type) round-trips JSON +> `null`, so its schema — and any string rule — must be null-tolerant: use the shipped +> `[NullOrNonWhiteSpace]` (which accepts `null` but rejects whitespace) rather than `[RequiredZod]`, which +> always rejects `null`. + The `[ZodSchema]` attribute also exposes generator options that tune the emitted schema: - `SchemaName` — overrides the generated schema class name (default `{TypeName}Schema`); the ZodSharp DI diff --git a/package.json b/package.json index f2b6a9e..333d7a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-value-objects", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 932f60f..5275528 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -6,6 +6,11 @@ + + + $(PurviewTargetFrameworksAll) + + diff --git a/src/src/Sample/Models.cs b/src/src/Sample/Models.cs index 27872ee..ebbc32f 100644 --- a/src/src/Sample/Models.cs +++ b/src/src/Sample/Models.cs @@ -48,6 +48,21 @@ static partial void OnValidate(Guid value) } } +// The automatic form: the generator declares `public Guid Value { get; init; }`. +[Scalar] +readonly partial record struct CustomerId +{ + static partial void OnValidate(Guid value) + { + if (value == Guid.Empty) + throw new ArgumentException("Customer id cannot be empty.", nameof(value)); + } +} + +// A nullable reference scalar: the generator declares `public string? Value { get; init; }`. +[Scalar(Nullable = true)] +readonly partial record struct Nickname { } + [ValueObject] readonly partial record struct Money { diff --git a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md index 5168060..e83be88 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md @@ -20,3 +20,11 @@ VO1017 | ValueObjects | Warning | Entity Framework JSON mapping requires the JSO VO1018 | ValueObjects | Warning | Entity Framework complex mapping cannot convert a member VO1019 | ValueObjects | Warning | Entity Framework complex type mapping requires Entity Framework Core 8 or later VO1021 | ValueObjects | Warning | Entity Framework key value generation is unavailable for the value object + +## Release 1.0.1 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +VO1022 | ValueObjects | Error | The automatic [Scalar]/[Scalar(typeof(T))] form declares the scalar property, so the author must not declare it diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index c753945..7a8f99a 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -1,5 +1,5 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md ; -; Every rule is recorded in AnalyzerReleases.Shipped.md under Release 1.0.0. Add a new or changed -; diagnostic here, and move it across when the next version ships. +; Every rule is recorded in AnalyzerReleases.Shipped.md. Add a new or changed diagnostic here, and move +; it into a new `## Release ` block when that version ships. diff --git a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs index 1b81416..3073727 100644 --- a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs @@ -13,6 +13,7 @@ public sealed class ValueObjectDiagnosticAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.GenericValueObjectsAreNotSupported, DiagnosticLibrary.ConflictingValueObjectAttributes, DiagnosticLibrary.ScalarPropertyMissing, + DiagnosticLibrary.ScalarPropertyIsGenerated, DiagnosticLibrary.ScalarShouldBeRecordStruct, DiagnosticLibrary.StrictDeserializationRequiresCreate, DiagnosticLibrary.EFMappingRequiresEntityFramework, @@ -39,14 +40,17 @@ public override void Initialize(AnalysisContext context) var scalarAttribute = compilationContext.Compilation.GetTypeByMetadataName( TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttributeFullName ); + var genericScalarAttribute = compilationContext.Compilation.GetTypeByMetadataName( + TypeLibrary.ScalarAttributeGenericMetadataName + ); var valueObjectAttribute = compilationContext.Compilation.GetTypeByMetadataName( TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttributeFullName ); - if (scalarAttribute is null && valueObjectAttribute is null) + if (scalarAttribute is null && genericScalarAttribute is null && valueObjectAttribute is null) return; compilationContext.RegisterSymbolAction( - context => ValidateValueObject(context, scalarAttribute, valueObjectAttribute), + context => ValidateValueObject(context, scalarAttribute, genericScalarAttribute, valueObjectAttribute), SymbolKind.NamedType ); }); @@ -55,12 +59,15 @@ public override void Initialize(AnalysisContext context) static void ValidateValueObject( SymbolAnalysisContext context, INamedTypeSymbol? scalarAttribute, + INamedTypeSymbol? genericScalarAttribute, INamedTypeSymbol? valueObjectAttribute ) { var typeSymbol = (INamedTypeSymbol)context.Symbol; - var hasScalarAttribute = scalarAttribute is not null && HasAttribute(typeSymbol, scalarAttribute); + var hasScalarAttribute = + (scalarAttribute is not null && HasAttribute(typeSymbol, scalarAttribute)) + || (genericScalarAttribute is not null && HasAttribute(typeSymbol, genericScalarAttribute)); var hasValueObjectAttribute = valueObjectAttribute is not null && HasAttribute(typeSymbol, valueObjectAttribute); if (!hasScalarAttribute && !hasValueObjectAttribute) @@ -101,5 +108,10 @@ is not TypeDeclarationSyntax syntax static bool HasAttribute(INamedTypeSymbol typeSymbol, INamedTypeSymbol attributeType) => typeSymbol .GetAttributes() - .Any(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType)); + .Any(attribute => + SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType) + // A generic attribute is applied as a constructed type (ScalarAttribute), while the + // definition resolved from metadata is the open generic (ScalarAttribute). + || SymbolEqualityComparer.Default.Equals(attribute.AttributeClass?.OriginalDefinition, attributeType) + ); } diff --git a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs index b0d21b3..4dc4c60 100644 --- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs @@ -182,4 +182,14 @@ public static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true ); + + /// VO1022: A scalar property owned by the generator must not be declared + public static readonly DiagnosticDescriptor ScalarPropertyIsGenerated = new( + id: "VO1022", + title: "Scalar property is generated by [Scalar]", + messageFormat: "Value object '{0}' uses the automatic [Scalar] form, which declares property '{1}'; remove the declaration, or use [Scalar] (without a type argument) to own the property yourself", + category: ValueObjectCategory, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); } diff --git a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs index a0a482f..130289b 100644 --- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs +++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs @@ -19,6 +19,9 @@ public static partial class TypeLibrary public const string ScalarAttributeFullTypeName = SerializationNamespace + ".ScalarAttribute"; + /// The metadata name of the automatic (generic) scalar attribute, [Scalar<T>]. + public const string ScalarAttributeGenericMetadataName = ScalarAttributeFullTypeName + "`1"; + public const string ValueObjectDeserializationModeFullTypeName = SerializationNamespace + ".ValueObjectDeserializationMode"; diff --git a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs index 63daf5a..2f5faed 100644 --- a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs @@ -5,6 +5,16 @@ namespace Purview.ValueObjects.SourceGenerator.Generators; [Generator] public sealed partial class ValueObjectSourceGenerator : IIncrementalGenerator { + /// + /// The automatic [Scalar<T>] attribute as an open generic definition, so the incremental + /// pipeline can match it by metadata name (ScalarAttribute`1). + /// + static readonly TypeIdentity GenericScalarAttributeIdentity = new( + "ScalarAttribute", + TypeLibrary.SerializationNamespace, + 1 + ); + public void Initialize(IncrementalGeneratorInitializationContext context) { context.RegisterEmbeddedAttribute(); @@ -37,6 +47,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context) trackingName: "GetScalarValueObjectTargets" ); + var genericScalarCandidates = IncrementalPipeline.ForAttributeWithMetadataName( + context, + GenericScalarAttributeIdentity, + static (ctx, ct) => + ScalarValueObjectModelBuilder.Build( + (INamedTypeSymbol)ctx.TargetSymbol, + (TypeDeclarationSyntax)ctx.TargetNode, + ctx.SemanticModel.Compilation, + ct + ), + static (node, _) => node is TypeDeclarationSyntax, + trackingName: "GetGenericScalarValueObjectTargets" + ); + var complexCandidates = IncrementalPipeline.ForAttributeWithMetadataName( context, TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute, @@ -55,6 +79,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) scalarCandidates.Combine(generationContext.Combine(efDisabled)), static (spc, tuple) => EmitScalarResult(spc, tuple.Left, tuple.Right.Left, tuple.Right.Right) ); + context.RegisterSourceOutput( + genericScalarCandidates.Combine(generationContext.Combine(efDisabled)), + static (spc, tuple) => EmitScalarResult(spc, tuple.Left, tuple.Right.Left, tuple.Right.Right) + ); context.RegisterSourceOutput( complexCandidates.Combine(generationContext.Combine(efDisabled)), static (spc, tuple) => EmitComplexResult(spc, tuple.Left, tuple.Right.Left, tuple.Right.Right) @@ -65,6 +93,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) RegisterEFRegistryOutput( context, scalarCandidates, + genericScalarCandidates, complexCandidates, efDisabled, registryDisabled, @@ -137,6 +166,7 @@ IncrementalValueProvider generationContext static void RegisterEFRegistryOutput( IncrementalGeneratorInitializationContext context, IncrementalValuesProvider> scalarCandidates, + IncrementalValuesProvider> genericScalarCandidates, IncrementalValuesProvider> complexCandidates, IncrementalValueProvider efDisabled, IncrementalValueProvider registryDisabled, @@ -146,6 +176,9 @@ IncrementalValueProvider generationContext var scalarDescriptors = scalarCandidates .Select(static (result, _) => result.ShouldProcess ? BuildScalarEFDescriptor(result.Value) : null) .Collect(); + var genericScalarDescriptors = genericScalarCandidates + .Select(static (result, _) => result.ShouldProcess ? BuildScalarEFDescriptor(result.Value) : null) + .Collect(); var complexDescriptors = complexCandidates .Select(static (result, _) => result.ShouldProcess ? BuildComplexEFDescriptor(result.Value) : null) .Collect(); @@ -158,7 +191,10 @@ IncrementalValueProvider generationContext ReferencedEFValueObjectDiscovery.Scan(compilation, cancellationToken) ); - var combinedDescriptors = scalarDescriptors.Combine(complexDescriptors).Combine(referencedDescriptors); + var combinedDescriptors = scalarDescriptors + .Combine(genericScalarDescriptors) + .Combine(complexDescriptors) + .Combine(referencedDescriptors); // Complex-type mapping and the compiled-model converter members need Entity Framework Core 8, so // the emitted registry omits them below that and still compiles. @@ -167,10 +203,11 @@ IncrementalValueProvider generationContext ); var anyEFValueObject = combinedDescriptors.Select( - static (pair, _) => - pair.Left.Left.Any(static descriptor => descriptor is not null) - || pair.Left.Right.Any(static descriptor => descriptor is not null) - || !pair.Right.IsEmpty + static (combined, _) => + combined.Left.Left.Left.Any(static descriptor => descriptor is not null) + || combined.Left.Left.Right.Any(static descriptor => descriptor is not null) + || combined.Left.Right.Any(static descriptor => descriptor is not null) + || !combined.Right.IsEmpty ); var registryInput = anyEFValueObject @@ -196,7 +233,13 @@ IncrementalValueProvider generationContext var scalars = ImmutableArray.CreateBuilder(); var complex = ImmutableArray.CreateBuilder(); - foreach (var descriptor in combined.Left.Left) + foreach (var descriptor in combined.Left.Left.Left) + { + if (descriptor is not null) + scalars.Add(descriptor.Value); + } + + foreach (var descriptor in combined.Left.Left.Right) { if (descriptor is not null) scalars.Add(descriptor.Value); diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index 373eb0e..ce80cf7 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs @@ -28,12 +28,7 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "ValueObject", ]; var attributes = typeSymbol.GetAttributes(); - if ( - ValueObjectSymbolInspector.HasAttribute( - attributes, - TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute - ) - ) + if (ScalarAttributeParser.Find(attributes) is not null) { diagnosticsList.Add( ReportableDiagnostic.Create( diff --git a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs index d3b3fd8..bb231ce 100644 --- a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs +++ b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs @@ -21,7 +21,11 @@ readonly record struct EFScalarDescriptor( string? EFProviderTypeName, string? EFHydrateCastTypeName, bool HasEFMembers, - bool GenerateEFValueGenerator + bool GenerateEFValueGenerator, + // True when the scalar's underlying property is a nullable reference type. The provider type is the + // non-nullable CLR type (a nullable annotation is not representable in typeof), so the inline + // conversion reads the property with the null-forgiving operator. + bool ProviderTypeIsNullableReference = false ); /// diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs index 409d4f9..5fad761 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs @@ -8,7 +8,10 @@ sealed record class ScalarValueObjectModel( string TypeName, string ScalarTypeName, string ScalarPropertyName, + bool GenerateScalarProperty, bool ScalarCanBeNull, + bool ScalarTypeIsNullableReference, + bool ScalarValueIsNullable, bool ScalarIsReferenceType, bool IsReferenceType, bool IsRecord, diff --git a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs index 3d16e2e..28718bc 100644 --- a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs +++ b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs @@ -144,18 +144,13 @@ CancellationToken cancellationToken // neither the marker interfaces nor an EF nested class. The consumer still discovers them from // their [Scalar]/[ValueObject] attributes and emits the conversions inline. var attributes = type.GetAttributes(); - if ( - ValueObjectSymbolInspector.HasAttribute( - attributes, - TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute - ) - ) + if (ScalarAttributeParser.Find(attributes) is { } scalarAttribute) { var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData( type.ContainingAssembly.GetAttributes() ); var options = ValueObjectDefaultsHelper.Apply( - ScalarAttributeData.FromAttributeData(attributes), + ScalarAttributeParser.Parse(scalarAttribute), assemblyDefaults, attributes ); @@ -186,7 +181,9 @@ CancellationToken cancellationToken HasEFMembers: false, GenerateEFValueGenerator: options.GenerateEFValueGenerator && options.GenerateEFConverter - && ValueObjectSymbolInspector.IsGuidProviderType(scalarProperty.Type) + && ValueObjectSymbolInspector.IsGuidProviderType(scalarProperty.Type), + ProviderTypeIsNullableReference: scalarProperty.Type.IsReferenceType + && scalarProperty.Type.NullableAnnotation == NullableAnnotation.Annotated ) ); return; @@ -241,8 +238,11 @@ static bool IsValueGenerationEnabled(INamedTypeSymbol type, ITypeSymbol provider return false; var attributes = type.GetAttributes(); + if (ScalarAttributeParser.Find(attributes) is not { } scalarAttribute) + return false; + var options = ValueObjectDefaultsHelper.Apply( - ScalarAttributeData.FromAttributeData(attributes), + ScalarAttributeParser.Parse(scalarAttribute), ValueObjectDefaultsAttributeData.FromAttributeData(type.ContainingAssembly.GetAttributes()), attributes ); diff --git a/src/src/SourceGenerator/ValueObject/ScalarAttributeParser.cs b/src/src/SourceGenerator/ValueObject/ScalarAttributeParser.cs new file mode 100644 index 0000000..03de7bb --- /dev/null +++ b/src/src/SourceGenerator/ValueObject/ScalarAttributeParser.cs @@ -0,0 +1,77 @@ +namespace Purview.ValueObjects.SourceGenerator.ValueObject; + +/// +/// Locates and parses the [Scalar] and [Scalar<T>] attributes. The two attribute +/// types share their options through a common base (ScalarOptionsAttribute) but are distinct +/// attribute classes, so the generated attribute-data model — which matches a single attribute type — +/// cannot be used for both. Detection here is by name and namespace. +/// +static class ScalarAttributeParser +{ + /// + /// True when is the scalar attribute, generic or not. + /// + public static bool IsScalarAttribute(INamedTypeSymbol? attributeClass) => + attributeClass is { Arity: <= 1, Name: "ScalarAttribute" } + && attributeClass.ContainingNamespace.ToDisplayString() == TypeLibrary.SerializationNamespace; + + /// The scalar attribute applied to the type, or when absent. + public static AttributeData? Find(ImmutableArray attributes) => + attributes.FirstOrDefault(static attribute => IsScalarAttribute(attribute.AttributeClass)); + + /// True when the attribute is the generic [Scalar<T>] form. + public static bool IsGeneric(AttributeData attribute) => + attribute.AttributeClass is { IsGenericType: true, TypeArguments.Length: 1 }; + + /// + /// The underlying value type that selects the automatic form, from either the generic argument + /// ([Scalar<T>]) or the typeof(...) constructor argument + /// ([Scalar(typeof(T))]). selects the manual form. + /// + public static ITypeSymbol? GetValueType(AttributeData attribute) + { + if (IsGeneric(attribute)) + return attribute.AttributeClass!.TypeArguments[0]; + + foreach (var argument in attribute.ConstructorArguments) + { + if (argument.Kind == TypedConstantKind.Type && argument.Value is ITypeSymbol type) + return type; + } + + return null; + } + + /// True when the attribute declares the underlying type and the generator owns the property. + public static bool IsAutomatic(AttributeData attribute) => GetValueType(attribute) is not null; + + /// Whether the automatic form declares the property as a nullable reference type. + public static bool GetNullable(AttributeData attribute) => attribute.GetNamedArgument("Nullable", false); + + /// + /// Reads the shared option set from either attribute form into the same model. + /// + public static ScalarAttributeData Parse(AttributeData attribute) => + new( + true, + attribute.GetConstructorArgument("propertyName", "Value")!, + attribute.GetNamedArgument("GenerateJsonConverter", true), + attribute.GetNamedArgument("GenerateComparable", true), + attribute.GetNamedArgument("GenerateComparisonOperators", true), + attribute.GetNamedArgument("GenerateEnumProperties", true), + attribute.GetNamedArgument("GenerateImplicitFromPrimitive", true), + attribute.GetNamedArgument("GenerateImplicitToPrimitive", true), + attribute.GetNamedArgument("GenerateEmpty", true), + attribute.GetNamedArgument("GenerateEFConverter", true), + attribute.GetNamedArgument("GenerateEFComparer", true), + attribute.GetNamedArgument("GenerateEFValueGenerator", false), + attribute.GetEnumNamedArgument( + "DeserializationMode", + TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate" + )!, + attribute.GetEnumNamedArgument( + "ZodSchemaMode", + TypeLibrary.ZodSchemaModeFullTypeName + ".InAdditionToHooks" + )! + ); +} diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs index 60495a5..ec322b9 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs @@ -63,15 +63,7 @@ static void EmitComparison(CodeWriter writer, ScalarValueObjectModel model) writer.MethodExpression( new("CompareTo", PurviewTypeLibrary.System.Int32, TypeDeclarationAccessibility.Public) { - Parameters = - [ - new( - "other", - model.ScalarIsReferenceType - ? model.ScalarTypeReference.Nullable(writer) - : model.ScalarTypeReference - ), - ], + Parameters = [new("other", ScalarComparableParameter(model, writer))], ExpressionBody = $"{ScalarComparerExpression(model)}.Compare({model.ScalarPropertyName}, other)", } ); @@ -92,8 +84,8 @@ static void EmitComparison(CodeWriter writer, ScalarValueObjectModel model) ifBody => ifBody.Return("CompareTo(otherValueObject)") ); body.IfBlock( - $"obj is {model.ScalarTypeName} primitive", - ifBody => ifBody.Return("CompareTo(primitive)") + $"obj is {ScalarPatternTypeName(model)} primitive", + ifBody => ifBody.Return($"CompareTo({ScalarPatternArgument(model)})") ); body.Throw( $"new global::System.ArgumentException($\"Object must be of type {{nameof({model.TypeModel.Name})}} or {model.ScalarTypeName}.\", nameof(obj))" diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs index 3c6e972..c6ba7f1 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs @@ -23,7 +23,8 @@ static void EmitEF(CodeWriter writer, ScalarValueObjectModel model, bool emitEF) model.EFProviderTypeName, ValueObjectEFConverterEmitter.ToProviderExpression( model.ScalarPropertyName, - model.EFHydrateCastTypeName is null ? null : model.EFProviderTypeName + model.EFHydrateCastTypeName is null ? null : model.EFProviderTypeName, + model.ScalarTypeIsNullableReference ), ValueObjectEFConverterEmitter.FromProviderExpression(model.TypeName, model.EFHydrateCastTypeName), ValueObjectEmitterHelpers.EFJsonReaderWriterType(model.EFProviderTypeName), diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs index 1a66133..dc18637 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs @@ -37,7 +37,7 @@ static void EmitEquality(CodeWriter writer, ScalarValueObjectModel model) IsOverride = true, Parameters = [new("obj", PurviewTypeLibrary.System.Object.MakeNullable(writer))], ExpressionBody = - $"obj is {model.TypeName} other ? Equals(other) : obj is {model.ScalarTypeName} primitive && Equals(primitive)", + $"obj is {model.TypeName} other ? Equals(other) : obj is {ScalarPatternTypeName(model)} primitive && Equals({ScalarPatternArgument(model)})", } ); } @@ -48,8 +48,11 @@ static void EmitEquality(CodeWriter writer, ScalarValueObjectModel model) new("GetHashCode", PurviewTypeLibrary.System.Int32, TypeDeclarationAccessibility.Public) { IsOverride = true, + // EqualityComparer.GetHashCode is annotated [DisallowNull]; a nullable scalar + // (string? or int?) still hashes null as 0 at runtime, so the annotation is suppressed + // rather than branched. ExpressionBody = - $"global::System.Collections.Generic.EqualityComparer<{model.ScalarTypeName}>.Default.GetHashCode({model.ScalarPropertyName})", + $"global::System.Collections.Generic.EqualityComparer<{model.ScalarTypeName}>.Default.GetHashCode({model.ScalarPropertyName}{(model.ScalarValueIsNullable ? "!" : string.Empty)})", } ); } @@ -186,7 +189,9 @@ static void EmitToString(CodeWriter writer, ScalarValueObjectModel model) new("ToString", PurviewTypeLibrary.System.String, TypeDeclarationAccessibility.Public) { IsOverride = true, - ExpressionBody = $"{model.ScalarPropertyName}.ToString() ?? string.Empty", + ExpressionBody = model.ScalarTypeIsNullableReference + ? $"{model.ScalarPropertyName}?.ToString() ?? string.Empty" + : $"{model.ScalarPropertyName}.ToString() ?? string.Empty", } ); } @@ -234,7 +239,9 @@ [new MethodCallArgumentOptions("reader", ParameterModifier.Ref), "options"], ) ); - if (model.ScalarCanBeNull) + // A nullable scalar (`string?`, `int?`) round-trips JSON null; only a non-nullable + // reference scalar rejects it. + if (model.ScalarCanBeNull && !model.ScalarValueIsNullable) { methodBody.IfBlock( "value is null", diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs index 372563e..835edca 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs @@ -24,6 +24,7 @@ public static void Emit(CodeWriter writer, ScalarValueObjectModel model, bool em static void EmitBody(CodeWriter writer, ScalarValueObjectModel model, bool emitEF) { + EmitScalarProperty(writer, model); EmitHookDeclarations(writer, model); EmitScalarInterfaceValue(writer, model); EmitFactories(writer, model); @@ -173,6 +174,52 @@ model.TypeModel.Namespace is null ? model.ZodSchemaClassName! : $"global::{model.TypeModel.Namespace}.{model.ZodSchemaClassName}"; + /// + /// Declares the underlying property for the automatic [Scalar<T>] form. The manual form + /// declares the property itself, so this emits nothing for it. + /// + /// + /// The scalar type name safe to use in an is pattern. Neither a nullable-reference annotation nor + /// a nullable value type is legal in a pattern (CS8116), so the trailing ? is trimmed; a boxed + /// value matches the underlying type either way. + /// + internal static string ScalarPatternTypeName(ScalarValueObjectModel model) => model.ScalarTypeName.TrimEnd('?'); + + /// + /// The argument to pass to the primitive comparison/equality overload from an is pattern. A + /// nullable value type's pattern variable is the underlying non-nullable type, which is convertible to + /// both the primitive overload and the value object (via the implicit-from-primitive operator), so it is + /// cast to the scalar type to select the primitive overload unambiguously. + /// + internal static string ScalarPatternArgument(ScalarValueObjectModel model) => + model.ScalarValueIsNullable && !model.ScalarIsReferenceType + ? $"({model.ScalarTypeName})primitive" + : "primitive"; + + /// + /// The parameter type for the primitive comparison overload. The BCL comparison interfaces annotate the + /// parameter as nullable, so a reference scalar's parameter must be nullable too; a scalar whose + /// underlying type is already a nullable reference keeps its single annotation. + /// + internal static TypeReference ScalarComparableParameter(ScalarValueObjectModel model, CodeWriter writer) => + model.ScalarIsReferenceType && !model.ScalarTypeIsNullableReference + ? model.ScalarTypeReference.Nullable(writer) + : model.ScalarTypeReference; + + static void EmitScalarProperty(CodeWriter writer, ScalarValueObjectModel model) + { + if (!model.GenerateScalarProperty) + return; + + writer.Property( + new(model.ScalarPropertyName, model.ScalarTypeReference, TypeDeclarationAccessibility.Public) + { + HasSetter = true, + IsInitOnly = true, + } + ); + } + /// /// Satisfies IScalarValueObject<TSelf, TValue>.Value when the scalar member is named /// something else. diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index c498c37..5373ad4 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -49,35 +49,37 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData( typeSymbol.ContainingAssembly.GetAttributes() ); + + var scalarAttribute = ScalarAttributeParser.Find(attributes); + if (scalarAttribute is null) + return GeneratorResult.Create([.. diagnosticsList]); + var scalarOptions = ValueObjectDefaultsHelper.Apply( - ScalarAttributeData.FromAttributeData(attributes), + ScalarAttributeParser.Parse(scalarAttribute), assemblyDefaults, attributes ); - var scalarProperty = typeSymbol - .GetMembers(scalarOptions.PropertyName) - .OfType() - .FirstOrDefault(property => !property.IsStatic && property.GetMethod is not null); - if (scalarProperty is null) - { - diagnosticsList.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.ScalarPropertyMissing, - isBlocking: true, - location, - typeSymbol.Name, - scalarOptions.PropertyName - ) - ); + if ( + !TryResolveScalarTarget( + typeSymbol, + scalarOptions, + scalarAttribute, + location, + diagnosticsList, + out var scalarType, + out var scalarPropertyName, + out var generateScalarProperty, + out var scalarProperty + ) + ) return GeneratorResult.Create([.. diagnosticsList]); - } var ctorExists = typeSymbol .Constructors.Where(static ctor => !ctor.IsStatic) .Any(ctor => ctor.Parameters.Length == 1 - && SymbolEqualityComparer.Default.Equals(ctor.Parameters[0].Type, scalarProperty.Type) + && SymbolEqualityComparer.Default.Equals(ctor.Parameters[0].Type, scalarType) ); var typeModel = ValueObjectSymbolInspector.BuildTypeModel(typeSymbol); @@ -97,30 +99,31 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca } var typeName = typeModel.Value.FullyQualifiedName; - var scalarTypeName = ValueObjectSymbolInspector.ToTypeName(scalarProperty.Type); + var scalarTypeName = ValueObjectSymbolInspector.ToTypeName(scalarType); var scalarCanBeNull = - scalarProperty.Type.IsReferenceType - || scalarProperty.Type.NullableAnnotation == NullableAnnotation.Annotated; - var scalarIsReferenceType = scalarProperty.Type.IsReferenceType; + scalarType.IsReferenceType || scalarType.NullableAnnotation == NullableAnnotation.Annotated; + var scalarTypeIsNullableReference = + scalarType.IsReferenceType && scalarType.NullableAnnotation == NullableAnnotation.Annotated; + // Whether null is a valid domain value: an annotated reference type (`string?`) or a nullable value + // type (`int?`). Distinct from ScalarCanBeNull, which is also true for a non-nullable reference type. + var scalarValueIsNullable = + scalarType.NullableAnnotation == NullableAnnotation.Annotated + || scalarType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }; + var scalarIsReferenceType = scalarType.IsReferenceType; var isReferenceType = typeSymbol.TypeKind == TypeKind.Class; - var scalarPropertyName = scalarProperty.Name; - var createExists = ValueObjectSymbolInspector.HasStaticFactory(typeSymbol, "Create", [scalarProperty.Type]); - var hydrateExists = ValueObjectSymbolInspector.HasStaticFactory(typeSymbol, "Hydrate", [scalarProperty.Type]); - var tryCreateExists = ValueObjectSymbolInspector.HasTryCreate(typeSymbol, scalarProperty.Type); + var createExists = ValueObjectSymbolInspector.HasStaticFactory(typeSymbol, "Create", [scalarType]); + var hydrateExists = ValueObjectSymbolInspector.HasStaticFactory(typeSymbol, "Hydrate", [scalarType]); + var tryCreateExists = ValueObjectSymbolInspector.HasTryCreate(typeSymbol, scalarType); var compareToSelfExists = ValueObjectSymbolInspector.HasInstanceMethod(typeSymbol, "CompareTo", [typeSymbol]); var compareToPrimitiveExists = ValueObjectSymbolInspector.HasInstanceMethod( typeSymbol, "CompareTo", - [scalarProperty.Type] + [scalarType] ); var compareToObjectExists = ValueObjectSymbolInspector.HasCompareToObject(typeSymbol); var equalsSelfExists = typeSymbol.IsRecord || ValueObjectSymbolInspector.HasInstanceMethod(typeSymbol, "Equals", [typeSymbol]); - var equalsPrimitiveExists = ValueObjectSymbolInspector.HasInstanceMethod( - typeSymbol, - "Equals", - [scalarProperty.Type] - ); + var equalsPrimitiveExists = ValueObjectSymbolInspector.HasInstanceMethod(typeSymbol, "Equals", [scalarType]); var equalsObjectExists = ValueObjectSymbolInspector.HasEqualsObject(typeSymbol); var getHashCodeExists = ValueObjectSymbolInspector.HasParameterlessMethod(typeSymbol, "GetHashCode"); var sameTypeEqualityOperatorExists = @@ -132,26 +135,25 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca var primitiveEqualityOperatorExists = ValueObjectSymbolInspector.HasBinaryOperator( typeSymbol, "op_Equality", - [typeSymbol, scalarProperty.Type] + [typeSymbol, scalarType] ); var primitiveInequalityOperatorExists = ValueObjectSymbolInspector.HasBinaryOperator( typeSymbol, "op_Inequality", - [typeSymbol, scalarProperty.Type] + [typeSymbol, scalarType] ); var reversePrimitiveEqualityOperatorExists = ValueObjectSymbolInspector.HasBinaryOperator( typeSymbol, "op_Equality", - [scalarProperty.Type, typeSymbol] + [scalarType, typeSymbol] ); var reversePrimitiveInequalityOperatorExists = ValueObjectSymbolInspector.HasBinaryOperator( typeSymbol, "op_Inequality", - [scalarProperty.Type, typeSymbol] + [scalarType, typeSymbol] ); - var enumPropertiesEnabled = - scalarOptions.GenerateEnumProperties && scalarProperty.Type.TypeKind == TypeKind.Enum; - var enumFieldNames = enumPropertiesEnabled ? BuildEnumFieldNames(typeSymbol, scalarProperty.Type) : []; + var enumPropertiesEnabled = scalarOptions.GenerateEnumProperties && scalarType.TypeKind == TypeKind.Enum; + var enumFieldNames = enumPropertiesEnabled ? BuildEnumFieldNames(typeSymbol, scalarType) : []; var toStringExists = ValueObjectSymbolInspector.HasParameterlessMethod(typeSymbol, "ToString"); var hasJsonConverterAttribute = ValueObjectSymbolInspector.HasAttribute( typeSymbol, @@ -193,61 +195,28 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca diagnosticsList ); - var isEFReferenced = ValueObjectSymbolInspector.IsEFReferenced(compilation); - if ( - isEFReferenced - && scalarOptions.GenerateEFConverter - && !ValueObjectSymbolInspector.IsEFMappableProviderType(scalarProperty.Type) - ) - { - diagnosticsList.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.EFAutoConversionSkipped, - isBlocking: false, - typeSymbol.Locations.FirstOrDefault(), - typeSymbol.Name, - scalarTypeName - ) - ); - } - else if ( - !isEFReferenced - && ( - ValueObjectDefaultsHelper.IsPropertyExplicitlySet( - attributes, - TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute, - "GenerateEFConverter" - ) - || ValueObjectDefaultsHelper.IsPropertyExplicitlySet( - attributes, - TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute, - "GenerateEFComparer" - ) - ) - ) - { - diagnosticsList.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.EFMappingRequiresEntityFramework, - isBlocking: false, - location, - typeSymbol.Name - ) - ); - } - - var (efProviderType, efHydrateCastTypeName) = ValueObjectSymbolInspector.ResolveEFProviderType( - scalarProperty.Type + var isEFReferenced = CollectEFDiagnostics( + typeSymbol, + scalarType, + scalarTypeName, + scalarOptions, + attributes, + compilation, + location, + diagnosticsList ); + var (efProviderType, efHydrateCastTypeName) = ValueObjectSymbolInspector.ResolveEFProviderType(scalarType); + var efValueGeneratorEnabled = ResolveEFValueGeneration( typeSymbol, - scalarProperty.Type, + scalarType, scalarOptions, isEFReferenced, diagnosticsList ); - ValueObjectSymbolInspector.CollectMutableMemberDiagnostics(typeSymbol, [scalarProperty], diagnosticsList); + if (scalarProperty is not null) + ValueObjectSymbolInspector.CollectMutableMemberDiagnostics(typeSymbol, [scalarProperty], diagnosticsList); ScalarValueObjectModel model = new( typeModel.Value, @@ -257,13 +226,16 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca typeName, scalarTypeName, scalarPropertyName, + generateScalarProperty, scalarCanBeNull, + scalarTypeIsNullableReference, + scalarValueIsNullable, scalarIsReferenceType, isReferenceType, typeSymbol.IsRecord, typeSymbol.IsReadOnly, typeSymbol.DeclaredAccessibility.ToTypeDeclarationAccessibility(), - TypeReference.Create(scalarProperty.Type), + TypeReference.Create(scalarType), createExists, hydrateExists, tryCreateExists, @@ -288,17 +260,17 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca declareOnValidate, ValueObjectSymbolInspector.ImplementsSelfEquatable(typeSymbol), ValueObjectSymbolInspector.HasMemberWithName(typeSymbol, "Empty"), - ValueObjectSymbolInspector.GetEmptyValueExpression(scalarProperty.Type), - ValueObjectSymbolInspector.HasConversionOperator(typeSymbol, scalarProperty.Type, fromPrimitive: true), - ValueObjectSymbolInspector.HasConversionOperator(typeSymbol, scalarProperty.Type, fromPrimitive: false), - ValueObjectSymbolInspector.HasContextualCreateOverload(typeSymbol, scalarProperty.Type), - SymbolEqualityComparer.Default.Equals(scalarProperty.Type, typeSymbol), + ValueObjectSymbolInspector.GetEmptyValueExpression(scalarType), + ValueObjectSymbolInspector.HasConversionOperator(typeSymbol, scalarType, fromPrimitive: true), + ValueObjectSymbolInspector.HasConversionOperator(typeSymbol, scalarType, fromPrimitive: false), + ValueObjectSymbolInspector.HasContextualCreateOverload(typeSymbol, scalarType), + SymbolEqualityComparer.Default.Equals(scalarType, typeSymbol), BuildExistingRelationalOperators(typeSymbol, typeName, typeName), BuildExistingRelationalOperators(typeSymbol, typeName, scalarTypeName), zodSchema.HasSchema, zodSchema.SchemaClassName, isEFReferenced, - ValueObjectSymbolInspector.IsEFMappableProviderType(scalarProperty.Type), + ValueObjectSymbolInspector.IsEFMappableProviderType(scalarType), ValueObjectSymbolInspector.ToTypeName(efProviderType), TypeReference.Create(efProviderType), efHydrateCastTypeName, @@ -310,6 +282,170 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); } + /// + /// Reports the Entity Framework Core availability diagnostics for the scalar and returns whether the + /// compilation references Entity Framework Core. + /// + static bool CollectEFDiagnostics( + INamedTypeSymbol typeSymbol, + ITypeSymbol scalarType, + string scalarTypeName, + ScalarAttributeData scalarOptions, + ImmutableArray attributes, + Compilation compilation, + Location location, + List diagnostics + ) + { + var isEFReferenced = ValueObjectSymbolInspector.IsEFReferenced(compilation); + if ( + isEFReferenced + && scalarOptions.GenerateEFConverter + && !ValueObjectSymbolInspector.IsEFMappableProviderType(scalarType) + ) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.EFAutoConversionSkipped, + isBlocking: false, + typeSymbol.Locations.FirstOrDefault(), + typeSymbol.Name, + scalarTypeName + ) + ); + } + else if ( + !isEFReferenced + && ( + ValueObjectDefaultsHelper.IsScalarPropertyExplicitlySet(attributes, "GenerateEFConverter") + || ValueObjectDefaultsHelper.IsScalarPropertyExplicitlySet(attributes, "GenerateEFComparer") + ) + ) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.EFMappingRequiresEntityFramework, + isBlocking: false, + location, + typeSymbol.Name + ) + ); + } + + return isEFReferenced; + } + + /// + /// Resolves the underlying value type and property name for either scalar form, reporting the + /// declaration errors as blocking diagnostics. + /// + /// + /// The manual [Scalar] form requires the author to declare the named property (VO1004); + /// the automatic [Scalar<T>] form derives the type from the attribute argument and forbids + /// a declared member with that name (VO1022). + /// + static bool TryResolveScalarTarget( + INamedTypeSymbol typeSymbol, + ScalarAttributeData scalarOptions, + AttributeData scalarAttribute, + Location location, + List diagnostics, + out ITypeSymbol scalarType, + out string scalarPropertyName, + out bool generateScalarProperty, + out IPropertySymbol? scalarProperty + ) + { + scalarType = null!; + scalarPropertyName = scalarOptions.PropertyName; + generateScalarProperty = false; + scalarProperty = null; + + if (ScalarAttributeParser.GetValueType(scalarAttribute) is { } declaredType) + { + scalarType = + ScalarAttributeParser.GetNullable(scalarAttribute) && declaredType.IsReferenceType + ? declaredType.WithNullableAnnotation(NullableAnnotation.Annotated) + : declaredType; + generateScalarProperty = true; + + // The generator owns the named property, so a declared member with that name (or with the + // interface's Value name, which is forwarded) is a conflict - never both. + var conflictingName = + HasDeclaredMember(typeSymbol, scalarPropertyName) ? scalarPropertyName + : !string.Equals(scalarPropertyName, "Value", StringComparison.Ordinal) + && HasDeclaredMember(typeSymbol, "Value") + ? "Value" + : null; + + if (conflictingName is not null) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.ScalarPropertyIsGenerated, + isBlocking: true, + GetDeclaredMemberLocation(typeSymbol, conflictingName) ?? location, + typeSymbol.Name, + conflictingName + ) + ); + return false; + } + + return true; + } + + scalarProperty = typeSymbol + .GetMembers(scalarOptions.PropertyName) + .OfType() + .FirstOrDefault(property => !property.IsStatic && property.GetMethod is not null); + + if (scalarProperty is null) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.ScalarPropertyMissing, + isBlocking: true, + location, + typeSymbol.Name, + scalarOptions.PropertyName + ) + ); + return false; + } + + scalarType = scalarProperty.Type; + scalarPropertyName = scalarProperty.Name; + return true; + } + + /// + /// True when the type already declares, in user-authored source, a property or field with the given + /// name. Used by the automatic form, where the generator owns the named property: a declared member + /// would be a duplicate (CS0102) in the merged partial, so it is reported as a blocking diagnostic + /// instead. Members the generator emitted (in a *.g.cs tree) are ignored, otherwise the + /// analyzer would see the property the generator just declared and flag its own output. + /// + static bool HasDeclaredMember(INamedTypeSymbol typeSymbol, string name) => + typeSymbol + .GetMembers(name) + .Where(static member => member is IPropertySymbol or IFieldSymbol) + .Any(static member => + member.DeclaringSyntaxReferences.Any(static reference => !IsGeneratedSyntaxTree(reference.SyntaxTree)) + ); + + static Location? GetDeclaredMemberLocation(INamedTypeSymbol typeSymbol, string name) => + typeSymbol + .GetMembers(name) + .Where(static member => member is IPropertySymbol or IFieldSymbol) + .SelectMany(static member => member.DeclaringSyntaxReferences) + .Where(static reference => !IsGeneratedSyntaxTree(reference.SyntaxTree)) + .Select(static reference => reference.GetSyntax().GetLocation()) + .FirstOrDefault(static location => location.IsInSource); + + static bool IsGeneratedSyntaxTree(SyntaxTree syntaxTree) => + syntaxTree.FilePath.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase); + /// /// Resolves whether the value object gets an Entity Framework Core key value generator, reporting the /// cases where the option was requested but cannot be honoured: a scalar whose underlying value is not diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs index 9e614db..2b83ed5 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs @@ -74,24 +74,18 @@ ImmutableArray attributes }; bool MergeBool(bool typeValue, bool assemblyValue, string propertyName) => - IsPropertyExplicitlySet( - attributes, - TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute, - propertyName - ) - ? typeValue - : assemblyValue; + IsScalarPropertyExplicitlySet(attributes, propertyName) ? typeValue : assemblyValue; string MergeString(string typeValue, string assemblyValue, string propertyName) => - IsPropertyExplicitlySet( - attributes, - TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute, - propertyName - ) - ? typeValue - : assemblyValue; + IsScalarPropertyExplicitlySet(attributes, propertyName) ? typeValue : assemblyValue; } + /// + /// True when is set on the applied scalar attribute, generic or not. + /// + public static bool IsScalarPropertyExplicitlySet(ImmutableArray attributes, string propertyName) => + ScalarAttributeParser.Find(attributes)?.NamedArguments.Any(argument => argument.Key == propertyName) ?? false; + public static ValueObjectAttributeData Apply( ValueObjectAttributeData typeOptions, ValueObjectDefaultsAttributeData assemblyDefaults, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs index 4d8b483..20ae341 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs @@ -30,11 +30,17 @@ static class ValueObjectEFConverterEmitter /// /// Builds the model-to-provider lambda. is the provider type when /// a cast is required (an enum-backed scalar converts through its underlying integral type), or - /// to read the scalar property directly. + /// to read the scalar property directly. adds the + /// null-forgiving operator when the underlying property is a nullable reference type but the provider + /// type is the non-nullable CLR type. /// - public static string ToProviderExpression(string scalarPropertyName, string? providerCastTypeName) => + public static string ToProviderExpression( + string scalarPropertyName, + string? providerCastTypeName, + bool nullForgiving = false + ) => providerCastTypeName is null - ? $"vo => vo.{scalarPropertyName}" + ? $"vo => vo.{scalarPropertyName}{(nullForgiving ? "!" : string.Empty)}" : $"vo => ({providerCastTypeName})vo.{scalarPropertyName}"; /// diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index 3f4d52e..de5d07d 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -623,7 +623,8 @@ Dictionary Fields providerTypeName, ValueObjectEFConverterEmitter.ToProviderExpression( descriptor.ScalarPropertyName!, - hydrateCastTypeName is null ? null : providerTypeName + hydrateCastTypeName is null ? null : providerTypeName, + descriptor.ProviderTypeIsNullableReference ), ValueObjectEFConverterEmitter.FromProviderExpression(descriptor.TypeName, hydrateCastTypeName) ) diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs index d0ae3dc..6b9cf3d 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -291,10 +291,10 @@ public static bool IsSupportedComplexMember(ITypeSymbol memberType) type.ContainingAssembly?.GetAttributes() ?? [] ); - if (HasAttribute(attributes, TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute)) + if (ScalarAttributeParser.Find(attributes) is { } scalarAttribute) { var scalarOptions = ValueObjectDefaultsHelper.Apply( - ScalarAttributeData.FromAttributeData(attributes), + ScalarAttributeParser.Parse(scalarAttribute), assemblyDefaults, attributes ); @@ -896,7 +896,16 @@ scalarType is INamedTypeSymbol namedType var underlyingType = nullableScalar is null ? scalarType : nullableScalar.TypeArguments[0]; if (underlyingType.TypeKind != TypeKind.Enum) - return (scalarType, null); + { + // A nullable reference annotation is not representable in a typeof() expression, and it carries + // no meaning for the provider CLR type, so strip it before it reaches the generated converter. + return ( + scalarType.IsReferenceType + ? scalarType.WithNullableAnnotation(NullableAnnotation.NotAnnotated) + : scalarType, + null + ); + } var integralType = ((INamedTypeSymbol)underlyingType).EnumUnderlyingType!; diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index 314bb61..c4b86ba 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -33,6 +33,10 @@ var email = EmailAddress.Create("Demo@Example.com"); // email.Value == "demo@example.com" ``` +The manual `[Scalar]` form above declares the underlying property. The automatic `[Scalar]` / +`[Scalar(typeof(T))]` forms have the generator declare it, and `[Scalar(Nullable = true)]` +expresses a nullable reference scalar. + ## JSON serialization Scalar value objects serialize as their underlying value. Register the converter factory on your diff --git a/src/src/ValueObjects/Serialization/ScalarAttribute.cs b/src/src/ValueObjects/Serialization/ScalarAttribute.cs index 7db8c3a..03598bb 100644 --- a/src/src/ValueObjects/Serialization/ScalarAttribute.cs +++ b/src/src/ValueObjects/Serialization/ScalarAttribute.cs @@ -1,8 +1,9 @@ namespace Purview.ValueObjects.Serialization; /// -/// Marks a struct or class as a scalar value object and controls how the source generator -/// produces conversion, comparison, and serialization members for it. +/// Marks a struct or class as a scalar value object whose underlying value property is declared by the +/// author, and controls how the source generator produces conversion, comparison, and serialization +/// members for it. /// /// /// @@ -11,116 +12,42 @@ namespace Purview.ValueObjects.Serialization; /// comparison operators, implicit conversions to and from the primitive, and an Empty instance. /// /// -/// The identifies the member holding the underlying value. Scalar value objects are -/// serialized using only that member's value, which is what makes them query-friendly for primitive inner values. +/// The identifies the member holding the underlying value. +/// Scalar value objects are serialized using only that member's value, which is what makes them +/// query-friendly for primitive inner values. +/// +/// +/// This is the manual form: the author declares the named property and the generator never emits +/// it. To have the generator declare the property instead, pass a value type to the constructor (for +/// example [Scalar(typeof(string))]) or use . The forms are +/// mutually exclusive for a given type. /// /// [AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class)] -public sealed class ScalarAttribute(string propertyName = "Value") : Attribute +public sealed class ScalarAttribute : ScalarOptionsAttribute { /// - /// Gets the name of the property that holds the underlying scalar value. - /// - /// Defaults to Value when not specified. - public string PropertyName { get; } = propertyName; - - /// - /// Gets or sets whether a JSON converter should be generated for the value object. - /// - /// Defaults to . - public bool GenerateJsonConverter { get; init; } = true; - - /// - /// Gets or sets whether the value object should implement . - /// - /// Defaults to . - public bool GenerateComparable { get; init; } = true; - - /// - /// Gets or sets whether comparison operators should be generated for the value object. - /// - /// Defaults to . - public bool GenerateComparisonOperators { get; init; } = true; - - /// - /// Gets or sets whether enum properties should be generated from the underlying value. - /// - /// Defaults to . - public bool GenerateEnumProperties { get; init; } = true; - - /// - /// Gets or sets whether an implicit conversion from the primitive value should be generated. - /// - /// Defaults to . - public bool GenerateImplicitFromPrimitive { get; init; } = true; - - /// - /// Gets or sets whether an implicit conversion to the primitive value should be generated. - /// - /// Defaults to . - public bool GenerateImplicitToPrimitive { get; init; } = true; - - /// - /// Gets or sets whether a static Empty instance should be generated. - /// - /// Defaults to . - public bool GenerateEmpty { get; init; } = true; - - /// - /// Gets or sets whether an Entity Framework Core ValueConverter and ValueComparer should - /// be generated for the value object. - /// - /// Defaults to . - /// - /// Entity Framework members are emitted only when the consuming project references - /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Set to - /// to opt out per type (see for - /// assembly-level opt-out). - /// - public bool GenerateEFConverter { get; init; } = true; - - /// - /// Gets or sets whether an Entity Framework Core ValueComparer should be generated for the - /// value object. - /// - /// Defaults to . - /// - /// Entity Framework members are emitted only when the consuming project references - /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. - /// - public bool GenerateEFComparer { get; init; } = true; - - /// - /// Gets or sets whether an Entity Framework Core value generator should be generated for this - /// Guid-backed scalar value object. + /// Initializes the manual form, where the author declares the named property. /// - /// Defaults to . - /// - /// - /// When enabled, the generator emits a ValueGenerator that assigns a time-ordered - /// (UUIDv7) identifier when an entity's key is left at , and publishes - /// it through the generated ValueObjectKeyValueGeneratorConvention so it applies to key - /// properties without per-entity configuration. A value supplied by domain code is never - /// overwritten, and an explicit ValueGeneratedNever() in an entity configuration wins. - /// - /// - /// Entity Framework members are emitted only when the consuming project references - /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Value generation is - /// supported for Guid-backed scalars only. - /// - /// - public bool GenerateEFValueGenerator { get; init; } + /// The name of the property that holds the underlying scalar value. + public ScalarAttribute(string propertyName = "Value") + : base(propertyName) { } /// - /// Gets or sets the deserialization mode used by the generated JSON converter. + /// Initializes the automatic form, where the generator declares the named property of type + /// . /// - /// Defaults to . - public ValueObjectDeserializationMode DeserializationMode { get; init; } = ValueObjectDeserializationMode.Hydrate; + /// + /// The underlying value type, for example typeof(string) or typeof(int?). A nullable + /// reference type cannot be a typeof operand (CS8639); use + /// for those, for example [Scalar(typeof(string), Nullable = true)]. + /// + /// The name of the property the generator declares. + public ScalarAttribute(Type valueType, string propertyName = "Value") + : base(propertyName) => ValueType = valueType; /// - /// Gets or sets how a source-generated ZodSharp schema validator (from the [ZodSchema] - /// attribute on this type) participates in the generated Create path. + /// Gets the underlying value type for the automatic form, or for the manual form. /// - /// Defaults to . - public ZodSchemaMode ZodSchemaMode { get; init; } = ZodSchemaMode.InAdditionToHooks; + public Type? ValueType { get; } } diff --git a/src/src/ValueObjects/Serialization/ScalarAttributeOfT.cs b/src/src/ValueObjects/Serialization/ScalarAttributeOfT.cs new file mode 100644 index 0000000..25cb698 --- /dev/null +++ b/src/src/ValueObjects/Serialization/ScalarAttributeOfT.cs @@ -0,0 +1,45 @@ +namespace Purview.ValueObjects.Serialization; + +/// +/// Marks a struct or class as a scalar value object whose underlying value type is given by +/// , and lets the source generator declare the underlying property. +/// +/// The type of the underlying value the value object wraps. +/// +/// +/// This is the automatic form: the generator emits the property named by +/// (default Value) as +/// public TValue Value { get; init; }. Declaring a member with that name yourself is an error +/// (VO1022) — use instead when you want to own the property. The +/// two forms are mutually exclusive for a given type. +/// +/// +/// Nullability. A nullable reference type cannot be used as a generic attribute argument (the +/// compiler reports CS8970 because nullable annotations are not representable in attribute +/// metadata), so [Scalar<string?>] does not compile. Set +/// to have the generator declare the property as a nullable +/// reference type: [Scalar<string>(Nullable = true)] produces +/// public string? Value { get; init; }. This applies to reference types only; nullable value types +/// are written directly, for example [Scalar<int?>]. +/// +/// +/// Readability. The automatic form is concise, but the underlying type and nullability are +/// expressed in the attribute rather than in a property declaration. Some readers prefer the explicit +/// manual form: +/// +/// // Automatic. +/// [Scalar<string>(Nullable = true)] +/// public readonly partial record struct Handle { } +/// +/// // Manual. +/// [Scalar] +/// public readonly partial record struct Handle +/// { +/// public string? Value { get; init; } +/// } +/// +/// Both are supported; neither is reported as a diagnostic. +/// +/// +[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class)] +public sealed class ScalarAttribute(string propertyName = "Value") : ScalarOptionsAttribute(propertyName); diff --git a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs index 4e258f8..3ea5f1b 100644 --- a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs +++ b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs @@ -13,7 +13,7 @@ namespace Purview.ValueObjects.Serialization; /// /// /// -/// Scalar value objects are persisted as their underlying value +/// Scalar value objects are persisted as their underlying value /// rather than as a full object graph. On write the converter serializes the scalar member; on read it /// reconstructs the value object using the configured (defaulting /// to ), falling back to a public or non-public @@ -70,7 +70,7 @@ public ScalarJsonConverterFactory() { } /// The type being checked. /// True when the type is a scalar value object, otherwise false. public override bool CanConvert(Type typeToConvert) => - typeToConvert.GetCustomAttribute() is not null; + typeToConvert.GetCustomAttribute() is not null; /// /// Creates a converter for the given scalar value object type. @@ -108,7 +108,7 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer typeToConvert, static t => { - var attr = t.GetCustomAttribute()!; + var attr = t.GetCustomAttribute()!; var scalarProp = t.GetProperty(attr.PropertyName, BindingFlags.Instance | BindingFlags.Public) ?? throw new InvalidOperationException( @@ -145,16 +145,20 @@ ValueObjectDeserializationMode deserializationMode { readonly Func _getScalar = BuildGetter(scalarProperty); readonly Func _create = BuildCreator(deserializationMode); + readonly bool _allowsNull = AllowsNull(scalarProperty); public override TScalarObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var scalar = - JsonSerializer.Deserialize(ref reader, options) - ?? throw new JsonException($"Cannot deserialize {typeof(TScalarObject).Name} from null."); + // A nullable scalar (a `T?` reference or value type) round-trips JSON null; a non-nullable + // reference scalar rejects it. NullabilityInfoContext is unannotated and this factory is already + // the one reflection-based, non-AOT component, so this adds no trimming/AOT surface. + var scalar = JsonSerializer.Deserialize(ref reader, options); + if (!_allowsNull && scalar is null) + throw new JsonException($"Cannot deserialize {typeof(TScalarObject).Name} from null."); try { - return _create(scalar); + return _create(scalar!); } catch (Exception exception) when (IsValidationFailure(exception)) { @@ -182,6 +186,14 @@ static bool IsValidationFailure(Exception exception) => public override void Write(Utf8JsonWriter writer, TScalarObject value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, _getScalar(value), options); + /// + /// Whether the scalar member accepts : a nullable reference type or a nullable + /// value type. A nullable-oblivious property () is treated as + /// non-nullable, so the factory keeps rejecting null exactly as before. + /// + static bool AllowsNull(PropertyInfo property) => + new NullabilityInfoContext().Create(property).ReadState == NullabilityState.Nullable; + static Func BuildGetter(PropertyInfo property) { var obj = Expression.Parameter(typeof(TScalarObject), "x"); diff --git a/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs b/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs new file mode 100644 index 0000000..dd13c11 --- /dev/null +++ b/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs @@ -0,0 +1,139 @@ +namespace Purview.ValueObjects.Serialization; + +/// +/// Carries the options shared by and . +/// +/// +/// +/// This type exists so the automatic () and manual +/// () scalar declarations configure the generator identically without +/// duplicating the option set. It is abstract and is never applied directly. +/// +/// +public abstract class ScalarOptionsAttribute : Attribute +{ + /// + /// Initializes the attribute. + /// + /// The name of the property that holds the underlying scalar value. + protected ScalarOptionsAttribute(string propertyName) => PropertyName = propertyName; + + /// + /// Gets the name of the property that holds the underlying scalar value. + /// + /// Defaults to Value when not specified. + public string PropertyName { get; } + + /// + /// Gets or sets whether the generator declares the underlying property as a nullable reference type. + /// + /// Defaults to . + /// + /// Applies to the automatic forms ( or + /// ) and to reference types only. Nullable value types are + /// expressed directly, for example [Scalar<int?>] or [Scalar(typeof(int?))]. A + /// nullable reference type cannot be used as a generic attribute argument or a typeof operand + /// (CS8970/CS8639), so Nullable = true is the way to declare one. + /// + public bool Nullable { get; init; } + + /// + /// Gets or sets whether a JSON converter should be generated for the value object. + /// + /// Defaults to . + public bool GenerateJsonConverter { get; init; } = true; + + /// + /// Gets or sets whether the value object should implement . + /// + /// Defaults to . + public bool GenerateComparable { get; init; } = true; + + /// + /// Gets or sets whether comparison operators should be generated for the value object. + /// + /// Defaults to . + public bool GenerateComparisonOperators { get; init; } = true; + + /// + /// Gets or sets whether enum properties should be generated from the underlying value. + /// + /// Defaults to . + public bool GenerateEnumProperties { get; init; } = true; + + /// + /// Gets or sets whether an implicit conversion from the primitive value should be generated. + /// + /// Defaults to . + public bool GenerateImplicitFromPrimitive { get; init; } = true; + + /// + /// Gets or sets whether an implicit conversion to the primitive value should be generated. + /// + /// Defaults to . + public bool GenerateImplicitToPrimitive { get; init; } = true; + + /// + /// Gets or sets whether a static Empty instance should be generated. + /// + /// Defaults to . + public bool GenerateEmpty { get; init; } = true; + + /// + /// Gets or sets whether an Entity Framework Core ValueConverter and ValueComparer should + /// be generated for the value object. + /// + /// Defaults to . + /// + /// Entity Framework members are emitted only when the consuming project references + /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Set to + /// to opt out per type (see for + /// assembly-level opt-out). + /// + public bool GenerateEFConverter { get; init; } = true; + + /// + /// Gets or sets whether an Entity Framework Core ValueComparer should be generated for the + /// value object. + /// + /// Defaults to . + /// + /// Entity Framework members are emitted only when the consuming project references + /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. + /// + public bool GenerateEFComparer { get; init; } = true; + + /// + /// Gets or sets whether an Entity Framework Core value generator should be generated for this + /// Guid-backed scalar value object. + /// + /// Defaults to . + /// + /// + /// When enabled, the generator emits a ValueGenerator that assigns a time-ordered + /// (UUIDv7) identifier when an entity's key is left at , and publishes + /// it through the generated ValueObjectKeyValueGeneratorConvention so it applies to key + /// properties without per-entity configuration. A value supplied by domain code is never + /// overwritten, and an explicit ValueGeneratedNever() in an entity configuration wins. + /// + /// + /// Entity Framework members are emitted only when the consuming project references + /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Value generation is + /// supported for Guid-backed scalars only. + /// + /// + public bool GenerateEFValueGenerator { get; init; } + + /// + /// Gets or sets the deserialization mode used by the generated JSON converter. + /// + /// Defaults to . + public ValueObjectDeserializationMode DeserializationMode { get; init; } = ValueObjectDeserializationMode.Hydrate; + + /// + /// Gets or sets how a source-generated ZodSharp schema validator (from the [ZodSchema] + /// attribute on this type) participates in the generated Create path. + /// + /// Defaults to . + public ZodSchemaMode ZodSchemaMode { get; init; } = ZodSchemaMode.InAdditionToHooks; +} diff --git a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs index 6c05e14..819bd55 100644 --- a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs @@ -90,6 +90,48 @@ public readonly partial record struct EmailAddress await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ScalarPropertyMissing); } + [Test] + public async Task Generate_GivenAutomaticScalarWithDeclaredProperty_ReportsScalarPropertyIsGenerated( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct InstallationId + { + public System.Guid Value { get; init; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ScalarPropertyIsGenerated); + } + + [Test] + public async Task Generate_GivenTypeofScalarWithDeclaredProperty_ReportsScalarPropertyIsGenerated( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(System.Guid))] + public readonly partial record struct InstallationId + { + public System.Guid Value { get; init; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ScalarPropertyIsGenerated); + } + [Test] public async Task Generate_GivenNonRecordStructScalar_ReportsScalarShouldBeRecordStruct( CancellationToken cancellationToken @@ -133,6 +175,31 @@ public readonly partial record struct EmailAddress await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ConflictingValueObjectAttributes); } + [Test] + [Arguments("[Purview.ValueObjects.Serialization.Scalar]")] + [Arguments("[Purview.ValueObjects.Serialization.Scalar(typeof(System.Guid))]")] + public async Task Generate_GivenAutomaticScalarAndValueObject_ReportsConflictingValueObjectAttributes( + string scalarAttribute, + CancellationToken cancellationToken + ) + { + var source = $$""" + namespace Testing + { + {{scalarAttribute}} + [ValueObject] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ConflictingValueObjectAttributes); + } + [Test] public async Task Generate_GivenStrictModeWithoutCreate_ReportsStrictDeserializationRequiresCreate( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs new file mode 100644 index 0000000..e313f71 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs @@ -0,0 +1,492 @@ +using System.Reflection; + +namespace Purview.ValueObjects.SourceGenerator.Generators; + +/// +/// Tests for the automatic [Scalar<T>] / [Scalar(typeof(T))] forms, which declare the +/// underlying property themselves rather than requiring the author to. These compile the generated output, +/// because the property is emitted into a partial the consumer cannot edit. +/// +public sealed class ScalarAutomaticPropertyTests : ValueObjectSourceGeneratorTestBase +{ + [Test] + public async Task ScalarGeneration_GivenGenericAttribute_GeneratesValueProperty(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct InstallationId + { + static partial void OnValidate(System.Guid value) + { + if (value == System.Guid.Empty) + throw new System.ArgumentException("required", nameof(value)); + } + } + + public static class Harness + { + public static System.Guid RoundTrip(System.Guid id) => InstallationId.Create(id).Value; + + public static bool RejectsEmpty() + { + try + { + InstallationId.Create(System.Guid.Empty); + return false; + } + catch (System.ArgumentException) + { + return true; + } + } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var id = System.Guid.NewGuid(); + var roundTrip = (System.Guid)harnessType.GetMethod("RoundTrip")!.Invoke(null, [id])!; + var rejectsEmpty = (bool)harnessType.GetMethod("RejectsEmpty")!.Invoke(null, null)!; + + await Assert.That(roundTrip).IsEqualTo(id); + await Assert.That(rejectsEmpty).IsTrue(); + } + + [Test] + public async Task ScalarGeneration_GivenNullableReferenceType_GeneratesNullableProperty( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + public readonly partial record struct Handle { } + + public static class Harness + { + public static string? RoundTrip(string? value) => Handle.Hydrate(value).Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var roundTripNull = (string?)harnessType.GetMethod("RoundTrip")!.Invoke(null, [null])!; + + await Assert.That(roundTripNull).IsNull(); + } + + [Test] + public async Task ScalarGeneration_GivenCustomPropertyName_GeneratesNamedPropertyAndForwardsValue( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar("Amount")] + public readonly partial record struct Money { } + + public static class Harness + { + public static decimal ReadNamed() => Money.Create(12.5m).Amount; + + public static decimal ReadThroughInterface() + { + global::Purview.ValueObjects.IScalarValueObject asInterface = Money.Create(12.5m); + + return asInterface.Value; + } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var named = (decimal)harnessType.GetMethod("ReadNamed")!.Invoke(null, null)!; + var throughInterface = (decimal)harnessType.GetMethod("ReadThroughInterface")!.Invoke(null, null)!; + + await Assert.That(named).IsEqualTo(12.5m); + await Assert.That(throughInterface).IsEqualTo(12.5m); + } + + [Test] + public async Task ScalarGeneration_GivenNullableString_RoundTripsNullAndValidatesNullOrNonWhitespace( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + public readonly partial record struct Nickname + { + static partial void OnValidate(string? value) + { + if (value is not null && string.IsNullOrWhiteSpace(value)) + throw new System.ArgumentException("Value must be null or non-whitespace.", nameof(value)); + } + } + + public static class Harness + { + public static string? Deserialize(string json) => + System.Text.Json.JsonSerializer.Deserialize(json)!.Value; + + public static string SerializeNull() => + System.Text.Json.JsonSerializer.Serialize(Nickname.Hydrate(null)); + + public static bool CreateAcceptsNull() => Nickname.Create(null).Value is null; + + public static bool CreateRejectsWhitespace() + { + try + { + Nickname.Create(" "); + return false; + } + catch (System.ArgumentException) + { + return true; + } + } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var deserialized = harnessType.GetMethod("Deserialize")!.Invoke(null, ["null"]); + var serialized = (string)harnessType.GetMethod("SerializeNull")!.Invoke(null, null)!; + var acceptsNull = (bool)harnessType.GetMethod("CreateAcceptsNull")!.Invoke(null, null)!; + var rejectsWhitespace = (bool)harnessType.GetMethod("CreateRejectsWhitespace")!.Invoke(null, null)!; + + await Assert.That(deserialized).IsNull(); + await Assert.That(serialized).IsEqualTo("null"); + await Assert.That(acceptsNull).IsTrue(); + await Assert.That(rejectsWhitespace).IsTrue(); + } + + [Test] + public async Task ScalarGeneration_GivenNullableValueType_RoundTripsNull(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Score { } + + public static class Harness + { + public static int? Deserialize(string json) => + System.Text.Json.JsonSerializer.Deserialize(json)!.Value; + + public static string SerializeNull() => + System.Text.Json.JsonSerializer.Serialize(Score.Hydrate(null)); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var deserialized = harnessType.GetMethod("Deserialize")!.Invoke(null, ["null"]); + var serialized = (string)harnessType.GetMethod("SerializeNull")!.Invoke(null, null)!; + + await Assert.That(deserialized).IsNull(); + await Assert.That(serialized).IsEqualTo("null"); + } + + [Test] + public async Task ScalarGeneration_GivenNonNullableString_StillRejectsNull(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct RequiredName { } + + public static class Harness + { + public static bool RejectsNull() + { + try + { + System.Text.Json.JsonSerializer.Deserialize("null"); + return false; + } + catch (System.Text.Json.JsonException) + { + return true; + } + } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var rejectsNull = (bool)harnessType.GetMethod("RejectsNull")!.Invoke(null, null)!; + + await Assert.That(rejectsNull).IsTrue(); + } + + [Test] + public async Task ScalarGeneration_GivenTypeofArgument_GeneratesValueProperty(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(System.Guid))] + public readonly partial record struct InstallationId { } + + public static class Harness + { + public static System.Guid RoundTrip(System.Guid id) => InstallationId.Create(id).Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var id = System.Guid.NewGuid(); + var roundTrip = (System.Guid)harnessType.GetMethod("RoundTrip")!.Invoke(null, [id])!; + + await Assert.That(roundTrip).IsEqualTo(id); + } + + [Test] + public async Task ScalarGeneration_GivenTypeofNullableString_RoundTripsNull(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(string), Nullable = true)] + public readonly partial record struct Nickname { } + + public static class Harness + { + public static string? Deserialize(string json) => + System.Text.Json.JsonSerializer.Deserialize(json)!.Value; + + public static string SerializeNull() => + System.Text.Json.JsonSerializer.Serialize(Nickname.Hydrate(null)); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var deserialized = harnessType.GetMethod("Deserialize")!.Invoke(null, ["null"]); + var serialized = (string)harnessType.GetMethod("SerializeNull")!.Invoke(null, null)!; + + await Assert.That(deserialized).IsNull(); + await Assert.That(serialized).IsEqualTo("null"); + } + + [Test] + public async Task ScalarGeneration_GivenTypeofNullableValueType_RoundTripsNull(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(int?))] + public readonly partial record struct Score { } + + public static class Harness + { + public static int? Deserialize(string json) => + System.Text.Json.JsonSerializer.Deserialize(json)!.Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var deserialized = harnessType.GetMethod("Deserialize")!.Invoke(null, ["null"]); + + await Assert.That(deserialized).IsNull(); + } + + [Test] + public async Task ScalarGeneration_GivenTypeofWithCustomPropertyName_GeneratesNamedProperty( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(decimal), "Amount")] + public readonly partial record struct Money { } + + public static class Harness + { + public static decimal Read() => Money.Create(12.5m).Amount; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var read = (decimal)harnessType.GetMethod("Read")!.Invoke(null, null)!; + + await Assert.That(read).IsEqualTo(12.5m); + } + + [Test] + public async Task ScalarGeneration_GivenGenerateJsonConverterFalse_DoesNotEmitConverter( + CancellationToken cancellationToken + ) + { + // The options are read for both automatic spellings. + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(GenerateJsonConverter = false)] + public readonly partial record struct NoConverterId { } + + [Purview.ValueObjects.Serialization.Scalar(typeof(System.Guid), GenerateJsonConverter = false)] + public readonly partial record struct TypeofNoConverterId { } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + foreach (var typeName in new[] { "Testing.NoConverterId", "Testing.TypeofNoConverterId" }) + { + var attribute = assembly + .GetType(typeName)! + .GetCustomAttribute(); + await Assert.That(attribute).IsNull(); + } + } + + [Test] + public async Task ScalarGeneration_GivenGenerateEmptyFalse_DoesNotEmitEmpty(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(GenerateEmpty = false)] + public readonly partial record struct NoEmptyId { } + + [Purview.ValueObjects.Serialization.Scalar(typeof(System.Guid), GenerateEmpty = false)] + public readonly partial record struct TypeofNoEmptyId { } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + foreach (var typeName in new[] { "Testing.NoEmptyId", "Testing.TypeofNoEmptyId" }) + { + var empty = assembly.GetType(typeName)!.GetProperty("Empty", BindingFlags.Public | BindingFlags.Static); + await Assert.That(empty).IsNull(); + } + } + + [Test] + public async Task ScalarGeneration_GivenStrictMode_RevalidatesOnDeserialize(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar( + DeserializationMode = Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict + )] + public readonly partial record struct StrictId + { + static partial void OnValidate(System.Guid value) + { + if (value == System.Guid.Empty) + throw new System.ArgumentException("required", nameof(value)); + } + } + + public static class Harness + { + public static bool RejectsEmpty() + { + try + { + System.Text.Json.JsonSerializer.Deserialize( + "\"00000000-0000-0000-0000-000000000000\""); + return false; + } + catch (System.ArgumentException) + { + // Strict re-validates through Create, which throws the OnValidate exception. + return true; + } + } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.Harness")!; + var rejects = (bool)harnessType.GetMethod("RejectsEmpty")!.Invoke(null, null)!; + + await Assert.That(rejects).IsTrue(); + } + + [Test] + public async Task ScalarGeneration_GivenAssemblyDefaults_AppliesAndTypeOverrideWins( + CancellationToken cancellationToken + ) + { + const string source = """ + using Purview.ValueObjects.Serialization; + + [assembly: ValueObjectDefaults(GenerateEmpty = false)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct DefaultedId { } + + [Purview.ValueObjects.Serialization.Scalar(GenerateEmpty = true)] + public readonly partial record struct OverriddenId { } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var defaultedEmpty = assembly + .GetType("Testing.DefaultedId")! + .GetProperty("Empty", BindingFlags.Public | BindingFlags.Static); + var overriddenEmpty = assembly + .GetType("Testing.OverriddenId")! + .GetProperty("Empty", BindingFlags.Public | BindingFlags.Static); + + await Assert.That(defaultedEmpty).IsNull(); + await Assert.That(overriddenEmpty).IsNotNull(); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs index a2adf18..34a1a65 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs @@ -4,7 +4,7 @@ namespace Purview.ValueObjects.SourceGenerator.Generators; /// Regression tests for [Scalar("CustomName")]. /// /// -/// is a documented public +/// is a documented public /// option, but the generated partial unconditionally implements /// IScalarValueObject<TSelf, TValue>, which declares a member named Value. With a /// custom name no Value was emitted, so the generated code failed with CS0535 — an error inside diff --git a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs index 95fa3e9..7973a27 100644 --- a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs +++ b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs @@ -35,6 +35,13 @@ static partial void OnValidate(string value) } } +/// +/// A nullable value-type scalar using the automatic form. Its provider type is int?, so the generated +/// Entity Framework Core converter has to accept and produce null on both directions. +/// +[Scalar] +public readonly partial record struct CompatibilityScore { } + /// /// A complex value object mapped to a JSON column. This mapping is a value converter, so it works on every /// Entity Framework Core version the package supports. @@ -75,6 +82,9 @@ sealed class CompatibilityEntity /// Gets or sets the code column. public CompatibilityCode Code { get; set; } + /// Gets or sets the nullable value-type scalar column. + public CompatibilityScore Score { get; set; } + /// Gets or sets the JSON column. public CompatibilityStamp Stamp { get; set; } diff --git a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs index 5b2a310..738f56d 100644 --- a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs +++ b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs @@ -64,6 +64,33 @@ await Assert .IsLessThan(0); } + [Test] + public async Task NullableScalarValueObject_ConverterHandlesNullAndColumnRoundTrips() + { + // The provider is int?, so the generated converter must accept and produce null on both directions. + var converter = CompatibilityScore.EF.Converter; + await Assert.That(converter.ProviderClrType).IsEqualTo(typeof(int?)); + await Assert.That(converter.ConvertToProvider(CompatibilityScore.Hydrate(null))).IsNull(); + + var fromNull = converter.ConvertFromProvider(null); + await Assert.That(fromNull).IsNull(); + + // A non-null value round-trips through a real column. + await using var connection = await OpenConnectionAsync(); + await using var context = CreateContext(connection); + await context.Database.EnsureCreatedAsync(); + + var entity = CreateEntity("NULLABLE"); + entity.Score = CompatibilityScore.Create(42); + context.Entities.Add(entity); + await context.SaveChangesAsync(); + var generated = entity.Id; + context.ChangeTracker.Clear(); + var found = await context.Entities.SingleAsync(row => row.Id == generated); + + await Assert.That(found.Score.Value).IsEqualTo(42); + } + [Test] public async Task ComplexTypeValueObjectMappedToJsonColumn_RoundTrips() { @@ -211,6 +238,7 @@ static CompatibilityEntity CreateEntity(string code) => new() { Code = CompatibilityCode.Create(code), + Score = CompatibilityScore.Create(0), Stamp = CreateStamp(), #if !NET8_0 Total = CompatibilityMoney.Create(1250.75m, CompatibilityCode.Create("GBP")), diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaAutomaticScalarModels.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaAutomaticScalarModels.cs new file mode 100644 index 0000000..ea354d0 --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaAutomaticScalarModels.cs @@ -0,0 +1,105 @@ +using ZodSharp; +using ZodSharp.Rules; + +namespace Purview.ValueObjects.Serialization; + +/// +/// Dual-generator fixtures for the automatic scalar forms. The value-object generator declares the +/// underlying property, so the ZodSharp generator cannot see the member and resolves the underlying type +/// and property name from the attribute. Type-level rules written against the underlying value are adapted +/// through the the value-object generator emits. +/// +/// +/// Both automatic spellings are covered: the generic [Scalar<TValue>] and the non-generic +/// [Scalar(typeof(TValue))], including their nullable variants. +/// +[Scalar] +[ZodSchema] +[NonSentinel(Message = "InstallationId must not be empty.")] +public readonly partial record struct InstallationId { } + +/// The non-generic automatic form of the same shape. +[Scalar(typeof(Guid))] +[ZodSchema] +[NonSentinel(Message = "TypeofInstallationId must not be empty.")] +public readonly partial record struct TypeofInstallationId { } + +/// The automatic form with a custom property name (Id). +[Scalar("Id")] +[ZodSchema] +[NonSentinel(Message = "TenantId must not be empty.")] +public readonly partial record struct AutoTenantId { } + +/// The non-generic automatic form with a custom property name (Id). +[Scalar(typeof(Guid), "Id")] +[ZodSchema] +[NonSentinel(Message = "TypeofTenantId must not be empty.")] +public readonly partial record struct TypeofAutoTenantId { } + +/// A nullable reference scalar; it round-trips JSON null and rejects whitespace. +[Scalar(Nullable = true)] +[ZodSchema] +[NullOrNonWhiteSpace(Message = "Nickname must be null or non-whitespace.")] +public readonly partial record struct Nickname { } + +/// The non-generic automatic form of a nullable reference scalar. +[Scalar(typeof(string), Nullable = true)] +[ZodSchema] +[NullOrNonWhiteSpace(Message = "TypeofNickname must be null or non-whitespace.")] +public readonly partial record struct TypeofNickname { } + +/// A nullable value type scalar. +[Scalar] +[ZodSchema] +public readonly partial record struct Score { } + +/// The non-generic automatic form of a nullable value type scalar. +[Scalar(typeof(int?))] +[ZodSchema] +public readonly partial record struct TypeofScore { } + +/// A nullable scalar that requires a value ([RequiredZod] rejects null). +[Scalar(Nullable = true)] +[ZodSchema] +[RequiredZod] +public readonly partial record struct RequiredName { } + +/// +/// The automatic form with the default : the schema runs and +/// the OnValidate hook still runs. +/// +[Scalar] +[ZodSchema] +public readonly partial record struct AutoHookEmail +{ + static partial void OnValidate(string value) + { + if (value != "allowed") + throw new System.ArgumentException("Only 'allowed' is accepted.", nameof(value)); + } +} + +/// +/// The automatic form with : the hook throws, so a successful +/// Create proves it was skipped. +/// +/// +/// The OnValidate body is deliberately unreachable, which is what VO1013 reports; the test +/// project opts out of that rule. +/// +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)] +[ZodSchema] +public readonly partial record struct AutoInsteadOfHooksEmail +{ + static partial void OnValidate(string value) => + throw new System.ArgumentException("OnValidate must not run.", nameof(value)); +} + +/// +/// The automatic form with a custom generated schema class name; the value-object generator's +/// Create must resolve the same name. +/// +[Scalar] +[ZodSchema(SchemaName = "AutoNamedSchemaIdSchema")] +[NonSentinel(Message = "AutoNamedSchemaId must not be empty.")] +public readonly partial record struct AutoNamedSchemaId { } diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs index b7a7c70..c10097a 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs @@ -194,4 +194,158 @@ public async Task ContactDto_GivenShippedMemberAttributes_ValidatesMembers() await Assert.That(codes.Contains(EmailRule.ErrorCode)).IsTrue(); await Assert.That(codes.Contains(MinLengthRule.ErrorCode)).IsTrue(); } + + [Test] + public async Task AutomaticScalar_GivenGenericForm_CreateRoutesThroughSchema() + { + // Act — the schema is generated from the attribute even though the property is not visible to + // the ZodSharp generator. + var created = InstallationId.Create(Guid.NewGuid()); + + // Assert + await Assert.That(created.Value).IsNotEqualTo(Guid.Empty); + + var exception = await Assert.That(() => InstallationId.Create(Guid.Empty)).Throws(); + var error = exception!.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(NonSentinelRule.ErrorCode); + await Assert.That(error.Message).IsEqualTo("InstallationId must not be empty."); + await Assert.That(error.Path).IsEmpty(); + + // Hydrate stays replay-safe: the rule is not re-run. + await Assert.That(InstallationId.Hydrate(Guid.Empty).Value).IsEqualTo(Guid.Empty); + } + + [Test] + public async Task AutomaticScalar_GivenTypeofForm_CreateRoutesThroughSchema() + { + // Act — the non-generic [Scalar(typeof(T))] form is detected even though it is not a generic attribute. + var exception = await Assert.That(() => TypeofInstallationId.Create(Guid.Empty)).Throws(); + + // Assert + var error = exception!.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(NonSentinelRule.ErrorCode); + await Assert.That(error.Message).IsEqualTo("TypeofInstallationId must not be empty."); + await Assert.That(error.Path).IsEmpty(); + } + + [Test] + public async Task AutomaticScalar_GivenCustomPropertyName_ReadsTheNamedProperty() + { + // Act — [Scalar("Id")] makes the value-object generator declare `Id`, not `Value`. + var created = AutoTenantId.Create(Guid.NewGuid()); + + // Assert + await Assert.That(created.Id).IsEqualTo(created.Value); + + var exception = await Assert.That(() => AutoTenantId.Create(Guid.Empty)).Throws(); + await Assert.That(exception!.Errors.Single().Message).IsEqualTo("TenantId must not be empty."); + } + + [Test] + public async Task AutomaticScalar_GivenTypeofFormWithCustomPropertyName_ReadsTheNamedProperty() + { + // Act — the non-generic [Scalar(typeof(Guid), "Id")] form resolves the name from the second argument. + var created = TypeofAutoTenantId.Create(Guid.NewGuid()); + + // Assert + await Assert.That(created.Id).IsEqualTo(created.Value); + + var exception = await Assert.That(() => TypeofAutoTenantId.Create(Guid.Empty)).Throws(); + await Assert.That(exception!.Errors.Single().Message).IsEqualTo("TypeofTenantId must not be empty."); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task AutomaticScalar_GivenNullableReference_AcceptsNull(bool generic) + { + // Act & Assert — both automatic spellings accept null. + if (generic) + await Assert.That(Nickname.Create(null).Value).IsNull(); + else + await Assert.That(TypeofNickname.Create(null).Value).IsNull(); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task AutomaticScalar_GivenNullableReference_RejectsWhitespaceViaNullTolerantRule(bool generic) + { + // Act + var exception = await Assert + .That(() => + { + if (generic) + Nickname.Create(" "); + else + TypeofNickname.Create(" "); + }) + .Throws(); + + // Assert + await Assert.That(exception!.Errors.Single().Code).IsEqualTo(NullOrNonWhiteSpaceRule.ErrorCode); + } + + [Test] + public async Task AutomaticScalar_GivenNullableReference_RoundTripsJsonNull() + { + // Act + var json = System.Text.Json.JsonSerializer.Serialize(Nickname.Hydrate(null)); + + // Assert + await Assert.That(json).IsEqualTo("null"); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task AutomaticScalar_GivenNullableValueType_AcceptsNull(bool generic) + { + // Act & Assert + if (generic) + await Assert.That(Score.Create(null).Value).IsNull(); + else + await Assert.That(TypeofScore.Create(null).Value).IsNull(); + } + + [Test] + public async Task AutomaticScalar_GivenRequiredZod_RejectsNull() + { + // Act — [RequiredZod] always rejects null, even for a nullable scalar. + var exception = await Assert.That(() => RequiredName.Create(null)).Throws(); + + // Assert + await Assert.That(exception!.Errors.Single().Code).IsEqualTo(RequiredRule.ErrorCode); + } + + [Test] + public async Task AutomaticScalar_GivenInAdditionToHooks_RunsOnValidateToo() + { + // Act — the default ZodSchemaMode runs the schema and the OnValidate hook. + var created = AutoHookEmail.Create("allowed"); + + // Assert + await Assert.That(created.Value).IsEqualTo("allowed"); + await Assert.That(() => AutoHookEmail.Create("denied")).Throws(); + } + + [Test] + public async Task AutomaticScalar_GivenInsteadOfHooks_DoesNotRunOnValidate() + { + // Act — the hook throws, so a successful Create proves InsteadOfHooks skipped it. + var created = AutoInsteadOfHooksEmail.Create("anything"); + + // Assert + await Assert.That(created.Value).IsEqualTo("anything"); + } + + [Test] + public async Task AutomaticScalar_GivenSchemaName_RoutesThroughTheNamedSchema() + { + // Assert — the configured schema class is the one the generated Create validates through. + await Assert.That(typeof(AutoNamedSchemaIdSchema)).IsNotNull(); + + var exception = await Assert.That(() => AutoNamedSchemaId.Create(Guid.Empty)).Throws(); + await Assert.That(exception!.Errors.Single().Message).IsEqualTo("AutoNamedSchemaId must not be empty."); + } } diff --git a/src/tests/ValueObjects.UnitTests/Serialization/Models.cs b/src/tests/ValueObjects.UnitTests/Serialization/Models.cs index 0a09c6d..7b46bb2 100644 --- a/src/tests/ValueObjects.UnitTests/Serialization/Models.cs +++ b/src/tests/ValueObjects.UnitTests/Serialization/Models.cs @@ -76,6 +76,54 @@ public static CustomerId Create(Guid value) public static CustomerId Hydrate(Guid value) => new(value); } +/// +/// The manual [Scalar] form with a nullable reference property and the generated converter +/// disabled, so has to accept JSON null for it. +/// +[Scalar( + GenerateJsonConverter = false, + GenerateComparable = false, + GenerateComparisonOperators = false, + GenerateEnumProperties = false, + GenerateImplicitFromPrimitive = false, + GenerateImplicitToPrimitive = false, + GenerateEmpty = false +)] +readonly partial record struct NullableEmailAddress +{ + public string? Value { get; } + + NullableEmailAddress(string? value) => Value = value; + + public static NullableEmailAddress Create(string? value) => new(value); + + public static NullableEmailAddress Hydrate(string? value) => new(value); +} + +/// +/// Uses the automatic [Scalar<T>] form (the generator declares the property) with the +/// generated JSON converter disabled, so has to recognise the +/// generic attribute and resolve the property it declares. +/// +[Scalar( + GenerateJsonConverter = false, + GenerateComparable = false, + GenerateComparisonOperators = false, + GenerateEnumProperties = false, + GenerateImplicitFromPrimitive = false, + GenerateImplicitToPrimitive = false, + GenerateEmpty = false +)] +readonly partial record struct AutomaticEmailAddress +{ + AutomaticEmailAddress(string value) => Value = value; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase")] + public static AutomaticEmailAddress Create(string value) => new(value.Trim().ToLowerInvariant()); + + public static AutomaticEmailAddress Hydrate(string value) => new(value); +} + /// /// Holds a strict scalar, so a deserialization failure can be observed with a JSON path. /// diff --git a/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs b/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs index 46ffc13..24a79aa 100644 --- a/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs +++ b/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs @@ -57,6 +57,38 @@ public async Task Deserialize_NonStringScalar_UsesHydrateFactory() await Assert.That(value.Value).IsEqualTo(id); } + [Test] + public async Task Deserialize_NullableScalarProperty_AcceptsNull() + { + var options = CreateOptions(); + var value = JsonSerializer.Deserialize("null", options); + + await Assert.That(value.Value).IsNull(); + } + + [Test] + public async Task Deserialize_NonNullableScalarProperty_RejectsNull() + { + var options = CreateOptions(); + + var exception = Assert.Throws(() => + JsonSerializer.Deserialize("null", options) + ); + + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task Deserialize_AutomaticScalarAttribute_IsRecognizedByTheFactory() + { + // The automatic [Scalar] form derives from the shared ScalarOptionsAttribute base, so the + // reflection factory has to match the base rather than the non-generic ScalarAttribute type. + var options = CreateOptions(); + var value = JsonSerializer.Deserialize("\"TEST@Example.COM\"", options); + + await Assert.That(value.Value).IsEqualTo("TEST@Example.COM"); + } + [Test] public async Task Serialize_DefaultScalarMode_WritesUnderlyingValue() { From 40d3cf1fd2011ba01b5e00dc4e070a6d66e3728f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 8 Oct 2026 18:36:08 +0100 Subject: [PATCH 2/3] fix: let projects narrow the repository target framework set src/Directory.Build.props assigned TargetFrameworks the All list after the SDK import, which applied it to every non-Roslyn project unconditionally. No project could narrow itself - ValueObjects.csproj already selected the All set and that selection did nothing - and anything carrying the centrally managed Entity Framework Core 10 pin could not restore for net8.0 or net9.0, because that release ships net10.0 assets only. The set is now selected before the import, so it is a real default, and the seven projects coupled to that EF pin select Supported. The runtime keeps its full net8.0-net11.0 reach, and ValueObjects.EFCompatibility.IntegrationTests keeps its deliberate span with a per-target EF reference set - its TargetFramework workaround goes, since Purview.BuildSdk 1.0.4 no longer leaves a single-framework default in place for a project that declares its own list. --- .../project-placement-defaults/SKILL.md | 132 ------- .../sdk-configuration-reference/SKILL.md | 204 ---------- .../sdk-engineering-principles/SKILL.md | 94 ----- .../SKILL.md | 214 ---------- .../SKILL.md | 324 ---------------- .../skills/source-generator-testing/SKILL.md | 364 ------------------ .agents/skills/tunit-test-authoring/SKILL.md | 214 ---------- global.json | 2 +- src/Directory.Build.props | 11 +- .../EFDomainSample.Persistence.csproj | 3 + src/src/Sample/Sample.csproj | 5 + .../SharedTestingInfra.csproj | 5 + .../SourceGenerator.IntegrationTests.csproj | 3 + .../SourceGenerator.UnitTests.csproj | 5 + ...ts.EFCompatibility.IntegrationTests.csproj | 6 +- .../ValueObjects.IntegrationTests.csproj | 3 + .../ValueObjects.UnitTests.csproj | 5 + 17 files changed, 38 insertions(+), 1556 deletions(-) delete mode 100644 .agents/skills/project-placement-defaults/SKILL.md delete mode 100644 .agents/skills/sdk-configuration-reference/SKILL.md delete mode 100644 .agents/skills/sdk-engineering-principles/SKILL.md delete mode 100644 .agents/skills/sdk-project-behavior-and-detection/SKILL.md delete mode 100644 .agents/skills/source-generator-codewriter-modernization/SKILL.md delete mode 100644 .agents/skills/source-generator-testing/SKILL.md delete mode 100644 .agents/skills/tunit-test-authoring/SKILL.md diff --git a/.agents/skills/project-placement-defaults/SKILL.md b/.agents/skills/project-placement-defaults/SKILL.md deleted file mode 100644 index 1c4ff1c..0000000 --- a/.agents/skills/project-placement-defaults/SKILL.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -name: project-placement-defaults -description: "Use when creating, moving, or splitting projects in a repository that uses Purview.BuildSdk, especially for src/tests placement, test suffix naming, namespace alignment, and automatic project-reference behavior." ---- - -# Project placement defaults for Purview.BuildSdk - -Use this skill whenever a task asks to add, move, split, or create a project in a repository that uses `Purview.BuildSdk` and you need placement, naming, and reference decisions to remain consistent with the SDK's automatic conventions. - -## Core principle - -Preserve the host repository's existing layout first; only introduce new structure when no established pattern exists. In repositories that use `Purview.BuildSdk`, prefer layouts that let the SDK's naming and auto-reference rules work without extra overrides. - -Treat naming and placement as configuration, not decoration. - -## Placement heuristics - -Use the repository's current structure as the source of truth, with these Purview-friendly defaults: - -1. Prefer source projects under `src/`. -2. Prefer test projects under `tests/`. -3. Place new projects beside similar projects (same language, layer, and test type). -4. Keep one test type per project by default. -5. Keep shared helper projects in explicit shared/shared-testing locations when those concepts exist. - -When a repo has no clear structure, use these conservative defaults because they align well with the SDK's automatic project-reference search paths: - -- Source/library projects under `src/` -- Test projects under `tests/` -- Integration/end-to-end tests in explicit sibling projects/folders such as `tests/Api.IntegrationTests/` or `tests/Api.E2ETests/` - -## Naming rules - -The SDK relies heavily on project names. - -- Keep the `.csproj` filename equal to its containing directory name unless `DisableProjectFileNamingConventionCheck=true` is explicitly used. -- Use the common conventional test suffixes by default: `.UnitTests`, `.IntegrationTests`, `.E2ETests`, `.FunctionalTests`, `.ContractTests`. -- Use other supported `*Tests` suffixes only when the test type itself carries important operational meaning. -- Keep shared helper projects on the SDK's exact recognized names when you want shared behavior: - - Shared projects: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` - - Shared testing projects: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, `SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, `SharedTestingHelpers` -- Do not invent near-miss names if you expect the SDK to classify the project automatically. - -## Test-type boundaries - -Separate tests by behavior and dependency scope: - -- **Unit tests**: isolate logic with minimal external dependencies. -- **Integration tests**: verify behavior across component boundaries (I/O, framework integration, build/evaluation behavior). -- **End-to-end/system tests**: verify full workflow behavior across the assembled system. - -If specialized test categories exist (for example, analyzer diagnostics vs code-fix integration), keep category-specific tests in distinct projects/folders. - -The detected test type also becomes the baseline test category. Additional categories remain available and -should be added when they improve discoverability. - -The SDK recognizes many test suffixes, including `Unit`, `Integration`, `E2E`, `EndToEnd`, `Acceptance`, `Functional`, `Performance`, `Load`, `Smoke`, `Stress`, `Regression`, `Security`, `Chaos`, `Scenario`, `System`, `Threat`, `BlackBox`, `WhiteBox`, `Accessibility`, `Interactive`, `Environment`, `Architecture`, and `Contract`. - -## Naming and namespace defaults - -Align identities with existing repository conventions: - -- Project names should follow prevailing patterns in sibling projects. -- Test project names should clearly indicate scope/type with recognized test suffixes. -- `NamespacePrefix` should remain the root identity source for the repo. -- `RootNamespace` usually flows from the logical project identity generated by the SDK; avoid custom namespace overrides unless required. -- `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — or to the full logical project name when suffix-stripping removed a segment (e.g. `Shared`, `ServiceDefaults`) — so a project's package/assembly identity follows its namespace and stays distinct, unless the repo explicitly overrides `AssemblyName`/`PackageId`/`RootNamespace` or opts out via `EnableAssemblyNameGeneration=false`. -- When moving files between projects, update namespaces so they match the destination project's conventions. - -Do not invent a new naming scheme when an existing one is already in use. - -## Project defaults - -When creating a new project: - -1. Match the SDK/project style used by sibling projects. -2. Reuse central dependency/version management if present. -3. Add only dependencies required for the project's scope. -4. Add the project to the repository solution/workspace entry point. -5. Keep configuration consistent with neighboring projects (target frameworks, nullable, analyzers, warnings). - -When working in a Purview-based repo, also assume: - -- `TargetFramework` defaults to `net10.0` if not otherwise set, or `netstandard2.0` when the project explicitly declares `IsRoslynComponent=true`. -- Test projects receive framework packages and coverage defaults from the SDK. -- Standard test projects receive `TUnit`, `TUnit.Mocks`, `Bogus`, and Microsoft.Testing.Platform wiring by default. -- Non-test projects receive SourceLink and telemetry defaults unless explicitly opted out. - -## Test readability defaults - -When organizing tests: - -- Prefer one subject-focused `{SubjectName}Tests` class per owned subject. -- Use `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` for subject-based test methods. -- Treat constructors, properties, operators, conversions, and validation hooks as valid subjects. -- Allow broader suite names for non-subject-based tests such as build, generator, workflow, package, or - full-system suites. -- Use TUnit display names and categories to keep large suites readable. - -## Move/split workflow checklist - -When splitting or relocating tests/projects: - -1. Create destination project/folder using established layout patterns. -2. Move files physically. -3. Update namespaces/imports/references for the destination. -4. Verify the destination project name still produces the intended `TestingType`, `TargetProjectName`, and `RootNamespace`. -5. Remove stale dependencies from the source project. -6. Update solution/workspace membership and project references. -6. Run build and relevant tests. - -## Automatic project-reference behavior to preserve - -The SDK automatically searches for project references based on naming and placement. - -- Test projects probe for their target project in these relative locations: - - `../$(TargetProjectName)/$(TargetProjectName).csproj` - - `../../$(TargetProjectName)/$(TargetProjectName).csproj` - - `../src/$(TargetProjectName)/$(TargetProjectName).csproj` - - `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` -- Non-test projects automatically look for sibling shared projects via `../Shared*/Shared*.csproj`. -- Test projects automatically look for sibling shared-testing projects via `../SharedTesting*/SharedTesting*.csproj`. - -If you move projects away from these conventions, be prepared to add explicit project references. - -## Guardrails - -- Prefer minimal, targeted diffs. -- Avoid cross-cutting renames unrelated to the move/split intent. -- Keep test intent unchanged while relocating. -- If structure is ambiguous, infer from nearest sibling projects and document the assumption in the change summary. -- When in doubt, preserve compatibility with the SDK's automatic naming, namespace, and project-reference behavior. diff --git a/.agents/skills/sdk-configuration-reference/SKILL.md b/.agents/skills/sdk-configuration-reference/SKILL.md deleted file mode 100644 index 5dd68c6..0000000 --- a/.agents/skills/sdk-configuration-reference/SKILL.md +++ /dev/null @@ -1,204 +0,0 @@ ---- -name: sdk-configuration-reference -description: "Use when configuring Purview.BuildSdk through Directory.Build.props or a .csproj, especially for NamespacePrefix, version detection, testing framework selection, telemetry, repo bootstrapping, and embedded agent-skill settings." ---- - -# Purview.BuildSdk configuration reference - -Use this skill when a task asks what can be configured in `Purview.BuildSdk`, where a property must be set, or which defaults the SDK applies automatically. - -## First rule: know where a property must be set - -Set repo-wide bootstrap properties **before** importing the SDK in `Directory.Build.props` when the value must affect `Sdk.props` evaluation. - -Common pre-import properties: - -- `NamespacePrefix` -- `UsePackageJsonVersion` -- `RootPackageJson` -- Repo-wide testing framework selection properties when you want every project to inherit them - -If a property changes behavior in `Sdk.targets` instead, it can usually be set later (for example in a project file), but prefer repo-wide defaults in `Directory.Build.props` unless the scenario is intentionally project-specific. - -## Version detection settings - -These properties control package/app version resolution from `package.json`: - -- `UsePackageJsonVersion` — default `true`; supported values: `true`, `false`, `Strict` -- `RootPackageJson` — explicit path to the `package.json` to read -- `EnableVersionDetectionCache` — default `true`; enables local caching of resolved version data -- `VersionDetectionCacheFile` — optional explicit cache file path -- `VersionDetectionLogEnabled` — default `false`; set to `true` to log the detected package version - -Behavior rules: - -1. If `RootPackageJson` is set, the SDK uses that path. -2. Otherwise it tries to discover the repo root from CI variables, `.git`, or a nearby `package.json`. -3. When version detection succeeds, both `Version` and `PackageVersion` are set from the `version` field. -4. `UsePackageJsonVersion=Strict` should be treated as “fail if discovery/resolution cannot succeed”. - -## Core identity and build settings - -These are the most important configurable properties exposed by the SDK: - -- `NamespacePrefix` — required unless `DisableNamespacePrefixCheck=true` -- `DisableNamespacePrefixCheck` — default `false` -- `DisablePurviewStylePolicyValidation` — default `false`; set to `true` to stop the build failing (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy (`dotnet_style_require_accessibility_modifiers` other than `omit_if_default`), hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field naming rule, or adds the accessibility rules to `NoWarn`. Entries the SDK injects itself (the test-context rule set for test/shared-testing projects, `CA1515` for Aspire hosts and CLI apps) are ignored -- `TargetFramework` — defaults to `net10.0` when neither `TargetFramework` nor `TargetFrameworks` is set; projects explicitly declaring `IsRoslynComponent=true` default to `netstandard2.0` -- `IsRoslynComponent` — when explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, compiler-generated output under the framework-specific intermediate directory, telemetry exclusion, and `PrivateAssets=all` applied to `Microsoft.CodeAnalysis.*` / `Microsoft.CodeAnalysis.Analyzers` references. Packable Roslyn components automatically pack the built analyzer assembly and PDB into `analyzers/dotnet/cs/`; a pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true` -- `IsRoslynComponentOnly` — defaults to `true` for Roslyn components and creates an analyzer-only package: it sets `IncludeBuildOutput=false`, `IncludeSymbols=false`, and packages the portable PDB alongside the analyzer under `analyzers/dotnet/cs/`. Set it to `false` for a dual-role Roslyn component that uses normal library symbol packaging. -- `PackProjectReferencedSourceGenerators` — default `true`; packable projects automatically include analyzer `ProjectReference` outputs and runtime dependencies under `analyzers/dotnet/cs/`. Set it to `false` globally or use `Pack="false"` on one analyzer reference to opt out. -- `EnableAssemblyNameGeneration` — default `true`; when `true`, `AssemblyName` and default `PackageId` follow the fully evaluated `RootNamespace` (or the full logical project name when suffix-stripping removed a segment, e.g. `Shared`/`ServiceDefaults`). Set `false` before the SDK import to use the standard project-name behaviour -- `PurviewSharedTestingOutputType` — default `Library`; forced onto `IsSharedTestingProject` projects (with `IsTestProject`/`IsTestingPlatformApplication` cleared), because the test packages otherwise flip them into an executable test host. Set it to `Exe` before the SDK import to keep that package-driven shape -- `PurviewTestContextNoWarn` — default `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/fixtures, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Override before the SDK import to narrow or extend the set -- `DisablePurviewTestContextRuleSet` — default `false`; set to `true` to make test and shared-testing projects enforce the production API-surface rules as well -- `DisableProjectFileNamingConventionCheck` — default `false`; disables the directory-name/file-name match validation -- `DisableGenerateAssemblyInfoClass` — default `false`; disables generated `AssemblyInfo` -- `DisableAutoInternalsVisibleTo` — default `false`; disables automatic friend assembly generation -- `AutoIncludeUsings` — default `true`; controls SDK-added global usings -- `SourceLinkPackageName` — default `Microsoft.SourceLink.GitHub` -- `DisableSourceLink` — default `false` - -## Telemetry and package-related settings - -- `ExcludePurviewTelemetry` — default `false`; removes `Purview.Telemetry.SourceGenerator` -- `ExcludeMSTelemetryExtension` — default `false`; removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — when `ExcludePurviewTelemetry=true` the whole telemetry group is skipped anyway -- `IsPackable` — defaults to `false` if not set elsewhere -- `PackageTags`, `IncludeSource`, `IncludeSymbols`, `PublishRepositoryUrl`, `SymbolPackageFormat` — standard pack-related settings the SDK participates in for packable projects -- Packable-project defaults (only applied when the consuming project has not supplied a value): `GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does not receive PDB files unless the project opts in explicitly. Roslyn-component-only packages default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/` instead -- If the repo root is discoverable, the repository-root `README.md` is packed automatically (and registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` was not configured explicitly - -## Test framework settings - -The SDK supports opinionated testing defaults and validation. - -Primary settings: - -- `TestingFramework` — default `TUnit`; supported values: `TUnit`, `Xunit`, `None` -- `SubstituteFramework` — default `TUnitMocks`; supported values: `TUnitMocks`, `NSubstitute`, `None` -- `TestDataFramework` — default `Bogus`; supported values: `Bogus`, `None` - -Default outcome for standard test projects: - -- `TUnit` -- `TUnit.Mocks` -- `Bogus` -- Microsoft.Testing.Platform integration - -Specialised packages such as `TUnit.Aspire` and `Testcontainers` are not automatic defaults; they remain -explicit choices based on the project's purpose. - -Related toggles and derived settings: - -- `CollectCoverage` — defaults to `true` for detected test projects -- `EnableStaticNativeInstrumentation` — defaults to `false` for test projects -- `EnableDynamicNativeInstrumentation` — defaults to `false` for test projects -- `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` — enabled automatically for TUnit test projects - -## Repo bootstrap and developer-experience settings - -These settings control the SDK’s repo-level helper file bootstrapping: - -- `DisableAutoCopySdkFiles` — default `false`; master switch for SDK-managed repo file copying -- `BootstrapEditorConfigToRepoRoot` — default `true` -- `RepositoryEditorConfigFilePath` — optional override for the destination `.editorconfig` -- `BootstrapGlobalJsonToRepoRoot` — default `true` -- `RepositoryGlobalJsonFilePath` — optional override for the destination `global.json` -- `PurviewBuildSdkVersionForGlobalJson` — defaults to detected SDK package version, fallback `1.0.0` -- `PurviewAutoSdkPack` — default `true`; when `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths -- `EnableAgentFolderInPackage` — default `true`; mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repo’s `.agents/` -- `AgentPackDestinationFolder` — default `.agents`; repo-relative destination folder that receives mirrored agent content as `$(AgentPackDestinationFolder)/**` -- `PurviewAgentFolderSourcePath` — overrides the folder that provides the bundled `.agents` content (defaults to the package-level `.agents` folder beside `Sdk/`) -- `PurviewAgentFolderCopyRetries` — default `3`; copy attempts per file before a failure is reported -- `PurviewAgentFolderCopyRetryDelayMilliseconds` — default `500`; base delay between copy attempts -- `PurviewAgentFolderCopyFailureAsError` — default `true`; when `false`, a copy that still fails after every retry is a warning instead of an error -- `PurviewAgentSyncManifestPath` — overrides the change-detection manifest (default `/.purview/agent-sync.cache`) used to skip unchanged agent content -- `PurviewSuppressCopyRetryWarnings` — default `true`; demotes built-in copy task retry notices (`MSB3026`) to messages. Set to `false` to see every retry attempt - -## Shared repository copy behaviour - -`.agents` content, `.editorconfig` and `global.json` live in one repository-wide location but are written -by every project, so parallel builds race for the same destinations. The SDK therefore: - -1. Skips unchanged content using the `.purview/agent-sync.cache` manifest (fingerprint + content hash per file), so repeat builds touch nothing. This also detects an in-place package republish that keeps the same version. -2. Stages every write into a temporary file in the destination folder and renames it into place, so readers never see partial content and writers cannot interleave. -3. Retries quietly — retry attempts are low-importance messages, `MSB3026` notices are demoted — and only reports a copy that still fails after `Purview*CopyRetries` attempts, as an error by default. -4. Treats "another project already wrote identical content" as success, so a lost race is a no-op instead of a failure. - -`PurviewRepoBootstrapMode` (`IfMissing` default, or `Always`/`WarnOnDrift`/`Never`) controls whether -existing `.editorconfig`/`global.json` files may be overwritten or reported as drifted. - -**Hard requirement:** This SDK must pack the contents of `Sdk/` into the NuGet package so that downstream consumers of `Purview.BuildSdk` receive the same `Sdk/**` files. The `PurviewAutoSdkPack` feature (default `true`) is the mechanism that delivers this for standard consuming projects. When a project is packable, the SDK automatically adds `Sdk/**/*` as package content with the correct root-level paths: - -- `Sdk/.agents/**` → `.agents/**` -- `Sdk/.github/**` → `.github/**` -- `Sdk/build/**` → `build/**` -- `Sdk/buildTransitive/**` → `buildTransitive/**` -- `Sdk/buildMultiTargeting/**` → `buildMultiTargeting/**` -- `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. → package root -- everything else under `Sdk/` → `Sdk/` - -The SDK injects a `.gitignore` file into each second-level folder under `Sdk/.agents` during packaging with the following content: - -```text[.gitignore] -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore -``` - -This lets consuming repos keep the agent folder structure discoverable while ignoring the copied content in Git. - -## Important derived properties you can inspect - -When explaining SDK behavior, prefer these derived values over guessing: - -- `PurviewLogicalProjectName` -- `PurviewNamespacePrefix` -- `PurviewProjectShortName` -- `PurviewTestType` -- `PurviewSharedTestingOutputType` (default `Library`; `Exe` keeps the test packages' executable/test-host shape) -- `PurviewTestContextNoWarn` (production API-surface rules exempted in test/shared-testing projects) -- `PurviewPolicyExemptNoWarn` (SDK-injected `NoWarn` entries that `ValidatePurviewStylePolicy` accepts) -- `RootNamespace` -- `AssemblyName` -- `PackageVersion` -- `TestingType` -- `TargetProjectName` -- `RepoRoot` -- `RootPackageJson` - -## Compiler-visible properties - -The SDK exports many properties for analyzers and source generators through `build_property.`. When authoring analyzers or generators, prefer those exported properties instead of re-deriving SDK behavior manually. - -Especially relevant exported properties include: - -- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` -- `NamespacePrefix`, `DisableNamespacePrefixCheck` -- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` -- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` -- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass` -- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` -- `TestingType`, `TargetProjectName` -- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` -- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` -- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` -- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` -- `PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopyRetries`, `PurviewAgentFolderCopyRetryDelayMilliseconds`, `PurviewAgentFolderCopyFailureAsError`, `PurviewAgentSyncManifestPath`, `PurviewSuppressCopyRetryWarnings` -- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile` - -## Guidance for edits - -When changing SDK configuration: - -1. Preserve existing defaults unless the task explicitly changes product behavior. -2. Keep README, SDK property declarations, validation, and any shipped skills aligned. -3. If you add a new user-facing property, update both the configuration docs and the bundled skills. -4. If the property affects import-time behavior, document that it must be set before the SDK import. -5. Keep repository policy guidance aligned with the engineering-principles documentation, and keep low-level - property explanations aligned with the wiki reference pages. diff --git a/.agents/skills/sdk-engineering-principles/SKILL.md b/.agents/skills/sdk-engineering-principles/SKILL.md deleted file mode 100644 index c5c3140..0000000 --- a/.agents/skills/sdk-engineering-principles/SKILL.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -name: sdk-engineering-principles -description: "Use when creating or rationalising a repository that uses Purview.BuildSdk and you need the policy-level conventions for project placement, naming, namespace identity, test categories, and large-suite readability." ---- - -# Purview.BuildSdk engineering principles - -Use this skill when the question is not just "what property does the SDK set?" but "how should this -repository be structured so the SDK can work predictably?" - -## First principle - -Treat naming, placement, and test structure as configuration. - -The SDK infers namespaces, identities, categories, package wiring, and project references from a small set -of conventions. The more a repository follows those conventions, the less it needs bespoke overrides. - -## Canonical defaults - -- Prefer a `src/` + `tests/` split for new repositories. -- Keep solution entry points under `src/{SolutionName}.slnx`. -- Keep source projects under `src/src/{ProjectName}/{ProjectName}.csproj`. -- Keep test projects under `src/tests/{ProjectName}.{TestType}Tests/{ProjectName}.{TestType}Tests.csproj`. -- Keep the `.csproj` filename equal to its containing directory name. - -## Identity rules - -- `NamespacePrefix` is the root identity source. -- Use short project names; let the SDK apply the prefix. -- `RootNamespace` is the canonical code identity by default. -- `AssemblyName` and `PackageId` usually follow the resolved project identity. -- When suffix stripping would collapse distinct artifacts, `AssemblyName` and `PackageId` keep the fuller - logical identity. - -Examples: - -- `NamespacePrefix=Aspire`, project `Hosting` -> `Aspire.Hosting` -- `NamespacePrefix=Acme.Sales.RegionalPipeline`, project `Identity.API` -> - `Acme.Sales.RegionalPipeline.Identity.API` -- `NamespacePrefix=Acme.Sales.RegionalPipeline`, project `Identity.Core` -> `RootNamespace` - `Acme.Sales.RegionalPipeline.Identity`, but a distinct assembly/package identity that keeps `Core` - -## Common test project types - -Prefer these by default: - -- `UnitTests` -- `IntegrationTests` -- `E2ETests` -- `FunctionalTests` -- `ContractTests` - -The SDK supports more suffixes, but use them only when the test type itself is important enough to carry in -the project name. - -## Test categories - -- The detected test type becomes the baseline category automatically. -- Additional categories are allowed. -- Add more categories when they improve discoverability for large suites. - -## Default test stack - -Standard test projects receive these by default: - -- `TUnit` -- `TUnit.Mocks` -- `Bogus` -- Microsoft.Testing.Platform integration - -Specialized additions remain explicit: - -- `TUnit.Aspire` for Aspire lifecycle/AppHost-backed integration tests -- `Testcontainers` for container-backed integration tests - -## Test readability rules - -For subject-based tests: - -- Prefer `{SubjectName}Tests` for the class. -- Prefer `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` for methods. -- Treat methods, constructors, properties, operators, conversions, and validation hooks as valid subjects. - -For non-subject-based suites: - -- Broader names are allowed when they are more truthful and readable. -- Use TUnit display names, categories, and data-driven metadata to keep the suite navigable. - -## When to use this skill vs others - -- Use this skill for policy, structure, naming, and repository-shape questions. -- Use `sdk-project-behavior-and-detection` for "why did the SDK classify this project this way?" -- Use `sdk-configuration-reference` for property-level questions. -- Use `project-placement-defaults` when physically creating or moving projects. diff --git a/.agents/skills/sdk-project-behavior-and-detection/SKILL.md b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md deleted file mode 100644 index 9961d00..0000000 --- a/.agents/skills/sdk-project-behavior-and-detection/SKILL.md +++ /dev/null @@ -1,214 +0,0 @@ ---- -name: sdk-project-behavior-and-detection -description: "Use when explaining why Purview.BuildSdk classified a project as test, shared, CLI, web, Aspire host, or container, or when reasoning about auto-added packages, project references, namespaces, and naming conventions." ---- - -# Purview.BuildSdk project behavior and detection - -Use this skill when a task asks **why** the SDK applied a behavior automatically, or when adding/moving projects in a repo that relies on the SDK's naming and project-type inference. - -## Project-type detection rules - -The SDK infers behavior from project names, project contents, and SDK declarations. - -### Test detection - -A project is treated as a test project when its name ends with `*Test` or `*Tests` and the suffix before `Test(s)` matches a supported testing type such as: - -- `Unit` -- `Integration` -- `E2E` -- `EndToEnd` -- `Acceptance` -- `Functional` -- `Performance` -- `Load` -- `Smoke` -- `Stress` -- `Regression` -- `Security` -- `Chaos` -- `Scenario` -- `System` -- `Threat` -- `BlackBox` -- `WhiteBox` -- `Accessibility` -- `Interactive` -- `Environment` -- `Architecture` -- `Contract` - -Derived properties: - -- `IsTestProject=true` -- `TestingType=` -- `PurviewTestType=Tests` -- `TargetProjectName=` - -### Shared project detection - -The SDK recognizes shared project names exactly. These are not generic substring matches. - -Shared project names: - -- `Shared` -- `SharedFramework` -- `SharedInfrastructure` -- `SharedInfra` -- `SharedUtilities` -- `SharedUtils` -- `SharedLibrary` -- `SharedLib` -- `SharedHelpers` - -Shared testing project names: - -- `SharedTestingFramework` -- `SharedTestingInfrastructure` -- `SharedTestingInfra` -- `SharedTestingUtilities` -- `SharedTestingUtils` -- `SharedTestingLibrary` -- `SharedTestingLib` -- `SharedTestingHelpers` - -Derived flags: - -- `IsSharedProject` -- `IsSharedTestingProject` - -### SDK/content-based detection - -- `IsSdkProject` / `SdkProjectName` come from parsing the project/import `Sdk="..."` declaration -- `IsWebSdkProject=true` for `Microsoft.NET.Sdk.Web` -- `IsWorkerSdkProject=true` for `Microsoft.NET.Sdk.Worker` -- `IsAspireHostProject=true` when the SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` -- `IsContainerProject=true` when `Dockerfile`, `dockerfile`, or `Dockerfile.dev` exists in the project directory -- `IsCLIProject=true` when the project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, or `QuickStarts` - -## Namespace and identity behavior - -The SDK derives the project identity from `NamespacePrefix` and the project name. - -Key behavior: - -1. `PurviewLogicalProjectName` is built from `NamespacePrefix` plus the project name, with deduplication when the project name already starts with the namespace tail. -2. `RootNamespace` defaults to `PurviewLogicalProjectName`. -3. Known suffixes are stripped from `RootNamespace`, including shared/shared-testing names and common segments like `Core`, `EF`, `Shared`, `ClientShared`, and `ServiceDefaults`. -4. Test suffixes are removed from `RootNamespace`, so `Acme.Api.UnitTests` still maps back to `Acme.Api`. -5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` (the canonical default public name) — except when suffix-stripping removed a segment of the logical project name (e.g. `Shared` or `ServiceDefaults`), in which case they use the full `PurviewLogicalProjectName` so those assemblies/packages stay distinct from their parent. Test/shared-testing projects keep their detected suffix in `AssemblyName`/`PackageId` so test assemblies stay distinct. Explicit `AssemblyName`/`PackageId`/`RootNamespace` values always win. -6. The naming defaults are applied during `Sdk.props` evaluation (before the Microsoft SDK computes `TargetName`), so the compiled output name always matches `AssemblyName`. -7. The SDK ships `Purview.BuildSdk.Analyzers` and adds it as an `` item to every C# project, so its rules (PDS0002 Extensions namespace, PDS0003 explicit types with target-typed `new()`, PDS0004 correct acronym capitalization) surface in both command-line builds and Visual Studio. The code-fix assembly ships beside it and is referenced as an `` item inside Visual Studio so the IDE discovers its code fixes. `PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Guid`, `Uuid`, `Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, ...) and exempts them by default — `Db` is exempt so `DbContext`/`DbConnection`/`DbSet` are never flagged (re-enable via `acronym_map = Db:DB`); a small set — `Api`, `Ai`, `Ui`, `Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh` — is still renamed to uppercase. Members mandated by a contract (interface implementations, base-class overrides) are never renamed. Customise via `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), `.acronym_map` (segment renames, e.g. `Sql:SQL`), and `.allowed_identifiers` (brand names exempted by exact name or word-boundary prefix, first match wins — e.g. `CosmosDb` covers `CosmosDbServer`). All three merge with and override the shipped defaults. The shipped `.editorconfig` treats 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. A VS code refactoring (`Split extensions class into one class per receiver type`) is offered on static extensions classes that target multiple receiver types: it splits them into one `Extensions` class per receiver (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes `HostApplicationBuilderExtensions`) and places each under `Extensions//` so the PDS0002 convention stays satisfied. A companion refactoring (`Move extensions class to conventional location`) is offered on single-receiver extensions classes that are misplaced: it re-paths the file to `Extensions//`, fixes the namespace to the receiver's namespace, and updates `using` directives in other referencing documents so the move compiles. - -Do not hand-author alternate namespace conventions unless the repository explicitly opts out of the SDK defaults. - -## Automatic project references - -The SDK adds project references based on layout conventions. - -### Non-test projects - -For ordinary non-test, non-shared projects, it automatically looks for sibling shared projects: - -- `../Shared*/Shared*.csproj` - -It also removes accidental self/shared-testing matches. - -### Test projects - -For detected test projects, it attempts these target-project paths in order when they exist: - -- `../$(TargetProjectName)/$(TargetProjectName).csproj` -- `../../$(TargetProjectName)/$(TargetProjectName).csproj` -- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` -- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` - -It also adds sibling shared-testing project references via: - -- `../SharedTesting*/SharedTesting*.csproj` - -This is why consistent naming and placement matter so much in repos that use the SDK. - -## Automatic framework/package behavior - -### For non-test C# projects - -- Adds SourceLink unless `DisableSourceLink=true` -- Adds Purview telemetry packages unless `ExcludePurviewTelemetry=true` -- Generates documentation files (`GenerateDocumentationFile=true`) unless explicitly disabled -- Generates `InternalsVisibleTo` attributes unless `DisableAutoInternalsVisibleTo=true` - -### For packable projects - -- Defaults `GenerateDocumentationFile`, `IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, `IncludeSource`, and `DebugType=portable` — only when the consuming project has not supplied a value -- Delivers portable PDBs via the `.snupkg`; the normal `.nupkg` does not receive PDBs unless the project opts in explicitly -- Packs the repository-root `README.md` (registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` is unset; skips when a README is already being packed -- Non-packable projects (including web apps) default `WarnOnPackingNonPackableProject=false` so solution-wide pack operations skip them silently - -### For Roslyn component (analyzer/source-generator) projects - -- Defaults a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, and SourceLink with `EmbedUntrackedSources=true` -- `IsRoslynComponentOnly` defaults to `true`, excluding normal build output (`IncludeBuildOutput=false`) and setting `IncludeSymbols=false`; no `.symbols.nupkg` or `.snupkg` is produced and the analyzer PDB ships inside the main `.nupkg` under `analyzers/dotnet/cs/` beside the analyzer assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a dual-role component that uses normal library symbol packaging. -- Packable Roslyn components automatically pack the built analyzer assembly (and PDB) into `analyzers/dotnet/cs/`; `SymbolPackageFormat` defaults to the modern `snupkg` if symbols are explicitly opted into -- `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers` references are defaulted to `PrivateAssets=all` (development-only dependencies) so they never leak into the packed nuspec -- A pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack of a packable Roslyn component if `LangVersion`, `Nullable`, `TreatWarningsAsErrors`, or `EnforceExtendedAnalyzerRules` is missing; opt out with `DisableRoslynCompilerDefaultsValidation=true` -- `ContinuousIntegrationBuild` is set only by real CI environment variables — packability alone never forces SourceLink's CI-mode dirty-repository checks - -### For test and shared-testing projects - -- Applies test-friendly `NoWarn` defaults -- Marks projects as not packable/publishable -- Adds substitute/test-data/testing packages based on `SubstituteFramework`, `TestDataFramework`, and `TestingFramework` -- For TUnit test projects, enables Microsoft.Testing.Platform integration properties automatically -- For shared-testing projects, skips the runnable test package and marks them with a skip/category pattern appropriate to the selected test framework - -For the default configuration, standard test projects receive: - -- `TUnit` -- `TUnit.Mocks` -- `Bogus` -- Microsoft.Testing.Platform integration - -Specialized testing dependencies such as `TUnit.Aspire` and `Testcontainers` are still explicit additions by -project purpose. - -### For special project types - -- CLI projects default to `OutputType=Exe` and include `appsettings*.json` as content -- Container projects enable `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, and container tooling package references -- Web SDK projects get `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces` unless marked as a separate web-project mode -- Aspire host projects default to `OutputType=Exe` - -## How to reason about surprising behavior - -If the SDK “did something unexpected”, inspect these values first: - -- `MSBuildProjectName` -- `NamespacePrefix` -- `PurviewLogicalProjectName` -- `RootNamespace` -- `TestingType` -- `TargetProjectName` -- `SdkProjectName` -- `IsTestProject` -- `IsSharedProject` -- `IsSharedTestingProject` -- `IsContainerProject` -- `IsCLIProject` -- `IsWebSdkProject` -- `IsAspireHostProject` - -Prefer explaining behavior from these computed properties rather than from assumptions about folder names alone. - -## Guidance for structural changes - -When adding or moving projects in a repo using this SDK: - -1. Keep the `.csproj` filename equal to its containing directory name unless the repo explicitly disables that validation. -2. Preserve established `src/` and `tests/`-style layouts whenever possible. -3. Use test project suffixes intentionally so auto-detection and auto-references work. -4. Keep shared helpers in exact shared/shared-testing names if you want the corresponding SDK behavior. -5. If you change a naming rule in the SDK, update the README and the shipped skills together. -6. If the question is really about repository policy rather than one computed property, point the user to the - engineering-principles documentation first, then explain the specific SDK mechanics. diff --git a/.agents/skills/source-generator-codewriter-modernization/SKILL.md b/.agents/skills/source-generator-codewriter-modernization/SKILL.md deleted file mode 100644 index 8501ca4..0000000 --- a/.agents/skills/source-generator-codewriter-modernization/SKILL.md +++ /dev/null @@ -1,324 +0,0 @@ ---- -name: source-generator-codewriter-modernization -description: "Use when implementing, reviewing, or refactoring C# source generators and analysers in Purview.SourceGeneratorFramework. Covers CodeWriter/XmlCommentWriter-style emission, incremental pipeline design, value equality, and Roslyn best practices." ---- - -# 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. - -## 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/source-generator-testing/SKILL.md b/.agents/skills/source-generator-testing/SKILL.md deleted file mode 100644 index 2a56544..0000000 --- a/.agents/skills/source-generator-testing/SKILL.md +++ /dev/null @@ -1,364 +0,0 @@ ---- -name: source-generator-testing -description: "Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — picking the right runner/base, configuring options, querying produced code with CodeQuery, and asserting incremental caching." ---- - -# 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 -`sdk` package's `tunit-test-authoring` skill. - -## 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 `tunit-test-authoring`): -`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. - -## 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). - -## 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. - -### 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/tunit-test-authoring/SKILL.md b/.agents/skills/tunit-test-authoring/SKILL.md deleted file mode 100644 index fa58cd8..0000000 --- a/.agents/skills/tunit-test-authoring/SKILL.md +++ /dev/null @@ -1,214 +0,0 @@ ---- -name: tunit-test-authoring -description: "Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — choosing the correct base class and method, customising options, using the assertion extensions, and modernising existing tests." ---- - -# 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 -`sdk` package's `source-generator-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. - -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 `source-generator-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 `source-generator-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`. -- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a - `ImmutableDictionary>` so assertions can address a stage - by name (see `source-generator-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 `source-generator-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/global.json b/global.json index 2811ad4..64ee3ac 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.3" + "Purview.BuildSdk": "1.0.4" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 5275528..7b698eb 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,15 +2,16 @@ Purview.ValueObjects true + + All - - - $(PurviewTargetFrameworksAll) - - diff --git a/src/src/EFDomainSample.Persistence/EFDomainSample.Persistence.csproj b/src/src/EFDomainSample.Persistence/EFDomainSample.Persistence.csproj index 9aee98d..a861e0d 100644 --- a/src/src/EFDomainSample.Persistence/EFDomainSample.Persistence.csproj +++ b/src/src/EFDomainSample.Persistence/EFDomainSample.Persistence.csproj @@ -2,6 +2,9 @@ Exe false + + Supported diff --git a/src/src/Sample/Sample.csproj b/src/src/Sample/Sample.csproj index 702b7fc..d94eb06 100644 --- a/src/src/Sample/Sample.csproj +++ b/src/src/Sample/Sample.csproj @@ -2,6 +2,11 @@ Exe false + + Supported diff --git a/src/tests/SharedTestingInfra/SharedTestingInfra.csproj b/src/tests/SharedTestingInfra/SharedTestingInfra.csproj index 68584bf..3d2c752 100644 --- a/src/tests/SharedTestingInfra/SharedTestingInfra.csproj +++ b/src/tests/SharedTestingInfra/SharedTestingInfra.csproj @@ -5,6 +5,11 @@ (ValueObjectEFExtensions/UseValueObjects/ValueObjectModelCustomizer). Consumers discover these types through the markers and map them from their own registry. --> true + + Supported diff --git a/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj b/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj index 1d91e88..e0d3d8b 100644 --- a/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj +++ b/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj @@ -2,6 +2,9 @@ + + Supported diff --git a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj index f3c5ecb..861e5c6 100644 --- a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj +++ b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj @@ -1,4 +1,9 @@  + + + Supported + + diff --git a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj index e9907e4..26273c6 100644 --- a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj +++ b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj @@ -3,10 +3,8 @@ - - diff --git a/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj b/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj index 3eded49..b86efbf 100644 --- a/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj +++ b/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj @@ -5,6 +5,9 @@ (VO1013). That rule is covered by the analyzer unit tests, so it is opted out here rather than weakening the fixtures. --> $(NoWarn);VO1013 + + Supported diff --git a/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj b/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj index 945ac18..2ba31e6 100644 --- a/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj +++ b/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj @@ -1,4 +1,9 @@  + + + Supported + + From f16bf2a59c7a568375056a3f46145346e8c5c58e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 8 Oct 2026 18:59:12 +0100 Subject: [PATCH 3/3] chore: structure --- purview-build.json => .config/purview-build.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename purview-build.json => .config/purview-build.json (100%) diff --git a/purview-build.json b/.config/purview-build.json similarity index 100% rename from purview-build.json rename to .config/purview-build.json