From 9a3b82ab30ad7cc466158d4995158931b9692c81 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 21 Oct 2026 14:04:38 +0100 Subject: [PATCH 1/5] feat: added better analysers for options and more ways of setting them --- .config/dotnet-tools.json | 2 +- .config/purview-build.json | 7 +- .csharpierignore | 2 +- CHANGELOG.md | 24 ++ README.md | 18 + docs/Diagnostics.md | 8 + docs/Getting-Started.md | 44 +++ docs/Value-Object-Design.md | 14 + package.json | 2 +- .../SKILL.md | 7 + skills/purview-value-objects-efcore/SKILL.md | 4 +- skills/purview-value-objects/SKILL.md | 5 +- .../AnalyzerReleases.Unshipped.md | 8 + .../ValueObjectDiagnosticAnalyzer.cs | 358 +++++++++++++++++- .../Common/DiagnosticLibrary.cs | 30 ++ .../Common/TypeLibrary.Constants.cs | 7 + .../ComplexValueObjectModelBuilder.cs | 56 ++- .../ReferencedEFValueObjectDiscovery.cs | 8 +- .../ScalarValueObjectModelBuilder.cs | 46 ++- .../ValueObject/ValueObjectDefaultsHelper.cs | 177 ++++++++- .../ValueObject/ValueObjectSymbolInspector.cs | 28 +- src/src/ValueObjects/Sdk/README.md | 4 +- .../Purview.ValueObjects.props | 67 ++++ .../Purview.ValueObjects.targets | 145 ++++++- .../Serialization/StringNormalizeAttribute.cs | 10 +- .../ValueObjectDefaultsAttribute.cs | 20 +- .../ValueObjectDiagnosticAnalyzerTests.cs | 342 +++++++++++++++++ .../Repository/BuildDefaultsTargetsTests.cs | 86 +++++ .../ValueObjectDefaultsHelperTests.cs | 110 ++++++ 29 files changed, 1587 insertions(+), 52 deletions(-) create mode 100644 src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.props create mode 100644 src/tests/SourceGenerator.UnitTests/Repository/BuildDefaultsTargetsTests.cs create mode 100644 src/tests/SourceGenerator.UnitTests/ValueObject/ValueObjectDefaultsHelperTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d51c9f0..8881711 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.5.4", + "version": "0.6.1", "commands": [ "purview-build" ], diff --git a/.config/purview-build.json b/.config/purview-build.json index 54c0d45..ce3a20f 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -10,11 +10,14 @@ // Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier // versions forced this check on and could not be configured off, so these packages have // always been validated against it; declaring it keeps that behaviour. - "RequiredCompilerFlags": ["optimization=release"], + "RequiredCompilerFlags": [ + "optimization=release" + ], "RequireSymbolPackage": true, "RequireSymbolFiles": true, "RequiredContent": { "purview.valueobjects": [ + "buildTransitive/Purview.ValueObjects.props", "buildTransitive/Purview.ValueObjects.targets", "analyzers/dotnet/cs/Purview.ValueObjects.SourceGenerator.dll", "analyzers/dotnet/cs/Purview.ValueObjects.SourceGenerator.Refactorings.dll", @@ -28,4 +31,4 @@ "Release": { "Mode": "None" } -} +} \ No newline at end of file diff --git a/.csharpierignore b/.csharpierignore index 8229a2c..40f9111 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -1,2 +1,2 @@ # Generated code produced by the repository's own source generator tests -src/tests/SourceGenerator.UnitTests/**/Generated/* \ No newline at end of file +src/tests/SourceGenerator.UnitTests/**/Generated/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6ca1b..18698aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,30 @@ Released versions correspond to `v` GitHub releases. `package.json` is newest heading below is the version in development. Earlier releases are listed under [releases](https://github.com/purview-dev/value-objects/releases). +## Unreleased + +### Added + +- **`VO1025`: `[StringNormalize]` placement is enforced.** The attribute is only read for a string member of a + complex `[ValueObject]`. On a `[Scalar]` (where `Trim`/`Casing` on the `[Scalar]` attribute is the supported + mechanism), on any type that is neither `[Scalar]` nor `[ValueObject]`, or on a property that is not a value + member, it previously compiled and did nothing; it is now the error `VO1025`, reported at the attribute. +- **MSBuild-configurable assembly defaults.** Every `[ValueObjectDefaults]` option now has a matching + `ValueObjectsDefault` MSBuild property (for example `ValueObjectsDefaultTrim`, + `ValueObjectsDefaultCasing`, `ValueObjectsDefaultGenerateConstructor`). The packaged targets turn the values + into a build-generated assembly-level `[ValueObjectDefaults]` under `obj/`, and the build props declares each + property (conditionally, so an earlier value is never overwritten) for IDE completion. The generator and + analyzer read the result through the compilation, so a referenced assembly keeps the defaults it was built + with. Precedence is built-in default → MSBuild property → an option named on your own `[ValueObjectDefaults]` + → per-type or per-member option. Unrecognised values fail the build with the allowed values in the message. +- **Redundant defaults are reported.** An author-declared `[assembly: ValueObjectDefaults]` argument that + repeats a value supplied by a `ValueObjectsDefault*` MSBuild property is the error `VO1026`; a + `[Scalar]`/`[ValueObject]` option that repeats the effective assembly default is the error `VO1027`. + `[StringNormalize]` remains a per-member override and is not part of that check. The rules keep each + option in one place instead of silently accepting two sources of truth. `[ValueObjectDefaults]` still + allows multiple applications so the build-generated attribute and an author-declared attribute can + coexist for the options the author deliberately overrides. + ## 1.1.0 ### Added diff --git a/README.md b/README.md index 797448f..4f2d674 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,24 @@ the `src/src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details fo - Contextual value objects (`IContextualValueObject`) validate against the owning instance through `ValueObjectContext`. +## Assembly-wide defaults from MSBuild + +Every `[ValueObjectDefaults]` option has a matching `ValueObjectsDefault` MSBuild property, so the +defaults can live once in `Directory.Build.props` instead of an assembly attribute in every project: + +```xml + + true + LowerInvariant + +``` + +The packaged targets emit the values as a build-generated assembly-level `[ValueObjectDefaults]`, which the +generator and analyzer read like any other attribute. Precedence is: built-in default, then the MSBuild property, +then a named argument on your own `[ValueObjectDefaults]`, then a per-type or per-member option. Unknown values +fail the build. See [Assembly-level defaults](docs/Getting-Started.md#6-assembly-level-defaults) for the full +property list. + ## Disabling the generator Set `DisableValueObjectsSourceGenerator` to `true` in your project: diff --git a/docs/Diagnostics.md b/docs/Diagnostics.md index c480e0b..c4ed554 100644 --- a/docs/Diagnostics.md +++ b/docs/Diagnostics.md @@ -40,6 +40,14 @@ See [Value object design](Value-Object-Design.md) for the built-in string normal | --- | --- | --- | --- | | `VO1023` | Warning | Built-in string normalization is configured but the value object implements `OnNormalize` or declares its own `Create`. | Remove the option, or move the normalization into the hook/`Create`. | | `VO1024` | Warning | Built-in string normalization is configured on a non-string scalar or member. | Remove the option; it only applies to `string` values. | +| `VO1025` | Error | `[StringNormalize]` is applied to a member of a `[Scalar]`, or of a type that is neither `[Scalar]` nor `[ValueObject]`, or to a property that is not a value member. | Move the attribute to a string member of a complex `[ValueObject]`. On a scalar, set `Trim`/`Casing` on `[Scalar]` instead. | +| `VO1026` | Error | An author-declared `[assembly: ValueObjectDefaults]` sets an option to the value the packaged targets already supplied from a `ValueObjectsDefault*` MSBuild property. | Remove the argument from the assembly attribute, or set a different value. | +| `VO1027` | Error | A `[Scalar]` or `[ValueObject]` sets an option to the effective assembly default. | Remove the option from the attribute; it repeats the inherited value. | + +Restating an option that the assembly defaults (or `ValueObjectsDefault*` MSBuild properties) already set is a +no-op. `VO1026`/`VO1027` report that redundancy for assembly, `[Scalar]` and `[ValueObject]` options, so the +configuration has a single source of truth. `[StringNormalize]` is a per-member override and is not included in +that check. ## Entity Framework rules diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 156f1c6..aa34527 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -263,6 +263,50 @@ Use `[ValueObjectDefaults]` to set generic defaults for the whole assembly. Ever Assembly defaults apply to every value object in the assembly; an option explicitly set on an individual `[Scalar]`/`[ValueObject]` attribute always overrides it. +### Setting defaults from MSBuild + +Every `[ValueObjectDefaults]` option also has a matching `ValueObjectsDefault` MSBuild property, so a +repository can configure the defaults once in `Directory.Build.props` without adding an attribute to each +project. Unset properties fall through to the attribute and then to the built-in default. + +```xml + + true + LowerInvariant + false + +``` + +| Property | Allowed values | +| --- | --- | +| `ValueObjectsDefaultGenerateJsonConverter` | `true` / `false` | +| `ValueObjectsDefaultGenerateComparable` | `true` / `false` | +| `ValueObjectsDefaultGenerateComparisonOperators` | `true` / `false` | +| `ValueObjectsDefaultGenerateEnumProperties` | `true` / `false` | +| `ValueObjectsDefaultGenerateImplicitFromPrimitive` | `true` / `false` | +| `ValueObjectsDefaultGenerateImplicitToPrimitive` | `true` / `false` | +| `ValueObjectsDefaultGenerateEmpty` | `true` / `false` | +| `ValueObjectsDefaultGenerateConstructor` | `true` / `false` | +| `ValueObjectsDefaultEFMapping` | `ComplexType`, `Json`, `None` | +| `ValueObjectsDefaultGenerateEFConverter` | `true` / `false` | +| `ValueObjectsDefaultGenerateEFComparer` | `true` / `false` | +| `ValueObjectsDefaultGenerateEFValueGenerator` | `true` / `false` | +| `ValueObjectsDefaultTrim` | `true` / `false` | +| `ValueObjectsDefaultCasing` | `None`, `LowerInvariant`, `UpperInvariant` | +| `ValueObjectsDefaultDeserializationMode` | `Hydrate`, `Strict` | +| `ValueObjectsDefaultZodSchemaMode` | `InAdditionToHooks`, `InsteadOfHooks` | + +The packaged targets turn the properties into a build-generated assembly-level `[ValueObjectDefaults]` under +`obj/`, so the generator and analyzer read them exactly like an attribute you declared yourself. The package +declares the properties in its build props, so IDEs offer them while you edit the project file; the declarations +are conditional and never overwrite a value set earlier. An option named explicitly on your own +`[ValueObjectDefaults]` wins over the MSBuild value, and a per-type or per-member option wins over both. An +unrecognised value fails the build instead of silently falling back to the built-in default. + +Because the attribute is compiled into the assembly, the defaults a project generates with are part of its +public contract: a value object declared in a referenced assembly keeps the defaults its own project built it +with. + ## 7. Validate with ZodSharp [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) is a C# port of Zod that can validate diff --git a/docs/Value-Object-Design.md b/docs/Value-Object-Design.md index b51dd39..6309b81 100644 --- a/docs/Value-Object-Design.md +++ b/docs/Value-Object-Design.md @@ -47,6 +47,15 @@ on any other scalar or member is reported (`VO1024`) and ignored. The option is `Create`, so a hand-written `OnNormalize` hook or a hand-written `Create` takes precedence: a configured option alongside either is reported (`VO1023`) and ignored. +Setting an option to the value it already has is a no-op and is reported as redundant: with an assembly +default of `Trim = true`, `[Scalar(Trim = true)]` is reported as `VO1027`, and an author-declared +`[assembly: ValueObjectDefaults(Trim = true)]` that repeats a `ValueObjectsDefaultTrim` value is reported as +`VO1026`. `[StringNormalize(Trim = true)]` is a per-member override and is not part of that check. + +`[StringNormalize]` belongs on a string member of a complex `[ValueObject]`. On a `[Scalar]` — where +`Trim`/`Casing` on the `[Scalar]` attribute is the supported mechanism — or on any type that is neither +`[Scalar]` nor `[ValueObject]`, the attribute can never take effect and is reported as the error `VO1025`. + An assembly-level default applies to every string-backed scalar and every string member of a `[ValueObject]`, and can be overridden per type or per member: @@ -54,6 +63,11 @@ An assembly-level default applies to every string-backed scalar and every string [assembly: ValueObjectDefaults(Trim = true, Casing = StringCasing.LowerInvariant)] ``` +The same defaults can be supplied from MSBuild with the `ValueObjectsDefault` properties +(`ValueObjectsDefaultTrim`, `ValueObjectsDefaultCasing`, and one for every other `[ValueObjectDefaults]` +option). An option named explicitly on the attribute wins over the MSBuild value. See +[Getting Started](Getting-Started.md#setting-defaults-from-msbuild) for the full list. + Casing uses the invariant culture only, because a value object's canonical form must not depend on the current thread culture. diff --git a/package.json b/package.json index 66f057c..cf83340 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "value-objects", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/skills/purview-value-objects-diagnostics/SKILL.md b/skills/purview-value-objects-diagnostics/SKILL.md index df4acf3..8d03cf0 100644 --- a/skills/purview-value-objects-diagnostics/SKILL.md +++ b/skills/purview-value-objects-diagnostics/SKILL.md @@ -39,6 +39,13 @@ failure to the missing member. | --- | --- | --- | --- | | `VO1023` | Warning | Built-in string normalization is configured but the type implements `OnNormalize` or declares its own `Create`. | Remove the option, or move the normalization into the hook/`Create`. The hand-written path wins either way. | | `VO1024` | Warning | Built-in string normalization is configured on a non-string scalar or member. | Remove the option; `Trim`/`Casing` apply only to `string`. | +| `VO1025` | Error | `[StringNormalize]` is on a member of a `[Scalar]`, on a type that is neither `[Scalar]` nor `[ValueObject]`, or on a property that is not a value member. | Move it to a string member of a complex `[ValueObject]`; on a scalar set `Trim`/`Casing` on `[Scalar]`. | +| `VO1026` | Error | An author-declared `[assembly: ValueObjectDefaults]` repeats a value already supplied by the packaged targets from `ValueObjectsDefault*` MSBuild properties. | Remove the argument, or set a different value. | +| `VO1027` | Error | A `[Scalar]`/`[ValueObject]` option repeats the effective assembly default. | Remove the option; it repeats the inherited value. | + +Restating an assembly or type default is reported as `VO1026`/`VO1027` so each option has a single source of +truth. Only `[StringNormalize]` remains a per-member override outside that check; a value that changes the +effective configuration still raises `VO1023`/`VO1024` where those rules apply. ## ZodSharp validation rules diff --git a/skills/purview-value-objects-efcore/SKILL.md b/skills/purview-value-objects-efcore/SKILL.md index f87692f..cb57c12 100644 --- a/skills/purview-value-objects-efcore/SKILL.md +++ b/skills/purview-value-objects-efcore/SKILL.md @@ -18,7 +18,9 @@ EF dependency, so a domain assembly stays free of Entity Framework while the per gets the converters, comparers and conventions. Everything is opt-in per feature with a three-level opt-out: the `DisableValueObjectsEFGeneration` -MSBuild property, then assembly defaults, then per-type options. +MSBuild property, then assembly defaults (the attribute, or the `ValueObjectsDefaultEFMapping`, +`ValueObjectsDefaultGenerateEFConverter`, `ValueObjectsDefaultGenerateEFComparer` and +`ValueObjectsDefaultGenerateEFValueGenerator` properties), then per-type options. ## Wiring it up diff --git a/skills/purview-value-objects/SKILL.md b/skills/purview-value-objects/SKILL.md index a7b24de..a0e6e2f 100644 --- a/skills/purview-value-objects/SKILL.md +++ b/skills/purview-value-objects/SKILL.md @@ -98,7 +98,10 @@ public readonly partial record struct Contact Trim runs first, then casing, then validation — and only inside the generated `Create`. Casing is invariant-culture only, deliberately: a canonical form that depends on the ambient thread culture is not canonical. A hand-written `OnNormalize` or `Create` wins and the option is ignored (`VO1023`); -the option on a non-string is ignored (`VO1024`). +the option on a non-string is ignored (`VO1024`); restating a value the assembly default already sets +is reported as redundant (`VO1026`/`VO1027`). `[StringNormalize]` itself only belongs on a complex value +object member — on a `[Scalar]` or any other type it is the error `VO1025`, and MSBuild can supply the +assembly defaults with `ValueObjectsDefaultTrim` / `ValueObjectsDefaultCasing`. ## Complex value objects diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index 7a8f99a..6d4b1dc 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -3,3 +3,11 @@ ; ; 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. + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +VO1025 | ValueObjects | Error | [StringNormalize] is only supported on a string member of a complex [ValueObject]; on a [Scalar] or any other type it has no effect +VO1026 | ValueObjects | Error | [ValueObjectDefaults] repeats a default already supplied by the project's MSBuild configuration +VO1027 | ValueObjects | Error | A [Scalar]/[ValueObject] option repeats the effective assembly default diff --git a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs index 35be741..32fb2b7 100644 --- a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs @@ -27,6 +27,9 @@ public sealed class ValueObjectDiagnosticAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.EFComplexTypeRequiresEntityFramework8, DiagnosticLibrary.StringNormalizationIgnored, DiagnosticLibrary.StringNormalizationRequiresString, + DiagnosticLibrary.StringNormalizeRequiresValueObject, + DiagnosticLibrary.AssemblyDefaultIsRedundant, + DiagnosticLibrary.ValueObjectOptionIsRedundant, ]; public override void Initialize(AnalysisContext context) @@ -39,22 +42,51 @@ public override void Initialize(AnalysisContext context) context.RegisterCompilationStartAction(compilationContext => { - var scalarAttribute = compilationContext.Compilation.GetTypeByMetadataName( + var compilation = compilationContext.Compilation; + var scalarAttribute = compilation.GetTypeByMetadataName( TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttributeFullName ); - var genericScalarAttribute = compilationContext.Compilation.GetTypeByMetadataName( + var genericScalarAttribute = compilation.GetTypeByMetadataName( TypeLibrary.ScalarAttributeGenericMetadataName ); - var valueObjectAttribute = compilationContext.Compilation.GetTypeByMetadataName( + var valueObjectAttribute = compilation.GetTypeByMetadataName( TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttributeFullName ); if (scalarAttribute is null && genericScalarAttribute is null && valueObjectAttribute is null) return; + var defaultsAttribute = compilation.GetTypeByMetadataName( + TypeLibrary.ValueObjectDefaultsAttributeFullTypeName + ); + if (defaultsAttribute is not null) + { + compilationContext.RegisterCompilationEndAction(context => + ValidateAssemblyDefaults(context, defaultsAttribute) + ); + } + compilationContext.RegisterSymbolAction( context => ValidateValueObject(context, scalarAttribute, genericScalarAttribute, valueObjectAttribute), SymbolKind.NamedType ); + + var stringNormalizeAttribute = compilation.GetTypeByMetadataName( + TypeLibrary.StringNormalizeAttributeFullTypeName + ); + if (stringNormalizeAttribute is not null) + { + compilationContext.RegisterSymbolAction( + context => + ValidateStringNormalizePlacement( + context, + scalarAttribute, + genericScalarAttribute, + valueObjectAttribute, + stringNormalizeAttribute + ), + SymbolKind.Property + ); + } }); } @@ -67,9 +99,7 @@ static void ValidateValueObject( { var typeSymbol = (INamedTypeSymbol)context.Symbol; - var hasScalarAttribute = - (scalarAttribute is not null && HasAttribute(typeSymbol, scalarAttribute)) - || (genericScalarAttribute is not null && HasAttribute(typeSymbol, genericScalarAttribute)); + var hasScalarAttribute = HasScalarAttribute(typeSymbol, scalarAttribute, genericScalarAttribute); var hasValueObjectAttribute = valueObjectAttribute is not null && HasAttribute(typeSymbol, valueObjectAttribute); if (!hasScalarAttribute && !hasValueObjectAttribute) @@ -105,8 +135,324 @@ is not TypeDeclarationSyntax syntax foreach (var diagnostic in result.Diagnostics) context.ReportDiagnostic(diagnostic.ToDiagnostic()); } + + ValidateTypeDefaults(context, typeSymbol, scalarAttribute, genericScalarAttribute, valueObjectAttribute); } + /// + /// Reports [StringNormalize] on a member of a type that is neither a [Scalar] nor a + /// [ValueObject]. Those two shapes are validated by the model builders, which report the same + /// rule with a context-specific reason; reporting here too would duplicate the diagnostic. + /// + static void ValidateStringNormalizePlacement( + SymbolAnalysisContext context, + INamedTypeSymbol? scalarAttribute, + INamedTypeSymbol? genericScalarAttribute, + INamedTypeSymbol? valueObjectAttribute, + INamedTypeSymbol stringNormalizeAttribute + ) + { + if (context.Symbol is not IPropertySymbol property) + return; + + var attribute = property + .GetAttributes() + .FirstOrDefault(candidate => + SymbolEqualityComparer.Default.Equals(candidate.AttributeClass, stringNormalizeAttribute) + ); + if (attribute?.ApplicationSyntaxReference is not { } application) + return; + + var containingType = property.ContainingType; + if (containingType is null) + return; + + if ( + (scalarAttribute is not null && HasAttribute(containingType, scalarAttribute)) + || (genericScalarAttribute is not null && HasAttribute(containingType, genericScalarAttribute)) + || (valueObjectAttribute is not null && HasAttribute(containingType, valueObjectAttribute)) + ) + return; + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.StringNormalizeRequiresValueObject, + application.GetSyntax(context.CancellationToken).GetLocation(), + containingType.Name, + property.Name, + "is neither a [Scalar] nor a [ValueObject] type" + ) + ); + } + + /// + /// Reports an author-declared assembly default that repeats a value the packaged targets already + /// supplied from a ValueObjectsDefault* MSBuild property. + /// + static void ValidateAssemblyDefaults(CompilationAnalysisContext context, INamedTypeSymbol defaultsAttribute) + { + var attributes = context + .Compilation.Assembly.GetAttributes() + .Where(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, defaultsAttribute)) + .ToArray(); + + var buildAttribute = attributes.FirstOrDefault(ValueObjectDefaultsHelper.IsBuildGenerated); + if (buildAttribute is null) + return; + + var projectDefaults = ValueObjectDefaultsAttributeData.FromAttributeData(buildAttribute); + foreach ( + var authorAttribute in attributes.Where(attribute => !ValueObjectDefaultsHelper.IsBuildGenerated(attribute)) + ) + { + ReportRedundantOptions( + context.ReportDiagnostic, + authorAttribute, + projectDefaults, + typeName: null, + DiagnosticLibrary.AssemblyDefaultIsRedundant, + configuredOptions: buildAttribute, + context.CancellationToken + ); + } + } + + /// + /// Reports a [Scalar] or [ValueObject] option that repeats the effective assembly + /// default. The option is redundant: removing it produces exactly the same configuration. + /// + static void ValidateTypeDefaults( + SymbolAnalysisContext context, + INamedTypeSymbol typeSymbol, + INamedTypeSymbol? scalarAttribute, + INamedTypeSymbol? genericScalarAttribute, + INamedTypeSymbol? valueObjectAttribute + ) + { + var assemblyDefaults = ValueObjectDefaultsHelper.ResolveAssemblyDefaults( + context.Compilation.Assembly.GetAttributes() + ); + if (!assemblyDefaults.Exists) + return; + + foreach (var attribute in typeSymbol.GetAttributes()) + { + if ( + ( + scalarAttribute is not null + && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, scalarAttribute) + ) + || ( + genericScalarAttribute is not null + && SymbolEqualityComparer.Default.Equals( + attribute.AttributeClass?.OriginalDefinition, + genericScalarAttribute + ) + ) + || ( + valueObjectAttribute is not null + && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, valueObjectAttribute) + ) + ) + { + ReportRedundantOptions( + context.ReportDiagnostic, + attribute, + assemblyDefaults, + GetAttributeDisplayName(attribute), + DiagnosticLibrary.ValueObjectOptionIsRedundant, + configuredOptions: null, + context.CancellationToken + ); + } + } + } + + /// + /// Compares the explicit named arguments on with the inherited defaults + /// and reports each argument that carries the same value. A positional constructor argument is not an + /// option and is never compared. + /// + static void ReportRedundantOptions( + Action report, + AttributeData attribute, + ValueObjectDefaultsAttributeData defaults, + string? typeName, + DiagnosticDescriptor descriptor, + AttributeData? configuredOptions, + CancellationToken cancellationToken + ) + { + foreach (var argument in attribute.NamedArguments) + { + var optionName = argument.Key; + var constant = argument.Value; + string? value; + + // For the assembly layer, only an option the project actually configured can be repeated. The + // baseline data carries built-in defaults for everything else, and those are not "project" + // configuration. + if ( + configuredOptions is not null + && !configuredOptions.NamedArguments.Any(candidate => + string.Equals(candidate.Key, optionName, StringComparison.Ordinal) + ) + ) + { + continue; + } + + if (TryGetBooleanDefault(defaults, optionName, out var expectedBoolean)) + { + if (constant.Value is not bool actualBoolean || actualBoolean != expectedBoolean) + continue; + + value = actualBoolean ? "true" : "false"; + } + else if (TryGetEnumDefault(defaults, optionName, out var expectedEnum)) + { + var actualEnum = GetEnumMemberName(constant); + if (actualEnum is null || !IsSameEnumMember(expectedEnum, actualEnum)) + continue; + + value = actualEnum; + } + else + { + continue; + } + + var location = + GetArgumentLocation(attribute, optionName, cancellationToken) + ?? attribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken).GetLocation(); + if (location is null) + continue; + + object[] messageArguments = typeName is null ? [optionName, value] : [typeName, optionName, value]; + report(Diagnostic.Create(descriptor, location, messageArguments)); + } + } + + static bool TryGetBooleanDefault(ValueObjectDefaultsAttributeData defaults, string optionName, out bool value) + { + switch (optionName) + { + case "GenerateJsonConverter": + value = defaults.GenerateJsonConverter; + return true; + case "GenerateComparable": + value = defaults.GenerateComparable; + return true; + case "GenerateComparisonOperators": + value = defaults.GenerateComparisonOperators; + return true; + case "GenerateEnumProperties": + value = defaults.GenerateEnumProperties; + return true; + case "GenerateImplicitFromPrimitive": + value = defaults.GenerateImplicitFromPrimitive; + return true; + case "GenerateImplicitToPrimitive": + value = defaults.GenerateImplicitToPrimitive; + return true; + case "GenerateEmpty": + value = defaults.GenerateEmpty; + return true; + case "GenerateConstructor": + value = defaults.GenerateConstructor; + return true; + case "GenerateEFConverter": + value = defaults.GenerateEFConverter; + return true; + case "GenerateEFComparer": + value = defaults.GenerateEFComparer; + return true; + case "GenerateEFValueGenerator": + value = defaults.GenerateEFValueGenerator; + return true; + case "Trim": + value = defaults.Trim; + return true; + default: + value = false; + return false; + } + } + + static bool TryGetEnumDefault(ValueObjectDefaultsAttributeData defaults, string optionName, out string value) + { + switch (optionName) + { + case "EFMapping": + value = defaults.EFMapping; + return true; + case "Casing": + value = defaults.Casing; + return true; + case "DeserializationMode": + value = defaults.DeserializationMode; + return true; + case "ZodSchemaMode": + value = defaults.ZodSchemaMode; + return true; + default: + value = string.Empty; + return false; + } + } + + static string? GetEnumMemberName(TypedConstant constant) + { + if (constant.Type is not INamedTypeSymbol enumType) + return null; + + foreach (var member in enumType.GetMembers().OfType()) + { + if (member.HasConstantValue && Equals(member.ConstantValue, constant.Value)) + return member.Name; + } + + return null; + } + + static bool IsSameEnumMember(string expectedCanonicalValue, string actualMemberName) + { + var separator = expectedCanonicalValue.LastIndexOf('.'); + var expectedMemberName = + separator < 0 ? expectedCanonicalValue : expectedCanonicalValue.Substring(separator + 1); + return string.Equals(expectedMemberName, actualMemberName, StringComparison.Ordinal); + } + + static Location? GetArgumentLocation( + AttributeData attribute, + string optionName, + CancellationToken cancellationToken + ) + { + if (attribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken) is not AttributeSyntax syntax) + return null; + + var argument = syntax.ArgumentList?.Arguments.FirstOrDefault(candidate => + string.Equals(candidate.NameEquals?.Name.Identifier.ValueText, optionName, StringComparison.Ordinal) + ); + return argument?.GetLocation(); + } + + static string GetAttributeDisplayName(AttributeData attribute) + { + var name = attribute.AttributeClass?.Name ?? "ValueObject"; + const string suffix = "Attribute"; + return name.EndsWith(suffix, StringComparison.Ordinal) ? name.Substring(0, name.Length - suffix.Length) : name; + } + + static bool HasScalarAttribute( + INamedTypeSymbol typeSymbol, + INamedTypeSymbol? scalarAttribute, + INamedTypeSymbol? genericScalarAttribute + ) => + (scalarAttribute is not null && HasAttribute(typeSymbol, scalarAttribute)) + || (genericScalarAttribute is not null && HasAttribute(typeSymbol, genericScalarAttribute)); + static bool HasAttribute(INamedTypeSymbol typeSymbol, INamedTypeSymbol attributeType) => typeSymbol .GetAttributes() diff --git a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs index 40b5684..69386ee 100644 --- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs @@ -212,4 +212,34 @@ public static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true ); + + /// VO1025: [StringNormalize] is only supported on a complex value object member + public static readonly DiagnosticDescriptor StringNormalizeRequiresValueObject = new( + id: "VO1025", + title: "[StringNormalize] is only supported on a value object member", + messageFormat: "Type '{0}' applies [StringNormalize] to member '{1}' but {2}; the attribute has no effect", + category: ValueObjectCategory, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + /// VO1026: An assembly default repeats the project default + public static readonly DiagnosticDescriptor AssemblyDefaultIsRedundant = new( + id: "VO1026", + title: "Assembly default is already configured by the project", + messageFormat: "[ValueObjectDefaults] sets '{0}' to '{1}', which is already the project default; remove the argument or change the value", + category: ValueObjectCategory, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + /// VO1027: A value object option repeats the assembly default + public static readonly DiagnosticDescriptor ValueObjectOptionIsRedundant = new( + id: "VO1027", + title: "Value object option is already the assembly default", + messageFormat: "[{0}] sets '{1}' to '{2}', which is already the assembly default; remove the option or change the value", + 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 b08592d..0b4d42d 100644 --- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs +++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs @@ -17,6 +17,13 @@ public static partial class TypeLibrary public const string ValueObjectDefaultsAttributeFullTypeName = SerializationNamespace + ".ValueObjectDefaultsAttribute"; + /// + /// The file name the packaged targets use for the build-generated [assembly: ValueObjectDefaults]. + /// The generator identifies that attribute by this file name, so the targets and the generator must + /// agree on it. + /// + public const string BuildDefaultsFileName = "Purview.ValueObjects.BuildDefaults.g.cs"; + public const string ScalarAttributeFullTypeName = SerializationNamespace + ".ScalarAttribute"; /// The metadata name of the automatic (generic) scalar attribute, [Scalar<T>]. diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index 650fc44..43dd2ba 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs @@ -41,7 +41,7 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "ValueObject", return GeneratorResult.Create([.. diagnosticsList]); } - var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData(compilation.Assembly.GetAttributes()); + var assemblyDefaults = ValueObjectDefaultsHelper.ResolveAssemblyDefaults(compilation.Assembly.GetAttributes()); var valueObjectOptions = ValueObjectDefaultsHelper.Apply( ValueObjectAttributeData.FromAttributeData(attributes), assemblyDefaults, @@ -55,16 +55,12 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "ValueObject", var properties = typeSymbol .GetMembers() .OfType() - .Where(property => - !property.IsStatic - && !property.IsIndexer - && property.GetMethod is not null - && ValueObjectSymbolInspector.IsValueObjectPropertyCandidate(typeSymbol, property) - && SymbolEqualityComparer.Default.Equals(property.ContainingType, typeSymbol) - ) + .Where(property => ValueObjectSymbolInspector.IsValueObjectMember(typeSymbol, property)) .OrderBy(property => property.Locations.FirstOrDefault()?.SourceSpan.Start ?? int.MaxValue) .ToArray(); + CollectStringNormalizePlacementDiagnostics(typeSymbol, properties, diagnosticsList, cancellationToken); + var ctorExists = typeSymbol .Constructors.Where(static ctor => !ctor.IsStatic) .Any(ctor => ValueObjectSymbolInspector.ConstructorMatches(ctor, properties)); @@ -343,12 +339,18 @@ List diagnostics var trim = attributeData.Exists ? attributeData.Trim : assemblyDefaults.Trim; var casing = attributeData.Exists ? attributeData.Casing : assemblyDefaults.Casing; + // A configured option is only "explicit" when it changes the effective default. Restating an + // assembly-level (or MSBuild-level) value is a no-op, so it must not be reported as ignored. + var normalizeExplicit = + trim != assemblyDefaults.Trim + || !ValueObjectDefaultsHelper.AreEquivalentEnumValues(casing, assemblyDefaults.Casing); + if (!trim && !ValueObjectEmitterHelpers.StringNormalization.IsCasingConfigured(casing)) return (false, TypeLibrary.StringCasingFullTypeName + ".None", false); if (!ValueObjectEmitterHelpers.StringNormalization.IsStringTypeName(propertyTypeName)) { - if (attributeData.Exists) + if (normalizeExplicit) { diagnostics.Add( ReportableDiagnostic.Create( @@ -365,7 +367,41 @@ List diagnostics return (false, TypeLibrary.StringCasingFullTypeName + ".None", false); } - return (trim, casing, attributeData.Exists); + return (trim, casing, normalizeExplicit); + } + + /// + /// Reports [StringNormalize] on a property of a complex value object that is not one of its + /// value members (static, indexer, no getter, or declared elsewhere), where the attribute can never + /// have an effect. + /// + static void CollectStringNormalizePlacementDiagnostics( + INamedTypeSymbol typeSymbol, + IPropertySymbol[] properties, + List diagnostics, + CancellationToken cancellationToken + ) + { + foreach (var property in typeSymbol.GetMembers().OfType()) + { + if (properties.Contains(property, SymbolEqualityComparer.Default)) + continue; + + if (ValueObjectSymbolInspector.FindStringNormalizeAttribute(property) is not { } attribute) + continue; + + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.StringNormalizeRequiresValueObject, + isBlocking: false, + attribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken).GetLocation() + ?? property.Locations.FirstOrDefault(), + typeSymbol.Name, + property.Name, + "the member is not a value member of the value object" + ) + ); + } } /// diff --git a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs index 28718bc..26aeae2 100644 --- a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs +++ b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs @@ -146,7 +146,7 @@ CancellationToken cancellationToken var attributes = type.GetAttributes(); if (ScalarAttributeParser.Find(attributes) is { } scalarAttribute) { - var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData( + var assemblyDefaults = ValueObjectDefaultsHelper.ResolveAssemblyDefaults( type.ContainingAssembly.GetAttributes() ); var options = ValueObjectDefaultsHelper.Apply( @@ -196,7 +196,7 @@ CancellationToken cancellationToken ) ) { - var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData( + var assemblyDefaults = ValueObjectDefaultsHelper.ResolveAssemblyDefaults( type.ContainingAssembly.GetAttributes() ); var options = ValueObjectDefaultsHelper.Apply( @@ -243,7 +243,7 @@ static bool IsValueGenerationEnabled(INamedTypeSymbol type, ITypeSymbol provider var options = ValueObjectDefaultsHelper.Apply( ScalarAttributeParser.Parse(scalarAttribute), - ValueObjectDefaultsAttributeData.FromAttributeData(type.ContainingAssembly.GetAttributes()), + ValueObjectDefaultsHelper.ResolveAssemblyDefaults(type.ContainingAssembly.GetAttributes()), attributes ); @@ -275,7 +275,7 @@ static bool HasEFMember(INamedTypeSymbol type, string memberName) static string ResolveEFMapping(INamedTypeSymbol type) { var attributes = type.GetAttributes(); - var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData( + var assemblyDefaults = ValueObjectDefaultsHelper.ResolveAssemblyDefaults( type.ContainingAssembly.GetAttributes() ); var options = ValueObjectDefaultsHelper.Apply( diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index 8c178f6..368b762 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -46,14 +46,14 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca return GeneratorResult.Create([.. diagnosticsList]); } - var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData( - typeSymbol.ContainingAssembly.GetAttributes() - ); + var assemblyDefaults = ValueObjectDefaultsHelper.ResolveAssemblyDefaults(compilation.Assembly.GetAttributes()); var scalarAttribute = ScalarAttributeParser.Find(attributes); if (scalarAttribute is null) return GeneratorResult.Create([.. diagnosticsList]); + CollectStringNormalizePlacementDiagnostics(typeSymbol, diagnosticsList, cancellationToken); + var scalarOptions = ValueObjectDefaultsHelper.Apply( ScalarAttributeParser.Parse(scalarAttribute), assemblyDefaults, @@ -206,7 +206,7 @@ formatProviderType is not null typeSymbol, scalarAttribute, scalarOptions, - attributes, + assemblyDefaults, scalarTypeName, scalarPropertyName, ValueObjectSymbolInspector.HasDeclaredStaticFactory(typeSymbol, "Create", [scalarType]), @@ -342,6 +342,36 @@ formatProviderType is not null return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); } + /// + /// Reports [StringNormalize] applied to a member of a [Scalar] type. Normalization for a + /// scalar is configured through Trim/Casing on [Scalar] or the assembly defaults; + /// the member attribute is never read. + /// + static void CollectStringNormalizePlacementDiagnostics( + INamedTypeSymbol typeSymbol, + List diagnostics, + CancellationToken cancellationToken + ) + { + foreach (var property in typeSymbol.GetMembers().OfType()) + { + if (ValueObjectSymbolInspector.FindStringNormalizeAttribute(property) is not { } attribute) + continue; + + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.StringNormalizeRequiresValueObject, + isBlocking: false, + attribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken).GetLocation() + ?? property.Locations.FirstOrDefault(), + typeSymbol.Name, + property.Name, + "is a [Scalar], which configures normalization through 'Trim'/'Casing' on the [Scalar] attribute" + ) + ); + } + } + /// /// Resolves the built-in string normalization for a scalar. It is only meaningful for a /// string-backed scalar; an explicit request on any other scalar is reported and dropped, while an @@ -353,7 +383,7 @@ formatProviderType is not null INamedTypeSymbol typeSymbol, AttributeData scalarAttribute, ScalarAttributeData scalarOptions, - ImmutableArray attributes, + ValueObjectDefaultsAttributeData assemblyDefaults, string scalarTypeName, string scalarPropertyName, bool createExists, @@ -368,9 +398,11 @@ CancellationToken cancellationToken if (!normalizeTrim && !ValueObjectEmitterHelpers.StringNormalization.IsCasingConfigured(normalizeCasing)) return (false, TypeLibrary.StringCasingFullTypeName + ".None"); + // A configured option is only "explicit" when it changes the effective default. Restating an + // assembly-level (or MSBuild-level) value is a no-op, so it must not be reported. var explicitlySet = - ValueObjectDefaultsHelper.IsScalarPropertyExplicitlySet(attributes, "Trim") - || ValueObjectDefaultsHelper.IsScalarPropertyExplicitlySet(attributes, "Casing"); + normalizeTrim != assemblyDefaults.Trim + || !ValueObjectDefaultsHelper.AreEquivalentEnumValues(normalizeCasing, assemblyDefaults.Casing); if (!ValueObjectEmitterHelpers.StringNormalization.IsStringTypeName(scalarTypeName)) { diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs index 9bec659..a7139c8 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs @@ -3,10 +3,185 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; /// /// Applies assembly-level [ValueObjectDefaults] to per-type [Scalar]/[ValueObject] /// options. Precedence is: an option explicitly set on the type wins, then the assembly default, then -/// the built-in default carried by the type data model. +/// the built-in default carried by the type data model. The assembly default itself is the MSBuild +/// ValueObjectsDefault* property overlaid by an explicit named argument on the attribute, so +/// checked-in source wins over project configuration. /// static class ValueObjectDefaultsHelper { + /// + /// Resolves the effective assembly defaults for the compilation being generated: the built-in + /// defaults, overlaid by the build-generated [ValueObjectDefaults] the packaged targets emit + /// from the ValueObjectsDefault* MSBuild properties, overlaid by the named arguments + /// explicitly set on an author-declared [ValueObjectDefaults] attribute. + /// + public static ValueObjectDefaultsAttributeData ResolveAssemblyDefaults( + ImmutableArray assemblyAttributes + ) + { + AttributeData? buildAttribute = null; + AttributeData? authorAttribute = null; + foreach (var attribute in assemblyAttributes) + { + if (attribute.AttributeClass?.ToDisplayString() != TypeLibrary.ValueObjectDefaultsAttributeFullTypeName) + continue; + + if (IsBuildGenerated(attribute)) + buildAttribute ??= attribute; + else + authorAttribute ??= attribute; + } + + if (buildAttribute is null && authorAttribute is null) + return ValueObjectDefaultsAttributeData.Empty; + + // With only one layer present, its data already carries the attribute's defaults for every option + // it does not name. The empty instance from the generated model holds nulls for enum strings, so it + // must not be used as a stand-in layer. + if (buildAttribute is null) + return Canonicalize(ValueObjectDefaultsAttributeData.FromAttributeData(authorAttribute!)); + if (authorAttribute is null) + return Canonicalize(ValueObjectDefaultsAttributeData.FromAttributeData(buildAttribute)); + + var build = ValueObjectDefaultsAttributeData.FromAttributeData(buildAttribute); + var author = ValueObjectDefaultsAttributeData.FromAttributeData(authorAttribute); + + return Canonicalize( + new( + exists: true, + GenerateJsonConverter: PickBool( + authorAttribute, + "GenerateJsonConverter", + author.GenerateJsonConverter, + build.GenerateJsonConverter + ), + GenerateComparable: PickBool( + authorAttribute, + "GenerateComparable", + author.GenerateComparable, + build.GenerateComparable + ), + GenerateComparisonOperators: PickBool( + authorAttribute, + "GenerateComparisonOperators", + author.GenerateComparisonOperators, + build.GenerateComparisonOperators + ), + GenerateEnumProperties: PickBool( + authorAttribute, + "GenerateEnumProperties", + author.GenerateEnumProperties, + build.GenerateEnumProperties + ), + GenerateImplicitFromPrimitive: PickBool( + authorAttribute, + "GenerateImplicitFromPrimitive", + author.GenerateImplicitFromPrimitive, + build.GenerateImplicitFromPrimitive + ), + GenerateImplicitToPrimitive: PickBool( + authorAttribute, + "GenerateImplicitToPrimitive", + author.GenerateImplicitToPrimitive, + build.GenerateImplicitToPrimitive + ), + GenerateEmpty: PickBool(authorAttribute, "GenerateEmpty", author.GenerateEmpty, build.GenerateEmpty), + GenerateConstructor: PickBool( + authorAttribute, + "GenerateConstructor", + author.GenerateConstructor, + build.GenerateConstructor + ), + EFMapping: PickString(authorAttribute, "EFMapping", author.EFMapping, build.EFMapping), + GenerateEFConverter: PickBool( + authorAttribute, + "GenerateEFConverter", + author.GenerateEFConverter, + build.GenerateEFConverter + ), + GenerateEFComparer: PickBool( + authorAttribute, + "GenerateEFComparer", + author.GenerateEFComparer, + build.GenerateEFComparer + ), + GenerateEFValueGenerator: PickBool( + authorAttribute, + "GenerateEFValueGenerator", + author.GenerateEFValueGenerator, + build.GenerateEFValueGenerator + ), + Trim: PickBool(authorAttribute, "Trim", author.Trim, build.Trim), + Casing: PickString(authorAttribute, "Casing", author.Casing, build.Casing), + DeserializationMode: PickString( + authorAttribute, + "DeserializationMode", + author.DeserializationMode, + build.DeserializationMode + ), + ZodSchemaMode: PickString(authorAttribute, "ZodSchemaMode", author.ZodSchemaMode, build.ZodSchemaMode) + ) + ); + + bool PickBool(AttributeData? attribute, string propertyName, bool authorValue, bool buildValue) => + IsExplicitlySet(attribute, propertyName) ? authorValue : buildValue; + + string PickString(AttributeData? attribute, string propertyName, string authorValue, string buildValue) => + IsExplicitlySet(attribute, propertyName) ? authorValue : buildValue; + + static bool IsExplicitlySet(AttributeData? attribute, string propertyName) => + attribute?.NamedArguments.Any(argument => argument.Key == propertyName) ?? false; + } + + /// + /// Normalises an enum option to the global::-qualified form the generator uses for its mode + /// constants, so every layer stores enum options in the same shape. + /// + static ValueObjectDefaultsAttributeData Canonicalize(ValueObjectDefaultsAttributeData defaults) => + defaults with + { + EFMapping = CanonicalizeEnum(defaults.EFMapping), + Casing = CanonicalizeEnum(defaults.Casing), + DeserializationMode = CanonicalizeEnum(defaults.DeserializationMode), + ZodSchemaMode = CanonicalizeEnum(defaults.ZodSchemaMode), + }; + + static string CanonicalizeEnum(string value) => + value.StartsWith("global::", StringComparison.Ordinal) ? value : "global::" + value; + + /// + /// Compares two enum-option strings from different attribute data models. An explicitly set value is + /// rendered with a global:: prefix, while the property default is not, so the prefix is ignored. + /// + public static bool AreEquivalentEnumValues(string? left, string? right) + { + if (left is null || right is null) + return left is null && right is null; + + return string.Equals(TrimGlobalAlias(left), TrimGlobalAlias(right), StringComparison.Ordinal); + } + + static string TrimGlobalAlias(string value) => + value.StartsWith("global::", StringComparison.Ordinal) ? value.Substring("global::".Length) : value; + + /// + /// True when the attribute was emitted by the packaged targets from MSBuild properties. The targets + /// write it to ; a file name match is stable across + /// builds and does not depend on compile-item ordering. The generated marker is also honoured so an + /// in-memory compilation (an analyzer test, for example) can model the generated file without + /// reproducing its path. + /// + internal static bool IsBuildGenerated(AttributeData attribute) + { + if (attribute.ApplicationSyntaxReference?.SyntaxTree is not { } tree) + return false; + + if (tree.FilePath.EndsWith(TypeLibrary.BuildDefaultsFileName, StringComparison.OrdinalIgnoreCase)) + return true; + + return tree.GetText().ToString().IndexOf("// ", StringComparison.Ordinal) >= 0; + } + public static ScalarAttributeData Apply( ScalarAttributeData typeOptions, ValueObjectDefaultsAttributeData assemblyDefaults, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs index 1b2ea41..af464dc 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -342,7 +342,10 @@ public static bool IsSupportedComplexMember(ITypeSymbol memberType) return true; var attributes = type.GetAttributes(); - var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData( + + // The defaults resolved here include any build-generated defaults the declaring assembly baked in, + // which is what its generated code was compiled against. + var assemblyDefaults = ValueObjectDefaultsHelper.ResolveAssemblyDefaults( type.ContainingAssembly?.GetAttributes() ?? [] ); @@ -564,6 +567,29 @@ public static bool IsValueObjectPropertyCandidate(INamedTypeSymbol typeSymbol, I ) ); + /// + /// True when the property is one of the value object's value members: the members the generated + /// equality, comparison, JSON, and normalization paths operate on. The analyzer and the complex-model + /// builder share this predicate so their diagnostics cannot disagree about what a member is. + /// + public static bool IsValueObjectMember(INamedTypeSymbol typeSymbol, IPropertySymbol property) => + !property.IsStatic + && !property.IsIndexer + && property.GetMethod is not null + && SymbolEqualityComparer.Default.Equals(property.ContainingType, typeSymbol) + && IsValueObjectPropertyCandidate(typeSymbol, property); + + /// + /// Returns the applied [StringNormalize] attribute, or when the member + /// does not carry one. + /// + public static AttributeData? FindStringNormalizeAttribute(IPropertySymbol property) => + property + .GetAttributes() + .FirstOrDefault(attribute => + attribute.AttributeClass?.ToDisplayString() == TypeLibrary.StringNormalizeAttributeFullTypeName + ); + public static bool ImplementsSelfEquatable(INamedTypeSymbol typeSymbol) => typeSymbol.AllInterfaces.Any(interfaceSymbol => interfaceSymbol is INamedTypeSymbol namedTypeSymbol diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index 429e04f..0546b2c 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -63,7 +63,9 @@ public readonly partial record struct EmailAddress { } ``` A complex member uses `[StringNormalize(Trim = true)]`, and `[assembly: ValueObjectDefaults(Trim = true)]` -applies to every string scalar and member. `Hydrate` is never normalized. +applies to every string scalar and member. `Hydrate` is never normalized. The same defaults can be set from +MSBuild with `ValueObjectsDefaultTrim`, `ValueObjectsDefaultCasing`, and one property per +`[ValueObjectDefaults]` option; an option named on the attribute wins over the property. `[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 diff --git a/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.props b/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.props new file mode 100644 index 0000000..00fa51a --- /dev/null +++ b/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.props @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + When true, the Purview.ValueObjects source generator is disabled. + + + When true, the Purview.ValueObjects source generator does not emit Entity Framework Core mapping members or the value object EF registry. + + + When true, the Purview.ValueObjects source generator does not emit the assembly-level value object EF registry (ConfigureValueObjects/UseValueObjects/ValueObjectModelCustomizer). Per-type Entity Framework Core mapping members are still emitted. + + + diff --git a/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets b/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets index 01dabf9..0ebe79c 100644 --- a/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets +++ b/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets @@ -1,13 +1,136 @@ - - - When true, the Purview.ValueObjects source generator is disabled. - - - When true, the Purview.ValueObjects source generator does not emit Entity Framework Core mapping members or the value object EF registry. - - - When true, the Purview.ValueObjects source generator does not emit the assembly-level value object EF registry (ConfigureValueObjects/UseValueObjects/ValueObjectModelCustomizer). Per-type Entity Framework Core mapping members are still emitted. - - + + + + + + + + + + + + + + + + + + + + + + + + <_ValueObjectsBuildDefaultsFile>$(IntermediateOutputPath)Purview.ValueObjects.BuildDefaults.g.cs + <_ValueObjectsBuildDefaultsArguments> + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateJsonConverter)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateJsonConverter = $(ValueObjectsDefaultGenerateJsonConverter), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateComparable)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateComparable = $(ValueObjectsDefaultGenerateComparable), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateComparisonOperators)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateComparisonOperators = $(ValueObjectsDefaultGenerateComparisonOperators), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateEnumProperties)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateEnumProperties = $(ValueObjectsDefaultGenerateEnumProperties), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateImplicitFromPrimitive)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateImplicitFromPrimitive = $(ValueObjectsDefaultGenerateImplicitFromPrimitive), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateImplicitToPrimitive)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateImplicitToPrimitive = $(ValueObjectsDefaultGenerateImplicitToPrimitive), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateEmpty)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateEmpty = $(ValueObjectsDefaultGenerateEmpty), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateConstructor)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateConstructor = $(ValueObjectsDefaultGenerateConstructor), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultEFMapping)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)EFMapping = global::Purview.ValueObjects.Serialization.EntityFrameworkMapping.$(ValueObjectsDefaultEFMapping), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateEFConverter)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateEFConverter = $(ValueObjectsDefaultGenerateEFConverter), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateEFComparer)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateEFComparer = $(ValueObjectsDefaultGenerateEFComparer), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultGenerateEFValueGenerator)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)GenerateEFValueGenerator = $(ValueObjectsDefaultGenerateEFValueGenerator), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultTrim)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)Trim = $(ValueObjectsDefaultTrim), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultCasing)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)Casing = global::Purview.ValueObjects.Serialization.StringCasing.$(ValueObjectsDefaultCasing), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultDeserializationMode)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)DeserializationMode = global::Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.$(ValueObjectsDefaultDeserializationMode), + <_ValueObjectsBuildDefaultsArguments Condition="'$(ValueObjectsDefaultZodSchemaMode)' != ''" + >$(_ValueObjectsBuildDefaultsArguments)ZodSchemaMode = global::Purview.ValueObjects.Serialization.ZodSchemaMode.$(ValueObjectsDefaultZodSchemaMode), + + + <_ValueObjectsBuildDefaultsArguments>$([System.String]::Copy('$(_ValueObjectsBuildDefaultsArguments)').TrimEnd(',')) + + + + + + + diff --git a/src/src/ValueObjects/Serialization/StringNormalizeAttribute.cs b/src/src/ValueObjects/Serialization/StringNormalizeAttribute.cs index 488efe8..6b2c269 100644 --- a/src/src/ValueObjects/Serialization/StringNormalizeAttribute.cs +++ b/src/src/ValueObjects/Serialization/StringNormalizeAttribute.cs @@ -12,13 +12,17 @@ namespace Purview.ValueObjects.Serialization; /// for that member; a member without the attribute inherits the assembly default. /// /// -/// The attribute only has an effect on members. Applying it to a member of any -/// other type is reported as a diagnostic, and the normalization is skipped. +/// The attribute only has an effect on members of a complex +/// value object. Applying it to a member of any other type, to a +/// scalar (which configures normalization with +/// and ), or to a +/// property that is not a value member is reported as the error VO1025. /// /// /// Normalization is only applied by the generated Create. When the value object implements its own /// OnNormalize hook or declares its own Create, that takes precedence and the configured -/// normalization is reported as ignored. +/// normalization is reported as ignored. Restating an option that the assembly defaults (or the +/// ValueObjectsDefault* MSBuild properties) already set is a no-op and is not reported. /// /// [AttributeUsage(AttributeTargets.Property)] diff --git a/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs b/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs index a8d55c9..1d4a2c8 100644 --- a/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs +++ b/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs @@ -5,7 +5,25 @@ namespace Purview.ValueObjects.Serialization; /// These defaults apply to every [Scalar]/[ValueObject] type in the assembly and can be /// overridden on individual / attributes. /// -[AttributeUsage(AttributeTargets.Assembly)] +/// +/// +/// The same options can be supplied from MSBuild with the ValueObjectsDefault<Property> +/// properties (for example ValueObjectsDefaultTrim). The packaged targets emit a second, +/// build-generated instance of this attribute for those values, so the attribute allows multiple +/// applications. An option named explicitly on an author-declared attribute wins over the MSBuild value, +/// and both are overridden by a per-type or per-member option. +/// +/// +/// An argument whose value repeats the project-level default is reported as VO1026, and a +/// [Scalar]/[ValueObject] option that repeats the effective assembly default is reported as +/// VO1027: keep each option in one place. +/// +/// +/// Do not declare a second attribute by hand to express alternatives: the first author-declared +/// attribute wins. Set the MSBuild property instead. +/// +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] public sealed class ValueObjectDefaultsAttribute : Attribute { /// diff --git a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs index 2650835..ef92f12 100644 --- a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs @@ -516,6 +516,348 @@ public readonly partial record struct EmailAddress await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.StringNormalizationIgnored); } + [Test] + public async Task Generate_GivenStringNormalizeOnNonValueObjectType_ReportsRequiresValueObject( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + public sealed class Contact + { + [Purview.ValueObjects.Serialization.StringNormalize(Trim = true)] + public string Email { get; init; } = string.Empty; + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.StringNormalizeRequiresValueObject); + } + + [Test] + public async Task Generate_GivenStringNormalizeOnScalar_ReportsRequiresValueObject( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + [Purview.ValueObjects.Serialization.StringNormalize(Trim = true)] + public string Value { get; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.StringNormalizeRequiresValueObject); + } + + [Test] + public async Task Generate_GivenStringNormalizeOnNonMemberProperty_ReportsRequiresValueObject( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Contact + { + public string Email { get; } + + [Purview.ValueObjects.Serialization.StringNormalize(Trim = true)] + public static string Domain => "example.com"; + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.StringNormalizeRequiresValueObject); + } + + [Test] + public async Task Generate_GivenStringNormalizeOnValueObjectMember_DoesNotReportRequiresValueObject( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Contact + { + [Purview.ValueObjects.Serialization.StringNormalize(Trim = true)] + public string Email { get; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.StringNormalizeRequiresValueObject); + } + + [Test] + public async Task Generate_GivenStringNormalizeOnNonStringMember_ReportsRequiresStringOnly( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Order + { + [Purview.ValueObjects.Serialization.StringNormalize(Trim = true)] + public System.Guid Id { get; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.StringNormalizationRequiresString); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.StringNormalizeRequiresValueObject); + } + + [Test] + public async Task Generate_GivenStringNormalizeRequiresValueObject_IsReportedAsError() + { + await Assert + .That(DiagnosticLibrary.StringNormalizeRequiresValueObject.DefaultSeverity) + .IsEqualTo(Microsoft.CodeAnalysis.DiagnosticSeverity.Error); + } + + [Test] + public async Task Generate_GivenRedundantScalarNormalizationWithHook_ReportsNoIgnored( + CancellationToken cancellationToken + ) + { + // The assembly default already trims; restating it on the type is redundant and is reported. + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = true)] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim()!; + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.StringNormalizationIgnored); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ValueObjectOptionIsRedundant); + } + + [Test] + public async Task Generate_GivenScalarNormalizationOverridingDefaultWithHook_ReportsIgnored( + CancellationToken cancellationToken + ) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = false, Casing = Purview.ValueObjects.Serialization.StringCasing.LowerInvariant)] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim()!; + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.StringNormalizationIgnored); + } + + [Test] + public async Task Generate_GivenRedundantNonStringScalarNormalization_ReportsNoRequiresString( + CancellationToken cancellationToken + ) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = true)] + public readonly partial record struct InstallationId { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.StringNormalizationRequiresString); + } + + [Test] + public async Task Generate_GivenRedundantMemberNormalizationWithHook_ReportsNoIgnored( + CancellationToken cancellationToken + ) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Contact + { + [Purview.ValueObjects.Serialization.StringNormalize(Trim = true)] + public string Email { get; } + + partial void OnNormalize(ref string email) => email = email?.Trim(); + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.StringNormalizationIgnored); + } + + [Test] + public async Task Generate_GivenAssemblyDefaultRepeatsBuildDefault_ReportsAssemblyDefaultIsRedundant( + CancellationToken cancellationToken + ) + { + const string authorSource = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults( + Trim = true, + DeserializationMode = Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict)] + """; + const string buildSource = """ + // + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults( + Trim = true, + DeserializationMode = Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict)] + """; + + var result = await AnalyzeAsync( + [authorSource, buildSource], + new AnalyzerTestOptions { IncludeDefaultNamespaces = false }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AssemblyDefaultIsRedundant); + } + + [Test] + public async Task Generate_GivenAssemblyDefaultDiffersFromBuildDefault_DoesNotReportAssemblyDefaultIsRedundant( + CancellationToken cancellationToken + ) + { + const string authorSource = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = false)] + """; + const string buildSource = """ + // + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + """; + + var result = await AnalyzeAsync( + [authorSource, buildSource], + new AnalyzerTestOptions { IncludeDefaultNamespaces = false }, + cancellationToken + ); + + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.AssemblyDefaultIsRedundant); + } + + [Test] + public async Task Generate_GivenScalarOptionRepeatsAssemblyDefault_ReportsValueObjectOptionIsRedundant( + CancellationToken cancellationToken + ) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = true)] + public readonly partial record struct TenantKey; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ValueObjectOptionIsRedundant); + } + + [Test] + public async Task Generate_GivenScalarOptionDiffersFromAssemblyDefault_DoesNotReportValueObjectOptionIsRedundant( + CancellationToken cancellationToken + ) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = false)] + public readonly partial record struct TenantKey; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ValueObjectOptionIsRedundant); + } + + [Test] + public async Task Generate_GivenValueObjectOptionRepeatsAssemblyDefault_ReportsValueObjectOptionIsRedundant( + CancellationToken cancellationToken + ) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults( + DeserializationMode = Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject( + DeserializationMode = Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict)] + public readonly partial record struct Contact + { + public string Email { get; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ValueObjectOptionIsRedundant); + } + + [Test] + public async Task Generate_GivenDefaultsDiagnostics_AreErrors() + { + await Assert + .That(DiagnosticLibrary.AssemblyDefaultIsRedundant.DefaultSeverity) + .IsEqualTo(Microsoft.CodeAnalysis.DiagnosticSeverity.Error); + await Assert + .That(DiagnosticLibrary.ValueObjectOptionIsRedundant.DefaultSeverity) + .IsEqualTo(Microsoft.CodeAnalysis.DiagnosticSeverity.Error); + } + protected override AnalyzerTestOptions OnBeforeRun( IEnumerable sources, AnalyzerTestOptions options, diff --git a/src/tests/SourceGenerator.UnitTests/Repository/BuildDefaultsTargetsTests.cs b/src/tests/SourceGenerator.UnitTests/Repository/BuildDefaultsTargetsTests.cs new file mode 100644 index 0000000..4857521 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Repository/BuildDefaultsTargetsTests.cs @@ -0,0 +1,86 @@ +namespace Purview.ValueObjects.SourceGenerator.Repository; + +/// +/// Guards the contract between the runtime [ValueObjectDefaults] options, the MSBuild properties +/// the packaged targets expose, and the file name the generator uses to recognise the build-generated +/// attribute. Adding an option without wiring it through the targets would silently do nothing. +/// +public sealed class BuildDefaultsTargetsTests +{ + [Test] + public async Task Props_DeclareOneBuildPropertyPerValueObjectDefaultsOption() + { + // The props is what gives the properties IDE completion and a value before the project body runs. + var props = ReadSdkFile("Purview.ValueObjects.props"); + var missing = typeof(Purview.ValueObjects.Serialization.ValueObjectDefaultsAttribute) + .GetProperties( + System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.DeclaredOnly + ) + .Select(static property => property.Name) + .Where(name => !props.Contains($" property; missing: {string.Join(", ", missing)}" + ); + } + + [Test] + public async Task Targets_ExposeOneBuildPropertyPerValueObjectDefaultsOption() + { + var targets = ReadSdkFile("Purview.ValueObjects.targets"); + var missing = typeof(Purview.ValueObjects.Serialization.ValueObjectDefaultsAttribute) + .GetProperties( + System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.DeclaredOnly + ) + .Select(static property => property.Name) + .Where(name => !targets.Contains($"ValueObjectsDefault{name}", StringComparison.Ordinal)) + .ToArray(); + + await Assert + .That(missing) + .IsEmpty() + .Because( + $"every [ValueObjectDefaults] option needs a ValueObjectsDefault property and a validation rule; missing: {string.Join(", ", missing)}" + ); + } + + [Test] + public async Task Targets_EmitTheBuildDefaultsFileNameTheGeneratorRecognises() + { + var targets = ReadSdkFile("Purview.ValueObjects.targets"); + + await Assert + .That(targets) + .Contains(TypeLibrary.BuildDefaultsFileName) + .Because("the generator identifies the build-generated attribute by this file name"); + } + + static string ReadSdkFile(string fileName) => + File.ReadAllText( + Path.Combine(FindRepositoryRoot(), "src", "src", "ValueObjects", "Sdk", "buildTransitive", fileName) + ); + + static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AGENTS.md"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new InvalidOperationException( + $"Could not find the repository root (an AGENTS.md marker) above '{AppContext.BaseDirectory}'." + ); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/ValueObject/ValueObjectDefaultsHelperTests.cs b/src/tests/SourceGenerator.UnitTests/ValueObject/ValueObjectDefaultsHelperTests.cs new file mode 100644 index 0000000..4947e10 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/ValueObject/ValueObjectDefaultsHelperTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Purview.ValueObjects.SourceGenerator.ValueObject; + +/// +/// White-box tests for the assembly-default layering: the build-generated attribute the packaged targets +/// emit from ValueObjectsDefault* MSBuild properties, and the author-declared attribute that can +/// override it. +/// +public sealed class ValueObjectDefaultsHelperTests +{ + const string BuildDefaultsPath = "obj/Debug/net10.0/" + TypeLibrary.BuildDefaultsFileName; + + [Test] + public async Task ResolveAssemblyDefaults_GivenBuildGeneratedAttribute_AppliesIt() + { + var source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true, Casing = Purview.ValueObjects.Serialization.StringCasing.LowerInvariant)] + """; + + var defaults = Resolve(source, BuildDefaultsPath); + + await Assert.That(defaults.Exists).IsTrue(); + await Assert.That(defaults.Trim).IsTrue(); + await Assert + .That(defaults.Casing) + .IsEqualTo("global::" + TypeLibrary.StringCasingFullTypeName + ".LowerInvariant"); + } + + [Test] + public async Task ResolveAssemblyDefaults_GivenNoAttributes_ReturnsEmpty() + { + var defaults = Resolve("", "Models.cs"); + + await Assert.That(defaults.Exists).IsFalse(); + } + + [Test] + public async Task ResolveAssemblyDefaults_GivenAuthorAttribute_OverridesExplicitBuildOption() + { + // The build says trim; the author explicitly turns it off. Only the author-declared option wins, + // while the build's casing still applies because the author attribute does not name it. + var buildSource = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true, Casing = Purview.ValueObjects.Serialization.StringCasing.UpperInvariant)] + """; + var authorSource = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = false)] + """; + + var defaults = Resolve([buildSource, authorSource], [BuildDefaultsPath, "Models.cs"]); + + await Assert.That(defaults.Trim).IsFalse(); + await Assert + .That(defaults.Casing) + .IsEqualTo("global::" + TypeLibrary.StringCasingFullTypeName + ".UpperInvariant"); + } + + [Test] + public async Task ResolveAssemblyDefaults_GivenBuildAttribute_LeavesUnsetOptionsAtBuiltInDefaults() + { + var source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + """; + + var defaults = Resolve(source, BuildDefaultsPath); + + await Assert.That(defaults.Trim).IsTrue(); + await Assert.That(defaults.GenerateJsonConverter).IsTrue(); + await Assert.That(defaults.GenerateEFValueGenerator).IsFalse(); + await Assert + .That(defaults.EFMapping) + .IsEqualTo("global::" + TypeLibrary.EntityFrameworkMappingFullTypeName + ".ComplexType"); + await Assert + .That(defaults.DeserializationMode) + .IsEqualTo("global::" + TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate"); + } + + static ValueObjectDefaultsAttributeData Resolve(string source, string path) => Resolve([source], [path]); + + static ValueObjectDefaultsAttributeData Resolve(string[] sources, string[] paths) => + ValueObjectDefaultsHelper.ResolveAssemblyDefaults(CreateCompilation(sources, paths).Assembly.GetAttributes()); + + static CSharpCompilation CreateCompilation(string[] sources, string[] paths) + { + var trees = sources.Select((source, index) => CSharpSyntaxTree.ParseText(source, path: paths[index])).ToArray(); + + return CSharpCompilation.Create( + "Purview.ValueObjects.DefaultsTests", + trees, + GetReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + } + + static ImmutableArray GetReferences() + { + var platform = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? string.Empty; + return + [ + .. platform + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(static path => (MetadataReference)MetadataReference.CreateFromFile(path)), + MetadataReference.CreateFromFile( + typeof(Purview.ValueObjects.Serialization.ValueObjectDefaultsAttribute).Assembly.Location + ), + ]; + } +} From 750f942d7826f20ba1cd265609dff064f0c444eb Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 21 Oct 2026 16:34:58 +0100 Subject: [PATCH 2/5] build: take Purview.BuildSdk 1.1.0, Purview.Build 0.7.0 and SourceGeneratorFramework 1.2.0 Also takes Purview.Skills.Testing 1.1.0, which declares TUnit 1.76.0, so the central TUnit pin moves with it. --- .config/dotnet-tools.json | 2 +- Directory.Packages.props | 6 +++--- global.json | 2 +- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 8881711..9edd0c0 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.6.1", + "version": "0.7.0", "commands": [ "purview-build" ], diff --git a/Directory.Packages.props b/Directory.Packages.props index 79edd7e..90c3bd6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,8 +5,8 @@ true true 5.9.0 - 1.75.0 - 1.1.1 + 1.76.0 + 1.2.0 2.1.0 @@ -25,7 +25,7 @@ - + diff --git a/global.json b/global.json index 5820a3b..8b388e3 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.6" + "Purview.BuildSdk": "1.1.0" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index cf83340..a41cd89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "value-objects", - "version": "1.2.0", + "version": "1.2.1", "license": "MIT", "author": { "name": "Kieron Lanning", From e66a0f0b802dd5e5ec5dde50c5c94889d8bed4b2 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 21 Oct 2026 22:58:05 +0100 Subject: [PATCH 3/5] chore: warnings and suggestions --- .config/purview-build.json | 4 +- Directory.Packages.props | 2 +- package.json | 4 +- .../ValueObject/ValueObjectDefaultsHelper.cs | 2 + .../ValueObjectEFSourceGeneratorTests.cs | 100 ++++++++------ .../ScalarAutomaticPropertyTests.cs | 84 ++++++++++-- .../ScalarCustomPropertyNameTests.cs | 12 +- .../Generators/ScalarIFormattableTests.cs | 36 ++++- .../ScalarInterfaceMirroringTests.cs | 60 ++++++-- .../Generators/ScalarToStringOverloadTests.cs | 30 +++- .../StringNormalizationGeneratorTests.cs | 54 ++++++-- .../ValueObjectDefaultsGeneratorTests.cs | 14 +- .../ValueObjectSourceGeneratorTests.cs | 128 ++++++++++++++---- .../ZodSchemaValidationGeneratorTests.cs | 54 ++++++-- ...ZodSharpScalarRuleAdapterGeneratorTests.cs | 6 +- .../Repository/BuildDefaultsTargetsTests.cs | 4 +- .../ValueObjectDefaultsHelperTests.cs | 4 +- 17 files changed, 451 insertions(+), 147 deletions(-) diff --git a/.config/purview-build.json b/.config/purview-build.json index ce3a20f..1a770f3 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -3,8 +3,8 @@ "Solution": "src/ValueObjects.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", - "TestProjects": "*UnitTests.csproj", - "TestFilter": "/*/*/*/*[Category=Unit]" + "TestProjects": "*Tests.csproj", + "TestFilter": "/*/*/*/*" }, "PackValidation": { // Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier diff --git a/Directory.Packages.props b/Directory.Packages.props index 90c3bd6..2b79a7b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ 5.9.0 1.76.0 1.2.0 - 2.1.0 + 2.3.0 diff --git a/package.json b/package.json index a41cd89..1394678 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "value-objects", - "version": "1.2.1", + "version": "1.2.0", "license": "MIT", "author": { "name": "Kieron Lanning", @@ -14,4 +14,4 @@ "type": "git", "url": "git+https://github.com/purview-dev/value-objects.git" } -} \ No newline at end of file +} diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs index a7139c8..ea00106 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs @@ -158,6 +158,7 @@ public static bool AreEquivalentEnumValues(string? left, string? right) if (left is null || right is null) return left is null && right is null; + // The generator uses the global::-qualified form for its mode constants, so the comparison must ignore return string.Equals(TrimGlobalAlias(left), TrimGlobalAlias(right), StringComparison.Ordinal); } @@ -179,6 +180,7 @@ internal static bool IsBuildGenerated(AttributeData attribute) if (tree.FilePath.EndsWith(TypeLibrary.BuildDefaultsFileName, StringComparison.OrdinalIgnoreCase)) return true; + // return tree.GetText().ToString().IndexOf("// ", StringComparison.Ordinal) >= 0; } diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs index a9da570..bfb93c4 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs @@ -61,7 +61,7 @@ public readonly partial record struct Money [Test] public async Task ScalarEFGeneration_EmitsEFClassConverterAndComparer(CancellationToken cancellationToken) { - var result = await GenerateAsync( + using var result = await GenerateAsync( ScalarSource, ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken @@ -92,7 +92,11 @@ public async Task ScalarEFGeneration_EmitsEFClassConverterAndComparer(Cancellati [Test] public async Task ScalarEFGeneration_UsesHydrateFactoryByDefault(CancellationToken cancellationToken) { - var result = await GenerateAsync(ScalarSource, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync( + ScalarSource, + ValueObjectsEFGeneratorTestOptions.Default, + cancellationToken + ); var query = result.Generated(); var emailAddress = query.GetRecord("EmailAddress", "Testing"); @@ -112,7 +116,11 @@ public async Task ScalarEFGeneration_UsesHydrateFactoryByDefault(CancellationTok public async Task ScalarEFGeneration_ConverterAcceptsProviderShapedValues(CancellationToken cancellationToken) { // Arrange - var result = await GenerateAsync(ScalarSource, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync( + ScalarSource, + ValueObjectsEFGeneratorTestOptions.Default, + cancellationToken + ); // Act var emailAddress = Normalize(result.Generated().GetRecord("EmailAddress", "Testing").Node.ToString()); @@ -163,7 +171,7 @@ public readonly partial record struct OrderStatus } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var record = Normalize(result.Generated().GetRecord("OrderStatus", "Testing").Node.ToString()); @@ -207,7 +215,7 @@ public sealed class Customer } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var query = result.Generated(); var registry = Normalize( @@ -244,7 +252,7 @@ public readonly partial record struct CustomerId """; // Arrange - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); // Act var query = result.Generated(); @@ -280,7 +288,7 @@ public readonly partial record struct CustomerId """; // Arrange - var result = await GenerateAsync( + using var result = await GenerateAsync( source, ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken @@ -318,7 +326,7 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var emailAddress = Normalize(result.Generated().GetRecord("EmailAddress", "Testing").Node.ToString()); @@ -342,7 +350,7 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var query = result.Generated(); await Assert.That(query.HasClass("EF")).IsFalse(); @@ -354,7 +362,7 @@ public readonly partial record struct EmailAddress [Test] public async Task ComplexEFGeneration_EmitsComparerAndComplexTypeMarker(CancellationToken cancellationToken) { - var result = await GenerateAsync( + using var result = await GenerateAsync( ComplexSource, ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken @@ -384,7 +392,7 @@ public readonly partial record struct Money } """; - var result = await GenerateAsync( + using var result = await GenerateAsync( source, ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken @@ -419,7 +427,7 @@ public readonly partial record struct Money } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var query = result.Generated(); await Assert.That(query.HasClass("EF")).IsFalse(); @@ -431,7 +439,7 @@ await Assert [Test] public async Task EFRegistry_EmittedWithConfigureValueObjectsExtension(CancellationToken cancellationToken) { - var result = await GenerateAsync( + using var result = await GenerateAsync( ScalarSource, ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken @@ -486,7 +494,7 @@ public sealed class Order : AuditedEntity """; // Act - var result = await GenerateAsync( + using var result = await GenerateAsync( source, ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken @@ -537,7 +545,7 @@ public readonly partial record struct Audit } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var query = result.Generated(); var registry = Normalize( @@ -566,7 +574,7 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); // Referenced assemblies (e.g. shared models with EF value objects) can cause the registry to be // emitted, but the opted-out local type must not be mapped by it. @@ -589,7 +597,7 @@ CancellationToken cancellationToken ), }; - var result = await GenerateAsync(ScalarSource, options, cancellationToken); + using var result = await GenerateAsync(ScalarSource, options, cancellationToken); var query = result.Generated(); await Assert.That(query.HasClass("EF")).IsFalse(); @@ -612,7 +620,7 @@ public readonly partial record struct Money } """; - var result = await GenerateAsync( + using var result = await GenerateAsync( source, ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken @@ -640,7 +648,7 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var query = result.Generated(); await Assert.That(query.HasClass("EF")).IsFalse(); @@ -656,7 +664,11 @@ public readonly partial record struct EmailAddress [Test] public async Task EFRegistry_EmitsUseValueObjectsAndModelCustomizer(CancellationToken cancellationToken) { - var result = await GenerateAsync(ScalarSource, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync( + ScalarSource, + ValueObjectsEFGeneratorTestOptions.Default, + cancellationToken + ); var query = result.Generated(); await Assert.That(query.HasClass("ValueObjectModelCustomizer", "Microsoft.EntityFrameworkCore")).IsTrue(); @@ -697,7 +709,7 @@ public readonly partial record struct CustomId } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); await Assert.That(result).HasDiagnostic("VO1010"); } @@ -736,7 +748,7 @@ public readonly partial record struct LocalId } """; - var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + using var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); var registry = Normalize( result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() @@ -784,7 +796,7 @@ public readonly partial record struct Audit } """; - var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + using var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); var registry = Normalize( result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() @@ -832,7 +844,7 @@ public readonly partial record struct Audit } """; - var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + using var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); var registry = Normalize( result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() @@ -878,7 +890,7 @@ public readonly partial record struct LocalId } """; - var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + using var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); var registry = Normalize( result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() @@ -930,7 +942,7 @@ public readonly partial record struct LocalId } """; - var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + using var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); var registry = Normalize( result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() @@ -968,7 +980,7 @@ public readonly partial record struct LocalId } """; - var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + using var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); var registry = Normalize( result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() @@ -1023,7 +1035,7 @@ public readonly partial record struct LocalId } """; - var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + using var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); var registry = Normalize( result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() @@ -1062,7 +1074,7 @@ public readonly partial record struct LocalId } """; - var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + using var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); var registry = Normalize( result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() @@ -1083,7 +1095,7 @@ CancellationToken cancellationToken ), }; - var result = await GenerateAsync(ScalarSource, options, cancellationToken); + using var result = await GenerateAsync(ScalarSource, options, cancellationToken); var query = result.Generated(); await Assert.That(query.HasClass("EF")).IsTrue(); @@ -1099,7 +1111,7 @@ async Task EmitStubAsync(string source, CancellationToken can { // Compiles a stub assembly against the same reference set the test framework uses, so the stub only // adds the types it declares. - var probe = await GenerateAsync( + using var probe = await GenerateAsync( "namespace Probe { }", ValueObjectsEFGeneratorTestOptions.Default, cancellationToken @@ -1151,7 +1163,7 @@ async Task EmitSharedReferenceWithoutEFAsync(string source, C { // Reuse the framework to obtain the full reference set, then drop Entity Framework so the value // object provider assembly is generated without any EF members or marker interfaces. - var probe = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var probe = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var references = probe .CompilationResult.Compilation.References.Where(static reference => !IsEntityFrameworkReference(reference.Display) @@ -1209,7 +1221,7 @@ public readonly partial record struct TenantId } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var generated = result.Generated(); // The value object's EF class carries the generator pair. @@ -1253,7 +1265,7 @@ public readonly partial record struct TenantId } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var generated = result.Generated(); // The value object's EF class carries a generator pair per ordering strategy. @@ -1316,7 +1328,7 @@ public readonly partial record struct TenantId } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); // The helper is public so application code in another assembly can mint the key a value object should // be saved with, before the round trip that would otherwise have generated it. @@ -1364,7 +1376,7 @@ public readonly partial record struct TenantId } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); await Assert .That(result.Generated().GetRecord("TenantId", "Testing").Node.ToString()) @@ -1387,7 +1399,7 @@ public readonly partial record struct CurrencyCode } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); await Assert.That(result).HasDiagnostic("VO1021"); await Assert @@ -1411,7 +1423,7 @@ public readonly partial record struct TenantId } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); await Assert.That(result).HasDiagnostic("VO1021"); } @@ -1435,7 +1447,7 @@ public readonly partial record struct Audit } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); await Assert.That(result).HasDiagnostic("VO1017"); } @@ -1456,7 +1468,7 @@ public readonly partial record struct Audit } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); await Assert.That(result).HasDiagnostic("VO1018"); } @@ -1479,7 +1491,7 @@ public readonly partial record struct Audit } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); await Assert.That(result).HasDiagnostic("VO1018"); } @@ -1519,7 +1531,7 @@ public readonly partial record struct Money } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); await Assert.That(result).DoesNotHaveDiagnostic("VO1018"); await Assert.That(result).HasNoErrorDiagnostics(); @@ -1572,7 +1584,7 @@ public readonly partial record struct AuditStamp } """; - var result = await GenerateAsync(source, WithSharedReference(ef7Stub), cancellationToken); + using var result = await GenerateAsync(source, WithSharedReference(ef7Stub), cancellationToken); // The complex-type mapping needs the Entity Framework Core 8 API, so the generator reports VO1019 // and the reference set describes what is missing. @@ -1616,7 +1628,7 @@ public readonly partial record struct AuditStamp } """; - var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var auditStamp = Normalize(result.Generated().GetRecord("AuditStamp", "Testing").Node.ToString()); await Assert.That(auditStamp).Contains("JsonReaderWriter"); diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs index 976a78a..20084fc 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs @@ -45,7 +45,11 @@ public static bool RejectsEmpty() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -75,7 +79,11 @@ public static class Harness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -109,7 +117,11 @@ public static decimal ReadThroughInterface() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -164,7 +176,11 @@ public static bool CreateRejectsWhitespace() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -199,7 +215,11 @@ public static string SerializeNull() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -237,7 +257,11 @@ public static bool RejectsNull() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -262,7 +286,11 @@ public static class Harness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -292,7 +320,11 @@ public static string SerializeNull() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -320,7 +352,11 @@ public static class Harness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -347,7 +383,11 @@ public static class Harness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -373,7 +413,11 @@ public readonly partial record struct TypeofNoConverterId { } } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using 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" }) @@ -399,7 +443,11 @@ public readonly partial record struct TypeofNoEmptyId { } } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using 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" }) @@ -447,7 +495,11 @@ public static bool RejectsEmpty() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; @@ -476,7 +528,11 @@ public readonly partial record struct OverriddenId { } } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var defaultedEmpty = assembly diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs index e7116bf..5b74e43 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs @@ -52,7 +52,11 @@ public static bool ValueForwardsToCustomName() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); // The assembly is only non-null when the generated code compiled, which is the actual assertion. var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); @@ -92,7 +96,11 @@ public static class DefaultNameHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.DefaultNameHarness")!; diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarIFormattableTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarIFormattableTests.cs index adfde4e..6bbfac0 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarIFormattableTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarIFormattableTests.cs @@ -35,7 +35,11 @@ public static string CompositeFormat() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var generated = result.Generated().GetRecord("Money", "Testing").Node.ToString(); await Assert.That(generated).Contains("global::System.IFormattable"); @@ -77,7 +81,11 @@ public static class EmailFormattingHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var generated = result.Generated().GetRecord("EmailAddress", "Testing").Node.ToString(); await Assert.That(generated).DoesNotContain("IFormattable"); @@ -110,7 +118,11 @@ public static string FormatValue() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.OptionalQuantityHarness")!; @@ -155,7 +167,11 @@ public static string FormatValue() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.MoneyLabelHarness")!; @@ -194,7 +210,11 @@ public static string FormatNumber() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.PriorityHarness")!; @@ -231,7 +251,11 @@ public static string Format() => // The assembly is only non-null when the generated partial did not collide with the author's // method (CS0111), which is the actual assertion. - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.AuthorFormattingHarness")!; diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs index ccc6d7c..bf173c5 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs @@ -71,7 +71,11 @@ public static bool TryParseInvalid() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.InstallationIdHarness")!; var id = Guid.NewGuid(); @@ -115,7 +119,11 @@ public static class OrderCodeHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.OrderCodeHarness")!; @@ -144,7 +152,11 @@ public static bool IsParsable() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.OptionalQuantityHarness")!; @@ -189,7 +201,11 @@ public static bool ParseEmptyThrows() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.TenantIdHarness")!; @@ -227,7 +243,11 @@ public static bool IsSpanFormattable() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.EmailAddressHarness")!; @@ -273,7 +293,11 @@ public static string TryFormatSpan() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.PriorityHarness")!; @@ -313,7 +337,11 @@ public static bool IsParsable() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.WrapperHarness")!; @@ -348,7 +376,11 @@ public static bool IsParsable() => """; // The assembly is only non-null when the generated partial did not collide with the author's method. - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.CustomCodeHarness")!; @@ -381,7 +413,11 @@ public static class CodeHarness // The generated code must not report CS8767 (the author's non-nullable parameter cannot satisfy // IEquatable.Equals(string?)), so the interface is left to the author. - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.CodeHarness")!; @@ -411,7 +447,11 @@ public static class CodeHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.CodeHarness")!; diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarToStringOverloadTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarToStringOverloadTests.cs index be692c0..11b1bc1 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarToStringOverloadTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarToStringOverloadTests.cs @@ -25,7 +25,11 @@ public static class InstallationIdHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var generated = result.Generated().GetRecord("InstallationId", "Testing").Node.ToString(); await Assert.That(generated).Contains("string? format"); @@ -67,7 +71,11 @@ public static class PriorityHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.PriorityHarness")!; @@ -98,7 +106,11 @@ public static class OptionalQuantityHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.OptionalQuantityHarness")!; @@ -128,7 +140,11 @@ public static class EmailAddressHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var generated = result.Generated().GetRecord("EmailAddress", "Testing").Node.ToString(); await Assert.That(generated).DoesNotContain("string? format"); @@ -165,7 +181,11 @@ public static class CodeHarness """; // The assembly is only non-null when the generated partial did not collide with the author's method. - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.CodeHarness")!; diff --git a/src/tests/SourceGenerator.UnitTests/Generators/StringNormalizationGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/StringNormalizationGeneratorTests.cs index 46a5fd4..531c42b 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/StringNormalizationGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/StringNormalizationGeneratorTests.cs @@ -25,7 +25,11 @@ public static class NameHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var createBody = result.Generated().GetRecord("Name", "Testing").GetMethod("Create").Node.Body?.ToString() ?? string.Empty; @@ -54,7 +58,11 @@ public static class EmailHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var createBody = result.Generated().GetRecord("Email", "Testing").GetMethod("Create").Node.Body?.ToString() ?? string.Empty; @@ -84,7 +92,11 @@ public static class CurrencyHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var createBody = result.Generated().GetRecord("CurrencyCode", "Testing").GetMethod("Create").Node.Body?.ToString() @@ -115,7 +127,11 @@ public static class NameHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly.GetType("Testing.NameHarness")!; @@ -141,7 +157,11 @@ public static class NameHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly.GetType("Testing.NameHarness")!; @@ -181,7 +201,11 @@ public static string CreateNormalized() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var createBody = result.Generated().GetRecord("Contact", "Testing").GetMethod("Create").Node.Body?.ToString() @@ -230,7 +254,11 @@ public static string CreateTrimmed() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var createBody = result.Generated().GetRecord("Contact", "Testing").GetMethod("Create").Node.Body?.ToString() @@ -265,7 +293,11 @@ public static class NameHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var createBody = result.Generated().GetRecord("Name", "Testing").GetMethod("Create").Node.Body?.ToString() ?? string.Empty; @@ -313,7 +345,11 @@ public static string CreateNormalized() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var createBody = result.Generated().GetRecord("Contact", "Testing").GetMethod("Create").Node.Body?.ToString() diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs index 5d1b58b..b25ad15 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs @@ -26,7 +26,7 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result.Generated().HasClass("EmailAddressJsonConverter", "Testing")).IsFalse(); } @@ -51,7 +51,7 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result.Generated().HasClass("EmailAddressJsonConverter", "Testing")).IsTrue(); } @@ -82,7 +82,7 @@ private Money(decimal amount, string currency) } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var money = result.Generated().GetRecord("Money", "Testing"); await Assert.That(money.TryGetConstructor(out _, [])).IsFalse(); @@ -114,7 +114,7 @@ private Money(decimal amount, string currency) } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var money = result.Generated().GetRecord("Money", "Testing"); await Assert.That(money.TryGetConstructor(out _, [])).IsTrue(); @@ -171,7 +171,11 @@ public static bool CreateSkipsOnValidate() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs index b8b919b..8dda34d 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs @@ -76,7 +76,11 @@ public static bool OperatorEqualsPrimitiveReverse() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.ValueObjectHarness")!; @@ -134,7 +138,7 @@ static partial void OnValidate(string value) } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var query = result.Generated(); var blobUri = query.GetRecord("BlobUri", "Testing"); @@ -170,7 +174,11 @@ public static class PhoneHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var phoneNumber = query.GetRecord("PhoneNumber", "Testing"); var ctor = phoneNumber.GetConstructor(TypeRefs.String); @@ -201,7 +209,7 @@ public sealed partial record BlobUri } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var query = result.Generated(); var blobUri = query.GetRecord("BlobUri", "Testing"); @@ -232,7 +240,11 @@ public static class EmailToStringHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var emailAddress = query.GetRecord("EmailAddress", "Testing"); var toString = emailAddress.GetMethod("ToString"); @@ -260,7 +272,7 @@ public sealed partial record BlobUri } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result.Generated().GetRecord("BlobUri", "Testing").HasProperty("Empty")).IsFalse(); } @@ -306,7 +318,11 @@ public static class ProjectHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var projectId = query.GetRecord("ProjectId", "Testing"); var ctor = projectId.GetConstructor(TypeRefs.String); @@ -338,7 +354,7 @@ public readonly partial struct LegacyStatus } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ScalarShouldBeRecordStruct); } @@ -362,7 +378,7 @@ public readonly partial record struct BrokenScalar """; var generatorOnlyOptions = ValueObjectsGeneratorTestOptions.Default with { AnalyzerTypes = [] }; - var result = await GenerateAsync(source, generatorOnlyOptions, cancellationToken); + using var result = await GenerateAsync(source, generatorOnlyOptions, cancellationToken); await Assert .That(result.AllSyntaxTrees.Length) @@ -387,7 +403,7 @@ public readonly partial record struct BrokenScalar } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ScalarPropertyMissing); await Assert @@ -427,7 +443,11 @@ public static bool AreEqual() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var reportProcessingStatus = query.GetRecord("ReportProcessingStatus", "Testing"); var statusType = TypeRefs.Named("ReportProcessingStatus", "Testing"); @@ -468,7 +488,7 @@ public enum ReportProcessingStatusCode } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var query = result.Generated(); var reportProcessingStatus = query.GetRecord("ReportProcessingStatus", "Testing"); @@ -546,7 +566,11 @@ public static decimal DeserializeAmount() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.ComplexHarness")!; @@ -587,7 +611,11 @@ public static class AddressHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var address = query.GetClass("Address", "Testing"); var addressType = TypeRefs.Named("Address", "Testing"); @@ -625,7 +653,7 @@ public partial class UserDetails } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var query = result.Generated(); var userDetails = query.GetClass("UserDetails", "Testing"); @@ -654,7 +682,11 @@ public partial class UserDetails(System.Guid id, string displayName); } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var userCaptureStruct = query.GetStruct("UserCaptureStruct", "Testing"); await Assert.That(userCaptureStruct.Node.BaseList?.ToString()).Contains("IEquatable"); @@ -686,7 +718,11 @@ public static bool EqualsIsValueBased() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var moneyAmount = query.GetClass("MoneyAmount", "Testing"); await Assert.That(moneyAmount.Node.BaseList?.ToString()).Contains("IEquatable"); @@ -724,7 +760,11 @@ public static bool RoundTripsAsEmptyJson() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var emptyValueObject = query.GetClass("EmptyValueObject", "Testing"); var create = emptyValueObject.GetMethod("Create"); @@ -759,7 +799,7 @@ public partial class UserDetails } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result.Generated().GetClass("UserDetails", "Testing").HasProperty("Empty")).IsFalse(); } @@ -794,7 +834,11 @@ public static class StrictHarness } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var converter = query.GetClass("StrictEmailAddressJsonConverter", "Testing"); await Assert.That(converter.GetMethod("Read").Node.Body?.ToString()).Contains("return Create(value);"); @@ -843,7 +887,7 @@ public readonly partial record struct Name } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var query = result.Generated(); var name = query.GetRecord("Name", "Testing"); @@ -916,7 +960,11 @@ public static bool AreEqual() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var reportProcessingStatus = query.GetStruct("ReportProcessingStatus", "Testing"); await Assert @@ -954,7 +1002,7 @@ public readonly partial record struct Name } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var query = result.Generated(); var name = query.GetRecord("Name", "Testing"); @@ -985,7 +1033,11 @@ public readonly partial record struct Name } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var name = query.GetRecord("Name", "Testing"); @@ -1030,7 +1082,7 @@ private Money(decimal amount) } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var query = result.Generated(); var money = query.GetRecord("Money", "Testing"); @@ -1141,7 +1193,11 @@ public static int CompareUserDetails2() => } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.UserDetailsHarness")!; @@ -1220,7 +1276,11 @@ public static object[] CreateAll() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var recordCtor = query.GetRecord("UserCaptureRecord", "Testing").GetConstructor(); @@ -1312,7 +1372,11 @@ public static bool ActiveBlankDisplayNameThrows() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.UserDetailsNormalizeHarness")!; @@ -1369,7 +1433,11 @@ public static bool ActiveBlankDisplayNameThrows() } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); var query = result.Generated(); var onValidate = query .GetRecord("UserDetails", "Testing") @@ -1460,7 +1528,7 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ZodSchemaNameInvalid); await Assert diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs index 104924d..8ff8981 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs @@ -48,7 +48,11 @@ public static bool CreateRejectsInvalid() } """; - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + using var result = await GenerateAsync( + source, + ZodSchemaValidationGeneratorTestOptions.Compile, + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; @@ -102,7 +106,11 @@ public static bool OnValidateRejects() } """; - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + using var result = await GenerateAsync( + source, + ZodSchemaValidationGeneratorTestOptions.Compile, + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; @@ -142,7 +150,11 @@ public static bool CreateSkipsOnValidate() => } """; - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + using var result = await GenerateAsync( + source, + ZodSchemaValidationGeneratorTestOptions.Compile, + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; @@ -199,7 +211,11 @@ public static bool HydrateSkipsHook() => } """; - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + using var result = await GenerateAsync( + source, + ZodSchemaValidationGeneratorTestOptions.Compile, + cancellationToken + ); var query = result.Generated(); var emailAddress = query.GetRecord("EmailAddress", "Testing"); @@ -263,7 +279,11 @@ public static class Harness } """; - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + using var result = await GenerateAsync( + source, + ZodSchemaValidationGeneratorTestOptions.Compile, + cancellationToken + ); var query = result.Generated(); var money = query.GetRecord("Money", "Testing"); @@ -307,7 +327,11 @@ static partial void OnValidate(string value) } """; - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync( + source, + ZodSchemaValidationGeneratorTestOptions.Default, + cancellationToken + ); await Assert.That(result).HasDiagnostic("VO1013"); } @@ -354,7 +378,11 @@ public static bool CreateRejectsInvalid() } """; - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + using var result = await GenerateAsync( + source, + ZodSchemaValidationGeneratorTestOptions.Compile, + cancellationToken + ); var query = result.Generated(); await Assert.That(query.GetClass("CorporateEmailSchema", "Testing").Node).IsNotNull(); @@ -431,7 +459,11 @@ public static int CreateReportsPathLength() } """; - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + using var result = await GenerateAsync( + source, + ZodSchemaValidationGeneratorTestOptions.Compile, + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; @@ -502,7 +534,11 @@ public static bool InvalidFails() => } """; - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + using var result = await GenerateAsync( + source, + ZodSchemaValidationGeneratorTestOptions.Compile, + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs index d87a683..5d1e8ae 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs @@ -25,7 +25,7 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default, cancellationToken); var adapter = await Assert.That(result.GetSource(TypeLibrary.ScalarRuleAdapterHintName)).IsNotNull(); @@ -85,7 +85,7 @@ public static bool RejectsSentinel() => } """; - var result = await GenerateAsync( + using var result = await GenerateAsync( source, ValueObjectsGeneratorTestOptions.Default with { @@ -134,7 +134,7 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default, cancellationToken); + using var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default, cancellationToken); await Assert.That(result.GetSource(TypeLibrary.ScalarRuleAdapterHintName)).IsNull(); } diff --git a/src/tests/SourceGenerator.UnitTests/Repository/BuildDefaultsTargetsTests.cs b/src/tests/SourceGenerator.UnitTests/Repository/BuildDefaultsTargetsTests.cs index 4857521..9fe7dba 100644 --- a/src/tests/SourceGenerator.UnitTests/Repository/BuildDefaultsTargetsTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Repository/BuildDefaultsTargetsTests.cs @@ -12,7 +12,7 @@ public async Task Props_DeclareOneBuildPropertyPerValueObjectDefaultsOption() { // The props is what gives the properties IDE completion and a value before the project body runs. var props = ReadSdkFile("Purview.ValueObjects.props"); - var missing = typeof(Purview.ValueObjects.Serialization.ValueObjectDefaultsAttribute) + var missing = typeof(ValueObjectDefaultsAttribute) .GetProperties( System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance @@ -34,7 +34,7 @@ await Assert public async Task Targets_ExposeOneBuildPropertyPerValueObjectDefaultsOption() { var targets = ReadSdkFile("Purview.ValueObjects.targets"); - var missing = typeof(Purview.ValueObjects.Serialization.ValueObjectDefaultsAttribute) + var missing = typeof(ValueObjectDefaultsAttribute) .GetProperties( System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance diff --git a/src/tests/SourceGenerator.UnitTests/ValueObject/ValueObjectDefaultsHelperTests.cs b/src/tests/SourceGenerator.UnitTests/ValueObject/ValueObjectDefaultsHelperTests.cs index 4947e10..188e180 100644 --- a/src/tests/SourceGenerator.UnitTests/ValueObject/ValueObjectDefaultsHelperTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ValueObject/ValueObjectDefaultsHelperTests.cs @@ -102,9 +102,7 @@ static ImmutableArray GetReferences() .. platform .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) .Select(static path => (MetadataReference)MetadataReference.CreateFromFile(path)), - MetadataReference.CreateFromFile( - typeof(Purview.ValueObjects.Serialization.ValueObjectDefaultsAttribute).Assembly.Location - ), + MetadataReference.CreateFromFile(typeof(ValueObjectDefaultsAttribute).Assembly.Location), ]; } } From 520f4c4eb6b3d2365755a0ac3587b83b453eaff1 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 22 Oct 2026 12:58:26 +0100 Subject: [PATCH 4/5] build: updated workflow for packaging --- .editorconfig | 26 +++++++++++++++++++++----- .github/workflows/release.yml | 7 +++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.editorconfig b/.editorconfig index 120d509..b511789 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,23 @@ +# Purview.BuildSdk 1.1.0 - managed file, do not edit +# +# This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version +# on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is +# yours, and is never touched. +# +# To add your own rules, create a second .editorconfig in a subdirectory - `src/.editorconfig` is the +# usual choice - without `root = true`. EditorConfig applies the nearest file last, so your settings win +# over these, and nothing you write is lost when this file is updated. +# +# To stop the SDK updating this file, set one of: +# false keep this file as it is +# Never opt out of all bootstrapping +# false +# +# Deleting the first line also works: without the marker the SDK treats the file as yours. You then own +# every rule in it, including the ones a future SDK version would have fixed. +# +# See https://github.com/purview-dev/build-sdk#editorconfig for the full description. + root = true # Base Configuration - General Settings, File Formatting, and Naming Conventions @@ -360,12 +380,8 @@ dotnet_style_require_accessibility_modifiers = omit_if_default:warning # Code-block preferences csharp_prefer_braces = when_multiline:suggestion csharp_prefer_simple_using_statement = false:silent -csharp_prefer_system_threading_lock = true dotnet_style_prefer_collection_expression = true:suggestion -# Enable foreach explicit cast preference -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning - # Prefer System.Threading.Lock csharp_prefer_system_threading_lock = true:warning @@ -497,7 +513,7 @@ dotnet_style_prefer_auto_properties = true:warning dotnet_style_prefer_compound_assignment = true:warning dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_return = true:suggestion -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning dotnet_style_prefer_inferred_tuple_names = true:warning dotnet_style_prefer_simplified_boolean_expressions = false:silent diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ad9387..ce008df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,13 @@ on: push: branches: [main] +permissions: + contents: write + # The shared release workflow pushes the same packages to nuget.org and, when + # PURVIEW_PUBLISH_GITHUB_PACKAGES=true, to GitHub Packages. A reusable workflow cannot grant + # more than its caller, so the permission is declared here. + packages: write + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true From 7fc00b444a6ea0a49efe72bf5526af4353c120b6 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 22 Oct 2026 13:09:33 +0100 Subject: [PATCH 5/5] build: updated workflow for packaging --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 9edd0c0..f8a4785 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.7.0", + "version": "0.7.1", "commands": [ "purview-build" ],