From fde6e939a1b6f102987eeaf5bab9a6c13a1dce21 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 2 Oct 2026 15:06:38 +0100 Subject: [PATCH 01/11] chore: formatting --- .agents/agents/sdk-consumer-setup.md | 8 +- .agents/agents/sdk-repository-rationaliser.md | 38 +++++ .../prompts/sdk-review-repository-shape.md | 33 +++++ .../sdk-engineering-principles/.gitignore | 8 ++ .editorconfig | 130 +++++++++--------- Directory.Packages.props | 2 +- global.json | 2 +- package.json | 2 +- src/Directory.Build.props | 7 + .../Common/DiagnosticLibrary.cs | 2 +- .../ValueObjectEFRegistryEmitter.cs | 48 +++++-- .../ValueObjectEFSourceGeneratorTests.cs | 51 +++++++ .../Common/ZodSharpSourceGenerators.cs | 8 +- .../PackagedAnalyzerComponents.cs | 5 +- .../CompatibilityModels.cs | 21 +++ .../EntityFrameworkCompatibilityTests.cs | 47 +++++++ 16 files changed, 323 insertions(+), 89 deletions(-) create mode 100644 .agents/agents/sdk-repository-rationaliser.md create mode 100644 .agents/prompts/sdk-review-repository-shape.md create mode 100644 .agents/skills/sdk-engineering-principles/.gitignore diff --git a/.agents/agents/sdk-consumer-setup.md b/.agents/agents/sdk-consumer-setup.md index 8e5066c..c3cc84f 100644 --- a/.agents/agents/sdk-consumer-setup.md +++ b/.agents/agents/sdk-consumer-setup.md @@ -14,11 +14,14 @@ Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, variables, `.git` root, or a nearby `package.json`. `UsePackageJsonVersion=Strict` fails fast instead of silently skipping resolution. 4. If the bundled `.agents/**` content isn't appearing in the repo root, check `EnableAgentFolderInPackage` - (default `true`) and `AgentPackDestinationFolder` (default `.agents`) — the copy runs before build via + (default `true`) and `AgentPackDestinationFolder` (default `.agents`) - the copy runs before build via `EnsureAgentFolderInPackageTarget`. 5. For test-framework or project-shape questions, confirm the project follows repo naming and placement conventions the SDK expects, rather than introducing bespoke structure. -6. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm +6. For naming, layout, and test readability questions, start from the engineering principles: naming and + placement are configuration, short project names are preferred, the detected test type becomes the baseline + category, and subject-based tests should be named for the subject they own. +7. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm the fix. ## Constraints @@ -32,3 +35,4 @@ Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, ## Related skill See `../skills/sdk-configuration-reference/SKILL.md` for the full property reference. +See `../skills/sdk-engineering-principles/SKILL.md` for the higher-level repository conventions. diff --git a/.agents/agents/sdk-repository-rationaliser.md b/.agents/agents/sdk-repository-rationaliser.md new file mode 100644 index 0000000..d41060b --- /dev/null +++ b/.agents/agents/sdk-repository-rationaliser.md @@ -0,0 +1,38 @@ +# sdk-repository-rationaliser (generic agent spec) + +## Goal + +Help a repository move toward `Purview.BuildSdk` conventions without unnecessary churn. + +## Workflow + +1. Start by reading the repo's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, + and current project layout. +2. Identify the current `NamespacePrefix`, project naming scheme, and test project suffixes in use. +3. Compare the repo's structure against the engineering principles: + - short project names + - source/test split where practical + - exact shared/shared-testing names when SDK behavior is expected + - readable, scalable test naming +4. Separate findings into three groups: + - already aligned + - misaligned but harmless + - misaligned and blocking SDK automatic behavior +5. Prefer the smallest sequence of changes that improves predictability without forcing broad renames. +6. When recommending test changes, preserve readable behavior-oriented suites while tightening subject-based + suites toward `{SubjectName}Tests` and `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. +7. Confirm whether specialized dependencies such as `TUnit.Aspire` or `Testcontainers` are genuinely needed + rather than treating them as universal defaults. + +## Constraints + +- Do not assume every older repo should be renamed wholesale. +- Preserve meaningful established structure unless it interferes with SDK inference. +- Prefer explaining the effect on `RootNamespace`, `AssemblyName`, `PackageId`, `TestingType`, and + `TargetProjectName` instead of arguing from taste. + +## Related skills + +- `../skills/sdk-engineering-principles/SKILL.md` +- `../skills/project-placement-defaults/SKILL.md` +- `../skills/sdk-project-behavior-and-detection/SKILL.md` diff --git a/.agents/prompts/sdk-review-repository-shape.md b/.agents/prompts/sdk-review-repository-shape.md new file mode 100644 index 0000000..eb089c9 --- /dev/null +++ b/.agents/prompts/sdk-review-repository-shape.md @@ -0,0 +1,33 @@ +# sdk-review-repository-shape (generic prompt spec) + +Review a repository that uses `Purview.BuildSdk` for naming, placement, and test-structure alignment. + +## Required behaviour + +1. Inspect the repository's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, and + project layout before making assumptions. +2. Identify whether the repository follows the SDK-friendly structure: + - source projects under `src/` + - test projects under `tests/` + - `.csproj` filenames matching directory names + - short project names with `NamespacePrefix` carrying the repo identity +3. Check whether test project names use recognised `*Tests` suffixes and whether shared/shared-testing + projects use exact SDK-recognised names. +4. Explain the consequences of deviations in terms of automatic `RootNamespace`, `AssemblyName`, + `PackageId`, `TargetProjectName`, and automatic project references. +5. Review test readability conventions: + - subject-based `{SubjectName}Tests` + - subject-based `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` method names + - non-subject-based suites named clearly for their broader role + - appropriate use of TUnit categories and display names +6. Distinguish between: + - acceptable existing variance worth preserving + - structural debt that blocks the SDK's automatic behavior + - incremental rationalisation opportunities + +## Suggested output + +- A concise summary of whether the repo broadly fits the SDK conventions. +- A list of concrete mismatches, ordered by impact. +- A list of low-risk rationalisation steps for naming, placement, identity, or test readability. +- Explicit note of which behaviors are already automatic defaults and which require manual configuration. diff --git a/.agents/skills/sdk-engineering-principles/.gitignore b/.agents/skills/sdk-engineering-principles/.gitignore new file mode 100644 index 0000000..2799754 --- /dev/null +++ b/.agents/skills/sdk-engineering-principles/.gitignore @@ -0,0 +1,8 @@ +# Ignore all files +* + +# Don't ignore directories, so Git can traverse them +!*/ + +# Keep this file +!.gitignore \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index e6735ee..120d509 100644 --- a/.editorconfig +++ b/.editorconfig @@ -27,8 +27,10 @@ dotnet_search_reference_assemblies = true # Nullability settings dotnet_build_property.Nullable = enable -# Enable or disable the analyzers -dotnet_analyzer_diagnostic.severity = warning +# Per-rule severity is authoritative in this file. The SDK sets AnalysisMode/AnalysisLevel as +# MSBuild properties, and the .NET SDK ignores bulk 'dotnet_analyzer_diagnostic.*' severity +# configuration whenever those properties are present. Do not re-add a bulk entry here: it +# silently does nothing and hides which severities are actually enforced. # Visual Studio XML Project Files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] @@ -76,23 +78,20 @@ indent_style = tab dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = warning dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields -dotnet_naming_rule.private_fields.severity = warning -dotnet_naming_rule.private_fields.style = camel_case_underscore -dotnet_naming_rule.private_fields.symbols = private_fields -dotnet_naming_rule.private_fields_style.severity = warning -dotnet_naming_rule.private_fields_style.style = camel_case -dotnet_naming_rule.private_fields_style.symbols = private_fields dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case +# NOTE: the private-field rules that used to sit here referenced a style ('camel_case_underscore') +# and a symbol group ('private_fields') that were never defined, so they never applied. The real +# private-field rules are declared in the "StyleCop Field Naming Rules" section below. dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field dotnet_naming_symbols.non_private_static_fields.required_modifiers = static -# Constants are PascalCase +# Constants are PascalCase (field constants; local constants are locals and stay camelCase) dotnet_naming_rule.constants_should_be_pascal_case.severity = warning dotnet_naming_rule.constants_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants dotnet_naming_style.constant_style.capitalization = pascal_case -dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.applicable_kinds = field dotnet_naming_symbols.constants.required_modifiers = const # Locals and parameters are camelCase @@ -102,9 +101,7 @@ dotnet_naming_rule.locals_should_be_camel_case.severity = warning # camel_case_style - Define the camelCase style dotnet_naming_style.camel_case_style.capitalization = camel_case -dotnet_naming_style.static_field_style.required_prefix = s_ dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local -dotnet_naming_symbols.static_fields.required_modifiers = static # first_upper_style - The first character must start with an upper-case character dotnet_naming_style.first_upper_style.capitalization = first_word_upper @@ -152,22 +149,22 @@ dotnet_naming_symbols.other_public_protected_fields_group.applicable_kinds = fie # StyleCop Field Naming Rules -# All constant fields must be PascalCase -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.severity = warning -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.style = _fieldname -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.symbols = private_or_internal_field +# All non-private constant fields must be PascalCase. Private constants are owned by +# 'private_static_fields_group' below - a field must be covered by exactly one rule, otherwise the +# same violation is reported once per matching rule. dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.severity = warning dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = non_private_static_field_style dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group -dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private +dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const -# All static readonly fields must be PascalCase +# All non-private static readonly fields must be PascalCase. Private static readonly fields are +# owned by 'private_static_fields_group' below (see the note on the constant rule above). dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.severity = warning dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = non_private_static_field_style dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group -dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private +dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly @@ -178,12 +175,22 @@ dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.symbols = style dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_kinds = field -# Private fields must be camelCase -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.severity = warning -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.style = camel_case_style -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.symbols = stylecop_private_fields_group -dotnet_naming_symbols.stylecop_private_fields_group.applicable_accessibilities = private -dotnet_naming_symbols.stylecop_private_fields_group.applicable_kinds = field +# Private static fields are PascalCase: they are type-level state and are never qualified with 'this.' +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.style = non_private_static_field_style +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.symbols = private_static_fields_group +dotnet_naming_symbols.private_static_fields_group.applicable_accessibilities = private +dotnet_naming_symbols.private_static_fields_group.applicable_kinds = field +dotnet_naming_symbols.private_static_fields_group.required_modifiers = static + +# Private instance fields must be camelCase with a leading underscore: '_name', never 'name'. The +# prefix keeps field access unambiguous (so the noisy 'this.' qualifier is never needed) and keeps +# fields distinguishable from locals and parameters. +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.severity = warning +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.style = camel_case_underscore_style +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.symbols = private_instance_fields_group +dotnet_naming_symbols.private_instance_fields_group.applicable_accessibilities = private +dotnet_naming_symbols.private_instance_fields_group.applicable_kinds = field # Local variables must be camelCase dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.severity = silent @@ -232,25 +239,14 @@ dotnet_naming_style.type_parameter_style.required_prefix = T dotnet_naming_symbols.type_parameter_symbol.applicable_accessibilities = * dotnet_naming_symbols.type_parameter_symbol.applicable_kinds = type_parameter -# Instance fields are camelCase and start with _ -dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion -dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style -dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields -dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion -dotnet_naming_rule.instance_fields_should_be_camel_case.style = camel_case_underscore_style -dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +# Naming style shared by the private-field rules: camelCase with a required '_' prefix. dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case dotnet_naming_style.camel_case_underscore_style.required_prefix = _ -dotnet_naming_style.instance_field_style.capitalization = camel_case -dotnet_naming_style.instance_field_style.required_prefix = _ -dotnet_naming_symbols.instance_fields.applicable_kinds = field -dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal -dotnet_naming_symbols.private_internal_fields.applicable_kinds = field # Local functions are PascalCase dotnet_naming_rule.local_functions_should_be_pascal_case.severity = warning -dotnet_naming_rule.local_functions_should_be_pascal_case.style = non_private_static_field_style -dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions dotnet_naming_style.local_function_style.capitalization = pascal_case dotnet_naming_symbols.local_functions.applicable_kinds = local_function @@ -264,12 +260,10 @@ dotnet_style_qualification_for_property = false:silent dotnet_style_operator_placement_when_wrapping = end_of_line # Naming styles -dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning -dotnet_naming_rule.interface_should_be_begins_with_i.style = prefix_interface_with_i_style -dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface -dotnet_naming_rule.types_should_be_pascal_case.severity = warning -dotnet_naming_rule.types_should_be_pascal_case.style = non_private_static_field_style -dotnet_naming_rule.types_should_be_pascal_case.symbols = types +# NOTE: a 'types_should_be_pascal_case' rule (symbol group 'types') and an +# 'interface_should_be_begins_with_i' rule (symbol group 'interface') used to sit here, but neither +# symbol group was ever defined, so neither rule applied. Types are covered by 'element_rule' and +# interfaces by 'interface_rule' above. # By default, name items with PascalCase dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning @@ -278,7 +272,6 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_m # pascal_case_style - Define the PascalCase style dotnet_naming_style.pascal_case_style.capitalization = pascal_case -dotnet_naming_symbols.all_members.applicable_kinds = * # Symbol specifications dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected @@ -286,7 +279,6 @@ dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, meth dotnet_naming_symbols.non_field_members.required_modifiers = * # Naming styles -dotnet_naming_style._fieldname.capitalization = camel_case dotnet_naming_style.begins_with_i.capitalization = pascal_case dotnet_naming_style.begins_with_i.required_prefix = I dotnet_naming_style.begins_with_i.required_suffix = @@ -314,9 +306,11 @@ dotnet_code_quality.prefer_const = true dotnet_code_quality.prefer_inferred_anonymous_type_member_names = true dotnet_code_quality.prefer_inferred_tuple_names = true dotnet_code_quality.prefer_readonly = true -dotnet_code_quality.require_accessibility_modifiers = true +# The real modifier policy is 'dotnet_style_require_accessibility_modifiers' (declared under +# "Modifier preferences" below). The 'require_accessibility_modifiers' and +# 'require_explicit_visibility' entries that used to live here are not .NET analyzer options: +# declaring them only pretended to enforce a policy that was never applied, so they were removed. dotnet_code_quality.require_explicit_type_arguments = true -dotnet_code_quality.require_explicit_visibility = true dotnet_code_quality.require_variable_declaration_for_explicit_type = false dotnet_code_quality_unused_parameters = all:warning dotnet_enable_roslyn_analyzers = true @@ -587,10 +581,14 @@ dotnet_diagnostic.CA1030.severity = suggestion dotnet_diagnostic.CA1031.severity = error # Implement standard exception constructors -dotnet_diagnostic.CA1032.severity = suggestion +# Raised to warning: the public API surface of exceptions is part of the accessibility policy and +# must not be invisible to command-line builds. +dotnet_diagnostic.CA1032.severity = warning # Interface methods should be callable by child types -dotnet_diagnostic.CA1033.severity = suggestion +# Raised to warning: explicit interface implementations that hide the member from derived types are +# an accessibility problem, not a style preference. +dotnet_diagnostic.CA1033.severity = warning # Nested types should not be visible dotnet_diagnostic.CA1034.severity = error @@ -599,7 +597,9 @@ dotnet_diagnostic.CA1034.severity = error dotnet_diagnostic.CA1036.severity = suggestion # Avoid empty interfaces -dotnet_diagnostic.CA1040.severity = suggestion +# Raised to warning: empty interfaces are unenforceable contracts and were previously invisible to +# command-line builds. +dotnet_diagnostic.CA1040.severity = warning # Provide ObsoleteAttribute message dotnet_diagnostic.CA1041.severity = warning @@ -794,7 +794,9 @@ dotnet_diagnostic.CA1513.severity = warning dotnet_diagnostic.CA1514.severity = warning # Consider making public types internal -dotnet_diagnostic.CA1515.severity = suggestion +# Reported as a real build warning (not a suggestion) so accessibility hygiene cannot be ignored: +# a public type in an application or test assembly should be made internal rather than suppressed. +dotnet_diagnostic.CA1515.severity = warning # Naming Rules (CA1700-CA1727 and IDE0130) # Naming rules support adherence to the naming conventions of the .NET design guidelines @@ -973,8 +975,9 @@ dotnet_diagnostic.CA1845.severity = error # Prefer AsSpan over Substring dotnet_diagnostic.CA1846.severity = error -dotnet_diagnostic.ignore_internalsvisibleto = true -dotnet_diagnostic.CA1852.ignore_internalsvisibleto = true +# CA1852 (seal internal types) is intentionally not suppressed for internal types that are exposed +# to other assemblies through InternalsVisibleTo: those types must still be sealed. The blanket +# suppression that used to sit here relied on a malformed key, so it never even applied. # Unsafe DataSet or DataTable in serializable type can be vulnerable to remote code execution attacks dotnet_diagnostic.CA2352.severity = error @@ -1319,8 +1322,9 @@ dotnet_diagnostic.IDE0038.severity = warning # Use local function instead of lambda dotnet_diagnostic.IDE0039.severity = suggestion -# Add accessibility modifiers -dotnet_diagnostic.IDE0040.severity = error +# IDE0040 is declared exactly once, in the "Accessibility modifiers" section above. A second entry +# here used to override that value (later entries win), which made the effective modifier policy +# ambiguous and let repos believe the rule had been turned off. # Use is null check dotnet_diagnostic.IDE0041.severity = suggestion @@ -1557,8 +1561,9 @@ dotnet_diagnostic.IDE0380.severity = warning # Remove unnecessary suppression (null-forgiving operator) dotnet_diagnostic.IDE0370.severity = warning -# Naming rule violation -dotnet_diagnostic.IDE1006.severity = silent +# Naming rule violation - must stay visible: the field-naming policy is only enforced when this +# diagnostic is reported, because naming rules without their own severity inherit this one. +dotnet_diagnostic.IDE1006.severity = warning # Embedded statements must be on their own line dotnet_diagnostic.IDE2001.severity = warning @@ -2671,17 +2676,14 @@ dotnet_diagnostic.PDS0004.severity = none [**/Extensions/**.{cs,vb}] # Files under the Extensions/ namespace-reset convention must not force developers to add pragmas; -# suppress the namespace-conflict diagnostics that arise from declaring framework-style namespaces. +# suppress only the namespace-conflict diagnostics that arise from declaring framework-style +# namespaces. Accessibility/design rules (for example CA1034) are deliberately not suppressed here. dotnet_diagnostic.IDE0130.severity = none -dotnet_diagnostic.CA1034.severity = none dotnet_diagnostic.CA1724.severity = none dotnet_diagnostic.CS0436.severity = none dotnet_diagnostic.CS1591.severity = none dotnet_diagnostic.IDE0005.severity = none -[**/{Extension,Extensions}.{cs,vb}] -dotnet_diagnostic.CA1034.severity = none - [**/Generated/**/*.{cs,vb}] generated_code = true dotnet_diagnostic.CS8602.severity = none diff --git a/Directory.Packages.props b/Directory.Packages.props index 22ab8ef..92202d1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ 5.9.0 1.68.17 1.0.0-prerelease.54 - 2.0.0 + 2.0.1-prerelease.1 diff --git a/global.json b/global.json index 7f367b1..e7065ab 100644 --- a/global.json +++ b/global.json @@ -3,7 +3,7 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.0" + "Purview.BuildSdk": "1.0.2.2" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index abb67de..c6f1662 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-value-objects", - "version": "1.0.0-prerelease.11", + "version": "1.0.0-prerelease.12", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 51848c3..f0325ba 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -6,6 +6,13 @@ + + + + + + + https://purview.dev/ $(PurviewHomepage)projects/value-objects/ diff --git a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs index 5bb8c6d..0686331 100644 --- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs @@ -8,7 +8,7 @@ namespace Purview.ValueObjects.SourceGenerator.Common; /// self-contained analyzer that the Purview.SourceGeneratorFramework merge pass produces, and that /// pass strips every InternalsVisibleTo declaration from the artifact. Reaching these members /// through internals therefore compiles against the unmerged build output and then fails with a -/// in the IDE, as soon as Roslyn reads a fixable +/// in the IDE, as soon as Roslyn reads a fixable /// diagnostic id from the shipped analyzer. Public members are the only cross-component contract a /// merged artifact preserves. /// diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index 4dbd1f4..e59ad5b 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -24,8 +24,8 @@ bool isEF8Referenced writer.AutoGeneratedHeader(); writer.FileScopedNamespace(TypeLibrary.EFValueObjectEFNamespace); - var (Definitions, Fields) = BuildInlineScalarConverters(scalars); - var inlineJsonConverters = BuildInlineJsonConverters(complex); + var (scalarDefinition, scalarFields) = BuildInlineScalarConverters(scalars); + var (inlineJsonDefinitions, inlineJsonFields) = BuildInlineJsonConverters(complex); var keyValueGenerators = BuildKeyValueGenerators(scalars); writer @@ -57,16 +57,16 @@ bool isEF8Referenced }, body => { - EmitMappingMethod(body, scalars, complex, Fields, inlineJsonConverters.Fields, isEF8Referenced); + EmitMappingMethod(body, scalars, complex, scalarFields, inlineJsonFields, isEF8Referenced); EmitUseValueObjects(body); if (!keyValueGenerators.IsDefaultOrEmpty) EmitUseValueObjectKeyGenerators(body); - EmitInlineConverterFields(body, Definitions); - EmitInlineConverterFields(body, inlineJsonConverters.Definitions); + EmitInlineConverterFields(body, scalarDefinition); + EmitInlineConverterFields(body, inlineJsonDefinitions); EmitHelperClass(body, "ScalarMapping", hasProviderMappable: true); EmitHelperClass(body, "JsonMapping", hasProviderMappable: false); - EmitInlineConverters(body, Definitions, isEF8Referenced); - EmitInlineConverters(body, inlineJsonConverters.Definitions, isEF8Referenced); + EmitInlineConverters(body, scalarDefinition, isEF8Referenced); + EmitInlineConverters(body, inlineJsonDefinitions, isEF8Referenced); EmitInlineKeyValueGenerators(body, keyValueGenerators, isEF8Referenced); } ); @@ -277,19 +277,21 @@ bool isEF8Referenced static void EmitScalarConversionLoop(CodeWriter writer) { + // The entity types are snapshotted: configuring a property adds it to the model, and an enumerator + // over the live collection throws as soon as the set of entity types changes underneath it. writer.Block( - "foreach (var entityType in modelBuilder.Model.GetEntityTypes())", + "foreach (var entityType in modelBuilder.Model.GetEntityTypes().ToList())", entityType => entityType.Block( "foreach (var property in entityType.ClrType.GetProperties().Where(p => p.GetMethod is not null && !p.GetMethod.IsStatic))", property => property.IfElse( "scalarMappings.TryGetValue(property.PropertyType, out var scalar) && scalar.Converter is not null && scalar.ProviderMappable", - scalar => ApplyConverterAndComparer(writer, "scalar", "property"), + scalar => ApplyConverterAndComparer(writer, "scalar", "property", "entityType.ClrType!"), elseBody => elseBody.IfBlock( "jsonMappings.TryGetValue(property.PropertyType, out var json) && json.Converter is not null", - json => ApplyConverterAndComparer(writer, "json", "property") + json => ApplyConverterAndComparer(writer, "json", "property", "entityType.ClrType!") ) ) ) @@ -302,8 +304,10 @@ static void EmitComplexMappingBlock(CodeWriter writer) "complexTypeMappings.Length > 0", complex => { + // Declaring a complex property can add complex types to the model, so the entity types are + // snapshotted before the loop mutates the model it is walking. complex.Block( - "foreach (var entityType in modelBuilder.Model.GetEntityTypes())", + "foreach (var entityType in modelBuilder.Model.GetEntityTypes().ToList())", entityType => entityType.Block( "foreach (var property in entityType.ClrType.GetProperties().Where(p => p.GetMethod is not null && !p.GetMethod.IsStatic))", @@ -325,7 +329,7 @@ static void EmitComplexMappingBlock(CodeWriter writer) ); complex.Block( - "foreach (var entityType in modelBuilder.Model.GetEntityTypes())", + "foreach (var entityType in modelBuilder.Model.GetEntityTypes().ToList())", entityType => entityType.Block( "foreach (var complexProperty in entityType.GetComplexProperties())", @@ -340,11 +344,27 @@ static void EmitComplexMappingBlock(CodeWriter writer) ); } - static void ApplyConverterAndComparer(CodeWriter writer, string mappingName, string propertyName) + /// + /// Applies the mapping's converter and comparer to an entity property. + /// + /// The writer for the generated method body. + /// The name of the mapping local holding the converter and comparer. + /// The name of the property local being configured. + /// + /// The expression for the entity type being configured. The property's declaring type cannot be used: an + /// inherited property is declared by a base type that is usually not an entity type at all, and asking the + /// model builder for it would add a new entity type while the registry is walking the model. + /// + static void ApplyConverterAndComparer( + CodeWriter writer, + string mappingName, + string propertyName, + string entityTypeExpression + ) { // The builder API is used so the property is configured regardless of convention discovery order. var builderExpression = - $"modelBuilder.Entity({propertyName}.DeclaringType!).Property({propertyName}.PropertyType, {propertyName}.Name)"; + $"modelBuilder.Entity({entityTypeExpression}).Property({propertyName}.PropertyType, {propertyName}.Name)"; writer.IfElse( $"{mappingName}.Comparer is not null", withComparer => diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs index 928e300..24334eb 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs @@ -452,6 +452,57 @@ public async Task EFRegistry_EmittedWithConfigureValueObjectsExtension(Cancellat await Assert.That(registryText).Contains(".Property("); } + /// + /// The registry configures a property through the entity type that owns it rather than the property's + /// declaring type. An inherited property is declared by a base type that is usually not an entity type at + /// all, and asking the model builder for that base type adds an entity type to the model while the + /// registry is still enumerating it, which throws InvalidOperationException at model creation. + /// + [Test] + public async Task EFRegistry_GivenInheritedScalarValueObjectProperty_MapsThroughTheOwningEntityType( + CancellationToken cancellationToken + ) + { + // Arrange — the value object column is declared by a base type that is not part of the model. + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct UserId + { + public System.Guid Value { get; } + } + + public abstract class AuditedEntity + { + public UserId CreatedById { get; set; } + } + + public sealed class Order : AuditedEntity + { + public System.Guid Id { get; set; } + } + } + """; + + // Act + var result = await GenerateAsync( + source, + ValueObjectsEFGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var registryText = Normalize( + result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + + // Assert — the conversion is declared on the entity type being configured, and the entity types are + // snapshotted so configuring a property cannot invalidate the enumeration. + await Assert.That(registryText).Contains("modelBuilder.Entity(entityType.ClrType!).Property("); + await Assert.That(registryText).DoesNotContain(".DeclaringType"); + await Assert.That(registryText).Contains("GetEntityTypes().ToList()"); + } + [Test] public async Task EFRegistry_IncludesComplexTypeAndJsonMappings(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerator.UnitTests/Common/ZodSharpSourceGenerators.cs b/src/tests/SourceGenerator.UnitTests/Common/ZodSharpSourceGenerators.cs index 886b689..61a9784 100644 --- a/src/tests/SourceGenerator.UnitTests/Common/ZodSharpSourceGenerators.cs +++ b/src/tests/SourceGenerator.UnitTests/Common/ZodSharpSourceGenerators.cs @@ -21,15 +21,15 @@ public static class ZodSharpSourceGenerators const string GeneratorTypeName = "ZodSharp.SourceGenerators.ZodSchemaGenerator"; const string AnalyzerTypeName = "ZodSharp.SourceGenerators.ZodSchemaAnalyzer"; - static readonly Lazy s_generator = new(() => Resolve(GeneratorTypeName)); + static readonly Lazy GeneratorFactory = new(() => Resolve(GeneratorTypeName)); - static readonly Lazy s_analyzer = new(() => Resolve(AnalyzerTypeName)); + static readonly Lazy AnalyzerFactory = new(() => Resolve(AnalyzerTypeName)); /// Gets the ZodSharp schema source generator type. - public static Type Generator => s_generator.Value; + public static Type Generator => GeneratorFactory.Value; /// Gets the ZodSharp schema diagnostic analyzer type. - public static Type Analyzer => s_analyzer.Value; + public static Type Analyzer => AnalyzerFactory.Value; static Type Resolve(string typeName) { diff --git a/src/tests/SourceGenerator.UnitTests/Refactorings/PackagedAnalyzerComponents.cs b/src/tests/SourceGenerator.UnitTests/Refactorings/PackagedAnalyzerComponents.cs index d29ce5b..0e07a9a 100644 --- a/src/tests/SourceGenerator.UnitTests/Refactorings/PackagedAnalyzerComponents.cs +++ b/src/tests/SourceGenerator.UnitTests/Refactorings/PackagedAnalyzerComponents.cs @@ -123,16 +123,19 @@ configuration is null StringComparison.OrdinalIgnoreCase ) ) - .OrderByDescending(path => File.GetLastWriteTimeUtc(path)) + .OrderByDescending(File.GetLastWriteTimeUtc) .ToArray(); if (candidates.Length == 0) + { throw new FileNotFoundException( "The merged, self-contained analyzer artifact was not found under " + $"'{mergedRoot}'. Building the solution runs the Purview.SourceGeneratorFramework merge pass that produces it.", mergedRoot ); + } + // The merge pass produces a single artifact, but the intermediate output can contain multiple return candidates[0]; } diff --git a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs index e111304..95fa3e9 100644 --- a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs +++ b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs @@ -84,6 +84,24 @@ sealed class CompatibilityEntity #endif } +/// +/// A base type that declares a scalar value object column while not being an entity type itself. Entities that +/// derive from it inherit the property, so the generated registry has to map it through the entity type that +/// owns the row rather than through the property's declaring type. +/// +abstract class CompatibilityAuditedEntity +{ + /// Gets or sets the code of the last change applied to the row. + public CompatibilityCode LastChangeCode { get; set; } +} + +/// An entity whose mapped value object column is inherited from a base type that is not in the model. +sealed class CompatibilityInheritedEntity : CompatibilityAuditedEntity +{ + /// Gets or sets the generated key. + public CompatibilityId Id { get; set; } +} + /// /// The context under test. It maps the value objects through the generated registry and registers the /// generated key value generator convention, exactly as an application would. @@ -93,6 +111,9 @@ sealed class CompatibilityDbContext(DbContextOptions opt /// Gets the entity set. public DbSet Entities => Set(); + /// Gets the entity set whose value object column is inherited. + public DbSet InheritedEntities => Set(); + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { base.ConfigureConventions(configurationBuilder); diff --git a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs index 7a49e37..5b2a310 100644 --- a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs +++ b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs @@ -148,6 +148,53 @@ public async Task ComplexTypeValueObject_GivenEntityFramework8OrLater_RoundTrips } #endif + /// + /// A value object column declared by a base type that is not an entity type is mapped on the entity type + /// that owns the row. The registry used to configure the property through its declaring type, which added + /// that type - and its own value object conversion - to the model while the registry was enumerating the + /// model, so creating a model for an entity with an inherited value object column threw + /// InvalidOperationException. + /// + [Test] + public async Task InheritedValueObjectColumn_GivenBaseTypeOutsideTheModel_IsMappedThroughTheEntityType() + { + // Arrange + await using var connection = await OpenConnectionAsync(); + await using var context = CreateContext(connection); + + // Act - creating the model is the operation that used to throw. + var property = context + .Model.FindEntityType(typeof(CompatibilityInheritedEntity))! + .FindProperty(nameof(CompatibilityAuditedEntity.LastChangeCode)); + var baseEntityType = context.Model.FindEntityType(typeof(CompatibilityAuditedEntity)); + + // Assert - the base type is not an entity type, and the inherited column keeps its conversion. + await Assert.That(baseEntityType).IsNull(); + await Assert.That(property).IsNotNull(); + await Assert.That(property!.GetTypeMapping().Converter?.ProviderClrType).IsEqualTo(typeof(string)); + } + + [Test] + public async Task InheritedValueObjectColumn_GivenRowWithInheritedValue_RoundTrips() + { + // Arrange + await using var connection = await OpenConnectionAsync(); + await using var context = CreateContext(connection); + await context.Database.EnsureCreatedAsync(); + + CompatibilityInheritedEntity entity = new() { LastChangeCode = CompatibilityCode.Create("INHERITED") }; + context.InheritedEntities.Add(entity); + + // Act + await context.SaveChangesAsync(); + var generated = entity.Id; + context.ChangeTracker.Clear(); + var found = await context.InheritedEntities.SingleAsync(row => row.Id == generated); + + // Assert + await Assert.That(found.LastChangeCode).IsEqualTo(CompatibilityCode.Create("INHERITED")); + } + static System.Reflection.PropertyInfo? CompiledModelReaderWriter(object converter) => converter.GetType().GetProperty("JsonReaderWriter"); From 05a0ccd8cfe9bf959a665479fc4002593c046016 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 3 Oct 2026 11:46:57 +0100 Subject: [PATCH 02/11] feat: better ZodSharp integration --- README.md | 5 +- docs/Getting-Started.md | 6 + docs/Value-Object-Design.md | 8 +- docs/ZodSharp-Validation.md | 124 ++++++++++++++- src/src/ValueObjects/Sdk/README.md | 19 ++- src/src/ZodSharpSample/CustomRules.cs | 129 ++++++++++++++++ src/src/ZodSharpSample/Program.cs | 41 +++++ src/src/ZodSharpSample/README.md | 8 +- .../ZodSchemaValidationGeneratorTests.cs | 93 ++++++++++++ .../ZodSchemaIntegrationTests.cs | 49 ++++++ .../Serialization/ZodSchemaRuleModels.cs | 143 ++++++++++++++++++ 11 files changed, 613 insertions(+), 12 deletions(-) create mode 100644 src/src/ZodSharpSample/CustomRules.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs diff --git a/README.md b/README.md index 5ef1aa6..58403fb 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ See the `src/src/Sample`, `src/src/EFDomainSample.Persistence` (domain + persist ## Validation with ZodSharp Validate value objects with [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp), a C# port of -Zod. Three patterns are supported: +Zod. Several patterns are supported: - **Generator-integrated** – a value object annotated with both `[Scalar]`/`[ValueObject]` and `[ZodSchema]` has its generated `Create` wired to the ZodSharp-generated schema (`Create` throws `ZodException` on invalid input). @@ -140,6 +140,9 @@ Zod. Three patterns are supported: `ZodSchemaMode.InsteadOfHooks` opts out of the `OnValidate` hook. - **Generated validators** – annotate a value object or DTO with `[ZodSchema]` + DataAnnotations; a source generator emits a zero-allocation `{Type}Schema` validator (`EmailAddressSchema.Validate(email)`). +- **Custom rules on scalars** – a `[ZodRule]`-mapped attribute closes a rule with the value object, so a rule + written against `IScalarValueObject` validates the scalar as a unit and owns the reported + `Code`/`Origin`. Reuse a rule written for the underlying value via `ScalarRuleAdapter`. - **Schema-first** – build a schema for the scalar's underlying value (`Z.String().Email()`, `Z.Number()`, `Z.Enum<>()`) and construct the value object through its strict `Create` factory. diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 61b72b8..a70c9c3 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -174,6 +174,12 @@ constructed instance through `EmailAddressSchema` — `EmailAddress.Create("not- `ZodException`. Use `ZodSchemaMode.InsteadOfHooks` on the attribute to run the schema instead of the `OnValidate` hook. +For rules that must observe the value object rather than its `Value` (or that reuse a rule written for the +underlying value), map a `[ZodRule(typeof(...))]` attribute to a rule constrained to the value object, or adapt +the rule with a `ScalarRuleAdapter`. The rule's `Code`/`Origin` flow through `Create` unchanged. See +[Custom rules on scalars](ZodSharp-Validation.md#custom-rules-on-scalars) and +[Reusing a normal rule for a scalar](ZodSharp-Validation.md#reusing-a-normal-rule-for-a-scalar). + In ASP.NET Core, `Purview.ZodSharp.AspNetCore` converts those `ZodException`s into standard Problem Details responses — combine `ValueObjectDeserializationMode.Strict` with `AddZodSharpProblemDetails()` + `UseExceptionHandler()` so invalid request bodies return diff --git a/docs/Value-Object-Design.md b/docs/Value-Object-Design.md index d74aec3..241465a 100644 --- a/docs/Value-Object-Design.md +++ b/docs/Value-Object-Design.md @@ -34,6 +34,7 @@ domain-appropriate exceptions. Keep validation pure and deterministic; it must n | Cross-field invariants | `partial void OnValidate(...)` in a `[ValueObject]` | | Owner/state-machine transitions | contextual `Create(TValue, in ValueObjectContext)` | | External schema rules / DTO validation | Purview.ZodSharp schemas (see `ZodSharp-Validation.md`) | +| Per-scalar custom rules (ZodSharp) | `[ZodRule]`-mapped attribute closed with the value object (see `ZodSharp-Validation.md`) | Use the value object hooks for invariants that must hold for every construction path. Use ZodSharp when you need schema-driven validation (DataAnnotations-based `[ZodSchema]` validators, hand-built `Z.*` schemas, or DTO @@ -133,6 +134,7 @@ query. | `TryCreate(...)` | Returns `false` instead of throwing. | | `Hydrate(...)` | Never validates. Persistence, replay, and deserialization use this path. | -A ZodSharp `ZodException` carries one or more `ValidationError` entries with a code and a path, so the same -error codes you use in hooks (`ErrorFactory`-style constants) flow to an ASP.NET Core Problem Details -response when `Purview.ZodSharp.AspNetCore` is registered. See `ZodSharp-Validation.md`. \ No newline at end of file +A ZodSharp `ZodException` carries one or more `ValidationError` entries with a code, message, path, and an +optional structured origin, so the same error codes you use in hooks (`ErrorFactory`-style constants) — or +that a custom `[ZodRule]`-mapped rule reports — flow to an ASP.NET Core Problem Details response when +`Purview.ZodSharp.AspNetCore` is registered. See `ZodSharp-Validation.md`. \ No newline at end of file diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md index d22aa08..e940a75 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -4,7 +4,7 @@ [Zod](https://github.com/colinhacks/zod) schema validation library. It complements `Purview.ValueObjects`: the value object owns the invariants, ZodSharp owns the rule definitions and validation results. -Three patterns are covered here, demonstrated in the `src/src/ZodSharpSample` project: +The patterns below are demonstrated in the `src/src/ZodSharpSample` project: 1. **Generator-integrated validation** — a value object annotated with both `[Scalar]`/`[ValueObject]` and `[ZodSchema]` has its generated `Create` wired to the ZodSharp-generated schema. @@ -13,6 +13,10 @@ Three patterns are covered here, demonstrated in the `src/src/ZodSharpSample` pr 3. **Schema-first validation** — build a schema for the scalar's underlying value with `Z.String()`, `Z.Number()`, `Z.Enum()`, then construct the value object through its strict `Create` factory. +See [Custom rules on scalars](#custom-rules-on-scalars) for type-level rules that validate a scalar as a unit, +and [Reusing a normal rule for a scalar](#reusing-a-normal-rule-for-a-scalar) for adapting a rule written +against the underlying value. + ## Install ```text @@ -165,6 +169,109 @@ CorporateEmail.Hydrate("demo@gmail.com"); // replay-safe: no validation runs - The hook is independent of `ZodSchemaMode`: `InsteadOfHooks` only skips the value object's own `OnValidate` hook, never the Zod refinement hook. +### Custom rules on scalars + +ZodSharp rules are first-class, and a scalar value object is validated as a **unit** (its single `Value` *is* +the value). A `[ZodRule]`-mapped attribute closes an unbound generic rule with the annotated type, so a rule +constrained to the value-object contract sees the whole value object: + +```csharp +using ZodSharp.Core; + +// Reads the value object through IScalarValueObject. Implementing IZodRule lets the rule own +// its error identity, so one attribute can report a different code per scalar. +public readonly record struct NonEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + + string? IZodRule.Code => Code; + + string? IZodRule.Origin => "value_object"; +} + +[ZodRule(typeof(NonEmptyRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class NonEmptyAttribute : ValidationAttribute +{ + public string? Code { get; set; } + public string? Message { get; set; } +} + +[Scalar] +[ZodSchema] +[NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; } +} +``` + +`AssetId.Create(Guid.Empty)` throws a `ZodException` whose error carries `Code = "invalid_asset_id"`, +`Origin = "value_object"`, and an **empty path** (the rule applies to the value object, not a member). Because +the rule runs inside the generated schema, `Hydrate` stays replay-safe and the value-objects layer needs no +extra code. A rule should expose `public const string ErrorCode` and `public const string MessageFormat`; +ZodSharp reports `ZODSGEN042` otherwise. + +The reported `Code`/`Origin` resolve in this order (first match wins): + +1. **Rule-owned** — the rule implements `IZodRule` (`IZodRule.Code`/`IZodRule.Origin`). +2. **Attribute-declared** — `Code`/`Origin` named arguments on the applied attribute. +3. **Mapping** — `[ZodRule(typeof(X), Code = "…", Origin = "…")]` on the attribute type. +4. **Default** — `validation_failed`, no origin. + +The value-object layer never reconstructs codes or messages: it calls `{Type}Schema.Validate(instance)` and +forwards `result.Errors` (`ImmutableArray`) into a `ZodException`, so a rule's `Code`, +`Message`, `Origin`, `Category`, and path arrive intact. + +#### Reusing a normal rule for a scalar + +A rule written against the underlying value (for example `NonSentinelRule`) validates that value, not +the value object: closing it with the scalar type (`NonSentinelRule`) compiles but always passes, +because the sentinel check only knows the primitive. Either put the attribute on the `Value` member, or adapt +the rule with a `ScalarRuleAdapter` and expose it as a scalar rule family member: + +```csharp +// Turns any IValidationRule into an IValidationRule for a scalar value object. +public readonly record struct ScalarRuleAdapter(TRule Rule) : IValidationRule + where TSelf : IScalarValueObject + where TRule : IValidationRule +{ + public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); + + public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); +} + +// The scalar-aware family member. The attribute name encodes the rule name, so [NonSentinelScalar] maps to it. +public readonly record struct NonSentinelScalarRule(string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public bool IsValid(in TSelf value) => + new ScalarRuleAdapter>(new(Message)).IsValid(value); + + public string GetErrorMessage(in TSelf value) => + new ScalarRuleAdapter>(new(Message)).GetErrorMessage(value); + + string? IZodRule.Code => NonSentinelRule.ErrorCode; + + string? IZodRule.Origin => "value_object"; +} + +[ZodRule(typeof(NonSentinelScalarRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class NonSentinelScalarAttribute : ValidationAttribute { } +``` + +`ScalarRuleAdapter` has three type parameters, so it cannot be referenced directly from `[ZodRule]` (which +addresses a non-generic rule or an arity-1 generic). Compose it inside a named family member as above, or +construct it directly for programmatic use. Copy `ScalarRuleAdapter` into a rules library that references both +`Purview.ValueObjects` and `Purview.ZodSharp` — a source generator cannot read another generator's output, so +the adapter cannot be shipped as a generator-emitted type that a rules library sees. + ### ZodSharp integration diagnostics The value-object analyzer reports the integration states that would otherwise pass silently: @@ -174,7 +281,11 @@ The value-object analyzer reports the integration states that would otherwise pa | `VO1013` | Warning | `OnValidate` is implemented while `ZodSchemaMode.InsteadOfHooks` is set, making that implementation unreachable in the generated `Create`. | | `VO1015` | Error | `[ZodSchema(SchemaName = "...")]` is not a valid C# identifier, which ZodSharp applies verbatim and this generator cannot reference. Generation is skipped for that type. | -ZodSharp's own diagnostics (`ZODSGEN034`-`ZODSGEN036`) cover the refinement hook itself. +ZodSharp's own diagnostics cover the refinement hook (`ZODSGEN034`-`ZODSGEN036`) and the rule mapping and +convention checks (`ZODSGEN030`-`ZODSGEN042`, for example `ZODSGEN042` for a rule that omits `ErrorCode`/ +`MessageFormat`); see ZodSharp's +[Source Generator Diagnostics](https://purview.dev/docs/zodsharp/source-generator-diagnostics/) for the full +list. ## 3. Schema-first validation @@ -299,11 +410,12 @@ var stringResult = factory.Validate("demo@example.com"); ## Error handling `ValidationResult` is a struct with `IsSuccess`, `Value` (only when successful), and `Errors` -(`ImmutableArray`). Each `ValidationError` has a `Path` and a `Message`: +(`ImmutableArray`). Each `ValidationError` carries a `Code`, `Message`, and `Path`, plus the +optional structured `Origin`, `Category`, and size bounds that rules and structured issues report: ```csharp foreach (var error in result.Errors) - Console.WriteLine($"{string.Join(".", error.Path)}: {error.Message}"); + Console.WriteLine($"{string.Join(".", error.Path)}: {error.Message} ({error.Code}, {error.Origin})"); ``` Use `Parse` / `GetValueOrThrow()` to throw a `ZodException` on failure instead of inspecting the @@ -414,7 +526,9 @@ Tests that run the value-object generator and the ZodSharp generator together co the test harness loads. `Common/ZodSharpSourceGeneratorsTests.cs` guards the invariant. - **Real compile (integration):** `src/tests/ValueObjects.IntegrationTests` declares `[Scalar]` + `[ZodSchema]` fixtures and asserts runtime behaviour directly — both generators run in the real - compiler for that project, so nothing has to be reflected or registered. + compiler for that project, so nothing has to be reflected or registered. The fixtures include type-level + custom rules (`Serialization/ZodSchemaRuleModels.cs`), so rule `Code`/`Origin` propagation through `Create` + is covered end-to-end. The loaded generator carries its own framework implementation, so it keeps its own log sink and CodeWriter scope validation: do not assert on its log entries, and leave `ValidateCodeWriterScopes` diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index aebfd20..99ee342 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -70,4 +70,21 @@ types (EF Core 8+) by default or JSON columns via `[ValueObject(EFMapping = Enti the value object type directly — no `.Value` required — or the raw underlying value (`c.Email == "..."`, `m.Id == guid`). See `docs/Entity-Framework.md` for the full guide. -See the `src/src/Sample` and `src/src/ZodSharpSample` projects for end-to-end examples. \ No newline at end of file +## Validation with ZodSharp + +Validate value objects with [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp), a C# port of Zod: + +- **Generator-integrated** – a `[Scalar]`/`[ValueObject]` type that is also `[ZodSchema]` has its generated + `Create` wired to the ZodSharp-generated schema (`Create` throws `ZodException` on invalid input). + `ZodSchemaMode.InsteadOfHooks` opts out of the `OnValidate` hook; implement the generated + `OnZodValidate(RefineCtx)` hook to add Zod-compatible refinements. +- **Generated validators** – annotate a type with `[ZodSchema]` + DataAnnotations to get a zero-allocation + `{Type}Schema` validator. +- **Custom rules on scalars** – a `[ZodRule]`-mapped attribute closes a rule with the value object, so a rule + written against `IScalarValueObject` validates the scalar as a unit and owns the reported + `Code`/`Origin`. Reuse a rule written for the underlying value via `ScalarRuleAdapter`. +- **Schema-first** – build a schema for the underlying value (`Z.String().Email()`, `Z.Number()`, `Z.Enum<>()`) + and construct the value object through its strict `Create` factory. + +See `docs/ZodSharp-Validation.md` for the full guide, and the `src/src/ZodSharpSample` / +`src/src/ZodSharp.AspNetCoreSample` projects for runnable examples. \ No newline at end of file diff --git a/src/src/ZodSharpSample/CustomRules.cs b/src/src/ZodSharpSample/CustomRules.cs new file mode 100644 index 0000000..132f6e0 --- /dev/null +++ b/src/src/ZodSharpSample/CustomRules.cs @@ -0,0 +1,129 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using Purview.ValueObjects.Serialization; +using ZodSharp; +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace Purview.ValueObjects.ZodSharpSample; + +/// +/// A custom rule that validates a scalar value object as a unit. It reads the value object through +/// its contract, so the same rule serves every Guid-backed +/// scalar. Implementing lets the rule own the reported error code and origin, which is +/// how one attribute can produce a different code per annotated scalar. +/// +readonly record struct NonEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, + IZodRule + where TSelf : IScalarValueObject +{ + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Value must not be empty."; + + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; + + string IValidationRule.Code => Code ?? ErrorCode; + + string? IZodRule.Code => Code; + + string? IZodRule.Origin => "value_object"; +} + +/// +/// Maps to a type-level DataAnnotations attribute. The ZodSharp generator +/// closes the open generic with the annotated scalar type, so the rule validates the value object as a unit +/// and reports an empty path. +/// +[ZodRule(typeof(NonEmptyRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +sealed class NonEmptyAttribute : ValidationAttribute +{ + public string? Code { get; set; } + + public string? Message { get; set; } +} + +/// +/// Turns an existing rule written against a scalar's underlying value into a rule that validates the scalar +/// value object as a unit. It is how a normal rule (for example ) is reused +/// for a scalar without re-authoring it against . +/// +[SuppressMessage( + "Design", + "CA1005:Avoid excessive parameters on generic types", + Justification = "The three type parameters are the scalar, its underlying value, and the adapted rule; all three are required." +)] +#pragma warning disable ZODSGEN042 // This is a composition adapter, not a rule with its own error identity. +readonly record struct ScalarRuleAdapter(TRule Rule) : IValidationRule + where TSelf : IScalarValueObject + where TRule : IValidationRule +{ + public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); + + public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); +} +#pragma warning restore ZODSGEN042 + +/// +/// The scalar-aware member of the non-sentinel rule family: it adapts for a +/// Guid-backed scalar value object and owns the reported error identity. +/// +readonly record struct NonSentinelScalarRule(string? Message = null) : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public const string ErrorCode = NonSentinelRule.ErrorCode; + public const string MessageFormat = NonSentinelRule.MessageFormat; + + public bool IsValid(in TSelf value) => + new ScalarRuleAdapter>(new NonSentinelRule(Message)).IsValid(value); + + public string GetErrorMessage(in TSelf value) => + new ScalarRuleAdapter>(new NonSentinelRule(Message)).GetErrorMessage( + value + ); + + string? IZodRule.Code => NonSentinelRule.ErrorCode; + + string? IZodRule.Origin => "value_object"; +} + +/// +/// Maps to a type-level attribute. The attribute name encodes the +/// rule name (NonSentinelScalarAttribute → NonSentinelScalarRule) so the mapping addresses the +/// whole family. +/// +[ZodRule(typeof(NonSentinelScalarRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +sealed class NonSentinelScalarAttribute : ValidationAttribute +{ + public string? Message { get; set; } +} + +/// +/// A Guid-backed scalar whose validation is driven entirely by a type-level custom rule +/// (). Create surfaces the rule's own code and origin, while +/// Hydrate stays replay-safe. +/// +[Scalar] +[ZodSchema] +[NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] +readonly partial record struct AssetId +{ + public Guid Value { get; } +} + +/// +/// A Guid-backed scalar whose validation reuses the normal through +/// , showing a rule written for an underlying value applied to a +/// scalar value object as a unit. +/// +[Scalar] +[ZodSchema] +[NonSentinelScalar] +readonly partial record struct TenantId +{ + public Guid Value { get; } +} diff --git a/src/src/ZodSharpSample/Program.cs b/src/src/ZodSharpSample/Program.cs index 7de219e..e485d7d 100644 --- a/src/src/ZodSharpSample/Program.cs +++ b/src/src/ZodSharpSample/Program.cs @@ -12,6 +12,10 @@ Console.WriteLine("== Zod refinement hook (OnZodValidate) =="); RefinementHookValidation(); +Console.WriteLine(); +Console.WriteLine("== Type-level custom rules on scalars =="); +CustomRuleValidation(); + Console.WriteLine(); Console.WriteLine("== Schema-first validation (hand-built schemas) =="); SchemaFirstValidation(); @@ -119,6 +123,43 @@ static void RefinementHookValidation() Console.WriteLine($"CorporateEmail.Hydrate('demo@gmail.com') -> '{replayed.Value}'"); } +static void CustomRuleValidation() +{ + // A type-level [ZodRule]-mapped attribute closes the generic rule with the scalar type, so the rule sees + // the value object as a unit. Create runs it through the ZodSharp schema; the rule owns the code and + // origin, so one attribute serves many scalars with a per-scalar code. + var assetId = AssetId.Create(Guid.NewGuid()); + Console.WriteLine($"AssetId.Create(guid) -> '{assetId.Value}'"); + + try + { + AssetId.Create(Guid.Empty); + Console.WriteLine("AssetId.Create(Guid.Empty) -> no exception"); + } + catch (ZodException ex) + { + Console.WriteLine($"AssetId.Create(Guid.Empty) -> {FormatErrors(ex.Errors)}"); + } + + // Hydrate is replay-safe: type-level rules are not re-run. + Console.WriteLine($"AssetId.Hydrate(Guid.Empty) -> '{AssetId.Hydrate(Guid.Empty).Value}'"); + + // A rule written for the underlying value (NonSentinelRule) is reused for a scalar through the + // scalar rule family member that composes ScalarRuleAdapter. + var tenantId = TenantId.Create(Guid.NewGuid()); + Console.WriteLine($"TenantId.Create(guid) -> '{tenantId.Value}'"); + + try + { + TenantId.Create(Guid.Empty); + Console.WriteLine("TenantId.Create(Guid.Empty) -> no exception"); + } + catch (ZodException ex) + { + Console.WriteLine($"TenantId.Create(Guid.Empty) -> {FormatErrors(ex.Errors)}"); + } +} + static void SchemaFirstValidation() { // ZodSharp validates the raw value as-is; the value object's Create normalizes afterwards. diff --git a/src/src/ZodSharpSample/README.md b/src/src/ZodSharpSample/README.md index eda5e60..ebdd4e8 100644 --- a/src/src/ZodSharpSample/README.md +++ b/src/src/ZodSharpSample/README.md @@ -18,14 +18,18 @@ dotnet run --project src/src/ZodSharpSample - **Zod refinement hooks** — `CorporateEmail` implements the generated `OnZodValidate(RefineCtx)` hook to add a rule ZodSharp's DataAnnotations cannot express; the generated `Create` merges the hook's issues with the schema's own, while `Hydrate` stays replay-safe. +- **Type-level custom rules on scalars** — `AssetId` carries a `[ZodRule]`-mapped `[NonEmpty]` attribute whose + rule reads the value object as a unit and owns its `Code`/`Origin`. `TenantId` reuses the normal + `NonSentinelRule` for a scalar through `ScalarRuleAdapter`, and the generated `Create` surfaces the + rule's code and message while `Hydrate` stays replay-safe. - **Generated validators on value objects** — `EmailAddressSchema.Validate/Parse` and `CurrencyCodeSchema.Validate` validate the value object directly; `ApplyRefine` composes extra rules such as "only example.com addresses allowed". - **Schema-first validation** — hand-built schemas (`Z.String().Email()`, `Z.String().Regex(...)`, `Z.Enum()`, `Z.Number().Positive()`) validate the raw underlying value, then the result is mapped onto the value object via its strict `Create` factory. -- **DTO validation** — a `[ZodSchema]` `RegistrationDto` validated by the generated schema, including - a custom refinement method wired via the `RefinementMethodName` option, then mapped to value objects. +- **DTO validation** — a `[ZodSchema]` `RegistrationDto` validated by the generated schema; it implements the + generated `OnZodValidate` hook to add a refinement, and the validated values are mapped to value objects. - **Async custom validation** — a `[ZodSchema(CustomValidationMethodName = ...)]` `PromoCode` whose generated `PromoCodeSchemaValidator.ValidateAsync` awaits a hand-written async rule after the synchronous DataAnnotations rules pass. diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs index d4ad2ce..8dec847 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs @@ -373,4 +373,97 @@ public static bool CreateRejectsInvalid() await Assert.That(acceptsValid).IsTrue(); await Assert.That(rejectsInvalid).IsTrue(); } + + [Test] + public async Task Scalar_GivenTypeLevelCustomRule_ReportsRuleCodeAndOriginThroughCreate( + CancellationToken cancellationToken + ) + { + // A type-level [ZodRule]-mapped attribute closes the generic rule with the scalar type, so the rule + // sees the value object as a unit. The generated Create runs it through the ZodSharp schema, which + // reports the rule's own code and origin with an empty path. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NonEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where TSelf : Purview.ValueObjects.IScalarValueObject + { + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Value must not be empty."; + + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; + + string IValidationRule.Code => Code ?? ErrorCode; + + string? IZodRule.Code => Code; + + string? IZodRule.Origin => "value_object"; + } + + [ZodRule(typeof(NonEmptyRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class NonEmptyAttribute : ValidationAttribute + { + public string? Code { get; set; } + + public string? Message { get; set; } + } + + [Scalar] + [ZodSchema] + [NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] + public readonly partial record struct AssetId + { + public Guid Value { get; } + } + + public static class Harness + { + public static bool CreateSucceeds() => AssetId.Create(Guid.NewGuid()).Value != Guid.Empty; + + public static string? CreateReportsOrigin() + { + try + { + AssetId.Create(Guid.Empty); + return null; + } + catch (global::ZodSharp.Core.ZodException exception) + { + foreach (var error in exception.Errors) + { + if (error.Code == "invalid_asset_id") + return error.Origin; + } + + return "no-code"; + } + } + + public static bool HydrateIsReplaySafe() => AssetId.Hydrate(Guid.Empty).Value == Guid.Empty; + } + } + """; + + var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly!.GetType("Testing.Harness")!; + + var createSucceeds = (bool)harness.GetMethod("CreateSucceeds")!.Invoke(null, null)!; + var origin = (string?)harness.GetMethod("CreateReportsOrigin")!.Invoke(null, null); + var hydrateSafe = (bool)harness.GetMethod("HydrateIsReplaySafe")!.Invoke(null, null)!; + + await Assert.That(createSucceeds).IsTrue(); + await Assert.That(origin).IsEqualTo("value_object"); + await Assert.That(hydrateSafe).IsTrue(); + } } diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs index dc500f7..b18ca32 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs @@ -1,4 +1,5 @@ using ZodSharp.Core; +using ZodSharp.Rules; namespace Purview.ValueObjects.Serialization; @@ -73,4 +74,52 @@ public async Task ZodInsteadOfHooksEmail_GivenInsteadOfHooksMode_DoesNotRunOnVal // Assert await Assert.That(created.Value).IsEqualTo("anything"); } + + [Test] + public async Task AssetId_GivenTypeLevelCustomRule_ReportsRuleCodeAndOriginThroughCreate() + { + // Act + var created = AssetId.Create(Guid.NewGuid()); + + // Assert + await Assert.That(created.Value).IsNotEqualTo(Guid.Empty); + + var exception = await Assert.That(() => AssetId.Create(Guid.Empty)).Throws(); + var error = exception!.Errors.Single(candidate => candidate.Code == "invalid_asset_id"); + await Assert.That(error.Message).IsEqualTo("AssetId must not be empty."); + // A type-level rule validates the value object as a unit, so it owns the structured origin and + // reports an empty path. + await Assert.That(error.Origin).IsEqualTo("value_object"); + await Assert.That(error.Path).IsEmpty(); + + // Hydrate stays replay-safe: the rule is not re-run. + await Assert.That(AssetId.Hydrate(Guid.Empty).Value).IsEqualTo(Guid.Empty); + } + + [Test] + public async Task TenantId_GivenAdaptedNormalRule_RejectsSentinelValueThroughCreate() + { + // Act + var created = TenantId.Create(Guid.NewGuid()); + + // Assert + await Assert.That(created.Value).IsNotEqualTo(Guid.Empty); + + var exception = await Assert.That(() => TenantId.Create(Guid.Empty)).Throws(); + await Assert.That(exception!.Errors.Any(error => error.Code == NonSentinelRule.ErrorCode)).IsTrue(); + + // Hydrate stays replay-safe: the rule is not re-run. + await Assert.That(TenantId.Hydrate(Guid.Empty).Value).IsEqualTo(Guid.Empty); + } + + [Test] + public async Task ScalarRuleAdapter_GivenUnderlyingRule_ValidatesScalarAsUnit() + { + // Arrange: the adapter is the seam that turns a normal underlying-value rule into a scalar rule. + ScalarRuleAdapter> adapter = new(new NonSentinelRule()); + + // Act & Assert + await Assert.That(adapter.IsValid(TenantId.Hydrate(Guid.NewGuid()))).IsTrue(); + await Assert.That(adapter.IsValid(TenantId.Hydrate(Guid.Empty))).IsFalse(); + } } diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs new file mode 100644 index 0000000..6bc5ef9 --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs @@ -0,0 +1,143 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using ZodSharp; +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace Purview.ValueObjects.Serialization; + +/// +/// A custom rule that validates a scalar value object as a unit. It reads the value object through +/// its contract, so the same rule serves every Guid-backed +/// scalar. Implementing lets the rule own the reported error code and origin, which is +/// how one attribute can produce a different code per annotated scalar. +/// +/// +/// This is the supported shape for a rule that must observe the value object rather than its +/// Value. A generic rule must have exactly one type parameter, so the underlying value type is pinned +/// by the constraint: define one rule per primitive +/// (NonEmptyRule<TSelf> where TSelf : IScalarValueObject<TSelf, Guid>, a long +/// variant, and so on). +/// +public readonly record struct NonEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, + IZodRule + where TSelf : IScalarValueObject +{ + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Value must not be empty."; + + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; + + // IValidationRule.Code is the rule's own default; IZodRule.Code is the optional per-use override, so + // the generator prefers a code supplied where the attribute is applied and falls back to ErrorCode. + string IValidationRule.Code => Code ?? ErrorCode; + + string? IZodRule.Code => Code; + + string? IZodRule.Origin => "value_object"; +} + +/// +/// Maps to a type-level DataAnnotations attribute. The ZodSharp generator +/// closes the open generic with the annotated scalar type, so the rule validates the value object as a unit +/// and reports an empty path. +/// +[ZodRule(typeof(NonEmptyRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class NonEmptyAttribute : ValidationAttribute +{ + public string? Code { get; set; } + + public string? Message { get; set; } +} + +/// +/// Turns an existing rule written against a scalar's underlying value into a rule that validates the scalar +/// value object as a unit. It is how a normal rule (for example ) is reused +/// for a scalar without re-authoring it against . +/// +/// +/// ZodSharp's [ZodRule] mapping supports a non-generic rule or an arity-1 generic; this adapter has +/// arity three, so it is composed inside a named rule family member (see +/// ) rather than referenced directly from an attribute. Copy this +/// type into a rules library that references both Purview.ValueObjects and Purview.ZodSharp. +/// +[SuppressMessage( + "Design", + "CA1005:Avoid excessive parameters on generic types", + Justification = "The three type parameters are the scalar, its underlying value, and the adapted rule; all three are required." +)] +#pragma warning disable ZODSGEN042 // This is a composition adapter, not a rule with its own error identity. +public readonly record struct ScalarRuleAdapter(TRule Rule) : IValidationRule + where TSelf : IScalarValueObject + where TRule : IValidationRule +{ + public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); + + public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); +} +#pragma warning restore ZODSGEN042 + +/// +/// The scalar-aware member of the non-sentinel rule family: it adapts for a +/// Guid-backed scalar value object and owns the reported error identity. +/// +public readonly record struct NonSentinelScalarRule(string? Message = null) : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public const string ErrorCode = NonSentinelRule.ErrorCode; + public const string MessageFormat = NonSentinelRule.MessageFormat; + + public bool IsValid(in TSelf value) => + new ScalarRuleAdapter>(new NonSentinelRule(Message)).IsValid(value); + + public string GetErrorMessage(in TSelf value) => + new ScalarRuleAdapter>(new NonSentinelRule(Message)).GetErrorMessage( + value + ); + + string? IZodRule.Code => NonSentinelRule.ErrorCode; + + string? IZodRule.Origin => "value_object"; +} + +/// +/// Maps to a type-level attribute. The attribute name encodes the +/// rule name (NonSentinelScalarAttribute → NonSentinelScalarRule) so the mapping addresses the +/// whole family. +/// +[ZodRule(typeof(NonSentinelScalarRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class NonSentinelScalarAttribute : ValidationAttribute +{ + public string? Message { get; set; } +} + +/// +/// A Guid-backed scalar whose validation is driven entirely by a type-level custom rule +/// (). The generated Create validates the value object through the +/// ZodSharp-generated schema, which reports the rule's own Code and Origin. +/// +[Scalar] +[ZodSchema] +[NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; } +} + +/// +/// A Guid-backed scalar whose validation reuses the normal through +/// , showing a rule written for an underlying value applied to a +/// scalar value object as a unit. +/// +[Scalar] +[ZodSchema] +[NonSentinelScalar] +public readonly partial record struct TenantId +{ + public Guid Value { get; } +} From 154818eaa3e73a9ddab35d9757002b88d4c17aef Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 3 Oct 2026 17:49:02 +0100 Subject: [PATCH 03/11] feat: better ZodSharp integration --- README.md | 3 +- docs/Getting-Started.md | 7 +- docs/ZodSharp-Validation.md | 43 +++- .../Common/TypeLibrary.Constants.cs | 20 ++ .../SourceGenerator/Common/TypeLibrarySpec.cs | 5 + .../Generators/ValueObjectSourceGenerator.cs | 28 +++ .../ValueObject/ValueObjectSymbolInspector.cs | 8 + .../ZodSharpScalarRuleAdapterEmitter.cs | 145 ++++++++++++++ src/src/ValueObjects/Sdk/README.md | 3 +- src/src/ZodSharpSample/CustomRules.cs | 27 +-- .../ValueObjectsGeneratorTestOptions.cs | 1 + ...ZodSharpScalarRuleAdapterGeneratorTests.cs | 185 ++++++++++++++++++ .../Serialization/ZodSchemaRuleModels.cs | 33 +--- 13 files changed, 444 insertions(+), 64 deletions(-) create mode 100644 src/src/SourceGenerator/ValueObject/ZodSharpScalarRuleAdapterEmitter.cs create mode 100644 src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs diff --git a/README.md b/README.md index 58403fb..138123f 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,8 @@ Zod. Several patterns are supported: generator emits a zero-allocation `{Type}Schema` validator (`EmailAddressSchema.Validate(email)`). - **Custom rules on scalars** – a `[ZodRule]`-mapped attribute closes a rule with the value object, so a rule written against `IScalarValueObject` validates the scalar as a unit and owns the reported - `Code`/`Origin`. Reuse a rule written for the underlying value via `ScalarRuleAdapter`. + `Code`/`Origin`. A rule written for the underlying value is adapted automatically via the generator-emitted + `ScalarRuleAdapter`. - **Schema-first** – build a schema for the scalar's underlying value (`Z.String().Email()`, `Z.Number()`, `Z.Enum<>()`) and construct the value object through its strict `Create` factory. diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index a70c9c3..df7afd5 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -174,9 +174,10 @@ constructed instance through `EmailAddressSchema` — `EmailAddress.Create("not- `ZodException`. Use `ZodSchemaMode.InsteadOfHooks` on the attribute to run the schema instead of the `OnValidate` hook. -For rules that must observe the value object rather than its `Value` (or that reuse a rule written for the -underlying value), map a `[ZodRule(typeof(...))]` attribute to a rule constrained to the value object, or adapt -the rule with a `ScalarRuleAdapter`. The rule's `Code`/`Origin` flow through `Create` unchanged. See +For rules that must observe the value object rather than its `Value`, map a `[ZodRule(typeof(...))]` attribute to +a rule constrained to the value object. A rule written for the underlying value is adapted automatically (via +the generator-emitted `ScalarRuleAdapter`) when applied to a `[Scalar]` type. The rule's `Code`/`Origin` flow +through `Create` unchanged. See [Custom rules on scalars](ZodSharp-Validation.md#custom-rules-on-scalars) and [Reusing a normal rule for a scalar](ZodSharp-Validation.md#reusing-a-normal-rule-for-a-scalar). diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md index e940a75..3eb50f8 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -231,12 +231,14 @@ forwards `result.Errors` (`ImmutableArray`) into a `ZodExceptio A rule written against the underlying value (for example `NonSentinelRule`) validates that value, not the value object: closing it with the scalar type (`NonSentinelRule`) compiles but always passes, -because the sentinel check only knows the primitive. Either put the attribute on the `Value` member, or adapt -the rule with a `ScalarRuleAdapter` and expose it as a scalar rule family member: +because the sentinel check only knows the primitive. + +The value-object generator emits `ScalarRuleAdapter` into every project that references +both `Purview.ValueObjects` and `Purview.ZodSharp`. It turns such a rule into one that validates the value +object as a unit: ```csharp -// Turns any IValidationRule into an IValidationRule for a scalar value object. -public readonly record struct ScalarRuleAdapter(TRule Rule) : IValidationRule +internal readonly record struct ScalarRuleAdapter(TRule Rule) : IValidationRule where TSelf : IScalarValueObject where TRule : IValidationRule { @@ -244,7 +246,33 @@ public readonly record struct ScalarRuleAdapter(TRule Rule public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); } +``` + +Do not declare it yourself. A project that already declares its own copy (the guidance before the generator +emitted it) is detected and the generated copy is skipped, so both keep compiling. + +ZodSharp applies the adapter automatically when a rule is applied to a `[Scalar]` type, so the common case +needs no extra code: +```csharp +[Scalar] +[ZodSchema] +[NonSentinel(Message = "AssetId must not be empty.")] // [ZodRule(typeof(NonSentinelRule<>))] +public readonly partial record struct AssetId +{ + public Guid Value { get; init; } +} +``` + +The generator closes `NonSentinelRule<>` with the underlying `Guid` and wraps it in +`ScalarRuleAdapter>`, so the rule runs against the value object with an +empty path. See ZodSharp's [Custom Rules](https://purview.dev/docs/zodsharp/custom-rules/) for the release +that introduced the automatic adaptation. + +When a rule must see the value object itself, expose it as a scalar rule family member and let the adapter do +the wiring: + +```csharp // The scalar-aware family member. The attribute name encodes the rule name, so [NonSentinelScalar] maps to it. public readonly record struct NonSentinelScalarRule(string? Message = null) : IValidationRule, IZodRule @@ -266,11 +294,8 @@ public readonly record struct NonSentinelScalarRule(string? Message = nul public sealed class NonSentinelScalarAttribute : ValidationAttribute { } ``` -`ScalarRuleAdapter` has three type parameters, so it cannot be referenced directly from `[ZodRule]` (which -addresses a non-generic rule or an arity-1 generic). Compose it inside a named family member as above, or -construct it directly for programmatic use. Copy `ScalarRuleAdapter` into a rules library that references both -`Purview.ValueObjects` and `Purview.ZodSharp` — a source generator cannot read another generator's output, so -the adapter cannot be shipped as a generator-emitted type that a rules library sees. +The adapter is `internal`, so it is only usable from source in the project that references both packages. A +rules library is the natural home for a composed family member, and it gets its own generated copy. ### ZodSharp integration diagnostics diff --git a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs index 1ffcf3b..a0a482f 100644 --- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs +++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs @@ -8,6 +8,8 @@ public static partial class TypeLibrary // {Member}FullName constants (those are only available in a later pass) and are kept as literals. // The TypeLibrary also exposes generated *FullName constants (via [TypeRef(generateFullNameConst: true)]) // which the generator's runtime logic uses instead of hard-coded type names. + public const string ValueObjectsNamespace = "Purview.ValueObjects"; + public const string SerializationNamespace = "Purview.ValueObjects.Serialization"; public const string ValueObjectAttributeFullTypeName = SerializationNamespace + ".ValueObjectAttribute"; @@ -51,6 +53,24 @@ public static partial class TypeLibrary /// The empty base path passed to a generated RefineCtx<T>. public const string ZodEmptyPathExpression = "global::System.Collections.Immutable.ImmutableArray.Empty"; + /// The ZodSharp rule contract the scalar rule adapter forwards to. + public const string ZodValidationRuleName = "IValidationRule"; + + /// + /// The metadata name used to detect a Purview.ZodSharp runtime reference: the adapter can only compile + /// when the rule interface is available. + /// + public const string ZodValidationRuleMetadataName = ZodSharpCoreNamespace + "." + ZodValidationRuleName + "`1"; + + /// The scalar rule adapter emitted into consumer compilations that reference ZodSharp. + public const string ScalarRuleAdapterName = "ScalarRuleAdapter"; + + /// The adapter's metadata name, used to skip emission when a consumer declares its own. + public const string ScalarRuleAdapterMetadataName = ValueObjectsNamespace + "." + ScalarRuleAdapterName + "`3"; + + /// The hint name the adapter source is registered under. + public const string ScalarRuleAdapterHintName = ScalarRuleAdapterName + ".g.cs"; + public const string EFValueObjectEFNamespace = "Microsoft.EntityFrameworkCore"; public const string EFValueGeneratorFactoryFullTypeName = diff --git a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs index f1a3200..ecb0adc 100644 --- a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs +++ b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs @@ -71,4 +71,9 @@ static partial class TypeLibrarySpec [TypeRef("Microsoft.EntityFrameworkCore.Metadata", generateFullNameConst: true)] static readonly TypeIdentity IComplexType = default; + + // The ZodSharp runtime rule contract. Only referenced by the scalar rule adapter, which is emitted + // when the consuming project references Purview.ZodSharp, so the name is resolved as text elsewhere. + [TypeRef("ZodSharp.Core", arity: 1)] + static readonly TypeIdentity IValidationRule = default; } diff --git a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs index 522008b..63daf5a 100644 --- a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs @@ -60,6 +60,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) static (spc, tuple) => EmitComplexResult(spc, tuple.Left, tuple.Right.Left, tuple.Right.Right) ); + RegisterZodSharpScalarRuleAdapterOutput(context, generationContext); + RegisterEFRegistryOutput( context, scalarCandidates, @@ -106,6 +108,32 @@ bool efDisabled context.AddSource(result.Value.HintName, writer); } + static void RegisterZodSharpScalarRuleAdapterOutput( + IncrementalGeneratorInitializationContext context, + IncrementalValueProvider generationContext + ) + { + // The adapter only compiles when the ZodSharp runtime is referenced, and it is skipped when the + // consumer declares its own so the copied adapters the earlier guidance produced keep working. + var adapterState = context.CompilationProvider.Select( + static (compilation, _) => ZodSharpScalarRuleAdapterEmitter.ResolveState(compilation) + ); + + context.RegisterSourceOutput( + adapterState.Combine(generationContext), + static (spc, tuple) => + { + var (state, generationContext) = tuple; + if (generationContext.Settings.IsSourceGeneratorDisabled || !state.ShouldEmit) + return; + + var writer = generationContext.CreateCodeWriter(); + ZodSharpScalarRuleAdapterEmitter.Emit(writer); + spc.AddSource(TypeLibrary.ScalarRuleAdapterHintName, writer); + } + ); + } + static void RegisterEFRegistryOutput( IncrementalGeneratorInitializationContext context, IncrementalValuesProvider> scalarCandidates, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs index d334b77..d0ae3dc 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -806,6 +806,14 @@ public static bool IsEFReferenced(Compilation compilation) => ) is not null; + /// + /// True when the compilation references the Purview.ZodSharp runtime. This is the only state in which the + /// scalar rule adapter can compile: it forwards a rule written against a value object's underlying value, + /// which is the ZodSharp.Core.IValidationRule<TValue> contract. + /// + public static bool IsZodSharpReferenced(Compilation compilation) => + compilation.GetTypeByMetadataName(TypeLibrary.ZodValidationRuleMetadataName) is not null; + /// /// True when the compilation references EF Core 8+, which introduced complex types /// (EntityTypeBuilder.ComplexProperty). diff --git a/src/src/SourceGenerator/ValueObject/ZodSharpScalarRuleAdapterEmitter.cs b/src/src/SourceGenerator/ValueObject/ZodSharpScalarRuleAdapterEmitter.cs new file mode 100644 index 0000000..f836fb8 --- /dev/null +++ b/src/src/SourceGenerator/ValueObject/ZodSharpScalarRuleAdapterEmitter.cs @@ -0,0 +1,145 @@ +namespace Purview.ValueObjects.SourceGenerator.ValueObject; + +/// +/// Emits the assembly-level composition type into consumer +/// compilations that reference both Purview.ValueObjects and Purview.ZodSharp. +/// +/// +/// +/// The adapter turns a rule written against a scalar's underlying value (IValidationRule<TValue>) +/// into a rule that validates the value object as a unit (IValidationRule<TSelf>), so a +/// [ZodRule]-mapped attribute can apply it to the value object itself. It only needs public runtime +/// contracts, so this generator can emit it: it reads no other generator's output. +/// +/// +/// The type is and emitted once per compilation, so a +/// consumer that references two assemblies which both emit it never sees the name twice. ZodSharp's generator +/// references it by fully qualified text, because a generator cannot resolve another generator's symbols. +/// +/// +static class ZodSharpScalarRuleAdapterEmitter +{ + /// Describes whether the adapter can be, or needs to be, emitted for a compilation. + /// True when the compilation references the Purview.ZodSharp runtime. + /// True when the compilation declares the adapter in its own source. + public readonly record struct EmissionState(bool IsZodSharpReferenced, bool AdapterAlreadyDeclared) + { + /// True when the adapter source should be added to the compilation. + public bool ShouldEmit => IsZodSharpReferenced && !AdapterAlreadyDeclared; + } + + /// + /// Resolves whether the compilation wants the adapter: it is only emitted when the ZodSharp runtime is + /// referenced (the adapter forwards to its rule contract) and the consumer has not declared the type + /// itself, which keeps the copied adapters the earlier guidance produced working. + /// + /// The compilation being generated for. + /// + /// Only a declaration in this compilation counts. A referenced assembly that also emitted its own adapter + /// declares it , so it is invisible to this compilation's name lookup and must + /// not suppress the local copy. + /// + public static EmissionState ResolveState(Compilation compilation) => + new( + IsZodSharpReferenced: ValueObjectSymbolInspector.IsZodSharpReferenced(compilation), + AdapterAlreadyDeclared: IsDeclaredInSource(compilation) + ); + + static bool IsDeclaredInSource(Compilation compilation) => + compilation.GetTypeByMetadataName(TypeLibrary.ScalarRuleAdapterMetadataName) + is { DeclaringSyntaxReferences.Length: > 0 }; + + /// Emits the adapter. + public static void Emit(CodeWriter writer) + { + // The adapter is a composition type with no error identity of its own; the inner rule (or the applied + // attribute) supplies it, so the ZodSharp convention check is silenced for this file only. + writer.AutoGeneratedHeader(pragmas: ["ZODSGEN042"]); + writer.FileScopedNamespace(TypeLibrary.ValueObjectsNamespace); + + writer + .XmlSummary( + "Turns a rule written against a scalar value object's underlying value into a rule that validates", + "the value object as a unit, so a [ZodRule]-mapped attribute can apply it to the value object", + "itself. Emitted when this project references both Purview.ValueObjects and Purview.ZodSharp." + ) + .RecordStruct( + new TypeDeclarationOptions(TypeLibrary.ScalarRuleAdapterName) + { + Accessibility = TypeDeclarationAccessibility.Internal, + IsPartial = false, + IsReadOnly = true, + GenericTypes = + [ + new GenericTypeParameterOptions(SelfName) + { + Constraints = + [ + TypeLibrary + .Purview.ValueObjects.IScalarValueObject.MakeGeneric(SelfReference, ValueReference) + .RenderFullName, + ], + }, + new GenericTypeParameterOptions(ValueName), + new GenericTypeParameterOptions(RuleName) + { + Constraints = + [ + TypeLibrary.ZodSharp.Core.IValidationRule.MakeGeneric(ValueReference).RenderFullName, + ], + }, + ], + PrimaryConstructorParameters = [new("Rule", RuleTypeReference)], + Interfaces = [RuleInterfaceForSelf], + }, + body => + { + body.XmlSummary("True when the adapted rule accepts the value object's underlying value.") + .MethodExpression( + new MethodDeclarationOptions( + "IsValid", + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public + ) + { + Parameters = [new("value", SelfReference, ParameterModifier.In)], + ExpressionBody = "Rule.IsValid(value.Value)", + } + ); + + body.XmlSummary("The adapted rule's message for the value object's underlying value.") + .MethodExpression( + new MethodDeclarationOptions( + "GetErrorMessage", + PurviewTypeLibrary.System.String, + TypeDeclarationAccessibility.Public + ) + { + Parameters = [new("value", SelfReference, ParameterModifier.In)], + ExpressionBody = "Rule.GetErrorMessage(value.Value)", + } + ); + } + ); + } + + const string SelfName = "TSelf"; + + const string ValueName = "TValue"; + + const string RuleName = "TRule"; + + /// + /// The type parameters are emitted as named identities without a namespace, which renders them as bare + /// parameter names: a TypeReference created for an open generic parameter cannot be used in + /// a declaration, which validates that every parameter names a type. + /// + static TypeReference SelfReference => new(new TypeIdentity(SelfName, null)); + + static TypeReference ValueReference => new(new TypeIdentity(ValueName, null)); + + static TypeReference RuleTypeReference => new(new TypeIdentity(RuleName, null)); + + static TypeReference RuleInterfaceForSelf => + new(TypeLibrary.ZodSharp.Core.IValidationRule.MakeGeneric(SelfReference)); +} diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index 99ee342..537d2d3 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -82,7 +82,8 @@ Validate value objects with [Purview.ZodSharp](https://www.nuget.org/packages/Pu `{Type}Schema` validator. - **Custom rules on scalars** – a `[ZodRule]`-mapped attribute closes a rule with the value object, so a rule written against `IScalarValueObject` validates the scalar as a unit and owns the reported - `Code`/`Origin`. Reuse a rule written for the underlying value via `ScalarRuleAdapter`. + `Code`/`Origin`. A rule written for the underlying value is adapted automatically via the generator-emitted + `ScalarRuleAdapter`. - **Schema-first** – build a schema for the underlying value (`Z.String().Email()`, `Z.Number()`, `Z.Enum<>()`) and construct the value object through its strict `Create` factory. diff --git a/src/src/ZodSharpSample/CustomRules.cs b/src/src/ZodSharpSample/CustomRules.cs index 132f6e0..5dfcd93 100644 --- a/src/src/ZodSharpSample/CustomRules.cs +++ b/src/src/ZodSharpSample/CustomRules.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using Purview.ValueObjects.Serialization; using ZodSharp; using ZodSharp.Core; @@ -46,31 +45,15 @@ sealed class NonEmptyAttribute : ValidationAttribute public string? Message { get; set; } } -/// -/// Turns an existing rule written against a scalar's underlying value into a rule that validates the scalar -/// value object as a unit. It is how a normal rule (for example ) is reused -/// for a scalar without re-authoring it against . -/// -[SuppressMessage( - "Design", - "CA1005:Avoid excessive parameters on generic types", - Justification = "The three type parameters are the scalar, its underlying value, and the adapted rule; all three are required." -)] -#pragma warning disable ZODSGEN042 // This is a composition adapter, not a rule with its own error identity. -readonly record struct ScalarRuleAdapter(TRule Rule) : IValidationRule - where TSelf : IScalarValueObject - where TRule : IValidationRule -{ - public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); - - public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); -} -#pragma warning restore ZODSGEN042 - /// /// The scalar-aware member of the non-sentinel rule family: it adapts for a /// Guid-backed scalar value object and owns the reported error identity. /// +/// +/// The it composes is emitted into +/// this project by the value-object generator, which runs because the project references both +/// Purview.ValueObjects and Purview.ZodSharp. +/// readonly record struct NonSentinelScalarRule(string? Message = null) : IValidationRule, IZodRule where TSelf : IScalarValueObject { diff --git a/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs b/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs index 8d9f5ec..08a6f02 100644 --- a/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs @@ -5,6 +5,7 @@ public record ValueObjectsGeneratorTestOptions : SourceGeneratorTestOptions public static readonly string[] ValueObjectGeneratedTypes = [ "EmbeddedAttribute.g.cs", + TypeLibrary.ScalarRuleAdapterHintName, $"{TypeLibrary.EFValueObjectExtensionsClassName}.g.cs", ]; diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs new file mode 100644 index 0000000..ef489ae --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs @@ -0,0 +1,185 @@ +namespace Purview.ValueObjects.SourceGenerator.Generators; + +/// +/// Tests the assembly-level ScalarRuleAdapter emission: it is only emitted when the compilation +/// references the Purview.ZodSharp runtime, and never over a declaration the consumer already made. +/// +/// +/// The adapter only needs the ZodSharp runtime's rule contract, so these tests reference the runtime without +/// running the ZodSharp schema generator: the emission is a value-object generator decision. +/// +public sealed class ZodSharpScalarRuleAdapterGeneratorTests + : ValueObjectSourceGeneratorTestBase +{ + [Test] + public async Task Emit_GivenZodSharpReferenced_EmitsAdapter(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default, cancellationToken); + + var adapter = await Assert.That(result.GetSource(TypeLibrary.ScalarRuleAdapterHintName)).IsNotNull(); + + await Assert + .That(adapter!) + .Contains("internal readonly record struct ScalarRuleAdapter(TRule Rule)"); + await Assert.That(adapter).Contains("global::ZodSharp.Core.IValidationRule"); + await Assert.That(adapter).Contains("Rule.IsValid(value.Value)"); + } + + [Test] + public async Task Emit_GivenZodSharpReferenced_IsUsableByAScalarRuleFamily(CancellationToken cancellationToken) + { + // The adapter is the seam a scalar rule family composes; composing it here proves the emitted type + // satisfies the [ZodRule] rule contract end to end. + const string source = """ + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NonSentinelRule(string? Message = null) : IValidationRule + where T : System.IEquatable + { + public bool IsValid(in T value) => !value!.Equals(default!); + + public string GetErrorMessage(in T value) => Message ?? "Value must not be the default."; + } + + public readonly record struct NonSentinelScalarRule(string? Message = null) : IValidationRule + where TSelf : Purview.ValueObjects.IScalarValueObject + { + public bool IsValid(in TSelf value) => + new global::Purview.ValueObjects.ScalarRuleAdapter>( + new(Message) + ).IsValid(value); + + public string GetErrorMessage(in TSelf value) => + new global::Purview.ValueObjects.ScalarRuleAdapter>( + new(Message) + ).GetErrorMessage(value); + } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct TenantId + { + public System.Guid Value { get; } + } + + public static class Harness + { + public static bool AcceptsNonSentinel() => + new NonSentinelScalarRule().IsValid(TenantId.Hydrate(System.Guid.NewGuid())); + + public static bool RejectsSentinel() => + !new NonSentinelScalarRule().IsValid(TenantId.Hydrate(System.Guid.Empty)); + } + } + """; + + var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default with + { + CompileToAssembly = true, + }, + cancellationToken + ); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly!.GetType("Testing.Harness")!; + + var accepts = (bool)harness.GetMethod("AcceptsNonSentinel")!.Invoke(null, null)!; + var rejects = (bool)harness.GetMethod("RejectsSentinel")!.Invoke(null, null)!; + + await Assert.That(accepts).IsTrue(); + await Assert.That(rejects).IsTrue(); + } + + [Test] + public async Task Emit_GivenConsumerDeclaredAdapter_IsSkipped(CancellationToken cancellationToken) + { + // A consumer that copied the adapter under the earlier guidance keeps theirs: emission is skipped so + // the compilation does not declare the type twice. + const string source = """ + using ZodSharp.Core; + + namespace Purview.ValueObjects + { + public readonly record struct ScalarRuleAdapter(TRule Rule) : IValidationRule + where TSelf : Purview.ValueObjects.IScalarValueObject + where TRule : IValidationRule + { + public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); + + public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); + } + } + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default, cancellationToken); + + await Assert.That(result.GetSource(TypeLibrary.ScalarRuleAdapterHintName)).IsNull(); + } + + [Test] + public async Task ResolveState_GivenZodSharpNotReferenced_DoesNotWantTheAdapter() + { + // The adapter forwards to the ZodSharp rule contract, so without that reference the emitted source + // would not compile. The harness always references the assemblies loaded into the test process, so + // the reference set is built explicitly here rather than through the generator options. + var compilation = Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create( + "WithoutZodSharp", + [], + [Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions( + Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary + ) + ); + + var state = ZodSharpScalarRuleAdapterEmitter.ResolveState(compilation); + + await Assert.That(state.IsZodSharpReferenced).IsFalse(); + await Assert.That(state.ShouldEmit).IsFalse(); + } + + [Test] + public async Task ResolveState_GivenZodSharpReferencedAndNoExistingAdapter_WantsTheAdapter() + { + var compilation = Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create( + "WithZodSharp", + [], + [ + Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + Microsoft.CodeAnalysis.MetadataReference.CreateFromFile( + typeof(ZodSharp.Core.IValidationRule<>).Assembly.Location + ), + ], + new Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions( + Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary + ) + ); + + var state = ZodSharpScalarRuleAdapterEmitter.ResolveState(compilation); + + await Assert.That(state.IsZodSharpReferenced).IsTrue(); + await Assert.That(state.ShouldEmit).IsTrue(); + } +} diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs index 6bc5ef9..6d26028 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using ZodSharp; using ZodSharp.Core; using ZodSharp.Rules; @@ -54,37 +53,15 @@ public sealed class NonEmptyAttribute : ValidationAttribute public string? Message { get; set; } } -/// -/// Turns an existing rule written against a scalar's underlying value into a rule that validates the scalar -/// value object as a unit. It is how a normal rule (for example ) is reused -/// for a scalar without re-authoring it against . -/// -/// -/// ZodSharp's [ZodRule] mapping supports a non-generic rule or an arity-1 generic; this adapter has -/// arity three, so it is composed inside a named rule family member (see -/// ) rather than referenced directly from an attribute. Copy this -/// type into a rules library that references both Purview.ValueObjects and Purview.ZodSharp. -/// -[SuppressMessage( - "Design", - "CA1005:Avoid excessive parameters on generic types", - Justification = "The three type parameters are the scalar, its underlying value, and the adapted rule; all three are required." -)] -#pragma warning disable ZODSGEN042 // This is a composition adapter, not a rule with its own error identity. -public readonly record struct ScalarRuleAdapter(TRule Rule) : IValidationRule - where TSelf : IScalarValueObject - where TRule : IValidationRule -{ - public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); - - public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); -} -#pragma warning restore ZODSGEN042 - /// /// The scalar-aware member of the non-sentinel rule family: it adapts for a /// Guid-backed scalar value object and owns the reported error identity. /// +/// +/// The it composes is emitted into +/// this project by the value-object generator, which runs because the project references both +/// Purview.ValueObjects and Purview.ZodSharp. +/// public readonly record struct NonSentinelScalarRule(string? Message = null) : IValidationRule, IZodRule where TSelf : IScalarValueObject { From 634fdd077f5234f9b888081b161b52a4a1b87b5e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 5 Oct 2026 00:14:35 +0100 Subject: [PATCH 04/11] refactor: update zod rule attrib gen --- README.md | 8 +- docs/ZodSharp-Validation.md | 151 +++++++++++------- src/src/ValueObjects/Sdk/README.md | 8 +- src/src/ZodSharpSample/CustomRules.cs | 112 ------------- src/src/ZodSharpSample/NonSentinelModels.cs | 32 ++++ src/src/ZodSharpSample/Program.cs | 23 ++- src/src/ZodSharpSample/README.md | 8 +- .../ZodSchemaValidationGeneratorTests.cs | 145 +++++++++++------ .../ZodSchemaIntegrationTests.cs | 106 ++++++++++-- .../Serialization/ZodSchemaRuleModels.cs | 129 +++++---------- 10 files changed, 374 insertions(+), 348 deletions(-) delete mode 100644 src/src/ZodSharpSample/CustomRules.cs create mode 100644 src/src/ZodSharpSample/NonSentinelModels.cs diff --git a/README.md b/README.md index 138123f..c0b272e 100644 --- a/README.md +++ b/README.md @@ -140,10 +140,10 @@ Zod. Several patterns are supported: `ZodSchemaMode.InsteadOfHooks` opts out of the `OnValidate` hook. - **Generated validators** – annotate a value object or DTO with `[ZodSchema]` + DataAnnotations; a source generator emits a zero-allocation `{Type}Schema` validator (`EmailAddressSchema.Validate(email)`). -- **Custom rules on scalars** – a `[ZodRule]`-mapped attribute closes a rule with the value object, so a rule - written against `IScalarValueObject` validates the scalar as a unit and owns the reported - `Code`/`Origin`. A rule written for the underlying value is adapted automatically via the generator-emitted - `ScalarRuleAdapter`. +- **Custom rules on scalars** – Purview.ZodSharp ships a validation attribute for every built-in rule (for + example `[NonSentinel(Message = "…")]`), and a custom `[ZodRule]`-mapped attribute validates the scalar as a + unit and owns the reported `Code`/`Origin`. A rule written for the underlying value is adapted automatically + via the generator-emitted `ScalarRuleAdapter`. - **Schema-first** – build a schema for the scalar's underlying value (`Z.String().Email()`, `Z.Number()`, `Z.Enum<>()`) and construct the value object through its strict `Create` factory. diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md index 3eb50f8..175a7a8 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -172,49 +172,101 @@ CorporateEmail.Hydrate("demo@gmail.com"); // replay-safe: no validation runs ### Custom rules on scalars ZodSharp rules are first-class, and a scalar value object is validated as a **unit** (its single `Value` *is* -the value). A `[ZodRule]`-mapped attribute closes an unbound generic rule with the annotated type, so a rule -constrained to the value-object contract sees the whole value object: +the value). Purview.ZodSharp ships a validation attribute for every built-in rule in the `ZodSharp.Rules` +namespace, so the common checks need no hand-written rule or attribute: + +| Attribute | Rule | Notes | +| --- | --- | --- | +| `[NonSentinel(Message = "…")]` | `NonSentinelRule` | Rejects `Guid.Empty`, the `DateTime`/`DateTimeOffset`/`DateOnly`/`TimeOnly` bounds, and null/empty/whitespace strings. | +| `[Email]` | `EmailRule` | | +| `[E164]` | `E164Rule` | | +| `[UUID(UuidVersion.V4)]` | `UUIDRule` | A rule value without a default becomes a required attribute constructor argument. | +| `[MinLengthZod(3)]` | `MinLengthRule` | The `Zod` suffix avoids a clash with `System.ComponentModel.DataAnnotations.MinLengthAttribute` (also `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`). | + +Each attribute mirrors its rule's constructor: a rule value without a default (the `minLength` of +`MinLengthRule`, the bound of `MinValueRule`, …) is a **required constructor argument**, while an optional +value keeps its default. The rule's `message` and `code` stay named properties (`[NonSentinel(Message = "…", +Code = "…")]`), so an error code or message can be overridden per use. + +The same attributes work on a primitive member and on a `[Scalar]` type. On a scalar, an attribute whose rule +is written against the underlying value is **adapted automatically** (see +[Reusing a normal rule for a scalar](#reusing-a-normal-rule-for-a-scalar)); on a member it validates the member +directly. + +```csharp +using ZodSharp; +using ZodSharp.Rules; + +[Scalar] +[ZodSchema] +[NonSentinel(Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; init; } +} +``` + +`AssetId.Create(Guid.Empty)` throws a `ZodException` whose error carries `Code = "invalid_value"` (the rule's +own `ErrorCode`), no structured `Origin`, and an **empty path** (the rule applies to the value object, not a +member). Because the rule runs inside the generated schema, `Hydrate` stays replay-safe and the value-objects +layer needs no extra code. + +#### Writing a custom rule attribute + +A custom rule is exposed as a `[ZodRule]`-mapped attribute in one of two ways. + +**1. Map a hand-authored attribute to the rule.** Declare a `ValidationAttribute` and point it at the rule with +`[ZodRule(typeof(...))]`; the open generic form serves a primitive member and a scalar: ```csharp using ZodSharp.Core; // Reads the value object through IScalarValueObject. Implementing IZodRule lets the rule own // its error identity, so one attribute can report a different code per scalar. -public readonly record struct NonEmptyRule(string? Code = null, string? Message = null) +public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule, IZodRule - where TSelf : IScalarValueObject + where TSelf : IScalarValueObject { - public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Value must not contain whitespace."; + + public bool IsValid(in TSelf value) => !value.Value.Any(char.IsWhiteSpace); - public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; - string? IZodRule.Code => Code; + string? IZodRule.Code => ErrorCode; string? IZodRule.Origin => "value_object"; } -[ZodRule(typeof(NonEmptyRule<>))] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class NonEmptyAttribute : ValidationAttribute +[ZodRule(typeof(NoWhitespaceRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NoWhitespaceAttribute : ValidationAttribute { - public string? Code { get; set; } public string? Message { get; set; } } +``` -[Scalar] -[ZodSchema] -[NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] -public readonly partial record struct AssetId +**2. Let the generator write the attribute.** Mark the rule with the parameterless `[ZodRule]` marker and the +ZodSharp generator emits a `{RuleName}Attribute` in the rule's namespace (a trailing `Rule` becomes +`Attribute`; a name that clashes with a `System.ComponentModel.DataAnnotations` attribute gets a `Zod` +suffix): + +```csharp +[ZodRule] // the generator emits NoWhitespaceAttribute +public readonly record struct NoWhitespaceRule(string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject { - public Guid Value { get; } + // ... } ``` -`AssetId.Create(Guid.Empty)` throws a `ZodException` whose error carries `Code = "invalid_asset_id"`, -`Origin = "value_object"`, and an **empty path** (the rule applies to the value object, not a member). Because -the rule runs inside the generated schema, `Hydrate` stays replay-safe and the value-objects layer needs no -extra code. A rule should expose `public const string ErrorCode` and `public const string MessageFormat`; -ZodSharp reports `ZODSGEN042` otherwise. +The generator runs over the compilation that declares the rule, so the marker form suits a rule in the same +project (or in a rules library that references Purview.ZodSharp). A rule that accepts a `code`/`origin` +constructor parameter must implement `IZodRule`, or ZodSharp reports `ZODSGEN039`. A rule should also expose +`public const string ErrorCode` and `public const string MessageFormat`; ZodSharp reports `ZODSGEN042` +otherwise. The reported `Code`/`Origin` resolve in this order (first match wins): @@ -252,50 +304,29 @@ Do not declare it yourself. A project that already declares its own copy (the gu emitted it) is detected and the generated copy is skipped, so both keep compiling. ZodSharp applies the adapter automatically when a rule is applied to a `[Scalar]` type, so the common case -needs no extra code: +needs no extra code. The built-in `[NonSentinel]` is exactly this shape — its rule is written against the +underlying value and the generator adapts it: ```csharp +using ZodSharp; +using ZodSharp.Rules; + [Scalar] [ZodSchema] -[NonSentinel(Message = "AssetId must not be empty.")] // [ZodRule(typeof(NonSentinelRule<>))] -public readonly partial record struct AssetId +[NonSentinel(Message = "UserId must not be empty.")] +public readonly partial record struct UserId { public Guid Value { get; init; } } ``` The generator closes `NonSentinelRule<>` with the underlying `Guid` and wraps it in -`ScalarRuleAdapter>`, so the rule runs against the value object with an -empty path. See ZodSharp's [Custom Rules](https://purview.dev/docs/zodsharp/custom-rules/) for the release -that introduced the automatic adaptation. - -When a rule must see the value object itself, expose it as a scalar rule family member and let the adapter do -the wiring: - -```csharp -// The scalar-aware family member. The attribute name encodes the rule name, so [NonSentinelScalar] maps to it. -public readonly record struct NonSentinelScalarRule(string? Message = null) - : IValidationRule, IZodRule - where TSelf : IScalarValueObject -{ - public bool IsValid(in TSelf value) => - new ScalarRuleAdapter>(new(Message)).IsValid(value); - - public string GetErrorMessage(in TSelf value) => - new ScalarRuleAdapter>(new(Message)).GetErrorMessage(value); - - string? IZodRule.Code => NonSentinelRule.ErrorCode; - - string? IZodRule.Origin => "value_object"; -} - -[ZodRule(typeof(NonSentinelScalarRule<>))] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class NonSentinelScalarAttribute : ValidationAttribute { } -``` - -The adapter is `internal`, so it is only usable from source in the project that references both packages. A -rules library is the natural home for a composed family member, and it gets its own generated copy. +`ScalarRuleAdapter>`, so the rule runs against the value object with an +empty path and the **wrapped** rule owns the reported `Code` (the built-in rules report no structured +`Origin`). One attribute serves every scalar backed by the same primitive. The runnable version lives in +`src/src/ZodSharpSample/NonSentinelModels.cs`. See ZodSharp's +[Custom Rules](https://purview.dev/docs/zodsharp/custom-rules/) for the rule contract and the shipped +attributes. ### ZodSharp integration diagnostics @@ -307,8 +338,9 @@ The value-object analyzer reports the integration states that would otherwise pa | `VO1015` | Error | `[ZodSchema(SchemaName = "...")]` is not a valid C# identifier, which ZodSharp applies verbatim and this generator cannot reference. Generation is skipped for that type. | ZodSharp's own diagnostics cover the refinement hook (`ZODSGEN034`-`ZODSGEN036`) and the rule mapping and -convention checks (`ZODSGEN030`-`ZODSGEN042`, for example `ZODSGEN042` for a rule that omits `ErrorCode`/ -`MessageFormat`); see ZodSharp's +convention checks (`ZODSGEN030`-`ZODSGEN043`, for example `ZODSGEN042` for a rule that omits `ErrorCode`/ +`MessageFormat`, or `ZODSGEN043` for a built-in rule that cannot produce a validation attribute); see +ZodSharp's [Source Generator Diagnostics](https://purview.dev/docs/zodsharp/source-generator-diagnostics/) for the full list. @@ -552,8 +584,9 @@ Tests that run the value-object generator and the ZodSharp generator together co - **Real compile (integration):** `src/tests/ValueObjects.IntegrationTests` declares `[Scalar]` + `[ZodSchema]` fixtures and asserts runtime behaviour directly — both generators run in the real compiler for that project, so nothing has to be reflected or registered. The fixtures include type-level - custom rules (`Serialization/ZodSchemaRuleModels.cs`), so rule `Code`/`Origin` propagation through `Create` - is covered end-to-end. + custom rules (`Serialization/ZodSchemaRuleModels.cs`), including a rule written against the underlying + value that the ZodSharp generator adapts automatically, so rule `Code`/`Origin` and the empty path are + covered end-to-end. The loaded generator carries its own framework implementation, so it keeps its own log sink and CodeWriter scope validation: do not assert on its log entries, and leave `ValidateCodeWriterScopes` diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index 537d2d3..314bb61 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -80,10 +80,10 @@ Validate value objects with [Purview.ZodSharp](https://www.nuget.org/packages/Pu `OnZodValidate(RefineCtx)` hook to add Zod-compatible refinements. - **Generated validators** – annotate a type with `[ZodSchema]` + DataAnnotations to get a zero-allocation `{Type}Schema` validator. -- **Custom rules on scalars** – a `[ZodRule]`-mapped attribute closes a rule with the value object, so a rule - written against `IScalarValueObject` validates the scalar as a unit and owns the reported - `Code`/`Origin`. A rule written for the underlying value is adapted automatically via the generator-emitted - `ScalarRuleAdapter`. +- **Custom rules on scalars** – Purview.ZodSharp ships a validation attribute for every built-in rule (for + example `[NonSentinel(Message = "…")]`), and a custom `[ZodRule]`-mapped attribute validates the scalar as a + unit and owns the reported `Code`/`Origin`. A rule written for the underlying value is adapted automatically + via the generator-emitted `ScalarRuleAdapter`. - **Schema-first** – build a schema for the underlying value (`Z.String().Email()`, `Z.Number()`, `Z.Enum<>()`) and construct the value object through its strict `Create` factory. diff --git a/src/src/ZodSharpSample/CustomRules.cs b/src/src/ZodSharpSample/CustomRules.cs deleted file mode 100644 index 5dfcd93..0000000 --- a/src/src/ZodSharpSample/CustomRules.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Purview.ValueObjects.Serialization; -using ZodSharp; -using ZodSharp.Core; -using ZodSharp.Rules; - -namespace Purview.ValueObjects.ZodSharpSample; - -/// -/// A custom rule that validates a scalar value object as a unit. It reads the value object through -/// its contract, so the same rule serves every Guid-backed -/// scalar. Implementing lets the rule own the reported error code and origin, which is -/// how one attribute can produce a different code per annotated scalar. -/// -readonly record struct NonEmptyRule(string? Code = null, string? Message = null) - : IValidationRule, - IZodRule - where TSelf : IScalarValueObject -{ - public const string ErrorCode = "invalid_value"; - public const string MessageFormat = "Value must not be empty."; - - public bool IsValid(in TSelf value) => value.Value != Guid.Empty; - - public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; - - string IValidationRule.Code => Code ?? ErrorCode; - - string? IZodRule.Code => Code; - - string? IZodRule.Origin => "value_object"; -} - -/// -/// Maps to a type-level DataAnnotations attribute. The ZodSharp generator -/// closes the open generic with the annotated scalar type, so the rule validates the value object as a unit -/// and reports an empty path. -/// -[ZodRule(typeof(NonEmptyRule<>))] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -sealed class NonEmptyAttribute : ValidationAttribute -{ - public string? Code { get; set; } - - public string? Message { get; set; } -} - -/// -/// The scalar-aware member of the non-sentinel rule family: it adapts for a -/// Guid-backed scalar value object and owns the reported error identity. -/// -/// -/// The it composes is emitted into -/// this project by the value-object generator, which runs because the project references both -/// Purview.ValueObjects and Purview.ZodSharp. -/// -readonly record struct NonSentinelScalarRule(string? Message = null) : IValidationRule, IZodRule - where TSelf : IScalarValueObject -{ - public const string ErrorCode = NonSentinelRule.ErrorCode; - public const string MessageFormat = NonSentinelRule.MessageFormat; - - public bool IsValid(in TSelf value) => - new ScalarRuleAdapter>(new NonSentinelRule(Message)).IsValid(value); - - public string GetErrorMessage(in TSelf value) => - new ScalarRuleAdapter>(new NonSentinelRule(Message)).GetErrorMessage( - value - ); - - string? IZodRule.Code => NonSentinelRule.ErrorCode; - - string? IZodRule.Origin => "value_object"; -} - -/// -/// Maps to a type-level attribute. The attribute name encodes the -/// rule name (NonSentinelScalarAttribute → NonSentinelScalarRule) so the mapping addresses the -/// whole family. -/// -[ZodRule(typeof(NonSentinelScalarRule<>))] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -sealed class NonSentinelScalarAttribute : ValidationAttribute -{ - public string? Message { get; set; } -} - -/// -/// A Guid-backed scalar whose validation is driven entirely by a type-level custom rule -/// (). Create surfaces the rule's own code and origin, while -/// Hydrate stays replay-safe. -/// -[Scalar] -[ZodSchema] -[NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] -readonly partial record struct AssetId -{ - public Guid Value { get; } -} - -/// -/// A Guid-backed scalar whose validation reuses the normal through -/// , showing a rule written for an underlying value applied to a -/// scalar value object as a unit. -/// -[Scalar] -[ZodSchema] -[NonSentinelScalar] -readonly partial record struct TenantId -{ - public Guid Value { get; } -} diff --git a/src/src/ZodSharpSample/NonSentinelModels.cs b/src/src/ZodSharpSample/NonSentinelModels.cs new file mode 100644 index 0000000..751677e --- /dev/null +++ b/src/src/ZodSharpSample/NonSentinelModels.cs @@ -0,0 +1,32 @@ +using Purview.ValueObjects.Serialization; +using ZodSharp; +using ZodSharp.Rules; + +namespace Purview.ValueObjects.ZodSharpSample; + +/// +/// A Guid-backed scalar whose non-default check is driven entirely by the built-in +/// attribute that ships with Purview.ZodSharp. The rule is written +/// against the underlying value, so the ZodSharp generator adapts it automatically through the value-object +/// generator's — no hand-authored rule or attribute is +/// required. +/// +[Scalar] +[ZodSchema] +[NonSentinel(Message = "AssetId must not be empty.")] +readonly partial record struct AssetId +{ + public Guid Value { get; } +} + +/// +/// A second Guid-backed scalar using the same built-in attribute, showing one attribute serving every scalar +/// backed by the same primitive. +/// +[Scalar] +[ZodSchema] +[NonSentinel(Message = "UserId must not be empty.")] +readonly partial record struct UserId +{ + public Guid Value { get; } +} diff --git a/src/src/ZodSharpSample/Program.cs b/src/src/ZodSharpSample/Program.cs index e485d7d..2df8187 100644 --- a/src/src/ZodSharpSample/Program.cs +++ b/src/src/ZodSharpSample/Program.cs @@ -14,7 +14,7 @@ Console.WriteLine(); Console.WriteLine("== Type-level custom rules on scalars =="); -CustomRuleValidation(); +NonSentinelValidation(); Console.WriteLine(); Console.WriteLine("== Schema-first validation (hand-built schemas) =="); @@ -123,11 +123,11 @@ static void RefinementHookValidation() Console.WriteLine($"CorporateEmail.Hydrate('demo@gmail.com') -> '{replayed.Value}'"); } -static void CustomRuleValidation() +static void NonSentinelValidation() { - // A type-level [ZodRule]-mapped attribute closes the generic rule with the scalar type, so the rule sees - // the value object as a unit. Create runs it through the ZodSharp schema; the rule owns the code and - // origin, so one attribute serves many scalars with a per-scalar code. + // The built-in [NonSentinel] attribute ships with Purview.ZodSharp. Its rule is written against the + // underlying value, so the ZodSharp generator adapts it to the scalar automatically: Create runs it + // through the generated schema and the wrapped rule owns the reported code. var assetId = AssetId.Create(Guid.NewGuid()); Console.WriteLine($"AssetId.Create(guid) -> '{assetId.Value}'"); @@ -144,19 +144,18 @@ static void CustomRuleValidation() // Hydrate is replay-safe: type-level rules are not re-run. Console.WriteLine($"AssetId.Hydrate(Guid.Empty) -> '{AssetId.Hydrate(Guid.Empty).Value}'"); - // A rule written for the underlying value (NonSentinelRule) is reused for a scalar through the - // scalar rule family member that composes ScalarRuleAdapter. - var tenantId = TenantId.Create(Guid.NewGuid()); - Console.WriteLine($"TenantId.Create(guid) -> '{tenantId.Value}'"); + // The same built-in attribute serves every scalar backed by the same primitive. + var userId = UserId.Create(Guid.NewGuid()); + Console.WriteLine($"UserId.Create(guid) -> '{userId.Value}'"); try { - TenantId.Create(Guid.Empty); - Console.WriteLine("TenantId.Create(Guid.Empty) -> no exception"); + UserId.Create(Guid.Empty); + Console.WriteLine("UserId.Create(Guid.Empty) -> no exception"); } catch (ZodException ex) { - Console.WriteLine($"TenantId.Create(Guid.Empty) -> {FormatErrors(ex.Errors)}"); + Console.WriteLine($"UserId.Create(Guid.Empty) -> {FormatErrors(ex.Errors)}"); } } diff --git a/src/src/ZodSharpSample/README.md b/src/src/ZodSharpSample/README.md index ebdd4e8..f43a1b8 100644 --- a/src/src/ZodSharpSample/README.md +++ b/src/src/ZodSharpSample/README.md @@ -18,10 +18,10 @@ dotnet run --project src/src/ZodSharpSample - **Zod refinement hooks** — `CorporateEmail` implements the generated `OnZodValidate(RefineCtx)` hook to add a rule ZodSharp's DataAnnotations cannot express; the generated `Create` merges the hook's issues with the schema's own, while `Hydrate` stays replay-safe. -- **Type-level custom rules on scalars** — `AssetId` carries a `[ZodRule]`-mapped `[NonEmpty]` attribute whose - rule reads the value object as a unit and owns its `Code`/`Origin`. `TenantId` reuses the normal - `NonSentinelRule` for a scalar through `ScalarRuleAdapter`, and the generated `Create` surfaces the - rule's code and message while `Hydrate` stays replay-safe. +- **Type-level rules on scalars** — `AssetId` and `UserId` use the built-in `[NonSentinel]` attribute that + ships with `Purview.ZodSharp`. Its rule is written against the underlying value, so the ZodSharp generator + adapts it automatically through `ScalarRuleAdapter`; `Create` surfaces the rule's code and message while + `Hydrate` stays replay-safe. One attribute serves every scalar backed by the same primitive. - **Generated validators on value objects** — `EmailAddressSchema.Validate/Parse` and `CurrencyCodeSchema.Validate` validate the value object directly; `ApplyRefine` composes extra rules such as "only example.com addresses allowed". diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs index 8dec847..104924d 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs @@ -375,80 +375,58 @@ public static bool CreateRejectsInvalid() } [Test] - public async Task Scalar_GivenTypeLevelCustomRule_ReportsRuleCodeAndOriginThroughCreate( - CancellationToken cancellationToken - ) + public async Task Scalar_GivenNonSentinelRule_IsAdaptedThroughCreate(CancellationToken cancellationToken) { - // A type-level [ZodRule]-mapped attribute closes the generic rule with the scalar type, so the rule - // sees the value object as a unit. The generated Create runs it through the ZodSharp schema, which - // reports the rule's own code and origin with an empty path. + // The built-in [NonSentinel] attribute (shipped in ZodSharp.Rules) is written against the underlying + // value, so the ZodSharp generator closes it with the scalar's value type and wraps it in the + // ScalarRuleAdapter the value-object generator emits. The wrapped rule owns the error identity and + // reports an empty path. const string source = """ using System; - using System.ComponentModel.DataAnnotations; using ZodSharp; - using ZodSharp.Core; + using ZodSharp.Rules; namespace Testing { - public readonly record struct NonEmptyRule(string? Code = null, string? Message = null) - : IValidationRule, IZodRule - where TSelf : Purview.ValueObjects.IScalarValueObject - { - public const string ErrorCode = "invalid_value"; - public const string MessageFormat = "Value must not be empty."; - - public bool IsValid(in TSelf value) => value.Value != Guid.Empty; - - public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; - - string IValidationRule.Code => Code ?? ErrorCode; - - string? IZodRule.Code => Code; - - string? IZodRule.Origin => "value_object"; - } - - [ZodRule(typeof(NonEmptyRule<>))] - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] - public sealed class NonEmptyAttribute : ValidationAttribute - { - public string? Code { get; set; } - - public string? Message { get; set; } - } - [Scalar] [ZodSchema] - [NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] - public readonly partial record struct AssetId + [NonSentinel(Message = "UserId must not be empty.")] + public readonly partial record struct UserId { public Guid Value { get; } } public static class Harness { - public static bool CreateSucceeds() => AssetId.Create(Guid.NewGuid()).Value != Guid.Empty; + public static bool CreateSucceeds() => UserId.Create(Guid.NewGuid()).Value != Guid.Empty; - public static string? CreateReportsOrigin() + public static string? CreateReportsCode() { try { - AssetId.Create(Guid.Empty); + UserId.Create(Guid.Empty); return null; } catch (global::ZodSharp.Core.ZodException exception) { - foreach (var error in exception.Errors) - { - if (error.Code == "invalid_asset_id") - return error.Origin; - } + return exception.Errors.Length == 1 ? exception.Errors[0].Code : "unexpected-count"; + } + } - return "no-code"; + public static int CreateReportsPathLength() + { + try + { + UserId.Create(Guid.Empty); + return -1; + } + catch (global::ZodSharp.Core.ZodException exception) + { + return exception.Errors[0].Path.Length; } } - public static bool HydrateIsReplaySafe() => AssetId.Hydrate(Guid.Empty).Value == Guid.Empty; + public static bool HydrateIsReplaySafe() => UserId.Hydrate(Guid.Empty).Value == Guid.Empty; } } """; @@ -459,11 +437,80 @@ public static class Harness var harness = assembly!.GetType("Testing.Harness")!; var createSucceeds = (bool)harness.GetMethod("CreateSucceeds")!.Invoke(null, null)!; - var origin = (string?)harness.GetMethod("CreateReportsOrigin")!.Invoke(null, null); + var code = (string?)harness.GetMethod("CreateReportsCode")!.Invoke(null, null); + var pathLength = (int)harness.GetMethod("CreateReportsPathLength")!.Invoke(null, null)!; var hydrateSafe = (bool)harness.GetMethod("HydrateIsReplaySafe")!.Invoke(null, null)!; await Assert.That(createSucceeds).IsTrue(); - await Assert.That(origin).IsEqualTo("value_object"); + await Assert.That(code).IsEqualTo("invalid_value"); + await Assert.That(pathLength).IsEqualTo(0); await Assert.That(hydrateSafe).IsTrue(); } + + [Test] + public async Task Class_GivenShippedMemberAttributes_ValidatesMembers(CancellationToken cancellationToken) + { + // [Email], [E164], [UUID], and [MinLengthZod] all ship in ZodSharp.Rules, so a consumer validates a + // DTO's members without hand-authoring a single rule attribute. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed partial class ContactDto + { + [Email] + public string Email { get; init; } = string.Empty; + + [E164] + public string Phone { get; init; } = string.Empty; + + [UUID(UuidVersion.V4)] + public string Id { get; init; } = string.Empty; + + [MinLengthZod(3)] + public string Code { get; init; } = string.Empty; + } + + public static class Harness + { + public static bool ValidPasses() => + ContactDtoSchema.Validate( + new ContactDto + { + Email = "demo@example.com", + Phone = "+14155552671", + Id = "123e4567-e89b-42d3-a456-426614174000", + Code = "abc", + } + ).IsSuccess; + + public static bool InvalidFails() => + !ContactDtoSchema.Validate( + new ContactDto + { + Email = "not-an-email", + Phone = "not-a-phone", + Id = "not-a-uuid", + Code = "ab", + } + ).IsSuccess; + } + } + """; + + var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly!.GetType("Testing.Harness")!; + + var validPasses = (bool)harness.GetMethod("ValidPasses")!.Invoke(null, null)!; + var invalidFails = (bool)harness.GetMethod("InvalidFails")!.Invoke(null, null)!; + + await Assert.That(validPasses).IsTrue(); + await Assert.That(invalidFails).IsTrue(); + } } diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs index b18ca32..b7a7c70 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs @@ -76,20 +76,22 @@ public async Task ZodInsteadOfHooksEmail_GivenInsteadOfHooksMode_DoesNotRunOnVal } [Test] - public async Task AssetId_GivenTypeLevelCustomRule_ReportsRuleCodeAndOriginThroughCreate() + public async Task AssetId_GivenNonSentinelRule_ReportsInvalidValueThroughCreate() { - // Act + // Act — the shipped [NonSentinel] attribute maps to the built-in NonSentinelRule, which the + // ZodSharp generator adapts to the scalar automatically. var created = AssetId.Create(Guid.NewGuid()); // Assert await Assert.That(created.Value).IsNotEqualTo(Guid.Empty); var exception = await Assert.That(() => AssetId.Create(Guid.Empty)).Throws(); - var error = exception!.Errors.Single(candidate => candidate.Code == "invalid_asset_id"); + var error = exception!.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(NonSentinelRule.ErrorCode); await Assert.That(error.Message).IsEqualTo("AssetId must not be empty."); - // A type-level rule validates the value object as a unit, so it owns the structured origin and - // reports an empty path. - await Assert.That(error.Origin).IsEqualTo("value_object"); + // Built-in rules own their code but report no structured origin. + await Assert.That(error.Origin).IsNull(); + // A scalar is validated as a unit, so the adapted rule reports an empty path. await Assert.That(error.Path).IsEmpty(); // Hydrate stays replay-safe: the rule is not re-run. @@ -97,29 +99,99 @@ public async Task AssetId_GivenTypeLevelCustomRule_ReportsRuleCodeAndOriginThrou } [Test] - public async Task TenantId_GivenAdaptedNormalRule_RejectsSentinelValueThroughCreate() + public async Task ScalarRuleAdapter_GivenUnderlyingRule_ValidatesScalarAsUnit() + { + // Arrange: the adapter is the seam that turns a normal underlying-value rule into a scalar rule. + ScalarRuleAdapter> adapter = new(new NonSentinelRule()); + + // Act & Assert + await Assert.That(adapter.IsValid(UserId.Hydrate(Guid.NewGuid()))).IsTrue(); + await Assert.That(adapter.IsValid(UserId.Hydrate(Guid.Empty))).IsFalse(); + } + + [Test] + public async Task UserId_GivenNonSentinelRule_IsAdaptedAndValidatesScalarAsUnit() + { + // Act — the same built-in attribute serves every scalar backed by the same primitive. + var created = UserId.Create(Guid.NewGuid()); + + // Assert + await Assert.That(created.Value).IsNotEqualTo(Guid.Empty); + + var exception = await Assert.That(() => UserId.Create(Guid.Empty)).Throws(); + var error = exception!.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(NonSentinelRule.ErrorCode); + await Assert.That(error.Message).IsEqualTo("UserId must not be empty."); + await Assert.That(error.Path).IsEmpty(); + + // Hydrate stays replay-safe: the rule is not re-run. + await Assert.That(UserId.Hydrate(Guid.Empty).Value).IsEqualTo(Guid.Empty); + } + + [Test] + public async Task ExternalId_GivenNonSentinelRule_IsAdaptedAndRejectsSentinel() { // Act - var created = TenantId.Create(Guid.NewGuid()); + var created = ExternalId.Create(Guid.NewGuid()); // Assert await Assert.That(created.Value).IsNotEqualTo(Guid.Empty); - var exception = await Assert.That(() => TenantId.Create(Guid.Empty)).Throws(); - await Assert.That(exception!.Errors.Any(error => error.Code == NonSentinelRule.ErrorCode)).IsTrue(); + var exception = await Assert.That(() => ExternalId.Create(Guid.Empty)).Throws(); + var error = exception!.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(NonSentinelRule.ErrorCode); + await Assert.That(error.Message).IsEqualTo("ExternalId must not be empty."); + await Assert.That(error.Path).IsEmpty(); // Hydrate stays replay-safe: the rule is not re-run. - await Assert.That(TenantId.Hydrate(Guid.Empty).Value).IsEqualTo(Guid.Empty); + await Assert.That(ExternalId.Hydrate(Guid.Empty).Value).IsEqualTo(Guid.Empty); } [Test] - public async Task ScalarRuleAdapter_GivenUnderlyingRule_ValidatesScalarAsUnit() + public async Task CorrelationId_GivenNonSentinelCodeOverride_ReportsTheOverride() { - // Arrange: the adapter is the seam that turns a normal underlying-value rule into a scalar rule. - ScalarRuleAdapter> adapter = new(new NonSentinelRule()); + // Act — the built-in rule's code parameter is set through the attribute, so the reported code is the + // override rather than the rule's ErrorCode default. + var exception = await Assert.That(() => CorrelationId.Create(Guid.Empty)).Throws(); - // Act & Assert - await Assert.That(adapter.IsValid(TenantId.Hydrate(Guid.NewGuid()))).IsTrue(); - await Assert.That(adapter.IsValid(TenantId.Hydrate(Guid.Empty))).IsFalse(); + // Assert + var error = exception!.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("invalid_correlation_id"); + await Assert.That(error.Message).IsEqualTo("CorrelationId must not be empty."); + await Assert.That(error.Path).IsEmpty(); + } + + [Test] + public async Task ContactDto_GivenShippedMemberAttributes_ValidatesMembers() + { + // Arrange — [Email], [E164], [UUID], and [MinLengthZod] all ship in ZodSharp.Rules. + ContactDto valid = new() + { + Email = "demo@example.com", + Phone = "+14155552671", + Id = "123e4567-e89b-42d3-a456-426614174000", + Code = "abc", + }; + + // Act + var validResult = ContactDtoSchema.Validate(valid); + + // Assert + await Assert.That(validResult.IsSuccess).IsTrue(); + + ContactDto invalid = new() + { + Email = "not-an-email", + Phone = "not-a-phone", + Id = "not-a-uuid", + Code = "ab", + }; + + var invalidResult = ContactDtoSchema.Validate(invalid); + await Assert.That(invalidResult.IsSuccess).IsFalse(); + + var codes = invalidResult.Errors.Select(error => error.Code).ToHashSet(StringComparer.Ordinal); + await Assert.That(codes.Contains(EmailRule.ErrorCode)).IsTrue(); + await Assert.That(codes.Contains(MinLengthRule.ErrorCode)).IsTrue(); } } diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs index 6d26028..86d947d 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs @@ -1,120 +1,75 @@ -using System.ComponentModel.DataAnnotations; using ZodSharp; -using ZodSharp.Core; using ZodSharp.Rules; namespace Purview.ValueObjects.Serialization; /// -/// A custom rule that validates a scalar value object as a unit. It reads the value object through -/// its contract, so the same rule serves every Guid-backed -/// scalar. Implementing lets the rule own the reported error code and origin, which is -/// how one attribute can produce a different code per annotated scalar. +/// Dual-generator fixtures for type-level rules on scalars: the value-object generator and the +/// Purview.ZodSharp generator both run over this project. The built-in [NonSentinel] attribute (shipped +/// in ZodSharp.Rules) is written against the underlying value, so the ZodSharp generator adapts it +/// automatically through the the +/// value-object generator emits — no hand-authored rule or attribute is required. /// -/// -/// This is the supported shape for a rule that must observe the value object rather than its -/// Value. A generic rule must have exactly one type parameter, so the underlying value type is pinned -/// by the constraint: define one rule per primitive -/// (NonEmptyRule<TSelf> where TSelf : IScalarValueObject<TSelf, Guid>, a long -/// variant, and so on). -/// -public readonly record struct NonEmptyRule(string? Code = null, string? Message = null) - : IValidationRule, - IZodRule - where TSelf : IScalarValueObject -{ - public const string ErrorCode = "invalid_value"; - public const string MessageFormat = "Value must not be empty."; - - public bool IsValid(in TSelf value) => value.Value != Guid.Empty; - - public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; - - // IValidationRule.Code is the rule's own default; IZodRule.Code is the optional per-use override, so - // the generator prefers a code supplied where the attribute is applied and falls back to ErrorCode. - string IValidationRule.Code => Code ?? ErrorCode; - - string? IZodRule.Code => Code; - - string? IZodRule.Origin => "value_object"; -} - -/// -/// Maps to a type-level DataAnnotations attribute. The ZodSharp generator -/// closes the open generic with the annotated scalar type, so the rule validates the value object as a unit -/// and reports an empty path. -/// -[ZodRule(typeof(NonEmptyRule<>))] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class NonEmptyAttribute : ValidationAttribute +[Scalar] +[ZodSchema] +[NonSentinel(Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId { - public string? Code { get; set; } - - public string? Message { get; set; } + public Guid Value { get; } } /// -/// The scalar-aware member of the non-sentinel rule family: it adapts for a -/// Guid-backed scalar value object and owns the reported error identity. +/// A second Guid-backed scalar using the same built-in attribute, showing one +/// attribute serving every scalar backed by the same primitive. /// -/// -/// The it composes is emitted into -/// this project by the value-object generator, which runs because the project references both -/// Purview.ValueObjects and Purview.ZodSharp. -/// -public readonly record struct NonSentinelScalarRule(string? Message = null) : IValidationRule, IZodRule - where TSelf : IScalarValueObject +[Scalar] +[ZodSchema] +[NonSentinel(Message = "UserId must not be empty.")] +public readonly partial record struct UserId { - public const string ErrorCode = NonSentinelRule.ErrorCode; - public const string MessageFormat = NonSentinelRule.MessageFormat; - - public bool IsValid(in TSelf value) => - new ScalarRuleAdapter>(new NonSentinelRule(Message)).IsValid(value); - - public string GetErrorMessage(in TSelf value) => - new ScalarRuleAdapter>(new NonSentinelRule(Message)).GetErrorMessage( - value - ); - - string? IZodRule.Code => NonSentinelRule.ErrorCode; - - string? IZodRule.Origin => "value_object"; + public Guid Value { get; } } /// -/// Maps to a type-level attribute. The attribute name encodes the -/// rule name (NonSentinelScalarAttribute → NonSentinelScalarRule) so the mapping addresses the -/// whole family. +/// A third Guid-backed scalar using the same built-in attribute. /// -[ZodRule(typeof(NonSentinelScalarRule<>))] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class NonSentinelScalarAttribute : ValidationAttribute +[Scalar] +[ZodSchema] +[NonSentinel(Message = "ExternalId must not be empty.")] +public readonly partial record struct ExternalId { - public string? Message { get; set; } + public Guid Value { get; } } /// -/// A Guid-backed scalar whose validation is driven entirely by a type-level custom rule -/// (). The generated Create validates the value object through the -/// ZodSharp-generated schema, which reports the rule's own Code and Origin. +/// A Guid-backed scalar that overrides the rule's error code through the attribute, showing the built-in +/// code parameter flowing into the reported error. /// [Scalar] [ZodSchema] -[NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] -public readonly partial record struct AssetId +[NonSentinel(Code = "invalid_correlation_id", Message = "CorrelationId must not be empty.")] +public readonly partial record struct CorrelationId { public Guid Value { get; } } /// -/// A Guid-backed scalar whose validation reuses the normal through -/// , showing a rule written for an underlying value applied to a -/// scalar value object as a unit. +/// A [ZodSchema] DTO whose members use the built-in validation attributes that ship in +/// ZodSharp.Rules: [Email], [E164], [UUID], and [MinLengthZod] (the +/// DataAnnotations name-collision suffix). The generated ContactDtoSchema validates them. /// -[Scalar] [ZodSchema] -[NonSentinelScalar] -public readonly partial record struct TenantId +public sealed partial class ContactDto { - public Guid Value { get; } + [Email] + public string Email { get; init; } = string.Empty; + + [E164] + public string Phone { get; init; } = string.Empty; + + [UUID(UuidVersion.V4)] + public string Id { get; init; } = string.Empty; + + [MinLengthZod(3)] + public string Code { get; init; } = string.Empty; } From a1982ffd9bae119902541159b2cb552809e55411 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 5 Oct 2026 11:50:02 +0100 Subject: [PATCH 05/11] fix: fixed scalar zod adapter --- .../ZodSharpScalarRuleAdapterEmitter.cs | 12 +++-- ...ZodSharpScalarRuleAdapterGeneratorTests.cs | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/src/SourceGenerator/ValueObject/ZodSharpScalarRuleAdapterEmitter.cs b/src/src/SourceGenerator/ValueObject/ZodSharpScalarRuleAdapterEmitter.cs index f836fb8..aaa8a0a 100644 --- a/src/src/SourceGenerator/ValueObject/ZodSharpScalarRuleAdapterEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ZodSharpScalarRuleAdapterEmitter.cs @@ -36,8 +36,8 @@ public readonly record struct EmissionState(bool IsZodSharpReferenced, bool Adap /// The compilation being generated for. /// /// Only a declaration in this compilation counts. A referenced assembly that also emitted its own adapter - /// declares it , so it is invisible to this compilation's name lookup and must - /// not suppress the local copy. + /// declares it , so it is inaccessible from this compilation and must not + /// suppress the local copy: the generated validators bind to the copy this compilation emits. /// public static EmissionState ResolveState(Compilation compilation) => new( @@ -45,8 +45,14 @@ public static EmissionState ResolveState(Compilation compilation) => AdapterAlreadyDeclared: IsDeclaredInSource(compilation) ); + /// + /// Looks the adapter up in this compilation's own assembly. Compilation.GetTypeByMetadataName also + /// searches referenced assemblies and ignores accessibility, and the IDE models a project reference as a + /// compilation reference, so a referenced project's generated adapter would otherwise look like a source + /// declaration and suppress the copy this compilation's generated validators bind to. + /// static bool IsDeclaredInSource(Compilation compilation) => - compilation.GetTypeByMetadataName(TypeLibrary.ScalarRuleAdapterMetadataName) + compilation.Assembly.GetTypeByMetadataName(TypeLibrary.ScalarRuleAdapterMetadataName) is { DeclaringSyntaxReferences.Length: > 0 }; /// Emits the adapter. diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs index ef489ae..d87a683 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs @@ -182,4 +182,48 @@ public async Task ResolveState_GivenZodSharpReferencedAndNoExistingAdapter_Wants await Assert.That(state.IsZodSharpReferenced).IsTrue(); await Assert.That(state.ShouldEmit).IsTrue(); } + + [Test] + public async Task ResolveState_GivenReferencedCompilationDeclaresTheAdapter_WantsTheAdapter() + { + // The IDE models a project reference as a compilation reference, so the referenced project's + // generated adapter is visible here with syntax references even though it is internal. It cannot be + // used by this compilation, so it must not suppress the copy this compilation's generated validators + // bind to. + const string referencedAdapter = """ + namespace Purview.ValueObjects + { + internal readonly record struct ScalarRuleAdapter(TRule Rule); + } + """; + + var referenced = Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create( + "ReferencedProject", + [Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(referencedAdapter)], + [Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions( + Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary + ) + ); + + var compilation = Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create( + "Consumer", + [Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText("namespace Consumer { class C { } }")], + [ + Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + Microsoft.CodeAnalysis.MetadataReference.CreateFromFile( + typeof(ZodSharp.Core.IValidationRule<>).Assembly.Location + ), + referenced.ToMetadataReference(), + ], + new Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions( + Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary + ) + ); + + var state = ZodSharpScalarRuleAdapterEmitter.ResolveState(compilation); + + await Assert.That(state.AdapterAlreadyDeclared).IsFalse(); + await Assert.That(state.ShouldEmit).IsTrue(); + } } From f77b7f0f839079628eff4d45d4da35899dc263ee Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 6 Oct 2026 18:34:53 +0100 Subject: [PATCH 06/11] fix: respect [NotMapped], use ordinal string ordering, and harden strict deserialization The Entity Framework registry mapped members the author had excluded, and broke the model outright for a computed one. ConfigureValueObjects walked every readable property of every entity type and configured it with modelBuilder.Entity(t).Property(type, name) - explicit configuration, which outranks [NotMapped] and Ignore(...): - a [NotMapped] property was pulled back into the model, producing a column and a migration nobody asked for, against a production schema - a property excluded with Ignore(...) was likewise re-added - a computed, setter-less property made the entire DbContext fail to build, and with [NotMapped] also being overridden there was no way to opt out The registry now skips a property that is [NotMapped], ignored on the entity type, or computed and setter-less. A get-only auto-property is still mapped: it has a compiler-generated backing field, which EF maps, so the check is for that field rather than merely for a missing setter. [NotMapped] and .Ignore( previously appeared nowhere in the test suite. Also fixed: - a string scalar's ordering disagreed with its own equality and varied by machine. Equality and GetHashCode use EqualityComparer.Default (ordinal) but CompareTo used Comparer.Default (current culture), so CompareTo == 0 did not imply Equals - breaking SortedSet, SortedDictionary and List.BinarySearch, which use CompareTo for identity - and sort order changed with the thread culture. String scalars now compare with StringComparer.Ordinal. - [Scalar("CustomName")] did not compile. The generated partial always implements IScalarValueObject, which declares a member named Value, but none was emitted for a custom property name, so the build failed with CS0535 inside generated code. A forwarding Value member is now emitted, keeping the author's chosen name as the primary accessor. - TryCreate threw instead of returning false for a ZodSharp-validated value object: it caught only ArgumentException, but a [ZodSchema] Create throws ZodException. - a strict-mode deserialization failure returned a 500 instead of a 400. The validation exception escaped JsonSerializer unwrapped; it is now wrapped in JsonException so System.Text.Json attaches Path and LineNumber and hosts treat it as bad input. - strict mode could silently fall back to Hydrate, skipping validation entirely. It now throws. Trimming and Native AOT: - Purview.ValueObjects is verified clean under both analyzers and marked IsAotCompatible. - ScalarJsonConverterFactory declares its requirement on its constructor, so a host registering it in a trimmed or AOT build is warned at the opt-in site. Generated scalars do not need it: they carry a [JsonConverter] pointing at a reflection-free generated converter. Also removed [assembly: InternalsVisibleTo] from the runtime assembly - it granted nothing, since the assembly declares no internal members, and leaked a test-assembly name into shipped metadata. Docs: a consolidated diagnostics reference for all 17 rules, the retired VO1011/1012/1014/1020 ids recorded, a dead mkdocs edit_uri branch fixed, and a mojibake copyright corrected. All nine remaining diagnostics moved into AnalyzerReleases.Shipped.md under Release 1.0.0. --- CHANGELOG.md | 131 ++++++++++++++++ docs/Diagnostics.md | 59 ++++++++ docs/index.md | 4 + mkdocs.yml | 2 +- src/Directory.Build.props | 2 +- .../AnalyzerReleases.Shipped.md | 11 +- .../AnalyzerReleases.Unshipped.md | 18 +-- .../ScalarValueObjectEmitter.Comparison.cs | 34 ++++- .../ValueObject/ScalarValueObjectEmitter.cs | 41 +++++ .../ValueObjectEFRegistryEmitter.cs | 65 +++++++- .../ValueObjects/Properties/AssemblyInfo.cs | 10 +- .../ScalarJsonConverterFactory.cs | 140 ++++++++++++++++-- src/src/ValueObjects/ValueObjects.csproj | 15 ++ .../ValueObjectEFSourceGeneratorTests.cs | 8 +- .../ScalarCustomPropertyNameTests.cs | 103 +++++++++++++ .../Serialization/EFNotMappedTests.cs | 131 ++++++++++++++++ .../StringScalarOrderingTests.cs | 107 +++++++++++++ .../ZodValidatedTryCreateTests.cs | 49 ++++++ .../Serialization/Models.cs | 8 + .../ScalarJsonConverterFactoryTests.cs | 38 +++-- 20 files changed, 932 insertions(+), 44 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/Diagnostics.md create mode 100644 src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/StringScalarOrderingTests.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/ZodValidatedTryCreateTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ff13cf1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,131 @@ +# Changelog + +All notable changes to this repository are recorded here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). `package.json` is the authoritative version. + +Released versions correspond to `v` GitHub releases. Entries below the `Unreleased` heading have not +been published to NuGet. No stable release has been cut yet; the published line is `1.0.0-prerelease.N`. + +## Unreleased + +### Added + +- Improved ZodSharp integration, including updated generation of Zod rule attributes. +- MIT `LICENSE.md`. +- [Diagnostics](docs/Diagnostics.md): a consolidated reference for all 17 `VO1xxx` rules. `VO1002`–`VO1008` + previously appeared only in the analyzer release table, and the Entity Framework and ZodSharp rules were split + across two guides. + +### Fixed + +- **The Entity Framework registry mapped members the author had excluded, and broke the model outright for + a computed one.** `ConfigureValueObjects` walked every readable property of every entity type and + configured it with `modelBuilder.Entity(t).Property(type, name)` — *explicit* configuration, which + outranks `[NotMapped]` and `Ignore(...)`. Consequences, all now fixed and covered by Entity Framework + integration tests: + - A property marked `[NotMapped]` was pulled back into the model, producing a column and a migration + nobody asked for — against a production schema. + - A property excluded with `modelBuilder.Entity().Ignore(...)` was likewise re-added. + - A **computed, setter-less** property made the entire `DbContext` fail to build: + `InvalidOperationException: No backing field could be found for property '...' and the property does + not have a setter.` With `[NotMapped]` also being overridden there was no way to opt out, so an entity + with a computed value-object property could not be used at all. + + The registry now skips a property that is `[NotMapped]`, ignored on the entity type, or computed and + setter-less. A get-only **auto**-property is still mapped — it has a compiler-generated backing field, + which Entity Framework Core maps — so the check is for that field rather than merely for a missing + setter. `[NotMapped]` and `.Ignore(` previously appeared nowhere in the test suite. +- **A strict-mode deserialization failure returned a 500 instead of a 400.** With + `ValueObjectDeserializationMode.Strict`, the validation exception from `Create` — `ArgumentException` by + default, `ZodException` with ZodSharp validation — escaped `JsonSerializer` unwrapped. ASP.NET Core treats + that as an unhandled exception, so a caller sending a bad value got a 500 and a stack trace rather than a + validation response, with no indication of which member failed. It is now wrapped in `JsonException`, so + System.Text.Json attaches `Path` and `LineNumber` and hosts treat it as bad input. The original exception + is the inner one. +- **Strict mode could silently fall back to `Hydrate`, skipping validation entirely.** If no + `Create(TScalar)` factory was resolvable, the converter quietly used the replay-safe factory instead — + a validation boundary reporting success while doing nothing, which is the worst possible failure mode for + the one mode chosen specifically to re-validate untrusted input. It now throws with an explanatory + message. Falling back the other way (Hydrate → Create) is still allowed, because that only ever adds + validation. Note this path is not reachable through the generator, which always emits `Create`; the fix + hardens it for hand-written scalars. +- **A `string` scalar's ordering disagreed with its own equality, and varied by machine.** Equality and + `GetHashCode` are generated with `EqualityComparer.Default` (ordinal), but `CompareTo` used + `Comparer.Default`, which orders by the **current culture**. Two consequences: + - `CompareTo(other) == 0` no longer implied `Equals(other)`, breaking the `IComparable` contract. A + `SortedSet`, `SortedDictionary` or `List.BinarySearch` — all of which use `CompareTo` for identity — + therefore disagreed with the type's own notion of equality. + - The same `OrderBy` over the same data produced different results under a different thread culture, and + different results again from the database's collation. + + A `string` scalar now compares with `StringComparer.Ordinal`, which agrees with equality and is + deterministic. The relational operators and the strongly typed `CompareTo` both delegate to it, so they + are fixed too. A get-only auto-property and non-string scalars are unaffected. +- **`TryCreate` threw instead of returning `false` for a ZodSharp-validated value object.** It caught only + `ArgumentException`, but a `[ZodSchema]` `Create` throws `ZodException` — so the documented "try" + contract did not hold for a headline feature. It now also catches `ZodException`, emitted only when Zod + validation is generated. Note a custom `OnValidate` that throws something else still propagates; throw + `ArgumentException` from it to signal a validation failure. +- **`[Scalar("CustomName")]` did not compile.** The generated partial always implements + `IScalarValueObject`, which declares a member named `Value`, but with a custom property name + no `Value` was emitted — so the build failed with `CS0535` *inside generated code the consumer cannot + edit*. `ScalarAttribute.PropertyName` is a documented public option. The generator now emits a `Value` + member forwarding to the custom-named one, so the author's chosen name stays the primary accessor while the + advertised interface contract is honoured. Covered by a **compiling** regression test; the previous + coverage was an incremental-cache test that never compiled its output, which is why this was missed. +- The scalar ZodSharp adapter. + +### Changed — trimming and Native AOT + +- `Purview.ValueObjects` is now marked `IsAotCompatible`, which enables `IsTrimmable` and both the trim and + AOT analyzers. The assembly is clean under both, so a future regression is a build warning here rather than + a runtime failure in a consumer's published application. +- `ScalarJsonConverterFactory` — the one deliberately reflection-based component — now declares its + requirement with `[RequiresUnreferencedCode]` and `[RequiresDynamicCode]` on its **constructor**, so a host + registering it in a trimmed or AOT build is warned at the opt-in site instead of failing at runtime. The + attributes cannot go on the `CreateConverter` override: `JsonConverterFactory.CreateConverter` is not + annotated and IL2046/IL3051 require them to match the base. +- Worth stating plainly: **you usually do not need that factory.** A generated scalar already carries + `[JsonConverter(typeof(JsonConverter))]` pointing at a generated, reflection-free converter, and that + path is trim- and AOT-safe. Register the factory only for a hand-written scalar, in a host that is neither + trimmed nor AOT-compiled. + +### Changed + +- Removed `[assembly: InternalsVisibleTo("SourceGenerator.UnitTests")]` from the runtime assembly. It granted + nothing — the assembly declares no internal members — and it leaked a test-assembly name into shipped public + metadata. This also aligns with the repository rule that cross-component contracts must be public, because the + `Purview.SourceGeneratorFramework` merge pass strips `InternalsVisibleTo`. + +### Documentation + +- Recorded `VO1011`, `VO1012`, `VO1014` and `VO1020` as retired identifiers that are never reused, so the + discontinuous numbering is intentional and documented. +- Fixed `mkdocs.yml`'s `edit_uri`, which pointed at the deleted `ef-integration` branch and so broke every + "Edit this page" link on the published site. +- Fixed the `Copyright` property in `src/Directory.Build.props`: the `©` had been replaced by U+FFFD in an + encoding round-trip and was shipping in package metadata. + +### Changed — analyzer release tracking + +- **All nine remaining diagnostics moved from `AnalyzerReleases.Unshipped.md` into + `AnalyzerReleases.Shipped.md`** under the existing `## Release 1.0.0` heading, so the catalogue records + all 17 rules as shipping in the first stable release. The heading was already there for `VO1001`–`VO1008` + even though no 1.0.0 was ever tagged, so merging into it is what makes the file accurate. + +### Still outstanding for a stable 1.0 + +- `VO1005` (scalar constructor missing) is recorded as a shipped `Error` but is **not** in the analyzer's + `SupportedDiagnostics`, so nothing reports it. Either wire it up or record it as a removed rule before the + catalogue becomes a 1.0 contract. +- `Purview.ZodSharp 2.0.1-prerelease.1` is resolvable only from the local feed, not nuget.org. A stable + `Purview.ValueObjects` cannot be published until a stable `Purview.ZodSharp` is on nuget.org. + +## 1.0.0-prerelease.11 + +See the +[`v1.0.0-prerelease.11`](https://github.com/purview-dev/value-objects/releases/tag/v1.0.0-prerelease.11) +release notes. Earlier prereleases `1` through `10` are listed under +[releases](https://github.com/purview-dev/value-objects/releases). diff --git a/docs/Diagnostics.md b/docs/Diagnostics.md new file mode 100644 index 0000000..50622b2 --- /dev/null +++ b/docs/Diagnostics.md @@ -0,0 +1,59 @@ +# Diagnostics + +`Purview.ValueObjects` ships a Roslyn analyzer and source generator that report the rules below. Every rule is +enabled by default. + +Suppress a rule the usual ways — `#pragma warning disable VO1006`, a `NoWarn` entry, or an `.editorconfig` +severity override (`dotnet_diagnostic.VO1006.severity = none`). Errors cannot be downgraded to warnings: they +mark shapes the generator cannot emit code for. + +## Core shape rules + +These govern the declaration shape a value object must have for the generator to emit anything. + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1001` | Error | A `[Scalar]` or `[ValueObject]` type is not declared `partial`. | Add `partial`. The code fix in `Purview.ValueObjects.SourceGenerator.Refactorings` does this for you. | +| `VO1002` | Error | The value object is nested inside another type. | Move it to its own top-level type. Nesting is not supported. | +| `VO1003` | Error | The value object is generic. | Remove the type parameters, or declare one concrete value object per closed type. | +| `VO1004` | Error | A `[Scalar]` type does not declare the configured scalar property. | Declare the property, or set `ScalarAttribute.PropertyName` to the one you have. | +| `VO1005` | Error | A `[Scalar]` type declares no constructor matching its scalar value. | Add a constructor taking the scalar's underlying type. | +| `VO1006` | Warning | A `[Scalar]` type is not a `readonly record struct`. | Prefer `readonly record struct` so equality, immutability and allocation behaviour match the contract. | +| `VO1007` | Warning | `ValueObjectDeserializationMode.Strict` is set but no `Create` overload exists to re-validate through. | Add the `Create` overload, or use the default `Hydrate` mode. | +| `VO1008` | Error | `[Scalar]` and `[ValueObject]` are both applied to the same type. | Pick one. A scalar wraps a single primitive; a value object composes members. | +| `VO1016` | Warning | A value object member is mutable. | Make the member `readonly`/`init`-only. A mutable member breaks immutability and makes equality unstable. | + +## ZodSharp validation rules + +See [ZodSharp validation](ZodSharp-Validation.md) for the integration itself. + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1013` | Warning | `OnValidate` is declared but never invoked because `ZodSchemaMode.InsteadOfHooks` is set. | Remove `OnValidate`, or choose a `ZodSchemaMode` that keeps the hooks. | +| `VO1015` | Error | The ZodSharp `SchemaName` is not a valid C# identifier. | Give it a valid identifier. | + +## Entity Framework rules + +See [Entity Framework integration](Entity-Framework.md) for the mapping model. + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1009` | Warning | Entity Framework mapping is requested but `Microsoft.EntityFrameworkCore` is not referenced. | Add the package reference, or stop requesting EF mapping. | +| `VO1010` | Warning | EF auto-conversion was skipped because the value object's underlying type is not mappable. | Map it by hand with a custom `ValueConverter`. | +| `VO1017` | Warning | EF JSON mapping is requested without the JSON converter. | Register the JSON converter for the value object. | +| `VO1018` | Warning | EF complex mapping cannot convert one of the members. | Simplify the member, or map it explicitly. | +| `VO1019` | Warning | EF complex-type mapping is requested but the project resolves to EF Core 7 or earlier. | Target EF Core 8+, or use a different `EntityFrameworkMapping`. | +| `VO1021` | Warning | EF key value generation is unavailable for the value object. | Assign keys explicitly instead of relying on store generation. | + +## Retired identifiers + +`VO1011`, `VO1012`, `VO1014` and `VO1020` were used during development and withdrawn before release. They are +**not** reported by any version and are **never reused**, so the numbering is intentionally discontinuous. Treat +an occurrence of one of these IDs as stale tooling or a stale suppression, and remove it. + +## Release tracking + +Rules are tracked in `src/src/SourceGenerator/AnalyzerReleases.Shipped.md` and +`AnalyzerReleases.Unshipped.md`, which the Roslyn `RS2008` catalogue rule enforces. A new or changed diagnostic +must be recorded there in the same change, and the unshipped entries move into a new `## Release ` +block when that version ships. diff --git a/docs/index.md b/docs/index.md index 1cd1e28..e0b7f98 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,3 +9,7 @@ Strongly typed value objects with Entity Framework and ZodSharp validation integ - [Value object design](Value-Object-Design.md) - [Entity Framework integration](Entity-Framework.md) - [ZodSharp validation](ZodSharp-Validation.md) + +## Reference + +- [Diagnostics](Diagnostics.md) — every `VO1xxx` rule, what triggers it, and how to fix it diff --git a/mkdocs.yml b/mkdocs.yml index 05c8792..112ecd8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,7 +1,7 @@ site_name: Purview Value Objects site_description: Developer documentation for Purview Value Objects repo_url: https://github.com/purview-dev/value-objects -edit_uri: edit/ef-integration/docs/ +edit_uri: edit/main/docs/ docs_dir: docs theme: diff --git a/src/Directory.Build.props b/src/Directory.Build.props index f0325ba..932f60f 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -22,7 +22,7 @@ Kieron Lanning Purview-Dev - Copyright � $(CurrentYear) Purview-Dev + Copyright © $(CurrentYear) Purview-Dev https://github.com/purview-dev/value-objects.git $(PurviewProjectUrl) git diff --git a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md index b01f902..2e95bf3 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md @@ -11,4 +11,13 @@ VO1004 | ValueObjects | Error | Scalar value objects must declare the configured VO1005 | ValueObjects | Error | Scalar value objects must declare a constructor matching their scalar value VO1006 | ValueObjects | Warning | Scalar value objects should be readonly record structs VO1007 | ValueObjects | Warning | Strict deserialization mode requires a Create overload -VO1008 | ValueObjects | Error | [Scalar] and [ValueObject] cannot be combined \ No newline at end of file +VO1008 | ValueObjects | Error | [Scalar] and [ValueObject] cannot be combined +VO1009 | ValueObjects | Warning | Entity Framework mapping requested but Microsoft.EntityFrameworkCore is not referenced +VO1010 | ValueObjects | Warning | Entity Framework auto-conversion skipped for a value object whose underlying type is not mappable +VO1013 | ValueObjects | Warning | OnValidate is not invoked because ZodSchemaMode.InsteadOfHooks is set +VO1015 | ValueObjects | Error | ZodSharp SchemaName is not a valid identifier +VO1016 | ValueObjects | Warning | Value object member is mutable +VO1017 | ValueObjects | Warning | Entity Framework JSON mapping requires the JSON converter +VO1018 | ValueObjects | Warning | Entity Framework complex mapping cannot convert a member +VO1019 | ValueObjects | Warning | Entity Framework complex type mapping requires Entity Framework Core 8 or later +VO1021 | ValueObjects | Warning | Entity Framework key value generation is unavailable for the value object diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index 24f483a..c753945 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -1,13 +1,5 @@ -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|------- -VO1009 | ValueObjects | Warning | Entity Framework mapping requested but Microsoft.EntityFrameworkCore is not referenced -VO1010 | ValueObjects | Warning | Entity Framework auto-conversion skipped for a value object whose underlying type is not mappable -VO1013 | ValueObjects | Warning | OnValidate is not invoked because ZodSchemaMode.InsteadOfHooks is set -VO1015 | ValueObjects | Error | ZodSharp SchemaName is not a valid identifier -VO1016 | ValueObjects | Warning | Value object member is mutable -VO1017 | ValueObjects | Warning | Entity Framework JSON mapping requires the JSON converter -VO1018 | ValueObjects | Warning | Entity Framework complex mapping cannot convert a member -VO1019 | ValueObjects | Warning | Entity Framework complex type mapping requires Entity Framework Core 8 or later -VO1021 | ValueObjects | Warning | Entity Framework key value generation is unavailable for the value object \ No newline at end of file +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +; +; Every rule is recorded in AnalyzerReleases.Shipped.md under Release 1.0.0. Add a new or changed +; diagnostic here, and move it across when the next version ships. diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs index 4c3ccb3..60495a5 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs @@ -2,6 +2,37 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static partial class ScalarValueObjectEmitter { + /// + /// The comparer the generated CompareTo uses for the scalar value. + /// + /// + /// + /// For a scalar this is StringComparer.Ordinal, not + /// Comparer<string>.Default. The default string comparer orders by the current culture, + /// while the generated equality and hash code use EqualityComparer<string>.Default, which is + /// ordinal. Mixing the two breaks the contract: CompareTo(other) == 0 + /// no longer implies Equals(other), so a SortedSet, SortedDictionary or + /// List.BinarySearch — all of which use CompareTo for identity — would treat values the + /// type considers distinct as the same, and vice versa. + /// + /// + /// It also made ordering machine-dependent: the same OrderBy over the same data produced + /// different results under a different thread culture, and different results again from the database's + /// collation. Ordinal ordering is deterministic and agrees with equality, which is what a value object + /// used as a domain identity needs. + /// + /// + static string ScalarComparerExpression(ScalarValueObjectModel model) => + IsStringScalar(model) + ? "global::System.StringComparer.Ordinal" + : $"global::System.Collections.Generic.Comparer<{model.ScalarTypeName}>.Default"; + + /// + /// Whether the scalar value is a , allowing for a nullable annotation. + /// + static bool IsStringScalar(ScalarValueObjectModel model) => + model.ScalarTypeName.TrimEnd('?') is "global::System.String" or "string"; + static void EmitComparison(CodeWriter writer, ScalarValueObjectModel model) { if (!model.CompareToSelfExists) @@ -41,8 +72,7 @@ static void EmitComparison(CodeWriter writer, ScalarValueObjectModel model) : model.ScalarTypeReference ), ], - ExpressionBody = - $"global::System.Collections.Generic.Comparer<{model.ScalarTypeName}>.Default.Compare({model.ScalarPropertyName}, other)", + ExpressionBody = $"{ScalarComparerExpression(model)}.Compare({model.ScalarPropertyName}, other)", } ); } diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs index 514f898..372563e 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs @@ -25,6 +25,7 @@ public static void Emit(CodeWriter writer, ScalarValueObjectModel model, bool em static void EmitBody(CodeWriter writer, ScalarValueObjectModel model, bool emitEF) { EmitHookDeclarations(writer, model); + EmitScalarInterfaceValue(writer, model); EmitFactories(writer, model); EmitEmpty(writer, model); EmitTryCreate(writer, model); @@ -172,6 +173,31 @@ model.TypeModel.Namespace is null ? model.ZodSchemaClassName! : $"global::{model.TypeModel.Namespace}.{model.ZodSchemaClassName}"; + /// + /// Satisfies IScalarValueObject<TSelf, TValue>.Value when the scalar member is named + /// something else. + /// + /// + /// adds IScalarValueObject<TSelf, TValue> to every scalar + /// value object, and that interface declares a member named Value. With + /// [Scalar("Amount")] the author declares Amount and no Value, so without this + /// the generated partial failed to compile with CS0535 — in generated code the consumer cannot edit. + /// Forwarding keeps the author's chosen name as the primary accessor while honouring the contract + /// the type already advertises; a consumer holding the interface sees Value regardless. + /// + static void EmitScalarInterfaceValue(CodeWriter writer, ScalarValueObjectModel model) + { + if (string.Equals(model.ScalarPropertyName, "Value", StringComparison.Ordinal)) + return; + + writer.Property( + new("Value", model.ScalarTypeReference, TypeDeclarationAccessibility.Public) + { + ExpressionBody = model.ScalarPropertyName, + } + ); + } + static void EmitEmpty(CodeWriter writer, ScalarValueObjectModel model) { if (!model.Options.GenerateEmpty || model.EmptyExists) @@ -219,6 +245,21 @@ static void EmitTryCreate(CodeWriter writer, ScalarValueObjectModel model) catchBlock.Return("false"); } ); + + // A ZodSharp-validated Create throws ZodException, not ArgumentException, so without this + // catch TryCreate threw for every [ZodSchema] value object instead of returning false — + // the documented "try" contract did not hold for a headline feature. + if (model.HasZodSchemaValidation) + { + body.Block( + $"catch (global::{TypeLibrary.ZodExceptionTypeName})", + catchBlock => + { + catchBlock.Assignment("result", "default!"); + catchBlock.Return("false"); + } + ); + } } ); } diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index e59ad5b..3f4d52e 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -275,15 +275,78 @@ bool isEF8Referenced ); } + /// + /// Emits the predicate deciding which CLR properties the registry may configure. + /// + /// + /// + /// The loop below configures a property with modelBuilder.Entity(t).Property(type, name), which is + /// explicit configuration and therefore outranks [NotMapped] and Ignore(...). The + /// predicate used to be "has a non-static getter", so the registry pulled deliberately excluded members + /// back into the model — an unasked-for column and migration — and, for a computed setter-less property, + /// broke the model outright: Entity Framework Core's own validation fails with "No backing field could be + /// found ... and the property does not have a setter", so the whole DbContext would not build. + /// + /// + /// Emitted as a local function rather than inlined so the rule exists once and is readable in the + /// generated output. + /// + /// + static void EmitMappablePropertyPredicate(CodeWriter writer) + { + writer.Block( + "static bool IsMappableValueObjectProperty(" + + "global::Microsoft.EntityFrameworkCore.Metadata.IMutableEntityType entityType, " + + "global::System.Reflection.PropertyInfo property)", + body => + { + body.Line("// Not readable, or not an instance member: never mapped.") + .Line("if (property.GetMethod is null || property.GetMethod.IsStatic)") + .Line("\treturn false;") + .Line("") + .Line("// The author excluded it with [NotMapped].") + .Line( + "if (global::System.Reflection.CustomAttributeExtensions" + + ".GetCustomAttribute" + + "(property) is not null)" + ) + .Line("\treturn false;") + .Line("") + .Line("// The author excluded it with modelBuilder.Entity().Ignore(...).") + .Line( + "if (((global::Microsoft.EntityFrameworkCore.Metadata.IConventionEntityType)entityType)" + + ".FindIgnoredConfigurationSource(property.Name) is not null)" + ) + .Line("\treturn false;") + .Line("") + .Line("// Computed and setter-less: there is nowhere to materialize into, and configuring it") + .Line("// fails Entity Framework Core's field-mapping validation. A get-only auto-property is") + .Line("// different - it has a compiler-generated backing field, which EF maps - so the check is") + .Line("// for that field rather than merely for a missing setter.") + .Line("if (property.SetMethod is null && property.DeclaringType is { } declaringType") + .Line( + "\t&& declaringType.GetField($\"<{property.Name}>k__BackingField\", " + + "global::System.Reflection.BindingFlags.Instance | " + + "global::System.Reflection.BindingFlags.NonPublic) is null)" + ) + .Line("\treturn false;") + .Line("") + .Line("return true;"); + } + ); + } + static void EmitScalarConversionLoop(CodeWriter writer) { + EmitMappablePropertyPredicate(writer); + // The entity types are snapshotted: configuring a property adds it to the model, and an enumerator // over the live collection throws as soon as the set of entity types changes underneath it. writer.Block( "foreach (var entityType in modelBuilder.Model.GetEntityTypes().ToList())", entityType => entityType.Block( - "foreach (var property in entityType.ClrType.GetProperties().Where(p => p.GetMethod is not null && !p.GetMethod.IsStatic))", + "foreach (var property in entityType.ClrType.GetProperties().Where(p => IsMappableValueObjectProperty(entityType, p)))", property => property.IfElse( "scalarMappings.TryGetValue(property.PropertyType, out var scalar) && scalar.Converter is not null && scalar.ProviderMappable", diff --git a/src/src/ValueObjects/Properties/AssemblyInfo.cs b/src/src/ValueObjects/Properties/AssemblyInfo.cs index 95fb40c..97946d9 100644 --- a/src/src/ValueObjects/Properties/AssemblyInfo.cs +++ b/src/src/ValueObjects/Properties/AssemblyInfo.cs @@ -1,3 +1,7 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("SourceGenerator.UnitTests")] +// Deliberately no [assembly: InternalsVisibleTo(...)]. +// +// This assembly declares no internal members, so the grant conveyed nothing, and it leaked a +// test-assembly name into the shipped public metadata of a 1.0 package. The repository rule in +// AGENTS.md ("Source generator rules") also applies: the Purview.SourceGeneratorFramework merge +// pass strips every InternalsVisibleTo declaration, so cross-component contracts must be public. +// Expose shared identity publicly (see DiagnosticLibrary) rather than reinstating a grant here. diff --git a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs index 2a66934..cce72d6 100644 --- a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs +++ b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; using System.Text.Json; @@ -21,11 +22,47 @@ namespace Purview.ValueObjects.Serialization; /// /// Converters are cached per type to avoid repeated reflection and expression compilation. /// +/// +/// Trimming and Native AOT. This factory is the one reflection-based component in +/// Purview.ValueObjects: it reads , looks the scalar member up by name, +/// builds a closed generic converter with , compiles accessors with +/// expression trees, and serializes through the reflection-based overloads. +/// None of that survives trimming or Native AOT, so is annotated and will +/// raise IL2026/IL3050 at your call site rather than failing at runtime in production. +/// +/// +/// You usually do not need it. A generated scalar value object already carries +/// [JsonConverter(typeof(<Type>JsonConverter))], pointing at a converter the generator emitted +/// with no reflection. That path is trim- and AOT-safe, and it is the default. Register this factory only +/// for a hand-written scalar that has no generated converter, and only in a host that is neither trimmed +/// nor AOT-compiled. +/// /// public sealed class ScalarJsonConverterFactory : JsonConverterFactory { static readonly ConcurrentDictionary Cache = new(); + /// + /// Creates the factory. + /// + /// + /// The trimming and Native AOT requirement is declared here because this is where a host opts in, and + /// because an override of cannot carry the attributes + /// itself (IL2046/IL3051 require them to match the unannotated base). Registering this factory in a + /// trimmed or AOT-compiled host will warn at the construction site instead of failing at runtime. + /// + [RequiresUnreferencedCode( + "ScalarJsonConverterFactory reads ScalarAttribute, resolves the scalar member and the " + + "Create/Hydrate factory by name, and serializes through reflection-based JsonSerializer " + + "overloads. Generated scalar value objects already carry a [JsonConverter] pointing at a " + + "reflection-free generated converter; prefer that and do not register this factory." + )] + [RequiresDynamicCode( + "ScalarJsonConverterFactory builds a closed generic converter with Type.MakeGenericType and compiles " + + "accessors with expression trees. Prefer the generated per-type JsonConverter." + )] + public ScalarJsonConverterFactory() { } + /// /// Determines whether the type can be converted, returning true when it is decorated with /// . @@ -45,6 +82,27 @@ public override bool CanConvert(Type typeToConvert) => /// Thrown when the type does not expose the configured scalar property, or no static /// Create/Hydrate factory or scalar constructor can be found. /// + /// + /// Not supported under trimming or Native AOT; prefer the generated per-type converter. See the remarks + /// on . The requirement is declared on the constructor rather + /// than here, because is not itself annotated and + /// IL2046/IL3051 forbid an override from adding the attribute. + /// + [UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "Only reachable through the annotated ScalarJsonConverterFactory constructor." + )] + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Only reachable through the annotated ScalarJsonConverterFactory constructor." + )] + [UnconditionalSuppressMessage( + "Trimming", + "IL2070:UnrecognizedReflectionPattern", + Justification = "Only reachable through the annotated ScalarJsonConverterFactory constructor." + )] public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => Cache.GetOrAdd( typeToConvert, @@ -62,6 +120,24 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer } ); + // Instances are only ever created by CreateConverter, which is itself only reachable through the + // annotated constructor, so the requirement is already declared to the consumer at the opt-in point. + // These suppressions stop it being reported a second time against code the consumer cannot change. + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Only constructed through the annotated ScalarJsonConverterFactory constructor." + )] + [UnconditionalSuppressMessage( + "Trimming", + "IL2090:UnrecognizedReflectionPattern", + Justification = "Only constructed through the annotated ScalarJsonConverterFactory constructor." + )] + [UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "Only constructed through the annotated ScalarJsonConverterFactory constructor." + )] sealed class ScalarJsonConverter( PropertyInfo scalarProperty, ValueObjectDeserializationMode deserializationMode @@ -73,11 +149,36 @@ ValueObjectDeserializationMode deserializationMode public override TScalarObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var scalar = JsonSerializer.Deserialize(ref reader, options); - return scalar is null - ? throw new JsonException($"Cannot deserialize {typeof(TScalarObject).Name} from null.") - : _create(scalar); + if (scalar is null) + throw new JsonException($"Cannot deserialize {typeof(TScalarObject).Name} from null."); + + try + { + return _create(scalar); + } + catch (Exception exception) when (IsValidationFailure(exception)) + { + // Surfaced as JsonException so System.Text.Json attaches Path and LineNumber, and so hosts + // treat it as bad input. Under Strict the factory is Create, which throws the validation + // exception of whatever validator the value object uses - ArgumentException by default, or + // ZodException with ZodSharp validation. Letting those escape unwrapped meant ASP.NET Core + // saw an unhandled exception and answered 500 instead of 400, with no indication of which + // property failed. + throw new JsonException( + $"Cannot deserialize {typeof(TScalarObject).Name}: the value failed validation.", + exception + ); + } } + /// + /// Whether is a validation failure from the value object's factory + /// rather than something that must not be reported as malformed input. + /// + static bool IsValidationFailure(Exception exception) => + exception + is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or JsonException); + public override void Write(Utf8JsonWriter writer, TScalarObject value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, _getScalar(value), options); @@ -91,13 +192,31 @@ static Func BuildGetter(PropertyInfo property) static Func BuildCreator(ValueObjectDeserializationMode deserializationMode) { var t = typeof(TScalarObject); - var preferredFactoryName = - deserializationMode == ValueObjectDeserializationMode.Strict ? "Create" : "Hydrate"; - var secondaryFactoryName = preferredFactoryName == "Hydrate" ? "Create" : "Hydrate"; + var isStrict = deserializationMode == ValueObjectDeserializationMode.Strict; + var preferredFactoryName = isStrict ? "Create" : "Hydrate"; + + var create = t.GetMethod( + preferredFactoryName, + BindingFlags.Public | BindingFlags.Static, + [typeof(TScalar)] + ); + + // Strict must never silently become Hydrate. Strict is chosen precisely to re-validate untrusted + // input, so falling back to the replay-safe factory would report success while skipping + // validation altogether - a validation boundary that does nothing. Hydrate may still fall back to + // Create, which only ever adds validation. + if (create is null && isStrict) + { + throw new InvalidOperationException( + $"'{t.Name}' is configured with {nameof(ValueObjectDeserializationMode)}." + + $"{nameof(ValueObjectDeserializationMode.Strict)}, but no public static " + + $"'Create({typeof(TScalar).Name})' factory could be found. Strict deserialization " + + "re-validates the incoming value, so it cannot fall back to Hydrate." + ); + } + + create ??= t.GetMethod("Create", BindingFlags.Public | BindingFlags.Static, [typeof(TScalar)]); - var create = - t.GetMethod(preferredFactoryName, BindingFlags.Public | BindingFlags.Static, [typeof(TScalar)]) - ?? t.GetMethod(secondaryFactoryName, BindingFlags.Public | BindingFlags.Static, [typeof(TScalar)]); if (create is not null) { var p = Expression.Parameter(typeof(TScalar), "v"); @@ -116,7 +235,8 @@ static Func BuildCreator(ValueObjectDeserializationMode } throw new InvalidOperationException( - $"{t.Name} must expose static {preferredFactoryName}({typeof(TScalar).Name}), static {secondaryFactoryName}({typeof(TScalar).Name}), or ctor({typeof(TScalar).Name})." + $"{t.Name} must expose static Hydrate({typeof(TScalar).Name}), static " + + $"Create({typeof(TScalar).Name}), or ctor({typeof(TScalar).Name})." ); } } diff --git a/src/src/ValueObjects/ValueObjects.csproj b/src/src/ValueObjects/ValueObjects.csproj index 41c52e7..5a205d4 100644 --- a/src/src/ValueObjects/ValueObjects.csproj +++ b/src/src/ValueObjects/ValueObjects.csproj @@ -8,6 +8,21 @@ $(PackageTags);purview;dotnet;value-objects;value-object;scalar;source-generator;domain-driven-design;dto;serialization;json;entity-framework; + + + true + + +/// Regression tests for [Scalar("CustomName")]. +/// +/// +/// is a documented public +/// option, but the generated partial unconditionally implements +/// IScalarValueObject<TSelf, TValue>, which declares a member named Value. With a +/// custom name no Value was emitted, so the generated code failed with CS0535 — an error inside +/// generated code that the consumer cannot edit. These tests must therefore compile the +/// generated output; asserting on generated text would not have caught it, which is why the existing +/// coverage (an incremental-cache test that never compiles) missed it. +/// +public sealed class ScalarCustomPropertyNameTests : ValueObjectSourceGeneratorTestBase +{ + [Test] + public async Task ScalarGeneration_GivenCustomPropertyName_CompilesAndForwardsValue( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + + [Purview.ValueObjects.Serialization.Scalar("Amount")] + public readonly partial record struct Money + { + public decimal Amount { get; } + } + + public static class CustomNameHarness + { + public static decimal CreateThenReadCustomName() => Money.Create(12.5m).Amount; + + // The interface contract the generated type advertises must actually be satisfied. + public static decimal ReadThroughInterface() + { + global::Purview.ValueObjects.IScalarValueObject asInterface = Money.Create(12.5m); + + return asInterface.Value; + } + + public static bool ValueForwardsToCustomName() + { + var money = Money.Create(99.25m); + global::Purview.ValueObjects.IScalarValueObject asInterface = money; + + return asInterface.Value == money.Amount; + } + } + } + """; + + 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(); + + var harnessType = assembly.GetType("Testing.CustomNameHarness")!; + + var created = (decimal)harnessType.GetMethod("CreateThenReadCustomName")!.Invoke(null, null)!; + var throughInterface = (decimal)harnessType.GetMethod("ReadThroughInterface")!.Invoke(null, null)!; + var forwards = (bool)harnessType.GetMethod("ValueForwardsToCustomName")!.Invoke(null, null)!; + + await Assert.That(created).IsEqualTo(12.5m); + await Assert.That(throughInterface).IsEqualTo(12.5m); + await Assert.That(forwards).IsTrue(); + } + + [Test] + public async Task ScalarGeneration_GivenDefaultPropertyName_DoesNotEmitADuplicateValueMember( + CancellationToken cancellationToken + ) + { + // The forwarding member must only appear when the scalar member is named something other than + // "Value", otherwise the generated partial would declare Value twice. + const string source = """ + namespace Testing + { + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + + public static class DefaultNameHarness + { + public static string CreateThenRead() => EmailAddress.Create("a@b.com").Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var harnessType = assembly.GetType("Testing.DefaultNameHarness")!; + var created = (string)harnessType.GetMethod("CreateThenRead")!.Invoke(null, null)!; + + await Assert.That(created).IsEqualTo("a@b.com"); + } +} diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs new file mode 100644 index 0000000..3b92e77 --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs @@ -0,0 +1,131 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Purview.ValueObjects.Serialization; + +/// +/// Covers which members the generated ConfigureValueObjects registry is allowed to map. +/// +/// +/// The registry walks every readable property of every entity type and attaches a value conversion through +/// modelBuilder.Entity(t).Property(type, name). That is explicit configuration, which +/// outranks [NotMapped] and Ignore(...) — so a member the author deliberately excluded could +/// be pulled back into the model, producing a column and a migration nobody asked for. A computed, +/// setter-less property is the same problem with an added failure: there is nowhere to materialize into. +/// +public class EFNotMappedTests +{ + [Test] + public async Task ConfigureValueObjects_DoesNotMapAPropertyMarkedNotMapped() + { + // Arrange + await using var context = CreateContext(); + + // Act + var entityType = context.Model.FindEntityType(typeof(EFSelectiveMappingEntity))!; + + // Assert + await Assert.That(entityType.FindProperty(nameof(EFSelectiveMappingEntity.Email))).IsNotNull(); + await Assert.That(entityType.FindProperty(nameof(EFSelectiveMappingEntity.ScratchEmail))).IsNull(); + } + + [Test] + public async Task ConfigureValueObjects_DoesNotMapAComputedGetOnlyProperty() + { + // Arrange + await using var context = CreateContext(); + + // Act + var entityType = context.Model.FindEntityType(typeof(EFSelectiveMappingEntity))!; + + // Assert + await Assert.That(entityType.FindProperty(nameof(EFSelectiveMappingEntity.PrimaryContact))).IsNull(); + } + + [Test] + public async Task ConfigureValueObjects_DoesNotMapAPropertyIgnoredByTheModelBuilder() + { + // Arrange + await using var context = CreateContext(); + + // Act + var entityType = context.Model.FindEntityType(typeof(EFSelectiveMappingEntity))!; + + // Assert + await Assert.That(entityType.FindProperty(nameof(EFSelectiveMappingEntity.IgnoredEmail))).IsNull(); + } + + [Test] + public async Task ConfigureValueObjects_StillRoundTripsTheMappedProperty(CancellationToken cancellationToken) + { + // Arrange — excluding members must not break the conversion on the ones that are mapped. + await using var context = CreateContext(); + await context.Database.EnsureCreatedAsync(cancellationToken); + + context.Entities.Add( + new EFSelectiveMappingEntity { Id = 1, Email = EmailAddress.Create("someone@example.com") } + ); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + + // Act + var loaded = await context.Entities.SingleAsync(cancellationToken); + + // Assert + await Assert.That(loaded.Email).IsEqualTo(EmailAddress.Create("someone@example.com")); + } + + static SelectiveMappingDbContext CreateContext() + { + SqliteConnection connection = new("DataSource=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + + return new SelectiveMappingDbContext(options, connection); + } +} + +sealed class SelectiveMappingDbContext(DbContextOptions options, SqliteConnection connection) + : DbContext(options) +{ + public DbSet Entities => Set(); + + public override void Dispose() + { + base.Dispose(); + connection.Dispose(); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + await connection.DisposeAsync(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Ignore declared before the registry runs: the registry must respect it rather than re-adding it. + modelBuilder.Entity().Ignore(entity => entity.IgnoredEmail); + + modelBuilder.ConfigureValueObjects(); + } +} + +sealed class EFSelectiveMappingEntity +{ + public int Id { get; set; } + + /// A normal mapped value-object property. + public EmailAddress Email { get; set; } + + /// Deliberately excluded by the author. + [System.ComponentModel.DataAnnotations.Schema.NotMapped] + public EmailAddress ScratchEmail { get; set; } + + /// Excluded through the model builder rather than an attribute. + public EmailAddress IgnoredEmail { get; set; } + + /// Computed and setter-less: there is nowhere for Entity Framework Core to materialize into. + public EmailAddress PrimaryContact => Email; +} diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/StringScalarOrderingTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/StringScalarOrderingTests.cs new file mode 100644 index 0000000..d3da079 --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/StringScalarOrderingTests.cs @@ -0,0 +1,107 @@ +namespace Purview.ValueObjects.Serialization; + +/// +/// Covers the agreement between equality and ordering for a -backed scalar. +/// +/// +/// Equality and GetHashCode are generated with EqualityComparer<string>.Default, which is +/// ordinal. CompareTo used Comparer<string>.Default, which orders by the current culture. +/// That broke the contract — CompareTo == 0 no longer implied +/// Equals — so SortedSet, SortedDictionary and List.BinarySearch, which use +/// CompareTo for identity, disagreed with the type's own notion of equality. It also made ordering +/// depend on the thread's culture. +/// +public class StringScalarOrderingTests +{ + [Test] + public async Task CompareTo_AgreesWithEquality() + { + // Arrange — "a" vs "A" is where ordinal and culture-aware comparison disagree. + var lower = OrderingCode.Create("abc"); + var upper = OrderingCode.Create("ABC"); + + // Act + var comparesEqual = lower.CompareTo(upper) == 0; + var equals = lower.Equals(upper); + + // Assert — whatever the answer, the two must agree. Under the culture-aware comparer they did not. + await Assert.That(comparesEqual).IsEqualTo(equals); + } + + [Test] + public async Task CompareTo_UsesOrdinalOrdering() + { + // Arrange — ordinal puts uppercase before lowercase, because 'A' (0x41) < 'a' (0x61). A + // culture-aware comparison does not. + var upper = OrderingCode.Create("ABC"); + var lower = OrderingCode.Create("abc"); + + // Act / Assert + await Assert.That(upper.CompareTo(lower)).IsLessThan(0); + } + + [Test] + public async Task SortedSet_DoesNotCollapseValuesTheTypeConsidersDistinct() + { + // Arrange — SortedSet uses CompareTo for identity. Under the culture-aware comparer these two + // compared equal while Equals said otherwise, so one was silently dropped. + var lower = OrderingCode.Create("abc"); + var upper = OrderingCode.Create("ABC"); + + // Act + SortedSet set = [lower, upper]; + + // Assert + await Assert.That(lower.Equals(upper)).IsFalse(); + await Assert.That(set.Count).IsEqualTo(2); + } + + [Test] + public async Task CompareTo_IsIndependentOfTheThreadCulture() + { + // Arrange — a culture-aware comparison can reorder these; ordinal cannot. + var first = OrderingCode.Create("cote"); + var second = OrderingCode.Create("cote\u0301"); + + var original = System.Globalization.CultureInfo.CurrentCulture; + + try + { + // Act + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("en-US"); + var underInvariantish = Math.Sign(first.CompareTo(second)); + + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("fr-FR"); + var underFrench = Math.Sign(first.CompareTo(second)); + + // Assert + await Assert.That(underFrench).IsEqualTo(underInvariantish); + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = original; + } + } + + [Test] + public async Task CompareTo_AgainstThePrimitive_AlsoUsesOrdinalOrdering() + { + // The primitive overload is what the relational operators and the self overload delegate to. + var upper = OrderingCode.Create("ABC"); + + await Assert.That(upper.CompareTo("abc")).IsLessThan(0); + } +} + +/// +/// A string scalar with no normalization, so case is preserved and ordering is observable. +/// +/// +/// The other string fixtures lowercase in OnNormalize, which makes case-based ordering untestable +/// through them. +/// +[Scalar] +public readonly partial record struct OrderingCode +{ + public string Value { get; } +} diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodValidatedTryCreateTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodValidatedTryCreateTests.cs new file mode 100644 index 0000000..97b59f6 --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodValidatedTryCreateTests.cs @@ -0,0 +1,49 @@ +namespace Purview.ValueObjects.Serialization; + +/// +/// Covers TryCreate on a ZodSharp-validated scalar. +/// +/// +/// The generated TryCreate caught only , but a ZodSharp-validated +/// Create throws ZodException. So for every [ZodSchema] value object — a headline +/// feature with its own documentation page — TryCreate threw instead of returning , +/// and the documented "try" contract did not hold. +/// +public class ZodValidatedTryCreateTests +{ + [Test] + public async Task TryCreate_GivenValidValue_ReturnsTrueAndTheValue() + { + // Act + var created = ZodValidatedEmail.TryCreate("someone@example.com", out var result); + + // Assert + await Assert.That(created).IsTrue(); + await Assert.That(result.Value).IsEqualTo("someone@example.com"); + } + + [Test] + public async Task TryCreate_GivenValueFailingZodValidation_ReturnsFalseInsteadOfThrowing() + { + // Act — "x" fails both the email rule and the minimum length, so Create throws ZodException. + var created = ZodValidatedEmail.TryCreate("x", out var result); + + // Assert + await Assert.That(created).IsFalse(); + await Assert.That(result).IsEqualTo(default(ZodValidatedEmail)); + } + + [Test] + public async Task TryCreate_GivenValueFailingZodValidation_DoesNotThrow() + { + // The regression this guards: the call itself used to throw rather than report failure. + await Assert.That(() => ZodValidatedEmail.TryCreate("not-an-email", out _)).ThrowsNothing(); + } + + [Test] + public async Task Create_GivenInvalidValue_StillThrows() + { + // TryCreate reporting failure must not change the strict factory's contract. + await Assert.That(() => ZodValidatedEmail.Create("x")).ThrowsException(); + } +} diff --git a/src/tests/ValueObjects.UnitTests/Serialization/Models.cs b/src/tests/ValueObjects.UnitTests/Serialization/Models.cs index 59aa819..0a09c6d 100644 --- a/src/tests/ValueObjects.UnitTests/Serialization/Models.cs +++ b/src/tests/ValueObjects.UnitTests/Serialization/Models.cs @@ -75,3 +75,11 @@ public static CustomerId Create(Guid value) public static CustomerId Hydrate(Guid value) => new(value); } + +/// +/// Holds a strict scalar, so a deserialization failure can be observed with a JSON path. +/// +sealed class StrictEmailHolder +{ + public StrictEmailAddress Email { get; set; } +} diff --git a/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs b/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs index 879d750..46ffc13 100644 --- a/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs +++ b/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs @@ -16,18 +16,34 @@ public async Task Deserialize_DefaultScalarMode_UsesHydrate() [Test] public async Task Deserialize_StrictScalarMode_UsesCreate() { + // The validation failure surfaces as JsonException, with the factory's own exception as the inner + // one. It used to escape as a bare ArgumentException, which ASP.NET Core treats as an unhandled + // exception — a 500 rather than a 400, and with no JSON path identifying the offending value. var options = CreateOptions(); - var threw = false; - try - { - _ = JsonSerializer.Deserialize("\"not-an-email\"", options); - } - catch (ArgumentException) - { - threw = true; - } - - await Assert.That(threw).IsTrue(); + + var exception = Assert.Throws(() => + JsonSerializer.Deserialize("\"not-an-email\"", options) + ); + + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.InnerException).IsTypeOf(); + } + + [Test] + public async Task Deserialize_StrictScalarMode_FailureCarriesTheJsonPath() + { + // Arrange — the whole point of reporting JsonException is that System.Text.Json attaches the + // position, so a caller can tell which member of the payload was rejected. + var options = CreateOptions(); + + // Act + var exception = Assert.Throws(() => + JsonSerializer.Deserialize("""{"Email":"not-an-email"}""", options) + ); + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Path).IsEqualTo("$.Email"); } [Test] From 1cd33fd66e4fbe2222d1393bb298b7b565a0f9a1 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 6 Oct 2026 18:46:55 +0100 Subject: [PATCH 07/11] fix: retire VO1005, which contradicted the generator and was never reported VO1005 ("scalar constructor is missing") was recorded in AnalyzerReleases.Shipped.md as a shipped Error and documented in docs/Diagnostics.md as requiring you to add a constructor taking the scalar's underlying type. It was absent from the analyzer's SupportedDiagnostics and had no reporting site anywhere: it was declared in the initial commit and never wired up, so it has never fired in any version. Implementing it would have been wrong. A missing constructor is not an error condition - it is the normal case. When a [Scalar] type declares no constructor matching its scalar value, ScalarValueObjectEmitter.EmitConstructor emits a private one, which is the documented and tested behaviour. The rule's message told consumers to supply the very thing the generator exists to supply, so reporting it would have failed every correctly written scalar value object. The id now sits with VO1011, VO1012, VO1014 and VO1020 as retired and never reused, leaving 16 live rules rather than 17. The docs call this one out specifically, because unlike the others it was published in the catalogue as an Error and described a requirement that never existed. DiagnosticLibrary.ScalarConstructorMissing is removed. It is a public member, but DiagnosticLibrary lives in the source generator, which ships IL-merged inside the package, so no consumer can reference it. A comment in its place records why the rule cannot be implemented, so it is not reintroduced. Also removed the DoesNotHaveDiagnostic(ScalarConstructorMissing) assertion from ScalarGeneration_GeneratesPrivateConstructorWhenMissing - it was vacuously true, asserting the absence of a diagnostic nothing could report. The rest of that test is what actually covers this behaviour: it proves the generated constructor is private and that Create and Hydrate work through it. Fixed two warnings introduced by the previous commit, so the build is clean again: - IDE0270 in ScalarJsonConverterFactory.Read, simplified to `?? throw`. - CA2000 in EFNotMappedTests.CreateContext, suppressed with justification. The SqliteConnection's lifetime transfers to SelectiveMappingDbContext, which closes it in its own Dispose override; disposing it locally would destroy the in-memory database the test has just populated. --- CHANGELOG.md | 13 +++++++++---- docs/Diagnostics.md | 13 +++++++++---- .../SourceGenerator/AnalyzerReleases.Shipped.md | 1 - .../SourceGenerator/Common/DiagnosticLibrary.cs | 14 +++++--------- .../Serialization/ScalarJsonConverterFactory.cs | 6 +++--- .../Generators/ValueObjectSourceGeneratorTests.cs | 1 - .../Serialization/EFNotMappedTests.cs | 8 ++++++++ 7 files changed, 34 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff13cf1..fa8d002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,14 +112,19 @@ been published to NuGet. No stable release has been cut yet; the published line - **All nine remaining diagnostics moved from `AnalyzerReleases.Unshipped.md` into `AnalyzerReleases.Shipped.md`** under the existing `## Release 1.0.0` heading, so the catalogue records - all 17 rules as shipping in the first stable release. The heading was already there for `VO1001`–`VO1008` + all 16 rules as shipping in the first stable release. The heading was already there for `VO1001`–`VO1008` even though no 1.0.0 was ever tagged, so merging into it is what makes the file accurate. +- **`VO1005` is retired rather than implemented.** The catalogue listed it as a shipped `Error` and the + documentation told you to add a constructor taking the scalar's underlying type, but it was absent from the + analyzer's `SupportedDiagnostics` and had no reporting site anywhere — it was declared in the initial commit + and never wired up, so it has never fired in any version. Implementing it would have been wrong: a missing + constructor is not an error condition. When a `[Scalar]` type declares no constructor matching its scalar + value, `ScalarValueObjectEmitter.EmitConstructor` **emits a private one**, which is the documented and + tested behaviour. The rule as written contradicted the generator. The id now sits with `VO1011`, `VO1012`, + `VO1014` and `VO1020` as retired and never reused, leaving 16 live rules. ### Still outstanding for a stable 1.0 -- `VO1005` (scalar constructor missing) is recorded as a shipped `Error` but is **not** in the analyzer's - `SupportedDiagnostics`, so nothing reports it. Either wire it up or record it as a removed rule before the - catalogue becomes a 1.0 contract. - `Purview.ZodSharp 2.0.1-prerelease.1` is resolvable only from the local feed, not nuget.org. A stable `Purview.ValueObjects` cannot be published until a stable `Purview.ZodSharp` is on nuget.org. diff --git a/docs/Diagnostics.md b/docs/Diagnostics.md index 50622b2..7b36722 100644 --- a/docs/Diagnostics.md +++ b/docs/Diagnostics.md @@ -17,7 +17,6 @@ These govern the declaration shape a value object must have for the generator to | `VO1002` | Error | The value object is nested inside another type. | Move it to its own top-level type. Nesting is not supported. | | `VO1003` | Error | The value object is generic. | Remove the type parameters, or declare one concrete value object per closed type. | | `VO1004` | Error | A `[Scalar]` type does not declare the configured scalar property. | Declare the property, or set `ScalarAttribute.PropertyName` to the one you have. | -| `VO1005` | Error | A `[Scalar]` type declares no constructor matching its scalar value. | Add a constructor taking the scalar's underlying type. | | `VO1006` | Warning | A `[Scalar]` type is not a `readonly record struct`. | Prefer `readonly record struct` so equality, immutability and allocation behaviour match the contract. | | `VO1007` | Warning | `ValueObjectDeserializationMode.Strict` is set but no `Create` overload exists to re-validate through. | Add the `Create` overload, or use the default `Hydrate` mode. | | `VO1008` | Error | `[Scalar]` and `[ValueObject]` are both applied to the same type. | Pick one. A scalar wraps a single primitive; a value object composes members. | @@ -47,9 +46,15 @@ See [Entity Framework integration](Entity-Framework.md) for the mapping model. ## Retired identifiers -`VO1011`, `VO1012`, `VO1014` and `VO1020` were used during development and withdrawn before release. They are -**not** reported by any version and are **never reused**, so the numbering is intentionally discontinuous. Treat -an occurrence of one of these IDs as stale tooling or a stale suppression, and remove it. +`VO1005`, `VO1011`, `VO1012`, `VO1014` and `VO1020` were used during development and withdrawn before release. +They are **not** reported by any version and are **never reused**, so the numbering is intentionally +discontinuous. Treat an occurrence of one of these IDs as stale tooling or a stale suppression, and remove it. + +`VO1005` ("scalar constructor is missing") is worth calling out, because it was listed as an `Error` in the +analyzer catalogue and documented here as requiring you to declare a constructor. It never had a reporting +site, and the requirement it described was the opposite of how the generator works: when a `[Scalar]` type +declares no constructor taking the scalar's underlying type, the generator **emits a private one**. You do not +need to declare it, and nothing ever asked you to. ## Release tracking diff --git a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md index 2e95bf3..5168060 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md @@ -8,7 +8,6 @@ VO1001 | ValueObjects | Error | Value objects must be partial VO1002 | ValueObjects | Error | Nested value objects are not supported VO1003 | ValueObjects | Error | Generic value objects are not supported VO1004 | ValueObjects | Error | Scalar value objects must declare the configured scalar property -VO1005 | ValueObjects | Error | Scalar value objects must declare a constructor matching their scalar value VO1006 | ValueObjects | Warning | Scalar value objects should be readonly record structs VO1007 | ValueObjects | Warning | Strict deserialization mode requires a Create overload VO1008 | ValueObjects | Error | [Scalar] and [ValueObject] cannot be combined diff --git a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs index 0686331..b0d21b3 100644 --- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs @@ -57,15 +57,11 @@ public static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1005: Scalar constructor is missing - public static readonly DiagnosticDescriptor ScalarConstructorMissing = new( - id: "VO1005", - title: "Scalar constructor is missing", - messageFormat: "Scalar value object '{0}' must declare a constructor '{0}({1})' to support generated Create/Hydrate", - category: ValueObjectCategory, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); + // VO1005 ("Scalar constructor is missing") is retired. It was declared in the initial commit and never + // had a reporting site, because a missing constructor is not an error condition: when a [Scalar] type + // declares no constructor taking the scalar's type, ScalarValueObjectEmitter.EmitConstructor emits a + // private one. Requiring the consumer to declare it, as the descriptor's message did, was the opposite + // of the generator's behaviour. The id is recorded as retired in docs/Diagnostics.md and never reused. /// VO1006: Scalar value objects should be record structs public static readonly DiagnosticDescriptor ScalarShouldBeRecordStruct = new( diff --git a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs index cce72d6..4e258f8 100644 --- a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs +++ b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs @@ -148,9 +148,9 @@ ValueObjectDeserializationMode deserializationMode public override TScalarObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var scalar = JsonSerializer.Deserialize(ref reader, options); - if (scalar is null) - throw new JsonException($"Cannot deserialize {typeof(TScalarObject).Name} from null."); + var scalar = + JsonSerializer.Deserialize(ref reader, options) + ?? throw new JsonException($"Cannot deserialize {typeof(TScalarObject).Name} from null."); try { diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs index da05807..2af2e2a 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs @@ -175,7 +175,6 @@ public static class PhoneHarness var phoneNumber = query.GetRecord("PhoneNumber", "Testing"); var ctor = phoneNumber.GetConstructor(TypeRefs.String); await Assert.That(ctor.Node.Modifiers.ToString()).Contains("private"); - await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ScalarConstructorMissing); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs index 3b92e77..67bb319 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs @@ -75,6 +75,14 @@ public async Task ConfigureValueObjects_StillRoundTripsTheMappedProperty(Cancell await Assert.That(loaded.Email).IsEqualTo(EmailAddress.Create("someone@example.com")); } + // The connection's lifetime is deliberately handed to the context, which closes it in its own Dispose + // override. An in-memory SQLite database exists only while the connection is open, so it cannot be + // disposed here without destroying the schema the test just created. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership transfers to SelectiveMappingDbContext, which disposes it." + )] static SelectiveMappingDbContext CreateContext() { SqliteConnection connection = new("DataSource=:memory:"); From 177588d7a673b8776bfd45b4476cd1f4563e088c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 6 Oct 2026 23:24:10 +0100 Subject: [PATCH 08/11] feat: target net11.0, and adopt stable Purview.BuildSdk and SourceGeneratorFramework Purview.ValueObjects now targets net11.0 alongside net8.0, net9.0 and net10.0. The runtime was the only core package not building for the newest major, and the blocker was never the code: global.json set allowPrerelease to false, so the .NET 11 RC SDK installed on the machine was never selected and the build failed with NETSDK1045. It is true now, matching zodsharp and results. The Entity Framework compatibility matrix covers the new target. net10.0 and net11.0 share the centrally managed EF Core version: no EF Core release targets net11.0 yet, and a net11.0 project resolves the net10.0 assets. Give net11.0 its own pin when one ships. That project keeps an explicit TargetFrameworks list rather than a curated set, because each target is paired with its own EF reference set and a target arriving from a set would silently build with no EF pin - so the list is deliberately coupled to the matrix below it. The runtime project selects the SDK's 'All' set instead of listing net8.0;net9.0;net10.0;net11.0, so a runtime lifecycle change is an SDK bump rather than an edit here. Moves from Purview.BuildSdk 1.0.2.2 and Purview.SourceGeneratorFramework 1.0.0-prerelease.54 to the first stable releases of both. The mirrored .agents files are no longer tracked: the SDK generates .agents/.gitignore listing the files it mirrors from packages, so a package upgrade no longer shows up as a local modification. Tests: 206 passing, up from 198 - the eight new executions are the EF compatibility suite running on net11.0. Pack validation 2/2 with net11.0 included. --- .agents/agents/sdk-consumer-setup.md | 38 --------- .agents/agents/sdk-repository-rationaliser.md | 38 --------- ...source-generator-framework-writer.agent.md | 79 ------------------- .agents/agents/test-author-writer.agent.md | 50 ------------ ...odernize-test-to-codequery-tunit.prompt.md | 48 ----------- ...r-source-generator-to-codewriter.prompt.md | 52 ------------ .../prompts/sdk-diagnose-agent-folder-copy.md | 37 --------- .../prompts/sdk-review-repository-shape.md | 33 -------- .../project-placement-defaults/.gitignore | 8 -- .../sdk-configuration-reference/.gitignore | 8 -- .../sdk-engineering-principles/.gitignore | 8 -- .../.gitignore | 8 -- .../.gitignore | 8 -- .../source-generator-testing/.gitignore | 8 -- .../skills/tunit-test-authoring/.gitignore | 8 -- Directory.Packages.props | 2 +- global.json | 4 +- src/src/ValueObjects/ValueObjects.csproj | 7 +- ...ts.EFCompatibility.IntegrationTests.csproj | 19 +++-- 19 files changed, 21 insertions(+), 442 deletions(-) delete mode 100644 .agents/agents/sdk-consumer-setup.md delete mode 100644 .agents/agents/sdk-repository-rationaliser.md delete mode 100644 .agents/agents/source-generator-framework-writer.agent.md delete mode 100644 .agents/agents/test-author-writer.agent.md delete mode 100644 .agents/prompts/modernize-test-to-codequery-tunit.prompt.md delete mode 100644 .agents/prompts/refactor-source-generator-to-codewriter.prompt.md delete mode 100644 .agents/prompts/sdk-diagnose-agent-folder-copy.md delete mode 100644 .agents/prompts/sdk-review-repository-shape.md delete mode 100644 .agents/skills/project-placement-defaults/.gitignore delete mode 100644 .agents/skills/sdk-configuration-reference/.gitignore delete mode 100644 .agents/skills/sdk-engineering-principles/.gitignore delete mode 100644 .agents/skills/sdk-project-behavior-and-detection/.gitignore delete mode 100644 .agents/skills/source-generator-codewriter-modernization/.gitignore delete mode 100644 .agents/skills/source-generator-testing/.gitignore delete mode 100644 .agents/skills/tunit-test-authoring/.gitignore diff --git a/.agents/agents/sdk-consumer-setup.md b/.agents/agents/sdk-consumer-setup.md deleted file mode 100644 index c3cc84f..0000000 --- a/.agents/agents/sdk-consumer-setup.md +++ /dev/null @@ -1,38 +0,0 @@ -# sdk-consumer-setup (generic agent spec) - -## Goal - -Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, without breaking existing build behaviour. - -## Workflow - -1. Confirm the SDK is imported in `Directory.Build.props`/`Directory.Build.targets` via - `` and the matching `Sdk.targets` import. -2. Check pre-import bootstrap properties are set **before** the `Sdk.props` import when they must affect - evaluation: `NamespacePrefix`, `UsePackageJsonVersion`, `RootPackageJson`. -3. If version resolution looks wrong, verify `package.json` discovery: explicit `RootPackageJson`, then CI - variables, `.git` root, or a nearby `package.json`. `UsePackageJsonVersion=Strict` fails fast instead of - silently skipping resolution. -4. If the bundled `.agents/**` content isn't appearing in the repo root, check `EnableAgentFolderInPackage` - (default `true`) and `AgentPackDestinationFolder` (default `.agents`) - the copy runs before build via - `EnsureAgentFolderInPackageTarget`. -5. For test-framework or project-shape questions, confirm the project follows repo naming and placement - conventions the SDK expects, rather than introducing bespoke structure. -6. For naming, layout, and test readability questions, start from the engineering principles: naming and - placement are configuration, short project names are preferred, the detected test type becomes the baseline - category, and subject-based tests should be named for the subject they own. -7. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm - the fix. - -## Constraints - -- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. -- Do not disable `PurviewAutoSdkPack` or `EnableAgentFolderInPackage` unless the consumer explicitly asks to - opt out. -- Do not duplicate SDK-managed properties in individual project files unless the scenario is intentionally - project-specific. - -## Related skill - -See `../skills/sdk-configuration-reference/SKILL.md` for the full property reference. -See `../skills/sdk-engineering-principles/SKILL.md` for the higher-level repository conventions. diff --git a/.agents/agents/sdk-repository-rationaliser.md b/.agents/agents/sdk-repository-rationaliser.md deleted file mode 100644 index d41060b..0000000 --- a/.agents/agents/sdk-repository-rationaliser.md +++ /dev/null @@ -1,38 +0,0 @@ -# sdk-repository-rationaliser (generic agent spec) - -## Goal - -Help a repository move toward `Purview.BuildSdk` conventions without unnecessary churn. - -## Workflow - -1. Start by reading the repo's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, - and current project layout. -2. Identify the current `NamespacePrefix`, project naming scheme, and test project suffixes in use. -3. Compare the repo's structure against the engineering principles: - - short project names - - source/test split where practical - - exact shared/shared-testing names when SDK behavior is expected - - readable, scalable test naming -4. Separate findings into three groups: - - already aligned - - misaligned but harmless - - misaligned and blocking SDK automatic behavior -5. Prefer the smallest sequence of changes that improves predictability without forcing broad renames. -6. When recommending test changes, preserve readable behavior-oriented suites while tightening subject-based - suites toward `{SubjectName}Tests` and `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. -7. Confirm whether specialized dependencies such as `TUnit.Aspire` or `Testcontainers` are genuinely needed - rather than treating them as universal defaults. - -## Constraints - -- Do not assume every older repo should be renamed wholesale. -- Preserve meaningful established structure unless it interferes with SDK inference. -- Prefer explaining the effect on `RootNamespace`, `AssemblyName`, `PackageId`, `TestingType`, and - `TargetProjectName` instead of arguing from taste. - -## Related skills - -- `../skills/sdk-engineering-principles/SKILL.md` -- `../skills/project-placement-defaults/SKILL.md` -- `../skills/sdk-project-behavior-and-detection/SKILL.md` diff --git a/.agents/agents/source-generator-framework-writer.agent.md b/.agents/agents/source-generator-framework-writer.agent.md deleted file mode 100644 index 65f5bd0..0000000 --- a/.agents/agents/source-generator-framework-writer.agent.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -name: Source Generator Framework Writer -description: "Specialist for Purview.SourceGeneratorFramework generation code using CodeWriter and XmlCodeWriter-style XML doc extensions; ideal for creating or refactoring generator emitters." -tools: - [ - "search/codebase", - "edit/editFiles", - "search", - "execute/getTerminalOutput", - "execute/runInTerminal", - "read/terminalLastCommand", - "read/terminalSelection", - "execute/createAndRunTask", - "execute/runTask", - "read/getTaskOutput", - "vscodeTasks/createAndRunTask", - "vscodeTasks/getTaskOutput", - "vscodeTasks/runTask", - ] ---- - -You are a specialist for `Purview.SourceGeneratorFramework` emitter authoring. - -## Primary objective - -Produce clear, deterministic, maintainable source-generator emission code using `CodeWriter` and XML extension helpers from `XmlCommentWriter`. - -## Background knowledge - -Before changing any source generator, analyser, or CodeWriter-related code, load and apply the `source-generator-codewriter-modernization` skill. It contains the full source-generator, analyser, and CodeWriter best-practices guidance for this framework, including incremental pipeline design, value equality, deterministic output, and Roslyn version compatibility. - -The most important rules are: - -- **Analyser for validation; generator for generation.** -- **Syntax for syntax, symbols for declarations, operations for executable semantics.** -- **Use `ForAttributeWithMetadataName` whenever possible.** -- **Remove `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`, `SyntaxTree`, `SyntaxNode`, and `Location` from incremental pipeline models as early as possible.** -- **Pipeline models must be immutable and value-equatable; use `EquatableArray` for collections.** -- **Avoid `Collect()` until global knowledge is genuinely required.** -- **Never combine `CompilationProvider` into the pipeline merely because it is convenient.** -- **Generate deterministic output and stable hint names.** -- **Test incrementally, not just generated text.** -- **Compile against the oldest Roslyn API version containing the functionality you need.** -- **Create `CodeWriter` inside the output callback and pass it to helpers within that callback; never create it earlier in the pipeline or store it in incremental provider state or custom contexts.** - -## Available resources - -- `skills/source-generator-codewriter-modernization/SKILL.md` — source-generator, analyser, and CodeWriter best practices for this framework. -- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. - -## Must-follow rules - -1. Load and apply the `source-generator-codewriter-modernization` skill. -2. Prefer structured declaration APIs over handwritten declaration strings. -3. Prefer XML helper extensions (`XmlSummary`, `XmlParam`, etc.) over raw `///` output. -4. Create `CodeWriter` inside each output callback; never create it earlier in the pipeline or cache it in incremental provider state or custom contexts. -5. Preserve semantic behavior while modernizing implementation style. -6. Keep edits minimal and localized to emitter concerns. - -## Refactoring posture - -When modernizing legacy code: - -- Replace manual indentation/braces with scope APIs. -- Replace signature text with declaration option records. -- Replace ad-hoc XML tags with helper APIs. -- Preserve diagnostics and emitted symbol names. - -## Quality gates - -- Build/tests pass for impacted projects. -- No scope leaks when materializing generated source. -- Generated artifacts remain deterministic and reviewable. - -## Skill routing - -When relevant, first load and apply: - -- `source-generator-codewriter-modernization` diff --git a/.agents/agents/test-author-writer.agent.md b/.agents/agents/test-author-writer.agent.md deleted file mode 100644 index fb5fb87..0000000 --- a/.agents/agents/test-author-writer.agent.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: Test Author Writer -description: "Specialist for Purview.SourceGeneratorFramework test suites — writing, fixing, and modernising TUnit tests for generators, diagnostic analyzers, code fixes, and refactorings, and for adding stage-by-stage incremental cache tests." -tools: - [ - "search/codebase", - "edit/editFiles", - "search", - "execute/getTerminalOutput", - "execute/runInTerminal", - "read/terminalLastCommand", - "read/terminalSelection", - "execute/createAndRunTask", - "execute/runTask", - "read/getTaskOutput", - "vscodeTasks/createAndRunTask", - "vscodeTasks/getTaskOutput", - "vscodeTasks/runTask", - ] ---- - -You are a specialist for `Purview.SourceGeneratorFramework` test authoring. - -## Primary objective - -Produce correct, maintainable TUnit tests for source generators, diagnostic analyzers, code fix -providers, and refactoring providers, and prove incremental pipelines cache correctly. - -## Background knowledge - -Before writing or changing any test, load and apply the `source-generator-testing` skill (runner layer, -result types, `CodeQuery`, options, cache testing) and the `tunit-test-authoring` skill (base classes, -methods, assertion extensions, modernisation checklist). For source-generator emission work, also load the -`source-generator-codewriter-modernization` skill. - -Key rules: - -- Pick the base class by the Roslyn component type: generator → `TUnitSourceGeneratorTestBase` + - `GenerateAsync`; analyzer → `TUnitDiagnosticAnalyzerTestBase` + `AnalyzeAsync`; code fix → - `TUnitCodeFixTestBase` + `ApplyCodeFixAsync`/`ApplyFixAllAsync`; refactor → - `TUnitRefactoringTestBase` + `RefactorAsync`. -- Prefer `CodeQuery` (`result.Generated()` / `result.FixedCode()` with `Get/Has/TryGet`) over - raw-string assertions. -- Prefer the terminal assertion extensions (`HasGeneratedMethod`, `HasGeneratedClass`, …) that return - syntax nodes. -- Derive a `SourceGeneratorTestOptions` record that seeds namespaces and additional assemblies. -- For incremental pipelines, add a stage-by-stage cache test with `RunIncrementalAsync` / - `GenerateIncrementalAsync`, asserting `New` on first run and `Cached`/`Unchanged` on an identical rerun, - and `Modified` only on the stages whose inputs changed. -- Keep generated-output assertions deterministic (no timestamps); enable CodeWriter scope validation. \ No newline at end of file diff --git a/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md b/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md deleted file mode 100644 index cf463ce..0000000 --- a/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -agent: ask -description: "Modernise a Roslyn test suite to use CodeQuery + TUnit assertion extensions, and add a stage-by-stage incremental cache test." ---- - -You are modernising tests in this repository. Apply the guidance from the `source-generator-testing` and -`tunit-test-authoring` skills for picking the right base class, querying generated code with `CodeQuery`, -and asserting incremental caching. - -## Inputs - -- Target test file(s): `${input:targetFiles:Path(s) to test file(s)}` -- Roslyn component under test: `${input:componentType:generator|analyzer|codefix|refactor}` (inferred if blank) -- Generator/analyzer/code-fix/refactor type name: `${input:componentName:Component type name}` - -## Task - -Modernise each test so it uses the framework's `CodeQuery` syntax-lookup API and the TUnit assertion -extensions, and add a stage-by-stage cache test proving each incremental pipeline layer caches correctly. - -### Requirements - -1. Choose the correct base class and method for the component type: - - Generator → `TUnitSourceGeneratorTestBase` → `GenerateAsync`. - - Analyzer → `TUnitDiagnosticAnalyzerTestBase` → `AnalyzeAsync`. - - Code fix → `TUnitCodeFixTestBase` → `ApplyCodeFixAsync` / `ApplyFixAllAsync`. - - Refactor → `TUnitRefactoringTestBase` → `RefactorAsync`. -2. Replace `GetGeneratedTree(...)` + `string.Contains(...)` assertions with `CodeQuery` - (`result.Generated().Get/Has/TryGet…`) and the terminal assertion extensions - (`await Assert.That(result).HasGeneratedMethod/Class/Property/Field/SyntaxTree(…)`) that return the node. -3. Replace signature string checks with `TypeReference` parameter/return-type matching. -4. Ensure options come from a derived `SourceGeneratorTestOptions` record seeding the required namespaces - and additional assemblies; remove per-test duplication. -5. Add an incremental cache test using `RunIncrementalAsync` (or `GenerateIncrementalAsync` on the TUnit - base) with the four scenarios from the skills' "Incremental cache testing" sections - (`ServiceRegistrationCacheTests` / `IncrementalPipelineCacheTests` are the reference pattern): - - first run → every framework stage `New`; - - identical rerun (`RunIncrementalAsync(sources, …)` runs the same source twice) → framework stages - `Cached`/`Unchanged`; - - source-only change → `ForAttribute_*` `Modified`, property/config stages stay `Cached`; - - property-only change (`new IncrementalRunInput(sources, [("build_property.X", "value")])`) → - `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/`GetGenerationContext_*` `Modified`, - `ForAttribute_*` stays `Cached`. - Use the `StepReasons(IncrementalCacheRun)` flattening helper; if the generator depends on its own - post-init output, assert on the framework-named stages rather than every tracked step. -6. Keep changes minimal and behavior equivalent; do not reformat unrelated tests. - -Verify by building the test project and running its suite before finishing. \ No newline at end of file diff --git a/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md b/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md deleted file mode 100644 index e230d22..0000000 --- a/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -agent: ask -description: "Refactor a legacy source generator emitter from string/StringBuilder to CodeWriter + XmlCodeWriter-style XML extensions with behavior parity." ---- - -You are modernizing a source generator implementation in this repository. Apply the guidance from the `source-generator-codewriter-modernization` skill for incremental pipelines, value equality, deterministic output, and CodeWriter scope safety. - -## Inputs - -- Target file(s): `${input:targetFiles:Path(s) to emitter file(s)}` -- Generator type name: `${input:generatorName:Generator class name}` -- Generator version: `${input:generatorVersion:Version string (for generated attributes/header)}` -- Keep output byte-identical where possible: `${input:preserveFormatting:true|false}` - -## Task - -Refactor the selected legacy emitter implementation from manual `string` / `StringBuilder` output construction to `CodeWriter` and XML documentation extension helpers from `XmlCommentWriter` (XmlCodeWriter-style API usage). - -### Requirements - -1. Use structured declaration APIs where applicable: - - `Class/Struct/RecordClass/Interface/Enum` - - `Method`, `Property`, `Field`, `Constructor` -2. Use XML helper extensions instead of raw `///` composition: - - `XmlSummary`, `XmlParam`, `XmlReturn`, `XmlRemarks`, `XmlCode` or `XmlCodeBlock` -3. Use `TypeReference` when type text becomes complex (nullability, generics, arrays). -4. Ensure writer lifetime is output-scoped (`generationContext.CreateCodeWriter()` inside callback). -5. Preserve behavior, diagnostics, and generated names. -6. Keep changes minimal and focused; do not reformat unrelated logic. - -### Migration strategy - -- Identify emitter phases: header, namespace, type declarations, member declarations. -- Replace indentation/braces with scoped APIs. -- Replace signature strings with declaration options. -- Replace XML comments with XmlCommentWriter extension methods. -- Keep semantic equivalence; call out any intentional deltas. - -### Verification - -- Run relevant tests. -- Confirm generated files still compile. -- Confirm no `CodeWriter` scope leaks (`OpenScopeCount == 0` when materialized). - -### Output format - -Return: - -1. Files changed -2. Why each change was necessary -3. Risks/behavior differences (if any) -4. Verification performed diff --git a/.agents/prompts/sdk-diagnose-agent-folder-copy.md b/.agents/prompts/sdk-diagnose-agent-folder-copy.md deleted file mode 100644 index e234df9..0000000 --- a/.agents/prompts/sdk-diagnose-agent-folder-copy.md +++ /dev/null @@ -1,37 +0,0 @@ -# sdk-diagnose-agent-folder-copy (generic prompt spec) - -Diagnose why the bundled `.agents/**` folder from `Purview.BuildSdk` did not appear at the expected -destination in a consuming repository. - -## Required behaviour - -1. Confirm the NuGet package actually contains `.agents/**` content (inspect the `.nupkg` if available). -2. Confirm the consuming project is packable/buildable and imports the SDK via - `Sdk.props`/`Sdk.targets`, since the copy runs in `EnsureAgentFolderInPackageTarget` before build. -3. Check `EnableAgentFolderInPackage` is not set to `false` anywhere in the build (project file, - `Directory.Build.props`, or command-line `-p:` overrides). -4. Confirm the destination folder: default is `.agents` at the repo root, overridable per-build with - `-p:AgentPackDestinationFolder=`, and the source defaults to the package-level `.agents` - folder (override with `PurviewAgentFolderSourcePath`). -5. Verify repo-root discovery succeeded: explicit `RepoRoot`, then a nearby `AGENTS.md`, then source-control - root metadata. -6. If the destination looks stale or incomplete, inspect the change-detection manifest - (`/.purview/agent-sync.cache`). It lists the files the SDK believes it already mirrored; a - matching entry with a present destination file means the sync was skipped as up to date. Delete the - manifest (or the affected destination file) to force a fresh copy. -7. Retry notices are demoted to low-importance messages, so a healthy build shows no `MSB3026` warnings. - A copy that still fails after every retry is reported as an error naming the source, destination and - OS error - search the build log for `The Purview SDK could not copy`. Temporarily set - `PurviewAgentFolderCopyRetries` / `PurviewAgentFolderCopyRetryDelayMilliseconds` to retry longer, and - `PurviewSuppressCopyRetryWarnings=false` to see every retry attempt. -8. Re-run the build and confirm the destination folder now contains the copied files (including the - generated `.gitignore` for skill/prompt/agent subfolders). - -## Suggested output - -- A short root-cause explanation (missing import, disabled flag, wrong destination override, repo-root - discovery miss, or a destination held open by another process). -- The exact command used to reproduce/verify the fix (for example - `dotnet build -p:AgentPackDestinationFolder=`). -- Confirmation that the expected files exist at the resolved destination path, plus whether the - manifest skipped the sync (in which case the content was already up to date). diff --git a/.agents/prompts/sdk-review-repository-shape.md b/.agents/prompts/sdk-review-repository-shape.md deleted file mode 100644 index eb089c9..0000000 --- a/.agents/prompts/sdk-review-repository-shape.md +++ /dev/null @@ -1,33 +0,0 @@ -# sdk-review-repository-shape (generic prompt spec) - -Review a repository that uses `Purview.BuildSdk` for naming, placement, and test-structure alignment. - -## Required behaviour - -1. Inspect the repository's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, and - project layout before making assumptions. -2. Identify whether the repository follows the SDK-friendly structure: - - source projects under `src/` - - test projects under `tests/` - - `.csproj` filenames matching directory names - - short project names with `NamespacePrefix` carrying the repo identity -3. Check whether test project names use recognised `*Tests` suffixes and whether shared/shared-testing - projects use exact SDK-recognised names. -4. Explain the consequences of deviations in terms of automatic `RootNamespace`, `AssemblyName`, - `PackageId`, `TargetProjectName`, and automatic project references. -5. Review test readability conventions: - - subject-based `{SubjectName}Tests` - - subject-based `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` method names - - non-subject-based suites named clearly for their broader role - - appropriate use of TUnit categories and display names -6. Distinguish between: - - acceptable existing variance worth preserving - - structural debt that blocks the SDK's automatic behavior - - incremental rationalisation opportunities - -## Suggested output - -- A concise summary of whether the repo broadly fits the SDK conventions. -- A list of concrete mismatches, ordered by impact. -- A list of low-risk rationalisation steps for naming, placement, identity, or test readability. -- Explicit note of which behaviors are already automatic defaults and which require manual configuration. diff --git a/.agents/skills/project-placement-defaults/.gitignore b/.agents/skills/project-placement-defaults/.gitignore deleted file mode 100644 index 2799754..0000000 --- a/.agents/skills/project-placement-defaults/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/sdk-configuration-reference/.gitignore b/.agents/skills/sdk-configuration-reference/.gitignore deleted file mode 100644 index 2799754..0000000 --- a/.agents/skills/sdk-configuration-reference/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/sdk-engineering-principles/.gitignore b/.agents/skills/sdk-engineering-principles/.gitignore deleted file mode 100644 index 2799754..0000000 --- a/.agents/skills/sdk-engineering-principles/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/sdk-project-behavior-and-detection/.gitignore b/.agents/skills/sdk-project-behavior-and-detection/.gitignore deleted file mode 100644 index 2799754..0000000 --- a/.agents/skills/sdk-project-behavior-and-detection/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/source-generator-codewriter-modernization/.gitignore b/.agents/skills/source-generator-codewriter-modernization/.gitignore deleted file mode 100644 index 2799754..0000000 --- a/.agents/skills/source-generator-codewriter-modernization/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/source-generator-testing/.gitignore b/.agents/skills/source-generator-testing/.gitignore deleted file mode 100644 index 2799754..0000000 --- a/.agents/skills/source-generator-testing/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/tunit-test-authoring/.gitignore b/.agents/skills/tunit-test-authoring/.gitignore deleted file mode 100644 index 2799754..0000000 --- a/.agents/skills/tunit-test-authoring/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 92202d1..bf706c4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ true 5.9.0 1.68.17 - 1.0.0-prerelease.54 + 1.0.0 2.0.1-prerelease.1 diff --git a/global.json b/global.json index e7065ab..b56cb0f 100644 --- a/global.json +++ b/global.json @@ -1,9 +1,9 @@ { "sdk": { - "allowPrerelease": false + "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.2.2" + "Purview.BuildSdk": "1.0.3" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/src/src/ValueObjects/ValueObjects.csproj b/src/src/ValueObjects/ValueObjects.csproj index 5a205d4..4884a53 100644 --- a/src/src/ValueObjects/ValueObjects.csproj +++ b/src/src/ValueObjects/ValueObjects.csproj @@ -1,8 +1,11 @@ true - net8.0;net9.0;net10.0 - + + All Purview Value Objects Source-generated scalar and complex value objects for .NET. Brings F#-style single-case types to C# with Create/Hydrate factories, normalization, validation, equality, comparison, implicit conversions, and JSON serialization for DTOs, Entity Framework, and domain models. $(PackageTags);purview;dotnet;value-objects;value-object;scalar;source-generator;domain-driven-design;dto;serialization;json;entity-framework; diff --git a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj index 3ffc6c2..e9907e4 100644 --- a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj +++ b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj @@ -1,12 +1,16 @@ + net9.0, and the centrally managed version covers net10.0 and net11.0. The generated code must + compile and run against every one of them, which is what makes the EF Core 8 API gates verifiable. + TargetFramework is cleared because the build SDK applies its single-framework default before this + project body is evaluated. --> - net8.0;net9.0;net10.0 + + net8.0;net9.0;net10.0;net11.0 @@ -22,7 +26,10 @@ - + + From 4b41aac3746f696a89c258aa6abf9adfd49c885f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 7 Oct 2026 00:14:33 +0100 Subject: [PATCH 09/11] ci: install the .NET 11 SDK now that the package targets net11.0 Adding net11.0 to Purview.ValueObjects left a gap in CI that does not show up locally: the shared purview-build/purview-release workflows default to the 10.0.x SDK, which cannot build a net11.0 target (NETSDK1045). It built here only because the .NET 11 RC SDK is installed on the development machine. global.json now pins the same SDK version the other net11.0 repositories pin, so the choice is deterministic rather than "whatever prerelease is newest", and both workflows pass it to the shared jobs. The exact RC version is used rather than 11.0.x because the shared workflows expose no dotnet-quality input, so a floating 11.0.x would not resolve a prerelease. Verified against the pinned SDK: build clean, 206 tests passing. --- .github/workflows/pr.yml | 3 +++ .github/workflows/release.yml | 3 +++ global.json | 2 ++ 3 files changed, 8 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7f1aed0..7d00df6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -13,6 +13,9 @@ jobs: name: Build and test uses: purview-dev/build/.github/workflows/purview-build.yml@main with: + # The shared workflow defaults to the 10.0.x SDK, which cannot build the net11.0 target this + # repository gained (NETSDK1045). Keep in sync with `sdk.version` in global.json. + dotnet-version: "11.0.100-rc.1.26425.128" run-pack: true validate-pack: true secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 721e51a..bc58704 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,9 @@ jobs: name: Release packages uses: purview-dev/build/.github/workflows/purview-release.yml@main with: + # The shared workflow defaults to the 10.0.x SDK, which cannot build the net11.0 target this + # repository gained. Keep in sync with `sdk.version` in global.json (and with pr.yml). + dotnet-version: "11.0.100-rc.1.26425.128" release-mode: NuGet release-branch: main secrets: inherit \ No newline at end of file diff --git a/global.json b/global.json index b56cb0f..2811ad4 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,7 @@ { "sdk": { + "version": "11.0.100-rc.1.26425.128", + "rollForward": "latestMajor", "allowPrerelease": true }, "msbuild-sdks": { From c5c73617ae5b850ac6e54c59986637a81ed5e0ad Mon Sep 17 00:00:00 2001 From: Kieron Date: Wed, 7 Oct 2026 07:45:10 +0100 Subject: [PATCH 10/11] chore: updated ZodSharp to 2.0.1 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index bf706c4..4e1caca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ 5.9.0 1.68.17 1.0.0 - 2.0.1-prerelease.1 + 2.0.1 From 319306a0ac46a2dcc87e92f52411477d37864fd6 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 7 Oct 2026 07:46:29 +0100 Subject: [PATCH 11/11] chore: bumped version --- .../project-placement-defaults/SKILL.md | 132 +++++++ .../sdk-configuration-reference/SKILL.md | 204 ++++++++++ .../sdk-engineering-principles/SKILL.md | 94 +++++ .../SKILL.md | 214 ++++++++++ .../SKILL.md | 324 ++++++++++++++++ .../skills/source-generator-testing/SKILL.md | 364 ++++++++++++++++++ .agents/skills/tunit-test-authoring/SKILL.md | 214 ++++++++++ package.json | 2 +- 8 files changed, 1547 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/project-placement-defaults/SKILL.md create mode 100644 .agents/skills/sdk-configuration-reference/SKILL.md create mode 100644 .agents/skills/sdk-engineering-principles/SKILL.md create mode 100644 .agents/skills/sdk-project-behavior-and-detection/SKILL.md create mode 100644 .agents/skills/source-generator-codewriter-modernization/SKILL.md create mode 100644 .agents/skills/source-generator-testing/SKILL.md create mode 100644 .agents/skills/tunit-test-authoring/SKILL.md diff --git a/.agents/skills/project-placement-defaults/SKILL.md b/.agents/skills/project-placement-defaults/SKILL.md new file mode 100644 index 0000000..1c4ff1c --- /dev/null +++ b/.agents/skills/project-placement-defaults/SKILL.md @@ -0,0 +1,132 @@ +--- +name: project-placement-defaults +description: "Use when creating, moving, or splitting projects in a repository that uses Purview.BuildSdk, especially for src/tests placement, test suffix naming, namespace alignment, and automatic project-reference behavior." +--- + +# Project placement defaults for Purview.BuildSdk + +Use this skill whenever a task asks to add, move, split, or create a project in a repository that uses `Purview.BuildSdk` and you need placement, naming, and reference decisions to remain consistent with the SDK's automatic conventions. + +## Core principle + +Preserve the host repository's existing layout first; only introduce new structure when no established pattern exists. In repositories that use `Purview.BuildSdk`, prefer layouts that let the SDK's naming and auto-reference rules work without extra overrides. + +Treat naming and placement as configuration, not decoration. + +## Placement heuristics + +Use the repository's current structure as the source of truth, with these Purview-friendly defaults: + +1. Prefer source projects under `src/`. +2. Prefer test projects under `tests/`. +3. Place new projects beside similar projects (same language, layer, and test type). +4. Keep one test type per project by default. +5. Keep shared helper projects in explicit shared/shared-testing locations when those concepts exist. + +When a repo has no clear structure, use these conservative defaults because they align well with the SDK's automatic project-reference search paths: + +- Source/library projects under `src/` +- Test projects under `tests/` +- Integration/end-to-end tests in explicit sibling projects/folders such as `tests/Api.IntegrationTests/` or `tests/Api.E2ETests/` + +## Naming rules + +The SDK relies heavily on project names. + +- Keep the `.csproj` filename equal to its containing directory name unless `DisableProjectFileNamingConventionCheck=true` is explicitly used. +- Use the common conventional test suffixes by default: `.UnitTests`, `.IntegrationTests`, `.E2ETests`, `.FunctionalTests`, `.ContractTests`. +- Use other supported `*Tests` suffixes only when the test type itself carries important operational meaning. +- Keep shared helper projects on the SDK's exact recognized names when you want shared behavior: + - Shared projects: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` + - Shared testing projects: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, `SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, `SharedTestingHelpers` +- Do not invent near-miss names if you expect the SDK to classify the project automatically. + +## Test-type boundaries + +Separate tests by behavior and dependency scope: + +- **Unit tests**: isolate logic with minimal external dependencies. +- **Integration tests**: verify behavior across component boundaries (I/O, framework integration, build/evaluation behavior). +- **End-to-end/system tests**: verify full workflow behavior across the assembled system. + +If specialized test categories exist (for example, analyzer diagnostics vs code-fix integration), keep category-specific tests in distinct projects/folders. + +The detected test type also becomes the baseline test category. Additional categories remain available and +should be added when they improve discoverability. + +The SDK recognizes many test suffixes, including `Unit`, `Integration`, `E2E`, `EndToEnd`, `Acceptance`, `Functional`, `Performance`, `Load`, `Smoke`, `Stress`, `Regression`, `Security`, `Chaos`, `Scenario`, `System`, `Threat`, `BlackBox`, `WhiteBox`, `Accessibility`, `Interactive`, `Environment`, `Architecture`, and `Contract`. + +## Naming and namespace defaults + +Align identities with existing repository conventions: + +- Project names should follow prevailing patterns in sibling projects. +- Test project names should clearly indicate scope/type with recognized test suffixes. +- `NamespacePrefix` should remain the root identity source for the repo. +- `RootNamespace` usually flows from the logical project identity generated by the SDK; avoid custom namespace overrides unless required. +- `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — or to the full logical project name when suffix-stripping removed a segment (e.g. `Shared`, `ServiceDefaults`) — so a project's package/assembly identity follows its namespace and stays distinct, unless the repo explicitly overrides `AssemblyName`/`PackageId`/`RootNamespace` or opts out via `EnableAssemblyNameGeneration=false`. +- When moving files between projects, update namespaces so they match the destination project's conventions. + +Do not invent a new naming scheme when an existing one is already in use. + +## Project defaults + +When creating a new project: + +1. Match the SDK/project style used by sibling projects. +2. Reuse central dependency/version management if present. +3. Add only dependencies required for the project's scope. +4. Add the project to the repository solution/workspace entry point. +5. Keep configuration consistent with neighboring projects (target frameworks, nullable, analyzers, warnings). + +When working in a Purview-based repo, also assume: + +- `TargetFramework` defaults to `net10.0` if not otherwise set, or `netstandard2.0` when the project explicitly declares `IsRoslynComponent=true`. +- Test projects receive framework packages and coverage defaults from the SDK. +- Standard test projects receive `TUnit`, `TUnit.Mocks`, `Bogus`, and Microsoft.Testing.Platform wiring by default. +- Non-test projects receive SourceLink and telemetry defaults unless explicitly opted out. + +## Test readability defaults + +When organizing tests: + +- Prefer one subject-focused `{SubjectName}Tests` class per owned subject. +- Use `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` for subject-based test methods. +- Treat constructors, properties, operators, conversions, and validation hooks as valid subjects. +- Allow broader suite names for non-subject-based tests such as build, generator, workflow, package, or + full-system suites. +- Use TUnit display names and categories to keep large suites readable. + +## Move/split workflow checklist + +When splitting or relocating tests/projects: + +1. Create destination project/folder using established layout patterns. +2. Move files physically. +3. Update namespaces/imports/references for the destination. +4. Verify the destination project name still produces the intended `TestingType`, `TargetProjectName`, and `RootNamespace`. +5. Remove stale dependencies from the source project. +6. Update solution/workspace membership and project references. +6. Run build and relevant tests. + +## Automatic project-reference behavior to preserve + +The SDK automatically searches for project references based on naming and placement. + +- Test projects probe for their target project in these relative locations: + - `../$(TargetProjectName)/$(TargetProjectName).csproj` + - `../../$(TargetProjectName)/$(TargetProjectName).csproj` + - `../src/$(TargetProjectName)/$(TargetProjectName).csproj` + - `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` +- Non-test projects automatically look for sibling shared projects via `../Shared*/Shared*.csproj`. +- Test projects automatically look for sibling shared-testing projects via `../SharedTesting*/SharedTesting*.csproj`. + +If you move projects away from these conventions, be prepared to add explicit project references. + +## Guardrails + +- Prefer minimal, targeted diffs. +- Avoid cross-cutting renames unrelated to the move/split intent. +- Keep test intent unchanged while relocating. +- If structure is ambiguous, infer from nearest sibling projects and document the assumption in the change summary. +- When in doubt, preserve compatibility with the SDK's automatic naming, namespace, and project-reference behavior. diff --git a/.agents/skills/sdk-configuration-reference/SKILL.md b/.agents/skills/sdk-configuration-reference/SKILL.md new file mode 100644 index 0000000..5dd68c6 --- /dev/null +++ b/.agents/skills/sdk-configuration-reference/SKILL.md @@ -0,0 +1,204 @@ +--- +name: sdk-configuration-reference +description: "Use when configuring Purview.BuildSdk through Directory.Build.props or a .csproj, especially for NamespacePrefix, version detection, testing framework selection, telemetry, repo bootstrapping, and embedded agent-skill settings." +--- + +# Purview.BuildSdk configuration reference + +Use this skill when a task asks what can be configured in `Purview.BuildSdk`, where a property must be set, or which defaults the SDK applies automatically. + +## First rule: know where a property must be set + +Set repo-wide bootstrap properties **before** importing the SDK in `Directory.Build.props` when the value must affect `Sdk.props` evaluation. + +Common pre-import properties: + +- `NamespacePrefix` +- `UsePackageJsonVersion` +- `RootPackageJson` +- Repo-wide testing framework selection properties when you want every project to inherit them + +If a property changes behavior in `Sdk.targets` instead, it can usually be set later (for example in a project file), but prefer repo-wide defaults in `Directory.Build.props` unless the scenario is intentionally project-specific. + +## Version detection settings + +These properties control package/app version resolution from `package.json`: + +- `UsePackageJsonVersion` — default `true`; supported values: `true`, `false`, `Strict` +- `RootPackageJson` — explicit path to the `package.json` to read +- `EnableVersionDetectionCache` — default `true`; enables local caching of resolved version data +- `VersionDetectionCacheFile` — optional explicit cache file path +- `VersionDetectionLogEnabled` — default `false`; set to `true` to log the detected package version + +Behavior rules: + +1. If `RootPackageJson` is set, the SDK uses that path. +2. Otherwise it tries to discover the repo root from CI variables, `.git`, or a nearby `package.json`. +3. When version detection succeeds, both `Version` and `PackageVersion` are set from the `version` field. +4. `UsePackageJsonVersion=Strict` should be treated as “fail if discovery/resolution cannot succeed”. + +## Core identity and build settings + +These are the most important configurable properties exposed by the SDK: + +- `NamespacePrefix` — required unless `DisableNamespacePrefixCheck=true` +- `DisableNamespacePrefixCheck` — default `false` +- `DisablePurviewStylePolicyValidation` — default `false`; set to `true` to stop the build failing (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy (`dotnet_style_require_accessibility_modifiers` other than `omit_if_default`), hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field naming rule, or adds the accessibility rules to `NoWarn`. Entries the SDK injects itself (the test-context rule set for test/shared-testing projects, `CA1515` for Aspire hosts and CLI apps) are ignored +- `TargetFramework` — defaults to `net10.0` when neither `TargetFramework` nor `TargetFrameworks` is set; projects explicitly declaring `IsRoslynComponent=true` default to `netstandard2.0` +- `IsRoslynComponent` — when explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, compiler-generated output under the framework-specific intermediate directory, telemetry exclusion, and `PrivateAssets=all` applied to `Microsoft.CodeAnalysis.*` / `Microsoft.CodeAnalysis.Analyzers` references. Packable Roslyn components automatically pack the built analyzer assembly and PDB into `analyzers/dotnet/cs/`; a pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true` +- `IsRoslynComponentOnly` — defaults to `true` for Roslyn components and creates an analyzer-only package: it sets `IncludeBuildOutput=false`, `IncludeSymbols=false`, and packages the portable PDB alongside the analyzer under `analyzers/dotnet/cs/`. Set it to `false` for a dual-role Roslyn component that uses normal library symbol packaging. +- `PackProjectReferencedSourceGenerators` — default `true`; packable projects automatically include analyzer `ProjectReference` outputs and runtime dependencies under `analyzers/dotnet/cs/`. Set it to `false` globally or use `Pack="false"` on one analyzer reference to opt out. +- `EnableAssemblyNameGeneration` — default `true`; when `true`, `AssemblyName` and default `PackageId` follow the fully evaluated `RootNamespace` (or the full logical project name when suffix-stripping removed a segment, e.g. `Shared`/`ServiceDefaults`). Set `false` before the SDK import to use the standard project-name behaviour +- `PurviewSharedTestingOutputType` — default `Library`; forced onto `IsSharedTestingProject` projects (with `IsTestProject`/`IsTestingPlatformApplication` cleared), because the test packages otherwise flip them into an executable test host. Set it to `Exe` before the SDK import to keep that package-driven shape +- `PurviewTestContextNoWarn` — default `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707`; the production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`), but are context aware: public test classes/fixtures, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Override before the SDK import to narrow or extend the set +- `DisablePurviewTestContextRuleSet` — default `false`; set to `true` to make test and shared-testing projects enforce the production API-surface rules as well +- `DisableProjectFileNamingConventionCheck` — default `false`; disables the directory-name/file-name match validation +- `DisableGenerateAssemblyInfoClass` — default `false`; disables generated `AssemblyInfo` +- `DisableAutoInternalsVisibleTo` — default `false`; disables automatic friend assembly generation +- `AutoIncludeUsings` — default `true`; controls SDK-added global usings +- `SourceLinkPackageName` — default `Microsoft.SourceLink.GitHub` +- `DisableSourceLink` — default `false` + +## Telemetry and package-related settings + +- `ExcludePurviewTelemetry` — default `false`; removes `Purview.Telemetry.SourceGenerator` +- `ExcludeMSTelemetryExtension` — default `false`; removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — when `ExcludePurviewTelemetry=true` the whole telemetry group is skipped anyway +- `IsPackable` — defaults to `false` if not set elsewhere +- `PackageTags`, `IncludeSource`, `IncludeSymbols`, `PublishRepositoryUrl`, `SymbolPackageFormat` — standard pack-related settings the SDK participates in for packable projects +- Packable-project defaults (only applied when the consuming project has not supplied a value): `GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does not receive PDB files unless the project opts in explicitly. Roslyn-component-only packages default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/` instead +- If the repo root is discoverable, the repository-root `README.md` is packed automatically (and registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` was not configured explicitly + +## Test framework settings + +The SDK supports opinionated testing defaults and validation. + +Primary settings: + +- `TestingFramework` — default `TUnit`; supported values: `TUnit`, `Xunit`, `None` +- `SubstituteFramework` — default `TUnitMocks`; supported values: `TUnitMocks`, `NSubstitute`, `None` +- `TestDataFramework` — default `Bogus`; supported values: `Bogus`, `None` + +Default outcome for standard test projects: + +- `TUnit` +- `TUnit.Mocks` +- `Bogus` +- Microsoft.Testing.Platform integration + +Specialised packages such as `TUnit.Aspire` and `Testcontainers` are not automatic defaults; they remain +explicit choices based on the project's purpose. + +Related toggles and derived settings: + +- `CollectCoverage` — defaults to `true` for detected test projects +- `EnableStaticNativeInstrumentation` — defaults to `false` for test projects +- `EnableDynamicNativeInstrumentation` — defaults to `false` for test projects +- `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` — enabled automatically for TUnit test projects + +## Repo bootstrap and developer-experience settings + +These settings control the SDK’s repo-level helper file bootstrapping: + +- `DisableAutoCopySdkFiles` — default `false`; master switch for SDK-managed repo file copying +- `BootstrapEditorConfigToRepoRoot` — default `true` +- `RepositoryEditorConfigFilePath` — optional override for the destination `.editorconfig` +- `BootstrapGlobalJsonToRepoRoot` — default `true` +- `RepositoryGlobalJsonFilePath` — optional override for the destination `global.json` +- `PurviewBuildSdkVersionForGlobalJson` — defaults to detected SDK package version, fallback `1.0.0` +- `PurviewAutoSdkPack` — default `true`; when `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths +- `EnableAgentFolderInPackage` — default `true`; mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repo’s `.agents/` +- `AgentPackDestinationFolder` — default `.agents`; repo-relative destination folder that receives mirrored agent content as `$(AgentPackDestinationFolder)/**` +- `PurviewAgentFolderSourcePath` — overrides the folder that provides the bundled `.agents` content (defaults to the package-level `.agents` folder beside `Sdk/`) +- `PurviewAgentFolderCopyRetries` — default `3`; copy attempts per file before a failure is reported +- `PurviewAgentFolderCopyRetryDelayMilliseconds` — default `500`; base delay between copy attempts +- `PurviewAgentFolderCopyFailureAsError` — default `true`; when `false`, a copy that still fails after every retry is a warning instead of an error +- `PurviewAgentSyncManifestPath` — overrides the change-detection manifest (default `/.purview/agent-sync.cache`) used to skip unchanged agent content +- `PurviewSuppressCopyRetryWarnings` — default `true`; demotes built-in copy task retry notices (`MSB3026`) to messages. Set to `false` to see every retry attempt + +## Shared repository copy behaviour + +`.agents` content, `.editorconfig` and `global.json` live in one repository-wide location but are written +by every project, so parallel builds race for the same destinations. The SDK therefore: + +1. Skips unchanged content using the `.purview/agent-sync.cache` manifest (fingerprint + content hash per file), so repeat builds touch nothing. This also detects an in-place package republish that keeps the same version. +2. Stages every write into a temporary file in the destination folder and renames it into place, so readers never see partial content and writers cannot interleave. +3. Retries quietly — retry attempts are low-importance messages, `MSB3026` notices are demoted — and only reports a copy that still fails after `Purview*CopyRetries` attempts, as an error by default. +4. Treats "another project already wrote identical content" as success, so a lost race is a no-op instead of a failure. + +`PurviewRepoBootstrapMode` (`IfMissing` default, or `Always`/`WarnOnDrift`/`Never`) controls whether +existing `.editorconfig`/`global.json` files may be overwritten or reported as drifted. + +**Hard requirement:** This SDK must pack the contents of `Sdk/` into the NuGet package so that downstream consumers of `Purview.BuildSdk` receive the same `Sdk/**` files. The `PurviewAutoSdkPack` feature (default `true`) is the mechanism that delivers this for standard consuming projects. When a project is packable, the SDK automatically adds `Sdk/**/*` as package content with the correct root-level paths: + +- `Sdk/.agents/**` → `.agents/**` +- `Sdk/.github/**` → `.github/**` +- `Sdk/build/**` → `build/**` +- `Sdk/buildTransitive/**` → `buildTransitive/**` +- `Sdk/buildMultiTargeting/**` → `buildMultiTargeting/**` +- `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. → package root +- everything else under `Sdk/` → `Sdk/` + +The SDK injects a `.gitignore` file into each second-level folder under `Sdk/.agents` during packaging with the following content: + +```text[.gitignore] +# Ignore all files +* + +# Don't ignore directories, so Git can traverse them +!*/ + +# Keep this file +!.gitignore +``` + +This lets consuming repos keep the agent folder structure discoverable while ignoring the copied content in Git. + +## Important derived properties you can inspect + +When explaining SDK behavior, prefer these derived values over guessing: + +- `PurviewLogicalProjectName` +- `PurviewNamespacePrefix` +- `PurviewProjectShortName` +- `PurviewTestType` +- `PurviewSharedTestingOutputType` (default `Library`; `Exe` keeps the test packages' executable/test-host shape) +- `PurviewTestContextNoWarn` (production API-surface rules exempted in test/shared-testing projects) +- `PurviewPolicyExemptNoWarn` (SDK-injected `NoWarn` entries that `ValidatePurviewStylePolicy` accepts) +- `RootNamespace` +- `AssemblyName` +- `PackageVersion` +- `TestingType` +- `TargetProjectName` +- `RepoRoot` +- `RootPackageJson` + +## Compiler-visible properties + +The SDK exports many properties for analyzers and source generators through `build_property.`. When authoring analyzers or generators, prefer those exported properties instead of re-deriving SDK behavior manually. + +Especially relevant exported properties include: + +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` +- `NamespacePrefix`, `DisableNamespacePrefixCheck` +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` +- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` +- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `TestingType`, `TargetProjectName` +- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` +- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopyRetries`, `PurviewAgentFolderCopyRetryDelayMilliseconds`, `PurviewAgentFolderCopyFailureAsError`, `PurviewAgentSyncManifestPath`, `PurviewSuppressCopyRetryWarnings` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile` + +## Guidance for edits + +When changing SDK configuration: + +1. Preserve existing defaults unless the task explicitly changes product behavior. +2. Keep README, SDK property declarations, validation, and any shipped skills aligned. +3. If you add a new user-facing property, update both the configuration docs and the bundled skills. +4. If the property affects import-time behavior, document that it must be set before the SDK import. +5. Keep repository policy guidance aligned with the engineering-principles documentation, and keep low-level + property explanations aligned with the wiki reference pages. diff --git a/.agents/skills/sdk-engineering-principles/SKILL.md b/.agents/skills/sdk-engineering-principles/SKILL.md new file mode 100644 index 0000000..c5c3140 --- /dev/null +++ b/.agents/skills/sdk-engineering-principles/SKILL.md @@ -0,0 +1,94 @@ +--- +name: sdk-engineering-principles +description: "Use when creating or rationalising a repository that uses Purview.BuildSdk and you need the policy-level conventions for project placement, naming, namespace identity, test categories, and large-suite readability." +--- + +# Purview.BuildSdk engineering principles + +Use this skill when the question is not just "what property does the SDK set?" but "how should this +repository be structured so the SDK can work predictably?" + +## First principle + +Treat naming, placement, and test structure as configuration. + +The SDK infers namespaces, identities, categories, package wiring, and project references from a small set +of conventions. The more a repository follows those conventions, the less it needs bespoke overrides. + +## Canonical defaults + +- Prefer a `src/` + `tests/` split for new repositories. +- Keep solution entry points under `src/{SolutionName}.slnx`. +- Keep source projects under `src/src/{ProjectName}/{ProjectName}.csproj`. +- Keep test projects under `src/tests/{ProjectName}.{TestType}Tests/{ProjectName}.{TestType}Tests.csproj`. +- Keep the `.csproj` filename equal to its containing directory name. + +## Identity rules + +- `NamespacePrefix` is the root identity source. +- Use short project names; let the SDK apply the prefix. +- `RootNamespace` is the canonical code identity by default. +- `AssemblyName` and `PackageId` usually follow the resolved project identity. +- When suffix stripping would collapse distinct artifacts, `AssemblyName` and `PackageId` keep the fuller + logical identity. + +Examples: + +- `NamespacePrefix=Aspire`, project `Hosting` -> `Aspire.Hosting` +- `NamespacePrefix=Acme.Sales.RegionalPipeline`, project `Identity.API` -> + `Acme.Sales.RegionalPipeline.Identity.API` +- `NamespacePrefix=Acme.Sales.RegionalPipeline`, project `Identity.Core` -> `RootNamespace` + `Acme.Sales.RegionalPipeline.Identity`, but a distinct assembly/package identity that keeps `Core` + +## Common test project types + +Prefer these by default: + +- `UnitTests` +- `IntegrationTests` +- `E2ETests` +- `FunctionalTests` +- `ContractTests` + +The SDK supports more suffixes, but use them only when the test type itself is important enough to carry in +the project name. + +## Test categories + +- The detected test type becomes the baseline category automatically. +- Additional categories are allowed. +- Add more categories when they improve discoverability for large suites. + +## Default test stack + +Standard test projects receive these by default: + +- `TUnit` +- `TUnit.Mocks` +- `Bogus` +- Microsoft.Testing.Platform integration + +Specialized additions remain explicit: + +- `TUnit.Aspire` for Aspire lifecycle/AppHost-backed integration tests +- `Testcontainers` for container-backed integration tests + +## Test readability rules + +For subject-based tests: + +- Prefer `{SubjectName}Tests` for the class. +- Prefer `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` for methods. +- Treat methods, constructors, properties, operators, conversions, and validation hooks as valid subjects. + +For non-subject-based suites: + +- Broader names are allowed when they are more truthful and readable. +- Use TUnit display names, categories, and data-driven metadata to keep the suite navigable. + +## When to use this skill vs others + +- Use this skill for policy, structure, naming, and repository-shape questions. +- Use `sdk-project-behavior-and-detection` for "why did the SDK classify this project this way?" +- Use `sdk-configuration-reference` for property-level questions. +- Use `project-placement-defaults` when physically creating or moving projects. diff --git a/.agents/skills/sdk-project-behavior-and-detection/SKILL.md b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md new file mode 100644 index 0000000..9961d00 --- /dev/null +++ b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md @@ -0,0 +1,214 @@ +--- +name: sdk-project-behavior-and-detection +description: "Use when explaining why Purview.BuildSdk classified a project as test, shared, CLI, web, Aspire host, or container, or when reasoning about auto-added packages, project references, namespaces, and naming conventions." +--- + +# Purview.BuildSdk project behavior and detection + +Use this skill when a task asks **why** the SDK applied a behavior automatically, or when adding/moving projects in a repo that relies on the SDK's naming and project-type inference. + +## Project-type detection rules + +The SDK infers behavior from project names, project contents, and SDK declarations. + +### Test detection + +A project is treated as a test project when its name ends with `*Test` or `*Tests` and the suffix before `Test(s)` matches a supported testing type such as: + +- `Unit` +- `Integration` +- `E2E` +- `EndToEnd` +- `Acceptance` +- `Functional` +- `Performance` +- `Load` +- `Smoke` +- `Stress` +- `Regression` +- `Security` +- `Chaos` +- `Scenario` +- `System` +- `Threat` +- `BlackBox` +- `WhiteBox` +- `Accessibility` +- `Interactive` +- `Environment` +- `Architecture` +- `Contract` + +Derived properties: + +- `IsTestProject=true` +- `TestingType=` +- `PurviewTestType=Tests` +- `TargetProjectName=` + +### Shared project detection + +The SDK recognizes shared project names exactly. These are not generic substring matches. + +Shared project names: + +- `Shared` +- `SharedFramework` +- `SharedInfrastructure` +- `SharedInfra` +- `SharedUtilities` +- `SharedUtils` +- `SharedLibrary` +- `SharedLib` +- `SharedHelpers` + +Shared testing project names: + +- `SharedTestingFramework` +- `SharedTestingInfrastructure` +- `SharedTestingInfra` +- `SharedTestingUtilities` +- `SharedTestingUtils` +- `SharedTestingLibrary` +- `SharedTestingLib` +- `SharedTestingHelpers` + +Derived flags: + +- `IsSharedProject` +- `IsSharedTestingProject` + +### SDK/content-based detection + +- `IsSdkProject` / `SdkProjectName` come from parsing the project/import `Sdk="..."` declaration +- `IsWebSdkProject=true` for `Microsoft.NET.Sdk.Web` +- `IsWorkerSdkProject=true` for `Microsoft.NET.Sdk.Worker` +- `IsAspireHostProject=true` when the SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` +- `IsContainerProject=true` when `Dockerfile`, `dockerfile`, or `Dockerfile.dev` exists in the project directory +- `IsCLIProject=true` when the project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, or `QuickStarts` + +## Namespace and identity behavior + +The SDK derives the project identity from `NamespacePrefix` and the project name. + +Key behavior: + +1. `PurviewLogicalProjectName` is built from `NamespacePrefix` plus the project name, with deduplication when the project name already starts with the namespace tail. +2. `RootNamespace` defaults to `PurviewLogicalProjectName`. +3. Known suffixes are stripped from `RootNamespace`, including shared/shared-testing names and common segments like `Core`, `EF`, `Shared`, `ClientShared`, and `ServiceDefaults`. +4. Test suffixes are removed from `RootNamespace`, so `Acme.Api.UnitTests` still maps back to `Acme.Api`. +5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` (the canonical default public name) — except when suffix-stripping removed a segment of the logical project name (e.g. `Shared` or `ServiceDefaults`), in which case they use the full `PurviewLogicalProjectName` so those assemblies/packages stay distinct from their parent. Test/shared-testing projects keep their detected suffix in `AssemblyName`/`PackageId` so test assemblies stay distinct. Explicit `AssemblyName`/`PackageId`/`RootNamespace` values always win. +6. The naming defaults are applied during `Sdk.props` evaluation (before the Microsoft SDK computes `TargetName`), so the compiled output name always matches `AssemblyName`. +7. The SDK ships `Purview.BuildSdk.Analyzers` and adds it as an `` item to every C# project, so its rules (PDS0002 Extensions namespace, PDS0003 explicit types with target-typed `new()`, PDS0004 correct acronym capitalization) surface in both command-line builds and Visual Studio. The code-fix assembly ships beside it and is referenced as an `` item inside Visual Studio so the IDE discovers its code fixes. `PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Guid`, `Uuid`, `Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, ...) and exempts them by default — `Db` is exempt so `DbContext`/`DbConnection`/`DbSet` are never flagged (re-enable via `acronym_map = Db:DB`); a small set — `Api`, `Ai`, `Ui`, `Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh` — is still renamed to uppercase. Members mandated by a contract (interface implementations, base-class overrides) are never renamed. Customise via `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), `.acronym_map` (segment renames, e.g. `Sql:SQL`), and `.allowed_identifiers` (brand names exempted by exact name or word-boundary prefix, first match wins — e.g. `CosmosDb` covers `CosmosDbServer`). All three merge with and override the shipped defaults. The shipped `.editorconfig` treats generated content (`Migrations/`, `*.g.cs`, `Generated/`, `*.Designer.cs`, `obj/`/`bin/`) as `generated_code = true`, and suppresses namespace-conflict diagnostics under `Extensions/` so no `#pragma` suppressions are needed there. A VS code refactoring (`Split extensions class into one class per receiver type`) is offered on static extensions classes that target multiple receiver types: it splits them into one `Extensions` class per receiver (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes `HostApplicationBuilderExtensions`) and places each under `Extensions//` so the PDS0002 convention stays satisfied. A companion refactoring (`Move extensions class to conventional location`) is offered on single-receiver extensions classes that are misplaced: it re-paths the file to `Extensions//`, fixes the namespace to the receiver's namespace, and updates `using` directives in other referencing documents so the move compiles. + +Do not hand-author alternate namespace conventions unless the repository explicitly opts out of the SDK defaults. + +## Automatic project references + +The SDK adds project references based on layout conventions. + +### Non-test projects + +For ordinary non-test, non-shared projects, it automatically looks for sibling shared projects: + +- `../Shared*/Shared*.csproj` + +It also removes accidental self/shared-testing matches. + +### Test projects + +For detected test projects, it attempts these target-project paths in order when they exist: + +- `../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` + +It also adds sibling shared-testing project references via: + +- `../SharedTesting*/SharedTesting*.csproj` + +This is why consistent naming and placement matter so much in repos that use the SDK. + +## Automatic framework/package behavior + +### For non-test C# projects + +- Adds SourceLink unless `DisableSourceLink=true` +- Adds Purview telemetry packages unless `ExcludePurviewTelemetry=true` +- Generates documentation files (`GenerateDocumentationFile=true`) unless explicitly disabled +- Generates `InternalsVisibleTo` attributes unless `DisableAutoInternalsVisibleTo=true` + +### For packable projects + +- Defaults `GenerateDocumentationFile`, `IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, `IncludeSource`, and `DebugType=portable` — only when the consuming project has not supplied a value +- Delivers portable PDBs via the `.snupkg`; the normal `.nupkg` does not receive PDBs unless the project opts in explicitly +- Packs the repository-root `README.md` (registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` is unset; skips when a README is already being packed +- Non-packable projects (including web apps) default `WarnOnPackingNonPackableProject=false` so solution-wide pack operations skip them silently + +### For Roslyn component (analyzer/source-generator) projects + +- Defaults a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, and SourceLink with `EmbedUntrackedSources=true` +- `IsRoslynComponentOnly` defaults to `true`, excluding normal build output (`IncludeBuildOutput=false`) and setting `IncludeSymbols=false`; no `.symbols.nupkg` or `.snupkg` is produced and the analyzer PDB ships inside the main `.nupkg` under `analyzers/dotnet/cs/` beside the analyzer assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a dual-role component that uses normal library symbol packaging. +- Packable Roslyn components automatically pack the built analyzer assembly (and PDB) into `analyzers/dotnet/cs/`; `SymbolPackageFormat` defaults to the modern `snupkg` if symbols are explicitly opted into +- `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers` references are defaulted to `PrivateAssets=all` (development-only dependencies) so they never leak into the packed nuspec +- A pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack of a packable Roslyn component if `LangVersion`, `Nullable`, `TreatWarningsAsErrors`, or `EnforceExtendedAnalyzerRules` is missing; opt out with `DisableRoslynCompilerDefaultsValidation=true` +- `ContinuousIntegrationBuild` is set only by real CI environment variables — packability alone never forces SourceLink's CI-mode dirty-repository checks + +### For test and shared-testing projects + +- Applies test-friendly `NoWarn` defaults +- Marks projects as not packable/publishable +- Adds substitute/test-data/testing packages based on `SubstituteFramework`, `TestDataFramework`, and `TestingFramework` +- For TUnit test projects, enables Microsoft.Testing.Platform integration properties automatically +- For shared-testing projects, skips the runnable test package and marks them with a skip/category pattern appropriate to the selected test framework + +For the default configuration, standard test projects receive: + +- `TUnit` +- `TUnit.Mocks` +- `Bogus` +- Microsoft.Testing.Platform integration + +Specialized testing dependencies such as `TUnit.Aspire` and `Testcontainers` are still explicit additions by +project purpose. + +### For special project types + +- CLI projects default to `OutputType=Exe` and include `appsettings*.json` as content +- Container projects enable `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, and container tooling package references +- Web SDK projects get `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces` unless marked as a separate web-project mode +- Aspire host projects default to `OutputType=Exe` + +## How to reason about surprising behavior + +If the SDK “did something unexpected”, inspect these values first: + +- `MSBuildProjectName` +- `NamespacePrefix` +- `PurviewLogicalProjectName` +- `RootNamespace` +- `TestingType` +- `TargetProjectName` +- `SdkProjectName` +- `IsTestProject` +- `IsSharedProject` +- `IsSharedTestingProject` +- `IsContainerProject` +- `IsCLIProject` +- `IsWebSdkProject` +- `IsAspireHostProject` + +Prefer explaining behavior from these computed properties rather than from assumptions about folder names alone. + +## Guidance for structural changes + +When adding or moving projects in a repo using this SDK: + +1. Keep the `.csproj` filename equal to its containing directory name unless the repo explicitly disables that validation. +2. Preserve established `src/` and `tests/`-style layouts whenever possible. +3. Use test project suffixes intentionally so auto-detection and auto-references work. +4. Keep shared helpers in exact shared/shared-testing names if you want the corresponding SDK behavior. +5. If you change a naming rule in the SDK, update the README and the shipped skills together. +6. If the question is really about repository policy rather than one computed property, point the user to the + engineering-principles documentation first, then explain the specific SDK mechanics. diff --git a/.agents/skills/source-generator-codewriter-modernization/SKILL.md b/.agents/skills/source-generator-codewriter-modernization/SKILL.md new file mode 100644 index 0000000..8501ca4 --- /dev/null +++ b/.agents/skills/source-generator-codewriter-modernization/SKILL.md @@ -0,0 +1,324 @@ +--- +name: source-generator-codewriter-modernization +description: "Use when implementing, reviewing, or refactoring C# source generators and analysers in Purview.SourceGeneratorFramework. Covers CodeWriter/XmlCommentWriter-style emission, incremental pipeline design, value equality, and Roslyn best practices." +--- + +# Source generator CodeWriter modernization + +Use this skill for any work involving C# source generators, analysers, or generated output in `Purview.SourceGeneratorFramework`. It combines CodeWriter/XmlCommentWriter emission guidance with the incremental-source-generator and analyser best practices that ship with the framework. + +## Required implementation pattern + +When creating source output, favor this shape: + +1. Build immutable, value-equatable pipeline values first. +2. Register source output. +3. Inside the callback: `var writer = generationContext.CreateCodeWriter();` +4. Write header/usings/namespace. +5. Write structured types and members using declaration options records. +6. Add source once per output artifact. + +### CodeWriter lifetime: created in the output callback, never in pipeline state + +The line between "fine" and "forbidden" is the **incremental-cache boundary**, not the act of +creating a writer or handing it to a helper. + +**Fine — output-scoped use inside a `RegisterSourceOutput` callback.** Create the writer inside the +callback and pass it to any emitter/helper methods called from that same callback. It may be held in +local variables, passed as a parameter, or wrapped in a short-lived output context: + +```csharp +context.RegisterSourceOutput( + targets.CombineWithContext(contextProvider), + static (spc, pair) => + { + var (model, generationContext) = pair; + var writer = generationContext.CreateCodeWriter(); + EmitHeader(writer, model); + EmitType(writer, model); + spc.AddSource($"{model.Name}.g.cs", writer.ToString()); + } +); +``` + +**Forbidden — persisting the writer across the incremental-cache boundary.** Do not create it earlier +in the pipeline and pass it down, and do not store it anywhere Roslyn caches or another callback can +observe it: + +- Do not create a `CodeWriter` in a provider stage and carry it through the pipeline. +- Do not store a `CodeWriter` as a property or field on `GenerationContext` or a custom context. +- Do not return a `CodeWriter` (or an object holding one) from an incremental provider. +- Do not cache or reuse a writer for a later callback or a different output. + +A cached writer can retain previously written source, mix output from concurrently running +callbacks, and defeat scope tracking. A writer created in the callback and used only within that +callback is safe and expected. + +## Source generator & analyser best practices + +Apply the following rules to every generator, analyser, and refactor. + +### 1. Core principles + +- **Analyser for validation; generator for generation.** +- **Syntax for syntax, symbols for declarations, operations for executable semantics.** +- **Use `ForAttributeWithMetadataName` for attribute-driven generators.** +- **Remove Roslyn objects from the incremental pipeline as early as possible.** +- **Every value crossing a pipeline boundary must have meaningful value equality.** +- **Prefer many small incremental stages over one large transform.** +- **Keep broad inputs such as `Compilation` away from downstream generation.** +- **Generate deterministic output.** +- **Compile against the oldest Roslyn API version containing the functionality you need.** +- **Test caching, not just generated text.** + +The guiding principle for an incremental generator is: + +> Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model. + +### 2. Analyser vs source generator + +Use a `DiagnosticAnalyser` when the question is: + +> Is the source code valid according to this library's rules? + +Use an `IIncrementalGenerator` when the question is: + +> Given valid source code, what source should be generated? + +| Requirement | Prefer | +| --- | --- | +| Require a class to be `partial` | Analyser | +| Require an attribute on a declaration | Analyser | +| Validate a method signature | Analyser | +| Reject unsupported property types | Analyser | +| Detect invalid attribute arguments | Analyser | +| Detect unsupported API usage | Analyser | +| Offer an automatic fix | Analyser + `CodeFixProvider` | +| Generate members for a marked class | Incremental generator | +| Generate serializers/validators/mappers | Incremental generator | +| Generate a registry from discovered types | Incremental generator | +| Read a schema file and generate C# | Incremental generator | +| Internal generation failure | Generator diagnostic | + +### 3. Choosing an analyser action + +Use the narrowest API that represents the concept being analysed: + +- `RegisterSyntaxNodeAction` — exact source syntax (e.g., modifier presence). +- `RegisterSymbolAction` — declaration semantics (e.g., attributes, interfaces, accessibility). +- `RegisterOperationAction` — executable behaviour (e.g., invocation, assignment, object creation). +- `RegisterOperationBlockStart/EndAction` — stateful method analysis. +- `RegisterSymbolStart/EndAction` — type-wide analysis across members. +- `RegisterCompilationStartAction` — resolve known framework symbols once. +- `RegisterAdditionalFileAction` — analyse `AdditionalFiles`. +- Avoid `RegisterSyntaxTreeAction`, `RegisterSemanticModelAction`, and compilation-end actions unless genuinely necessary. + +### 4. Syntax vs symbol vs operation + +Decision tree: + +1. Does exact source spelling/structure matter? → **Syntax** +2. Otherwise, is it a declaration? → **Symbol** +3. Otherwise, is it executable behaviour? → **Operation** + +Use `SymbolEqualityComparer.Default.Equals(...)` when comparing symbols. + +### 5. Analyser best practices + +- Enable concurrent execution with `context.EnableConcurrentExecution()`. +- Explicitly configure generated-code analysis with `context.ConfigureGeneratedCodeAnalysis(...)`. +- Resolve known framework/library symbols once in a `RegisterCompilationStartAction`. +- Prefer narrow registrations over scanning entire syntax trees or compilations. +- Treat diagnostic IDs as public contracts and maintain release tracking files when publishing public diagnostics. + +### 6. Incremental generator golden rules + +Implement `IIncrementalGenerator`. Simply implementing it is not enough; the pipeline must be incremental. + +> **Pipeline values must be immutable and value-equatable.** + +Never keep these in persistent pipeline models: + +| Type | Verdict | +| --- | --- | +| `ISymbol` / `INamedTypeSymbol` / `IMethodSymbol` / `IPropertySymbol` | Never retain | +| `Compilation` | Do not propagate | +| `SemanticModel` | Do not propagate | +| `IOperation` | Do not propagate | +| `SyntaxTree` | Do not propagate | +| `SyntaxNode` | Remove ASAP | +| `Location` | Remove ASAP | +| `AdditionalText` | Project immediately | +| `T[]` / `List` | Avoid | +| `ImmutableArray` | Wrap with sequence equality | + +Use immutable records and `EquatableArray` (sequence equality) for collection members. + +### 7. Designing the pipeline + +Pipeline shape: + +```text +Roslyn Input + ↓ +Cheap discovery + ↓ +Semantic extraction + ↓ +Small equatable model + ↓ +Validation/transformation + ↓ +Generation model + ↓ +Source output +``` + +Guidelines: + +- Project the semantic transform as the boundary where Roslyn objects disappear. +- Prefer `static` callbacks to avoid capturing generator state. +- Honour cancellation tokens. +- Split transformations into many small stages. +- Keep syntax predicates cheap. +- Avoid indirect discovery (every interface implementation, every subclass, entire-compilation scans). + +### 8. Syntax discovery + +- Prefer `context.SyntaxProvider.ForAttributeWithMetadataName(...)` for attribute-driven generators. +- Use `context.SyntaxProvider.CreateSyntaxProvider(...)` only when syntax itself is the trigger and there is no marker attribute. +- The predicate must be cheap; do not walk the tree or do semantic work in it. + +### 9. `Collect`, `Combine`, and invalidation + +- `Collect()` turns per-item outputs into one aggregate. Changing any item invalidates the aggregate. +- Use `Collect()` only for genuinely global output: registries, lookups, duplicate detection, aggregate switches. +- Prefer per-item `RegisterSourceOutput`. +- `Combine()` is correct when the output depends on two providers. +- Avoid `models.Combine(context.CompilationProvider)` — project the compilation to a tiny capability fact first. +- Use `.WithComparer(...)` only when logical equality differs from the default. + +### 10. Diagnostics + +- Prefer a separate `DiagnosticAnalyser` for normal user validation. +- Use generator diagnostics only for malformed additional files, generator-only configuration, conflicting output, or failures that cannot be expressed by an analyser. +- Report diagnostics on the most useful user-authored `Location`. +- Do not keep `Location` in long-lived pipeline models. + +### 11. Output generation + +- Output must be deterministic: no timestamps, random GUIDs, process IDs, machine paths, culture-dependent output, or unordered dictionary output. +- Hint names must be deterministic, unique, and stable. +- Prefer text generation or `CodeWriter` over building Roslyn syntax trees just to stringify them. +- Use `RegisterPostInitializationOutput` for constant source such as marker attributes. +- Add generated source once per output artifact. + +### 12. Testing incrementally + +- Snapshot-testing generated source is not enough. +- Test first execution, cached second execution, unrelated changes remaining cached, per-target invalidation, deletion, renaming, global options, additional files, and global registry invalidation. +- Use `GeneratorDriverOptions` with `trackIncrementalGeneratorSteps: true` and inspect reasons: `New`, `Modified`, `Unchanged`, `Cached`, `Removed`. + +### 13. Roslyn version compatibility and packaging + +- The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. +- The consumer's `TargetFramework` does not determine analyser compatibility. +- Choose the oldest Roslyn version that contains the APIs you need. +- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. +- Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. +- Do not mistake multi-targeting for automatic analyser asset selection. +- Use `PrivateAssets="all"` for Roslyn development dependencies. +- Enable `EnforceExtendedAnalyzerRules` and investigate `RSxxxx` diagnostics before suppressing them. + +### 14. Recommended project configuration + +A generator project should normally include: + +```xml + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + +``` + +## Preferred API map for CodeWriter + +### File and namespace + +- `AutoGeneratedHeader(...)` +- `Using(...)` +- `FileScopedNamespace(...)` or `BlockNamespace(...)` +- `OpenPragmasScope(...)` for warning suppression scopes + +### Types and members + +- Types: `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum`, `Delegate` +- Members: `Method`, `MethodScope`, `Property`, `Field`, `Constructor` +- Attributes: `AttributeDeclarationOptions`, `AttributeArgumentOptions` +- Type syntax: `TypeReference` (nullable/generic/array/pointer-safe composition) + +### XML documentation + +Use `XmlCommentWriter` extension methods on `CodeWriter`: + +- `XmlSummary(...)`, `XmlParam(...)`, `XmlTypeParam(...)`, `XmlReturn(...)`, `XmlRemarks(...)`, `XmlExample(...)` +- `XmlCode(...)` / `XmlCodeBlock(...)` +- `XmlList(...)`, `XmlSeeAlso(...)`, `XmlException(...)` + +Static helpers: `CodeWriter.XmlInlineCode(...)`, `CodeWriter.XmlSee(...)`, `CodeWriter.XmlParamRef(...)`, `CodeWriter.XmlText(...)`. + +## Refactoring guide: string/StringBuilder -> CodeWriter + +Apply this checklist in order: + +1. **Move emission boundaries** — replace giant string assembly with phases: header, namespace, type, members. +2. **Replace manual braces/indentation** — use `using` scopes (`ClassScope`, `MethodScope`, `OpenBlockScope`, `IndentedScope`). +3. **Replace handwritten signatures** — use declaration option records. +4. **Replace raw XML lines** — use XML extension methods (`XmlSummary`, `XmlParam`, etc.). +5. **Normalize type strings** — use `TypeReference`. +6. **Preserve semantics and ordering** — generated members and diagnostics must remain equivalent. +7. **Validate scope safety** — keep or enable `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` for tests/dev. + +## Anti-patterns to remove during refactors + +- `StringBuilder.AppendLine("public class ...")` for declarations that can be structured. +- Manually writing `{` / `}` around methods and types where scope APIs exist. +- Hard-coded nullable type suffixes and generic syntax in arbitrary strings when `TypeReference` is available. +- Raw XML tag string composition when XML extension methods can enforce consistency. +- Creating a `CodeWriter` before the output callback and passing it through the pipeline. +- Storing a `CodeWriter` on `GenerationContext`, a custom context, or any incremental pipeline model. +- Sharing one `CodeWriter` across multiple generated outputs or callbacks. +- Keeping Roslyn objects, `CodeWriter`, or mutable state in incremental pipeline models. + +## Review checklist for pull requests + +- Generated declarations use structured APIs for types and members. +- XML docs use XML extension methods rather than raw `///` fragments. +- `CodeWriter` is created inside each output callback and never persists in pipeline state; passing it + to helper methods within that callback is expected. +- Header and generated attributes are deterministic and consistent. +- Existing diagnostics, generated member names, and public behavior are preserved. +- Roslyn objects are removed from pipeline models; `EquatableArray` is used for collections. +- Per-target output is preferred over collected global output unless global knowledge is required. +- `CompilationProvider` is not casually combined into output. +- Deterministic hint names and source text are used. +- Incremental caching behavior is tested, not just generated text. + +## See also + +- `agents/source-generator-framework-writer.agent.md` — specialist agent for `Purview.SourceGeneratorFramework` emitter authoring. +- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. diff --git a/.agents/skills/source-generator-testing/SKILL.md b/.agents/skills/source-generator-testing/SKILL.md new file mode 100644 index 0000000..2a56544 --- /dev/null +++ b/.agents/skills/source-generator-testing/SKILL.md @@ -0,0 +1,364 @@ +--- +name: source-generator-testing +description: "Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — picking the right runner/base, configuring options, querying produced code with CodeQuery, and asserting incremental caching." +--- + +# Testing source generators, analyzers, code fixes and refactorings + +Use this skill whenever a task involves authoring, fixing, or modernising tests for Roslyn components +(generators, diagnostic analyzers, code fix providers, refactoring providers) built with +`Purview.SourceGeneratorFramework`. It covers the framework-agnostic test runner layer and the +`CodeQuery` syntax-lookup API. For the TUnit base classes and assertion extensions, also load the +`sdk` package's `tunit-test-authoring` skill. + +## Picking the right runner + +| Roslyn type | Runner | +|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `SourceGeneratorTestRunner` | +| `DiagnosticAnalyzer` | `DiagnosticAnalyzerTestRunner` | +| `CodeFixProvider` | `CodeFixTestRunner` (single) | +| — | `CodeFixTestRunner.RunFixAllAsync` (project-wide) | +| `CodeRefactoringProvider` | `RefactoringTestRunner` | + +TUnit projects should prefer the matching base class instead (see `tunit-test-authoring`): +`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, +`TUnitRefactoringTestBase`. + +## Result types + +- `DriverRunResult` (generator) — `DriverResult`, `AllSyntaxTrees`/`PrimarySyntaxTrees`, + `CompilationResult.Compilation`, `GetGeneratedTree`, `GetSource`, `GetTypeByMetadataName`, `LogEntries`. +- `AnalyzerTestResult` — `Diagnostics`, `Compilation`. +- `CodeFixTestResult` — `Diagnostics`, `CodeActions`, `FixedSource`, `Compilation`, `ChangedSolution`. +- `CodeFixFixAllResult` — `Diagnostics`, `CodeActions`, `FixedSources`, `ChangedSolution`. +- `RefactorTestResult` — `CodeActions`, `FixedSources`, `ChangedSolution`, `Compilation`. + +Use `DriverRunResultExtensions` (`AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, +`AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, …) for quick checks, but prefer +`CodeQuery` for structural assertions. + +## Querying produced code with `CodeQuery` + +Every result exposes a `CodeQuery` via extensions in `CodeQueryResultExtensions`: + +```csharp +result.Generated() // DriverRunResult: generated trees (default, generated-first) +result.Output() // DriverRunResult: entire output compilation (user + generated) +analyzerResult.Code() // AnalyzerTestResult: input compilation +codeFixResult.Code() // CodeFixTestResult: input compilation +codeFixResult.FixedCode() // CodeFixTestResult: parsed fixed source (or post-fix solution) +fixAllResult.FixedCode() // CodeFixFixAllResult: post-fix documents +refactorResult.FixedCode() // RefactorTestResult: post-refactor documents +``` + +Every `Get` has an accompanying `Has` (bool) and `TryGet` (out): `GetMethod`/`HasMethod`/`TryGetMethod`, +`GetClass`, `GetStruct`, `GetInterface`, `GetEnum`, `GetDelegate`, `GetRecord`, `GetProperty`, +`GetField`, `GetConstructor`, `GetNamespace`, `GetTypeDeclaration`, plus generic `Get`/`Has` +and `GetSyntaxTree`/`HasSyntaxTree`. `Get` throws `SyntaxNotFoundException` when nothing matches. + +Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` — and the +`TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so +`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the +non-generic `ResourceDefinition`. + +Every `Get` returns a `CodeQueryResult` — the matched syntax node (`Node`) plus a query scoped to it +(`Query`), with implicit conversions to both the node and the scoped query. Use `.Node` for direct syntax +access, or chain member queries (the originating query is carried by the result, so it is not passed again). + +Types can be matched against `TypeReference`/`TypeIdentity`, resolved through the compilation's semantic +model (nullable value types are significant, so `int?` never matches `int`): + +```csharp +result.Generated().HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType); +result.Generated().HasReturnType("Compute", TypeReference.Create()); +result.Generated().GetMethod("Format").HasParameters(TypeReference.Create(), objectReference); +``` + +When a test expects a nullable annotation, prefer the test-only `query.MakeNullable(type)` extension (on a +`CodeQuery`, accepting a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's +compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the +`PSGFR16` context-overload suggestion (tests have no generation context to pass). + +Member chaining from a type declaration (`MemberQueryExtensions`): + +```csharp +var service = result.Generated().GetClass("ServiceCollectionExtensions"); // or GetClass(name, "Namespace") +service.HasProperty("Count", intType); +service.HasIndexer(stringType, intType); +service.HasMethod("Add", intType, complexType); +service.HasMethodReturnType("Add", stringType); +service.HasConstructor(stringType); +service.HasAttribute("SomeAttribute"); +``` + +Node-inspection checks on scoped results: + +```csharp +result.Generated().GetClass("Service").HasAccessibility(Accessibility.Public); // resolves C# defaults +result.Generated().GetClass("Service").GetProperty("Name").HasSetterAccessibility(Accessibility.Private); +result.Generated().GetClass("ResourceDefinition", 1).HasGenericTypeParameters("TResourceType"); +result.Generated().GetClass("ResourceDefinition", 1).HasBaseType(new TypeIdentity("ResourceDefinition", ns)); +result.Generated().GetClass("Service").HasNestedType("Builder"); // GetNestedType(...) to fetch +result.Generated().GetClass("Service").IsInNamespace("Example.Models"); // IsInGlobalNamespace() for no namespace +result.Generated().IsInNamespace(cls.Node, "Example.Models"); // or on the query directly +``` + +## Configuring options and a reusable starting point + +`SourceGeneratorTestOptions` is the base record. Common knobs: + +- `AdditionalNamespaces` / `IncludeDefaultNamespaces` — namespaces prepended to test source. +- `AdditionalAssemblyTypes` / `AdditionalReferences` — assemblies referenced by the test compilation + (use `AdditionalAssemblyTypes = [typeof(SomeType)]` to pull in a whole assembly). +- `AdditionalSources` — extra source files added to every run. +- `AnalyzerConfigOptions` — `build_property.*` values; keys without the prefix are also exposed as MSBuild + properties. +- `DisableSourceGeneratorPropertyName` / `DisableSourceGeneratorValue` — generator disable toggle. +- `NullableContextOptions`, `OutputKind`, `LanguageVersion`, `CompileToAssembly`. +- `ValidateCodeWriterScopes`, `EnableLogging`. +- `ExcludeGeneratedSourceHintNames` — hides generated marker trees from `PrimarySyntaxTrees`. + +**Easy starting point recipe.** Derive an options record that seeds the namespaces and assemblies your +generator needs, so every test gets a working compilation with no boilerplate: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // the framework's Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableMyGenerator; + } +} +``` + +`Compile()` returns a copy with `CompileToAssembly = true`, preserving the derived options type. Use the +base class hooks `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` to mutate sources/options per run (for +example to append a marker attribute source via `WithAdditionalSources`). + +When `CompileToAssembly` is enabled, emission is fully in-memory. On .NET 8+ the assembly loads into a +collectible `AssemblyLoadContext`, so the result is `IDisposable` — `using var result = ...` unloads it and +keeps repeated runs from polluting the default context. `result.CompilationResult.Assembly` is the runnable +assembly (generated code can execute); `result.CompilationResult.Metadata` / `.MetadataAssembly` give a +metadata-only reflection view (types, members, attributes) over the emitted assembly without executing code. + +## Best practices + +- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with + `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. +- **Generator references**: to use a generated type in the test project AND pass the generator type to a + runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal + reference. +- **Multi-target**: build generators against the Roslyn version that supports the test matrix; the + framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep + `System.Collections.Immutable` version pinned to the shared one. +- **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes + undisposed `CodeWriter` scopes fail tests. +- **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). + +## Incremental cache testing (`RunIncrementalAsync`) + +To prove the pipeline caches correctly stage-by-stage, use `SourceGeneratorTestRunner.RunIncrementalAsync` +(or `GenerateIncrementalAsync` on the TUnit base). It runs a sequence of source sets over a **single shared +`GeneratorDriver`** and captures each run's `TrackedSteps`, keyed by tracking name. The canonical reference +implementation is `IncrementalPipelineCacheTests` in `SourceGeneratorShared.UnitTests`; the end-to-end +generator variant is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`. Copy the pattern into your own test project — do +not expect the source repo's files locally. + +### What is being asserted and why + +Roslyn reports one `IncrementalStepRunReason` per step output on each run: + +- `New` — the step ran for the first time. +- `Modified` — the step ran and produced a different value than the previous run. +- `Unchanged` — the step ran but produced the same value. +- `Cached` — the step was skipped and its previous result reused from the incremental cache. + +A pipeline is "caching correctly" when an unchanged input keeps every stage `Cached`/`Unchanged`, and a +targeted change marks **only** the stages whose inputs actually changed `Modified` while unrelated stages +stay `Cached`. If a generator accidentally leaks `Compilation`, `SemanticModel`, `ISymbol`, +`SyntaxNode`, or `Location` into a pipeline model, unrelated stages will report `Modified`/`New` on rerun — +these tests fail the build and catch the regression. + +### The four scenarios every cache test should cover + +1. **First run → all `New`.** Nothing can be cached on the first run; this confirms every stage is tracked + under the expected name. +2. **Identical rerun → all `Cached`/`Unchanged`.** `RunIncrementalAsync(sources, ...)` runs the same source + set twice for exactly this case. This is the strongest "it caches" proof. +3. **Source-only change → only the source/attribute stage `Modified`.** Changing an attributed class must + mark `ForAttribute_*` (and downstream output) `Modified` while property/config stages stay `Cached`. +4. **Property-only change → only the property/configuration stage `Modified`.** Toggling an MSBuild + property (via `IncrementalRunInput.AnalyzerConfig`) must mark `GetMSBuildPropertyValue_*` / + `GetGenerationConfiguration` / `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. + +### How the runner makes this possible + +`RunIncrementalAsync` creates **one** driver, enables incremental step tracking, and **reuses the same +`Compilation` instance for identical source sets** (keyed by prepared source text). Without that reuse, +Roslyn would see a fresh compilation on the second run and report stages `Modified`/`New` even though the +sources are byte-identical — the "cached" assertion would fail. + +### The `StepReasons` helper + +`IncrementalCacheRun.Steps` is `ImmutableDictionary>` +keyed by tracking name. Flatten each step's `Outputs` into the reasons list so assertions read cleanly: + +```csharp +static ImmutableDictionary> StepReasons(IncrementalCacheRun run) +{ + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + return builder.ToImmutable(); +} +``` + +### Framework pipeline stage names + +- `GetMSBuildPropertyValue_{Property}` — `IncrementalPipeline.PropertyValueProvider`. +- `GetGenerationConfiguration` — `IncrementalPipeline.GenerationContextValueProvider`. +- `GetGenerationContext_{Capabilities}` — e.g. `GetGenerationContext_EmptyCapabilities`. +- `ForAttribute_{AttributeType}` — `IncrementalPipeline.ForAttributeWithMetadataName`. + +The framework reference (`IncrementalPipelineCacheTests`, framework-agnostic runner) shows all four +scenarios against `TestGenerator`/`DiagnosticTestGenerator`: + +```csharp +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.TestGenerators; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +public class IncrementalPipelineCacheTests +{ + const string AttributedSource = """ + [TestAttribute] + public partial class MyClass { } + """; + const string ChangedAttributedSource = """ + [TestAttribute] + public partial class AnotherClass { } + """; + const string TestAttributeSource = """ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class TestAttribute : System.Attribute { } + """; + + static SourceGeneratorTestOptions CreateOptions() => + new SourceGeneratorTestOptions() + .WithAdditionalSources(TestAttributeSource) + .WithExcludeGeneratedSourceHintNames("TestAttribute"); + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) { /* as above */ } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource])], + CreateOptions(), + cancellationToken); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert.That(reasons.Values.SelectMany(r => r).All(r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner() + .RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second.Values.SelectMany(r => r).All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(r => r == StepReason.Cached)).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [ + new IncrementalRunInput([AttributedSource]), + new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]), + ], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_TestAttribute"].All(r => r == StepReason.Cached)).IsTrue(); + } +} +``` + +### End-to-end generator variant (`GenerateIncrementalAsync`) + +TUnit tests derive from the base class and call `GenerateIncrementalAsync` (which wires the +`OnBeforeRun`/`OnBeforeRunAsync` hooks and the derived options). `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests` is the reference: + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } +} +``` + +**Why the example filters to `frameworkStages`:** `ServiceRegistrationGenerator` emits its own +`GenerateServiceAttribute` via post-initialization output, which is regenerated as a new `SyntaxTree` each +run. That makes Roslyn's *internal* `ForAttributeWithMetadataName` `Compilation` step legitimately report +`Modified` on an identical rerun. Asserting on the framework-named stages (which stay `Cached`/`Unchanged`) +is the meaningful check. If your generator does not depend on its own post-init output, the stricter +"every tracked step is `Cached`/`Unchanged`" assertion (as in the framework `IncrementalPipelineCacheTests`) +is correct. + +Per-run MSBuild-property changes are supplied with `new IncrementalRunInput(sources, [("build_property.X", "value")])`. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/tunit-test-authoring/SKILL.md b/.agents/skills/tunit-test-authoring/SKILL.md new file mode 100644 index 0000000..fa58cd8 --- /dev/null +++ b/.agents/skills/tunit-test-authoring/SKILL.md @@ -0,0 +1,214 @@ +--- +name: tunit-test-authoring +description: "Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — choosing the correct base class and method, customising options, using the assertion extensions, and modernising existing tests." +--- + +# TUnit test authoring for Roslyn components + +Use this skill whenever a task involves authoring, fixing, or modernising **TUnit** tests for Roslyn +components built with `Purview.SourceGeneratorFramework`. It tells you which base class to derive from, +which method to call, how to customise options with an easy starting point, and how to use the TUnit +assertion extensions. For the framework-agnostic runner layer and the `CodeQuery` API, also load the +`sdk` package's `source-generator-testing` skill. + +## Base class → method matrix + +| Roslyn type | Base class | Method to call | +|---|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | +| `DiagnosticAnalyzer` | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | +| `CodeFixProvider` (single fix) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | +| `CodeFixProvider` (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | +| `CodeRefactoringProvider` | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | + +For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. + +Framework-agnostic equivalents (no TUnit): `SourceGeneratorTestRunner`, `DiagnosticAnalyzerTestRunner`, +`CodeFixTestRunner`, `RefactoringTestRunner`. + +## Easy starting point: derive your options record + +Create a test-options record that seeds the namespaces and assemblies your component needs, then pass it +to every test via `new MyTestOptions()`: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // framework Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = "DisableMyGenerator"; + } +} + +public class MyGeneratorTests : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GeneratesExpectedSource(CancellationToken ct) => + await GenerateAsync("...source...", ct); +} +``` + +Use the base hooks to customise per-run: `OnBeforeRun`/`OnBeforeRunAsync` (mutate sources/options, e.g. +`options.WithAdditionalSources(markerAttributeSource)`) and `OnAfterRun`/`OnAfterRunAsync`. Use +`options.Compile()` to opt into `CompileToAssembly` while preserving the derived options type. + +For code fixes/refactorings, select a specific registered action via `CodeFixTestOptions.EquivalenceKey` +or `CodeActionIndex`, and `RefactorTestOptions.Span`/`NodeSelector` (e.g. +`NodeSelector = query => query.GetMethod("M")`). + +## TUnit assertion extensions + +All assertion extensions live under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` +(globally imported by the package's props). `Assert.That(...)` calls are terminal and **return the value** +when awaited. + +- **`CodeQueryAssertions`** — return `CodeQueryResult` (the matched node via `.Node`, plus a query + scoped to it via `.Query`): `HasGeneratedMethod` (optionally with `TypeReference[]` parameter types), + `HasGeneratedMethodReturnType`, `HasGeneratedClass` (by name or `TypeReference`/`TypeIdentity` identity), + `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`; `HasFixedMethod` for code-fix and + refactor results. The assertions operate on a `CodeQuery` directly, so pass any query — `result.Generated()` + (generated trees), `result.Output()` (whole compilation), or `result.FixedCode()` — or use the convenience + overloads on the test result types, which query the relevant code for you. +- **Scoped member chaining** — `HasPropertyOfType(name, type)`, `HasFieldOfType(name, type)`, + `HasMethodOfType(name, TypeReference[])`, `HasConstructorOfType(TypeReference[])`, and + `HasAttributeOfType(name)` chain from a scoped `CodeQueryResult` (e.g. the result of `HasGeneratedClass`) + and return the matched member. They are named to avoid colliding with the bool `Has*` predicates in + `MemberQueryExtensions`. +- **Fluent `.And` chains** — the node-producing assertions (`HasGeneratedClass`, `HasPropertyOfType`, + `HasMethodOfType`, `HasNestedType`) move the chain onto the matched node, so further assertions can be + appended with `.And`. Node-inspection assertions — `WithAccessibility`, `WithGetterAccessibility`, + `WithSetterAccessibility`, `WithBaseType`, `WithGenericTypeParameter(s)`, `IsInNamespace`, + `IsInGlobalNamespace` — keep the node on the chain: + ```csharp + var method = await Assert.That(query) + .HasGeneratedClass("Service") + .And.HasNestedType("Builder") + .And.WithAccessibility(Accessibility.Private) + .And.HasMethodOfType("Build", []); + ``` + Generic types are matched by arity — `HasGeneratedClass(name, arity)` or a `TypeIdentity` with arity — + so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` + without matching the non-generic `ResourceDefinition`. +- **Nullable expected types in tests** — use the test-only `query.MakeNullable(type)` extension (on a + `CodeQuery`). It resolves the annotation against the query's compilation and, unlike + `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trip the `PSGFR16` context-overload + suggestion, since tests have no generation context to pass. + ```csharp + CodeQueryResult method = await Assert.That(result.Generated()).HasGeneratedMethod("DoWork", [intType, nullableInt]); + CodeQueryResult cls = await Assert.That(result.Generated()).HasGeneratedClass("Service"); + await Assert.That(cls.HasProperty("Count", intType)).IsTrue(); // chained member query + await Assert.That(cls.Node.Identifier.ValueText).IsEqualTo("Service"); // direct syntax access + await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results + + var query = result.Generated(); + var attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute); + await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); + ``` +- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, + `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` + (whitespace-flattened string comparison). + +For structural assertions (members, signatures, namespaces) prefer `result.Generated()` + +`Get/Has/TryGet` from `CodeQuery` (see `source-generator-testing`). + +## Incremental cache tests (`GenerateIncrementalAsync`) + +`TUnitSourceGeneratorTestBase` exposes `GenerateIncrementalAsync`, which mirrors `RunIncrementalAsync` but +also wires the base class hooks (`OnBeforeRun`/`OnBeforeRunAsync`) and your derived options record. Use it to +prove the pipeline caches stage-by-stage. The reference is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`; the framework-agnostic twin with a full walkthrough is +in the `source-generator-testing` skill. + +Why the tests look the way they do: + +- `GenerateIncrementalAsync([Source])` runs the **same source twice** on a single shared driver. The first + run reports every stage `New`; the second must report `Cached`/`Unchanged` for unchanged stages — that is + the core "it caches" proof. +- `GenerateIncrementalAsync([new IncrementalRunInput([Source]), new IncrementalRunInput([changed])])` runs two + **different** source sets, so a source-only change must mark `ForAttribute_*` `Modified` while + property/configuration stages stay `Cached`. +- `new IncrementalRunInput([Source], [("build_property.X", "value")])` toggles an MSBuild property for one + run only, so a property-only change must mark `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/ + `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. +- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a + `ImmutableDictionary>` so assertions can address a stage + by name (see `source-generator-testing` for the helper body). +- If the generator's pipeline depends on its own post-initialization output (a self-referencing generated + attribute), Roslyn's internal `ForAttributeWithMetadataName` `Compilation` step is legitimately `Modified` + on rerun; assert on the framework-named stages (e.g. `ForAttribute_GenerateServiceAttribute`, + `GetGenerationConfiguration`, `GetGenerationContext_EmptyCapabilities`) rather than every tracked step. + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync( + [ + new IncrementalRunInput([Source]), + new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]), + ], + cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_GenerateServiceAttribute"].All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } +} +``` + +Use `using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;` and your own stage names. + +## Modernising existing tests + +When converting legacy tests that do `result.GetGeneratedTree(...)` + `string.Contains(...)`: + +1. Replace tree-lookup + string matching with `result.Generated().GetClass/GetMethod/GetProperty(...)` and + the `Has*`/`TryGet*` family. +2. Replace signature string checks with `TypeReference` parameter/return-type matching. +3. Replace `Assert.That(text).Contains("...")` with the terminal assertion extensions that return nodes. +4. Verify options use a derived record (namespaces + assemblies) rather than repeating `AdditionalNamespaces` + per test. +5. Add a stage-by-stage cache test if the component has an incremental pipeline — first run `New`, + identical rerun `Cached`/`Unchanged`, and targeted changes mark only the affected stage `Modified`. + Use the inlined examples in this skill and in `source-generator-testing`'s "Incremental cache testing" + section, swapping in your own generator and stage names. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/package.json b/package.json index c6f1662..f2b6a9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-value-objects", - "version": "1.0.0-prerelease.12", + "version": "1.0.0", "license": "MIT", "author": { "name": "Kieron Lanning",