diff --git a/.agents/agents/sdk-consumer-setup.md b/.agents/agents/sdk-consumer-setup.md
deleted file mode 100644
index 8e5066c..0000000
--- a/.agents/agents/sdk-consumer-setup.md
+++ /dev/null
@@ -1,34 +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. 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.
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/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/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/.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-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/.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/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/.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-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/.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/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/.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/.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/.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/.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/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..fa8d002
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,136 @@
+# 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 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
+
+- `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/Directory.Packages.props b/Directory.Packages.props
index 22ab8ef..4e1caca 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,8 +6,8 @@
true
5.9.0
1.68.17
- 1.0.0-prerelease.54
- 2.0.0
+ 1.0.0
+ 2.0.1
diff --git a/README.md b/README.md
index 5ef1aa6..c0b272e 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,10 @@ 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** – 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/Diagnostics.md b/docs/Diagnostics.md
new file mode 100644
index 0000000..7b36722
--- /dev/null
+++ b/docs/Diagnostics.md
@@ -0,0 +1,64 @@
+# 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. |
+| `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
+
+`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
+
+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/Getting-Started.md b/docs/Getting-Started.md
index 61b72b8..df7afd5 100644
--- a/docs/Getting-Started.md
+++ b/docs/Getting-Started.md
@@ -174,6 +174,13 @@ 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`, 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).
+
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..175a7a8 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,165 @@ 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). 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 NoWhitespaceRule(string? Message = null)
+ : IValidationRule, IZodRule
+ where TSelf : IScalarValueObject
+{
+ 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 ?? MessageFormat;
+
+ string? IZodRule.Code => ErrorCode;
+
+ string? IZodRule.Origin => "value_object";
+}
+
+[ZodRule(typeof(NoWhitespaceRule<>))]
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)]
+public sealed class NoWhitespaceAttribute : ValidationAttribute
+{
+ public string? Message { get; set; }
+}
+```
+
+**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
+{
+ // ...
+}
+```
+
+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):
+
+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.
+
+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
+internal 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);
+}
+```
+
+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. 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 = "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 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
The value-object analyzer reports the integration states that would otherwise pass silently:
@@ -174,7 +337,12 @@ 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`-`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.
## 3. Schema-first validation
@@ -299,11 +467,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 +583,10 @@ 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`), 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/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/global.json b/global.json
index 7f367b1..2811ad4 100644
--- a/global.json
+++ b/global.json
@@ -1,9 +1,11 @@
{
"sdk": {
- "allowPrerelease": false
+ "version": "11.0.100-rc.1.26425.128",
+ "rollForward": "latestMajor",
+ "allowPrerelease": true
},
"msbuild-sdks": {
- "Purview.BuildSdk": "1.0.0"
+ "Purview.BuildSdk": "1.0.3"
},
"test": {
"runner": "Microsoft.Testing.Platform"
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/package.json b/package.json
index abb67de..f2b6a9e 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",
"license": "MIT",
"author": {
"name": "Kieron Lanning",
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 51848c3..932f60f 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -6,6 +6,13 @@
+
+
+
+
+
+
+
https://purview.dev/
$(PurviewHomepage)projects/value-objects/
@@ -15,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..5168060 100644
--- a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md
+++ b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md
@@ -8,7 +8,15 @@ 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
\ 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/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs
index 5bb8c6d..b0d21b3 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.
///
@@ -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/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/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 4dbd1f4..3f4d52e 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);
}
);
@@ -275,21 +275,86 @@ 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())",
+ "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",
- 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 +367,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 +392,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 +407,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/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..aaa8a0a
--- /dev/null
+++ b/src/src/SourceGenerator/ValueObject/ZodSharpScalarRuleAdapterEmitter.cs
@@ -0,0 +1,151 @@
+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 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(
+ IsZodSharpReferenced: ValueObjectSymbolInspector.IsZodSharpReferenced(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.Assembly.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/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/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md
index aebfd20..314bb61 100644
--- a/src/src/ValueObjects/Sdk/README.md
+++ b/src/src/ValueObjects/Sdk/README.md
@@ -70,4 +70,22 @@ 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** – 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.
+
+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/ValueObjects/Serialization/ScalarJsonConverterFactory.cs b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs
index 2a66934..4e258f8 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
@@ -72,12 +148,37 @@ 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);
+ var scalar =
+ JsonSerializer.Deserialize(ref reader, options)
+ ?? 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..4884a53 100644
--- a/src/src/ValueObjects/ValueObjects.csproj
+++ b/src/src/ValueObjects/ValueObjects.csproj
@@ -1,13 +1,31 @@
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;
+
+
+ true
+
+
+/// 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 7de219e..2df8187 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 ==");
+NonSentinelValidation();
+
Console.WriteLine();
Console.WriteLine("== Schema-first validation (hand-built schemas) ==");
SchemaFirstValidation();
@@ -119,6 +123,42 @@ static void RefinementHookValidation()
Console.WriteLine($"CorporateEmail.Hydrate('demo@gmail.com') -> '{replayed.Value}'");
}
+static void NonSentinelValidation()
+{
+ // 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}'");
+
+ 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}'");
+
+ // 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
+ {
+ UserId.Create(Guid.Empty);
+ Console.WriteLine("UserId.Create(Guid.Empty) -> no exception");
+ }
+ catch (ZodException ex)
+ {
+ Console.WriteLine($"UserId.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..f43a1b8 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 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".
- **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.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs
index 928e300..ff9f7ea 100644
--- a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs
+++ b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs
@@ -452,6 +452,63 @@ 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).Contains("GetEntityTypes().ToList()");
+
+ // The inherited property must not be configured against its declaring type: the base type is not
+ // part of the model. Asserted against the configuration call specifically — the mappability
+ // predicate also consults DeclaringType, to locate a compiler-generated backing field, and that is
+ // unrelated to which entity type the conversion is declared on.
+ await Assert.That(registryText).DoesNotContain("modelBuilder.Entity(property.DeclaringType");
+ await Assert.That(registryText).DoesNotContain("Entity(property.DeclaringType!)");
+ }
+
[Test]
public async Task EFRegistry_IncludesComplexTypeAndJsonMappings(CancellationToken cancellationToken)
{
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/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/Generators/ScalarCustomPropertyNameTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs
new file mode 100644
index 0000000..a2adf18
--- /dev/null
+++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs
@@ -0,0 +1,103 @@
+namespace Purview.ValueObjects.SourceGenerator.Generators;
+
+///
+/// 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/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/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs
index d4ad2ce..104924d 100644
--- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs
+++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs
@@ -373,4 +373,144 @@ public static bool CreateRejectsInvalid()
await Assert.That(acceptsValid).IsTrue();
await Assert.That(rejectsInvalid).IsTrue();
}
+
+ [Test]
+ public async Task Scalar_GivenNonSentinelRule_IsAdaptedThroughCreate(CancellationToken cancellationToken)
+ {
+ // 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 ZodSharp;
+ using ZodSharp.Rules;
+
+ namespace Testing
+ {
+ [Scalar]
+ [ZodSchema]
+ [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() => UserId.Create(Guid.NewGuid()).Value != Guid.Empty;
+
+ public static string? CreateReportsCode()
+ {
+ try
+ {
+ UserId.Create(Guid.Empty);
+ return null;
+ }
+ catch (global::ZodSharp.Core.ZodException exception)
+ {
+ return exception.Errors.Length == 1 ? exception.Errors[0].Code : "unexpected-count";
+ }
+ }
+
+ 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() => UserId.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 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(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/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs
new file mode 100644
index 0000000..d87a683
--- /dev/null
+++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSharpScalarRuleAdapterGeneratorTests.cs
@@ -0,0 +1,229 @@
+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();
+ }
+
+ [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();
+ }
+}
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");
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 @@
-
+
+
diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs
new file mode 100644
index 0000000..67bb319
--- /dev/null
+++ b/src/tests/ValueObjects.IntegrationTests/Serialization/EFNotMappedTests.cs
@@ -0,0 +1,139 @@
+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"));
+ }
+
+ // 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:");
+ 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/ZodSchemaIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs
index dc500f7..b7a7c70 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,124 @@ public async Task ZodInsteadOfHooksEmail_GivenInsteadOfHooksMode_DoesNotRunOnVal
// Assert
await Assert.That(created.Value).IsEqualTo("anything");
}
+
+ [Test]
+ public async Task AssetId_GivenNonSentinelRule_ReportsInvalidValueThroughCreate()
+ {
+ // 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();
+ await Assert.That(error.Code).IsEqualTo(NonSentinelRule.ErrorCode);
+ await Assert.That(error.Message).IsEqualTo("AssetId must not be empty.");
+ // Built-in rules own their code but report no structured origin.
+ await Assert.That(error.Origin).IsNull();
+ // 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.
+ await Assert.That(AssetId.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(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 = ExternalId.Create(Guid.NewGuid());
+
+ // Assert
+ await Assert.That(created.Value).IsNotEqualTo(Guid.Empty);
+
+ 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(ExternalId.Hydrate(Guid.Empty).Value).IsEqualTo(Guid.Empty);
+ }
+
+ [Test]
+ public async Task CorrelationId_GivenNonSentinelCodeOverride_ReportsTheOverride()
+ {
+ // 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();
+
+ // 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
new file mode 100644
index 0000000..86d947d
--- /dev/null
+++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs
@@ -0,0 +1,75 @@
+using ZodSharp;
+using ZodSharp.Rules;
+
+namespace Purview.ValueObjects.Serialization;
+
+///
+/// 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.
+///
+[Scalar]
+[ZodSchema]
+[NonSentinel(Message = "AssetId must not be empty.")]
+public 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.")]
+public readonly partial record struct UserId
+{
+ public Guid Value { get; }
+}
+
+///
+/// A third Guid-backed scalar using the same built-in attribute.
+///
+[Scalar]
+[ZodSchema]
+[NonSentinel(Message = "ExternalId must not be empty.")]
+public readonly partial record struct ExternalId
+{
+ public Guid Value { get; }
+}
+
+///
+/// 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]
+[NonSentinel(Code = "invalid_correlation_id", Message = "CorrelationId must not be empty.")]
+public readonly partial record struct CorrelationId
+{
+ public Guid Value { get; }
+}
+
+///
+/// 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.
+///
+[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;
+}
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]