diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index d51c9f0..f8a4785 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.7.1",
"commands": [
"purview-build"
],
diff --git a/.config/purview-build.json b/.config/purview-build.json
index 54c0d45..1a770f3 100644
--- a/.config/purview-build.json
+++ b/.config/purview-build.json
@@ -3,18 +3,21 @@
"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
// 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/.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
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/Directory.Packages.props b/Directory.Packages.props
index 79edd7e..2b79a7b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,9 +5,9 @@
true
true
5.9.0
- 1.75.0
- 1.1.1
- 2.1.0
+ 1.76.0
+ 1.2.0
+ 2.3.0
@@ -25,7 +25,7 @@
-
+
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/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 66f057c..1394678 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",
@@ -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/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..ea00106 100644
--- a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs
+++ b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs
@@ -3,10 +3,187 @@ 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;
+
+ // 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);
+ }
+
+ 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.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/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/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
new file mode 100644
index 0000000..9fe7dba
--- /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(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(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..188e180
--- /dev/null
+++ b/src/tests/SourceGenerator.UnitTests/ValueObject/ValueObjectDefaultsHelperTests.cs
@@ -0,0 +1,108 @@
+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(ValueObjectDefaultsAttribute).Assembly.Location),
+ ];
+ }
+}