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 85ad444..0000000
--- a/.agents/prompts/sdk-diagnose-agent-folder-copy.md
+++ /dev/null
@@ -1,26 +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=`.
-5. Verify repo-root discovery succeeded: explicit `RepoRoot`, then a nearby `AGENTS.md`, then source-control
- root metadata.
-6. 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, or repo-root
- discovery miss).
-- 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.
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/.csharpierignore b/.csharpierignore
new file mode 100644
index 0000000..f9b1858
--- /dev/null
+++ b/.csharpierignore
@@ -0,0 +1,3 @@
+# CSharpier 1.3.0 (latest) cannot parse the C# 15 `union` keyword, so files containing native
+# union declarations are excluded from formatting. Revisit once CSharpier supports C# 15 unions.
+src/src/ZodSharp/Unions/NativeUnion.cs
diff --git a/.editorconfig b/.editorconfig
index 959b462..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
@@ -2638,7 +2643,7 @@ trim_trailing_whitespace = false
# If it's a settings, model, dto, etc file, ignore the 'properties' cannot have arrays
# and other annoying rules - the following section is duplicated
-[**/*{ValueObjects,Settings,Options,Model,Models,DTO,Entity,Response,Request}.{cs,vb},]
+[**/*{ValueObjects,Settings,Options,Model,Models,DTO,Entity,Response,Request}{.Nav,}.{cs,vb}]
dotnet_diagnostic.CA1002.severity = none
dotnet_diagnostic.CA1024.severity = none
dotnet_diagnostic.CA1056.severity = none
@@ -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 f3632c2..5b0c0d9 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -13,6 +13,64 @@ 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, but this repository targets net11.0 and pins
+ # the .NET 11 SDK in global.json. Without the matching SDK the runner cannot resolve global.json
+ # ("A compatible .NET SDK was not found") and every step fails, starting with `dotnet tool
+ # install`. Before global.json pinned a version the same gap failed later instead, with
+ # NETSDK1045 on the net11.0 target, because the 10.0.x SDK cannot build it.
+ # 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
+
+ # The shared pipeline runs the C# suite only, so the TypeScript/Zod side of the cross-platform
+ # parity guarantee (docs/wiki/Guarantees-and-Limitations.md) would otherwise never run in CI.
+ # The vitest suite validates the JSON the C# cross-platform tests write, so the C# tests must run
+ # first, in this same workspace — hence running them here rather than relying on the `build` job.
+ cross-platform:
+ name: Cross-platform (C# to Zod)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ # Installs exactly the SDK global.json pins, which is the only thing the first dotnet command
+ # in the job can resolve against. Naming a floating version and a feed quality instead does
+ # not work: `11.0.x` with quality `preview` resolves the latest *preview*-channel build, and a
+ # preview sorts below the pinned rc, so global.json is left unsatisfiable.
+ - uses: actions/setup-dotnet@v6
+ with:
+ global-json-file: global.json
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version-file: package.json
+
+ - run: bun install --frozen-lockfile
+
+ - name: Run the C# cross-platform tests (writes the fixtures vitest reads)
+ run: >-
+ dotnet test --solution src/ZodSharp.slnx --configuration Debug
+ --treenode-filter '/*/ZodSharp.Json/*CrossPlatformTests/*'
+
+ - name: Run the TypeScript/Zod cross-platform tests
+ run: bun run test
+
+ # Pack validation checks the IL-merged generator is present in the .nupkg; it cannot check that the
+ # assembly loads and generates. That merge has regressed twice (#36, #38), both times breaking consumers
+ # while the in-repo tests stayed green, because those run against the unmerged generator.
+ packed-generator:
+ name: Packed generator smoke test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ # Same reason as the cross-platform job: the SDK comes from global.json, not a floating
+ # version plus a feed quality.
+ - uses: actions/setup-dotnet@v6
+ with:
+ global-json-file: global.json
+
+ - name: Build and run a consumer against the packed generator
+ shell: pwsh
+ run: ./scripts/test-packed-generator.ps1 -Configuration Release
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 162c17e..655aa10 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,6 +13,10 @@ 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, but this repository targets net11.0 and pins
+ # the .NET 11 SDK in global.json; without it the release job cannot resolve global.json.
+ # Keep in sync with `sdk.version` in global.json (and with .github/workflows/pr.yml).
+ dotnet-version: "11.0.100-rc.1.26425.128"
release-mode: NuGet
release-branch: main
secrets: inherit
diff --git a/AGENTS.md b/AGENTS.md
index 34a9451..0b49120 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@ Purview.ZodSharp is a high-performance schema validation library for C#, ported
- The project is maintained at `purview-dev/zodsharp`.
- Public API namespaces are `ZodSharp.*`; packages and assemblies are published under the `Purview.ZodSharp.*` package IDs.
-- Multi-targets `net8.0`, `net9.0` and `net10.0`; the source generator targets `netstandard2.0` so it runs in any compiler host.
+- Multi-targets `net8.0`, `net9.0`, `net10.0` and `net11.0`; the source generator targets `netstandard2.0` so it runs in any compiler host.
## Repository layout
@@ -94,6 +94,20 @@ The generator and analyzer are built with `Purview.SourceGeneratorFramework`:
- Use `ForAttributeWithMetadataName` for attribute-driven discovery.
- Test incrementally, not just generated text (see the skills above).
+Validation rules follow a conventions analyzer (`ValidationRuleConventionsAnalyzer`, diagnostic `ZODSGEN042`): a source-declared rule (a type implementing `ZodSharp.Core.IValidationRule`) must expose its error identity as public `const string ErrorCode` and `const string MessageFormat` constants, so tests can assert against the rule rather than duplicating literals. New built-in rules must follow the same convention; keep `AnalyzerReleases.Shipped.md`/`AnalyzerReleases.Unshipped.md` in sync when a diagnostic is added or changed.
+
+## Numeric rules
+
+The numeric rules are split by the constraint each family needs — pick the constraint that matches the operations, never widen a rule unnecessarily:
+
+- **Bound rules** — `MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule` — are generic over `T : IComparable`. Keep this constraint: `ZodDate` closes the bound rules with `DateTime`, which does **not** implement `INumber`. Do not change them to `INumber`.
+- **Arithmetic rules** — `IntRule`, `FiniteRule`, `MultipleOfRule`, `EvenRule`, `OddRule` — are generic over `T : INumber` so they close with any numeric type (`int`, `long`, `double`, `decimal`, …).
+- **`SafeIntegerRule`** is intentionally `double`-only, because "safe integer" is a JavaScript `Number` concept (`int.MinValue`..`int.MaxValue`); do not make it generic.
+- `ZodNumber` closes the rules with `double`, `ZodBigInt` with `long`, and `ZodDate` with `DateTime`. Fluent methods on `ZodNumber` therefore use `XxxRule`.
+- Any code that matches a rule by name (for example the JSON Schema converter) must tolerate the generic arity suffix: a generic rule's `Type.Name` is `IntRule\`1`, so match with `StartsWith` rather than equality.
+
+Generated rule attributes carry the rule's **value** parameters as a constructor: a parameter declared without a default is a required constructor argument (so it cannot be silently omitted), a parameter with a default keeps it, and `message`/`code`/`origin` stay properties. Attribute usages for required values are positional (`[MinValue(3)]`, `[Regex("^[a-z]+$")]`); defaulted values may still be set by property name. When changing rule constructor parameters, update the affected generator tests and the wiki attribute tables.
+
## Packing and package READMEs
Each package ships its own `README.md`, placed in the project's `Sdk/` folder (for example `src/src/ZodSharp/Sdk/README.md`). The SDK's `PurviewAutoSdkPack` automatically maps `Sdk/*.md` to the package root and `Sdk/buildTransitive/**` to `buildTransitive/`, and the repo-root `README.md` is skipped when a package already packs its own README. Packages also ship `purview-logo-light.png` (linked via `src/Directory.Build.props`) and the core package ships `buildTransitive/Purview.ZodSharp.props`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f283c58
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,201 @@
+# 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; see
+[Release Flow](docs/wiki/Release-Flow.md).
+
+Released versions correspond to `v` GitHub releases. Entries below the `Unreleased` heading have not
+been published to NuGet.
+
+## Unreleased
+
+> **This release contains breaking changes.** See [Breaking changes](#breaking-changes) below before
+> upgrading from `2.0.0`. The analyzer catalogue records them under `2.1.0`; `package.json` still carries the
+> prerelease version and is set at release time.
+>
+> Note the changes below are consumer-visible: a rule's public `Code` constant was renamed to `ErrorCode`,
+> and rule constructor signatures changed, so attribute usages and code referencing those constants may not
+> compile against this version. Read the migration notes before upgrading.
+
+### Breaking changes
+
+- **Rule error-identity constants renamed from `Code` to `ErrorCode`.** Every validation rule now exposes its
+ error identity as public `const string ErrorCode` and `const string MessageFormat`. Code that referenced a
+ rule's `Code` constant must use `ErrorCode`. The new `ZODSGEN042` analyzer enforces the convention on
+ source-declared rules.
+- **Rule constructors are standardised.** Every rule now has a consistent constructor, extension method and
+ generated attribute. Generated rule attributes carry the rule's *value* parameters as constructor arguments:
+ a parameter declared without a default becomes a required positional argument, a parameter with a default
+ keeps it, and `message`/`code`/`origin` remain properties. Attribute usages for required values become
+ positional — `[MinValue(3)]`, `[Regex("^[a-z]+$")]`. Usages that previously set a required value by property
+ name no longer compile.
+
+### Added
+
+- **Native C# 15 union support.** `Z.NativeUnion` and `ZodTypedNativeUnion` return a native union on `net11.0`,
+ giving allocation-free, exhaustively pattern-matchable options. `ZODSGEN041` suggests it where a typed union's
+ option types are all reference types.
+- `net11.0` added to the target frameworks (now `net8.0`, `net9.0`, `net10.0`, `net11.0`).
+- **Per-framework `Microsoft.Extensions.*` dependencies.** All target frameworks previously resolved
+ `Microsoft.Extensions.Options` and `…DependencyInjection.Abstractions` 10.0.12, so referencing this package
+ dragged a **net8.0 (LTS)** application onto .NET 10 assemblies. Each framework now gets its matching line:
+ net8.0 → 8.0.x, net9.0 → 9.0.20, net10.0 → 10.0.12. On net11.0 the SDK prunes them entirely because the
+ targeted framework supplies them, which is why the net11.0 dependency group in the `.nuspec` is empty —
+ that is expected, and a net11.0 consumer resolves them from the framework (verified).
+- **Auto-generated attributes for all rule types**, including non-generic and open-generic rules under a single
+ attribute, and expanded downstream rule generation for `Purview.ValueObjects` consumers.
+- A new `enum` rule, applied automatically on generated schemas, plus additional explicit rules.
+- `IZodRule` organisation with a supporting analyzer, and `RuleMessage` is now public.
+- Seven new diagnostics: `ZODSGEN037`–`ZODSGEN043`, recorded in `AnalyzerReleases.Shipped.md` under
+ `## Release 2.1.0`. See [Source Generator Diagnostics](docs/wiki/Source-Generator-Diagnostics.md).
+ `AnalyzerReleases.Unshipped.md` is now empty; the next diagnostic added goes there and moves across when
+ it ships.
+
+### Changed — trimming and Native AOT
+
+- `Purview.ZodSharp` is now marked `IsAotCompatible`, enabling `IsTrimmable` and both analyzers. Getting
+ there meant replacing the JSON Schema exporter's name-based reflection with type-checked internal seams
+ (`IJsonSchemaArrayInfo`, `IJsonSchemaInnerSchema`, `IJsonSchemaNullableInfo`, `IJsonSchemaLiteralInfo`,
+ plus internal accessors on the rules and `ZodType`). That removed all 17 trim warnings *and* the six
+ silent export defects above — both were symptoms of the same root cause.
+- **`RegisterFromAssembly` is now genuinely trim- and AOT-safe, not merely annotated.** It used to rebuild
+ the validator's type name as a string and ask `Assembly.GetType(string)` for it — untrimmable, so the
+ validators were removed from a published application and registration threw at runtime. The generator now
+ records the validator in the attribute as a `typeof`, which roots it, and
+ `ZodSchemaGeneratedAttribute.ValidatorType` is annotated
+ `[DynamicallyAccessedMembers(PublicParameterlessConstructor)]` so its constructor survives. No string
+ resolution remains, and the `[RequiresUnreferencedCode]` annotation has been removed entirely. Discovery
+ is also now proportional to the number of generated schemas rather than the size of the assembly.
+ - **Breaking:** `ZodSchemaGeneratedAttribute` now takes `(Type targetType, Type validatorType)`. The
+ attribute is generator-emitted, so it regenerates on rebuild; only hand-written usages need updating.
+- **`Purview.ZodSharp.SystemTextJson` is AOT-clean and marked.**
+ - The validating `JsonConverter` resolves a `JsonTypeInfo` from the serializer options instead of
+ calling the reflection-based `JsonSerializer` overloads, so it defers to whatever resolver the host
+ configured — a source-generated `JsonSerializerContext` in a trimmed or AOT application. It also caches
+ the converter-excluding options copy per instance; it previously rebuilt them on every read and write,
+ which meant a cold metadata cache on each call, not just an allocation.
+ - Every extension method now comes in a pair: a `JsonTypeInfo` overload that is safe everywhere, and
+ the existing `JsonSerializerOptions` overload annotated `[RequiresUnreferencedCode]`/
+ `[RequiresDynamicCode]`. This is the same shape `JsonSerializer` itself uses.
+ - JSON Schema import and export use this package's own source-generated `JsonSchemaJsonContext`, so they
+ need nothing from the consumer. To make that possible the custom `JsonSchemaNamingPolicy` was removed —
+ a naming policy is a runtime object the source generator cannot reproduce — and the four `$`-prefixed
+ keyword names are now declared with `[JsonPropertyName]` on `JsonSchemaDefinition`. **The wire format is
+ unchanged**, and the test for it now asserts the emitted JSON rather than the naming mechanism.
+- `Purview.ZodSharp.AspNetCore` is AOT-clean and marked. Graph-based assembly scanning
+ (`ScanAssemblyGraphs`) stays reflective by nature and is documented as the path to avoid in a trimmed or
+ AOT host, in favour of naming assemblies explicitly or registering validators directly.
+- The discriminated-union discriminator accessor falls back to plain reflection when
+ `RuntimeFeature.IsDynamicCodeSupported` is false, instead of relying on expression compilation that Native
+ AOT does not provide.
+- `Purview.ZodSharp.NewtonsoftJson` is **not** marked AOT-compatible and cannot be: Newtonsoft.Json is
+ reflection-based throughout. Use `Purview.ZodSharp.SystemTextJson` in a trimmed or Native AOT
+ application.
+
+### Changed
+
+- Clarified in `SourceGenerators.csproj` that `EnforceExtendedAnalyzerRules` and `TreatWarningsAsErrors` are
+ supplied and enforced by `Purview.BuildSdk` for every `IsRoslynComponent` project, rather than being optional
+ project settings. Both were already `true`; the commented-out block implied otherwise.
+
+### Security
+
+- **Regex match timeouts on every pattern the library compiles.** `RegexRule(string)` and the generated
+ `[RegularExpression]`/`[Regex]` support previously compiled patterns with **no** timeout, so a
+ catastrophically backtracking pattern could hang the calling thread — and the generated path, which is how
+ ASP.NET Core request DTOs are validated, dropped the 2-second default that
+ `System.ComponentModel.DataAnnotations.RegularExpressionAttribute` provides. JSON Schema import was worse:
+ both the pattern and the input come from outside the application. All of these now carry
+ `RegexRule.DefaultMatchTimeout`, and exceeding it is reported as a validation failure rather than an
+ exception escaping into the host.
+- The shared budget is 2 seconds, matching `RegularExpressionAttribute`. The built-in `Email`, `Url` and
+ `Duration` rules previously used 100 ms, which proved too tight: under full-suite parallel load their
+ matches exceeded it and threw `RegexMatchTimeoutException`, rejecting valid input. Bounding the work is
+ what defeats ReDoS; an aggressive bound only adds false negatives.
+
+### Fixed
+
+- **`ZodArray` built corrupt error paths for nested element failures.** It used the two-argument
+ `ImmutableArray.CopyTo(destination, destinationIndex)` — which copies *into* that index — and then
+ overwrote the copied element with the index segment. For an element error at path `["email"]` the result
+ was `[null, "[0]"]` instead of `["[0]", "email"]`: the field name was destroyed and a null segment
+ introduced. `ProblemDetails` therefore reported the key as `[0]`, so an API client could not tell which
+ field of which array element failed. Only arrays whose element schema produces a path (arrays of objects,
+ nested arrays) were affected, which is why the existing containment-based assertion did not catch it.
+- **`ZodArray` enforces its length constraints before validating any element.** They depend only on the
+ array's length, but were checked last — so an array of a million elements against `.Max(10)` ran a
+ million element validations and allocated a million `ValidationError`s, each with an interpolated index
+ string, before reporting that the array was simply too long. A single request could pin a core and a
+ large heap allocation on input the schema had already declared out of range. Note this changes which
+ error is reported when an array is both the wrong length *and* has invalid elements: the length failure
+ now wins, which matches the container constraint being the outer one.
+- **`CompiledValidator` no longer leaks a `DynamicMethod` per call.** It built an expression tree on every
+ invocation that compiled to a single `IZodSchema.Validate` call — no inlining, no devirtualisation, just a
+ `LambdaExpression.Compile` whose emitted method is never reclaimed in a non-collectible load context. The
+ documented inline usage therefore leaked managed and native memory for the process lifetime. The delegate
+ now binds `Validate` directly and is cached per schema instance with weak keys, which is behaviourally
+ identical, strictly cheaper, and removes the `RequiresDynamicCode` dependency.
+- **JSON Schema export was silently dropping most of the schema.** `ToJsonSchemaConverter` reached into the
+ library's own types by reflecting on type and field *names*, and several of those names matched nothing.
+ `GetField` returns null rather than throwing, so each lookup failed quietly. Fixed, with tests:
+ - **Object properties were always empty.** It looked for a field `_shape` on `ZodObject`; the shape is a
+ primary-constructor parameter and `ZodObject.Shape` has been public all along.
+ - **Object property types were empty even once the shape was found**, because the builders wrap each field
+ schema to present it untyped and the wrapper fell through to the generic fallback.
+ - **Array `Min`/`Max` never appeared.** It looked for rules named `MinItemsRule`/`MaxItemsRule` reading
+ fields `_minItems`/`_maxItems`. None of those four names exists anywhere in the library.
+ - **Array element schemas never appeared**, from a `_elementSchema` field that does not exist.
+ - **Optional properties exported as an empty "any" schema**, from a `_innerSchema` field that does not exist.
+ - **Unions exported with an empty `anyOf`**, from a `_options` field that does not exist — and the cast
+ expected an array where the member is an `IReadOnlyList`.
+ - **Optional properties were still listed as required**, because the required check matched the wrapper's
+ type name rather than asking `IOptionalSchema`.
+- Scalar schema generation, which had regressed.
+- The `uuid` attribute rule required both constructors to be present to generate.
+- Nullability annotations on `IZodRule` properties.
+
+### Added — packed generator smoke test
+
+- `just smoke-packed-generator` (and a `packed-generator` CI job) packs the package, then builds and runs a
+ throwaway consumer against it with an isolated package cache. Every in-repo generator test runs against
+ the **unmerged** generator by design, and pack validation only checks the IL-merged assembly is *present*
+ in the `.nupkg` — nothing checked that it loads and generates. That merge has regressed twice (`#36`,
+ `#38`), both times silently breaking consumers while this repository's suite stayed green. Verified to
+ have teeth: with generation disabled the consumer fails to compile with
+ `CS0103: The name 'PersonSchema' does not exist`.
+
+### Documentation
+
+- **Removed two unsubstantiated performance claims.** "10x faster than reflection-based validation" appeared
+ twice; the benchmark suite measures this library against itself across scenarios and contains no
+ comparison with another validation library, so there was nothing behind it. The README now says so
+ explicitly and points you at measuring your own schemas. Also removed "Array pooling via `ArrayPool`
+ for zero-allocation helpers": `ArrayPool` appears only inside `ZeroAllocationHelpers`, which is
+ `internal` and has no callers anywhere — the claim described dead code. The `Span` bullet now names
+ where that work actually is (`IStringValidationRule`, `ZodString.ValidateSpan`/`IsValidSpan`,
+ `EmojiRule`). The measured sub-microsecond timings are unchanged; those are benchmark-backed.
+- **Fixed a thread-safety contradiction.** `Compiled-Validators-and-Caching.md` claimed "Schemas are
+ immutable and shareable", while `Guarantees-and-Limitations.md` correctly documents that the fluent rule
+ methods mutate the receiver in place. A reader who believed the first would cache a schema and then
+ mutate it from a request path. That page now states the build-then-share rule, cross-references the
+ limitation, and documents three properties of `SchemaCache` that matter because it is process-global: a
+ shared key space, no type check on retrieval, and no eviction.
+- `Compiled-Validators-and-Caching.md` also described the old expression-tree implementation and claimed it
+ removed interface dispatch. Neither was true: the tree was a single `Validate` call. The page now
+ describes what the type actually does — and says plainly that it does not make validation faster.
+- `Performance.md` records that its figures were produced with BenchmarkDotNet 0.15.8 while the repository
+ now pins 0.16.0-preview.2, so the numbers are from a different version than the suite builds against.
+- Corrected the target-framework list in `README.md` and linked `LICENSE.md` from the licence section.
+- The vitest cross-platform suite now runs in CI, and it **fails** rather than silently passing when the C#
+ cross-platform fixtures are absent. It also now reads the C# output recursively — the previous flat directory
+ read never matched a file, so the C#-to-Zod handshake had never actually been asserted.
+
+## 2.0.0
+
+First stable release. See the
+[`v2.0.0`](https://github.com/purview-dev/zodsharp/releases/tag/v2.0.0) release notes, and
+[What's new in v2](README.md#whats-new-in-v2) for the summary of the `Purview.*` package IDs, the JSON
+integrations, JSON Schema interoperability, the ASP.NET Core `ProblemDetails` integration and the expanded
+DataAnnotations support.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d3b5995..93b3457 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,25 +11,48 @@
5.9.0
5.9.0
1.68.17
- 1.0.0-prerelease.54
+ 1.0.0
10.0.12
+
+ $(DotnetRuntimeVersion)
+ 8.0.2
+ 9.0.20
+ $(MicrosoftExtensionsVersion)
+ 8.0.1
-
+
-
+
+
-
+
` instances.
- **JSON Schema interoperability.** Schemas can be exported via `Z.ToJsonSchema` and imported via `Z.FromJsonSchema`, enabling cross-language reuse with TypeScript/Zod. The import API lives in the JSON integration package's namespace (`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`); export stays in the core package.
- **ASP.NET Core ProblemDetails integration.** Failed validation results convert directly to `HttpValidationProblemDetails` via `result.ToHttpValidationProblemDetails()`.
@@ -240,8 +240,8 @@ Purview.ZodSharp implements several optimizations for maximum performance:
#### 1. Zero-allocation Validation
- Validation rules implemented as `struct` to avoid allocations
-- Use of `Span` and `ReadOnlySpan` when appropriate
-- Array pooling via `ArrayPool` for zero-allocation helpers
+- Use of `Span` and `ReadOnlySpan` when appropriate — specifically `IStringValidationRule` with
+ `ZodString.ValidateSpan`/`IsValidSpan`, and `EmojiRule`
#### 2. Struct-based Rules
@@ -250,10 +250,15 @@ All validation rules are structs:
```csharp
public readonly struct MinLengthRule : IValidationRule
{
+ public const string ErrorCode = "too_small";
+ public const string MessageFormat = "String must be at least {0} characters long, but got {1}";
+
// Zero allocation validation
}
```
+Every rule exposes its reported code and message template as public `ErrorCode`/`MessageFormat` constants so tests can assert against the rule instead of duplicating literals (`ZODSGEN042` enforces this convention — see the [Validation Rules Reference](docs/wiki/Validation-Rules-Reference.md) for the catalogue of built-in rules, [Custom Rules](docs/wiki/Custom-Rules.md) for the rule contract and [Value Objects Integration](docs/wiki/Value-Objects-Integration.md) for scalar value objects).
+
#### 3. Compiled Validators
Use expression trees to compile validators at runtime for maximum speed:
@@ -291,12 +296,15 @@ dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -- --filter
**Key performance highlights**:
-- **10x faster** than reflection-based validation libraries
- **Zero allocations** for primitive validations
- **Sub-microsecond** validation for simple types
- **Minimal GC pressure** with struct-based architecture
- **Scalable** performance even with complex nested schemas
+> The benchmark suite measures this library against itself across scenarios; it does not benchmark against
+> other validation libraries, so no comparative claim is made here. If a comparison matters to your
+> decision, measure it against your own schemas and payloads.
+
See the [performance README](src/src/Benchmarks/README.md) for detailed benchmark results and optimization tips.
## Architecture
@@ -549,6 +557,7 @@ var either = UserSchema.ApplyOr(user, u => u.Age < 18, "Must be an adult or a mi
- Zero-reflection, zero-allocation validators
- Value-first composition methods (`.ApplyAnd()`, `.ApplyOr()`, `.ApplyRefine()`) plus instance schema-composing composition (`.Refine()`, `.SuperRefine()`, `.Pipe()`, `.Catch()`, `.Prefault()`, `.Default()`)
- Supports classes, structs, and records
+- Validates `Purview.ValueObjects` `[Scalar]` types as a unit, adapting a rule written against the underlying value automatically ([Value Objects Integration](docs/wiki/Value-Objects-Integration.md))
#### Supported DataAnnotations size validators
@@ -634,7 +643,11 @@ Package versions are declared centrally in `Directory.Packages.props`. No `packa
## License
-MIT — the license is declared in the NuGet package metadata (`PackageLicenseExpression`) and in `package.json`.
+MIT — see [`LICENSE.md`](LICENSE.md). The license is also declared in the NuGet package metadata
+(`PackageLicenseExpression`) and in `package.json`.
+
+This repository is a fork of [ZodSharp](https://github.com/guinhx/ZodSharp); see
+[Acknowledgments](#acknowledgments).
## Contributing
diff --git a/bun.lock b/bun.lock
index f41d0d4..0b62f9f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,7 +5,6 @@
"": {
"name": "zodsharp",
"devDependencies": {
- "tsx": "^4.23.13",
"vitest": "^5.0.0",
"zod": "^4.6.2",
},
@@ -176,8 +175,6 @@
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
- "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="],
-
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"vitest": ["vitest@5.0.0", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.0", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.0", "@vitest/browser-preview": "5.0.0", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.0", "@vitest/coverage-v8": "5.0.0", "@vitest/ui": "5.0.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q=="],
diff --git a/docs/wiki/Arrays-and-Other-Schemas.md b/docs/wiki/Arrays-and-Other-Schemas.md
index cf1aa79..589382e 100644
--- a/docs/wiki/Arrays-and-Other-Schemas.md
+++ b/docs/wiki/Arrays-and-Other-Schemas.md
@@ -34,6 +34,23 @@ var schema = Z.Boolean();
var schema = Z.Null();
```
+## Date
+
+`ZodDate` validates `DateTime` values, with optional inclusive `.Min`/`.Max` bounds. Equivalent to Zod's `z.date()`.
+
+```csharp
+var schema = Z.Date().Min(new DateTime(2020, 1, 1)).Max(new DateTime(2030, 12, 31));
+```
+
+## BigInt
+
+`ZodBigInt` validates 64-bit integers (`long`), with `.Min`, `.Max`, `.Gt`, `.Gte`, `.Lt`, `.Lte`, `.Positive`,
+`.Negative`, `.NonNegative`, and `.NonPositive`. Equivalent to Zod's `z.bigint()`.
+
+```csharp
+var schema = Z.BigInt().Positive().Max(9_000_000_000);
+```
+
## Enum (string values)
`ZodEnum` validates against a set of allowed strings. Failure produces `invalid_enum_value`.
diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md
index 0520f30..563d4cd 100644
--- a/docs/wiki/AspNetCore-Integration.md
+++ b/docs/wiki/AspNetCore-Integration.md
@@ -49,6 +49,35 @@ public sealed class ValidationIssue
}
```
+## Minimal API validation
+
+Use `WithZodSharpValidation()` to validate the request DTO at the endpoint and short-circuit to a standard
+validation-problem response, or `ToValidationProblem()` to return a `Results.ValidationProblem` `IResult`
+directly from a handler:
+
+```csharp
+using Microsoft.AspNetCore.Builder;
+using ZodSharp;
+using ZodSharp.AspNetCore;
+
+// Validate the bound request DTO automatically (requires AddZodSharp to register the validator):
+app.MapPost("/users", (UserDto dto) => dto)
+ .WithZodSharpValidation();
+
+// Or validate manually and return a minimal-API IResult:
+app.MapPost("/users", (UserDto dto) =>
+{
+ var result = UserDtoSchema.Validate(dto);
+ return result.IsSuccess ? TypedResults.Ok(result.Value) : result.ToValidationProblem();
+});
+```
+
+`WithZodSharpValidation()` adds an endpoint filter that validates the first bound argument of type `T`
+against the `IZodSchemaFactory` registered by `AddZodSharp` (or `AddZodSharpFactory`). On failure it returns
+the same `HttpValidationProblemDetails` payload produced by `ToHttpValidationProblemDetails()`; on success it
+passes through to the handler. `ToValidationProblem()` is the handler-side equivalent, returning a status-400
+`Results.ValidationProblem` `IResult`.
+
## Exception handling
A thrown `ZodException` (for example from `Parse`, `GetValueOrThrow()`, or a value object's generated
diff --git a/docs/wiki/Compiled-Validators-and-Caching.md b/docs/wiki/Compiled-Validators-and-Caching.md
index d2ec690..9311605 100644
--- a/docs/wiki/Compiled-Validators-and-Caching.md
+++ b/docs/wiki/Compiled-Validators-and-Caching.md
@@ -2,7 +2,7 @@
## CompiledValidator
-`CompiledValidator` (namespace `ZodSharp.Expressions`) compiles a schema into an expression tree and a delegate.
+`CompiledValidator` (namespace `ZodSharp.Expressions`) returns a cached delegate for a schema.
```csharp
using ZodSharp;
@@ -17,14 +17,27 @@ var value = parser(input); // T, throws ZodException on failure
| Member | Signature | Returns |
|---|---|---|
-| `Compile` | `Func> Compile(IZodSchema schema)` | compiled validation delegate |
+| `Compile` | `Func> Compile(IZodSchema schema)` | validation delegate |
| `CompileParser` | `Func CompileParser(IZodSchema schema)` | returns the value or throws `ZodException` |
-The expression tree calls `IZodSchema.Validate` on the schema bound as a constant, removing interface dispatch overhead.
+The delegate binds `IZodSchema.Validate` directly, and the result is cached per schema instance with
+weak keys — so repeated calls for the same schema return the same delegate, and a discarded schema is still
+collectable.
+
+> **It does not make validation faster.** Despite the name, this type adds no optimisation over calling
+> `schema.Validate(value)` yourself: the delegate performs the same single interface call. It exists to give
+> you a `Func>` where one is wanted — a cached field, a dictionary of validators,
+> something that takes a delegate. If you want genuinely faster validation, use the source generator.
+>
+> Earlier versions built an expression tree and called `LambdaExpression.Compile` on every invocation. That
+> removed no dispatch (the tree was a single `Validate` call) and emitted a `DynamicMethod` that is never
+> reclaimed in a non-collectible load context, so calling it per request leaked managed and native memory.
+> It also made the package incompatible with Native AOT. Both are fixed; nothing is compiled at runtime now.
## SchemaCache
-`SchemaCache` (namespace `ZodSharp.Core`) is a `ConcurrentDictionary`-backed cache for expensive schema construction.
+`SchemaCache` (namespace `ZodSharp.Core`) is a `ConcurrentDictionary`-backed cache for
+expensive schema construction.
```csharp
using ZodSharp.Core;
@@ -41,8 +54,27 @@ var schema = SchemaCache.GetOrCreate("user", () =>
| `Count` | number of cached entries |
| `Clear()` | empties the cache |
-Schemas are immutable and shareable, so caching identical definitions avoids repeated construction cost across request boundaries.
+### What to know before using it
+
+A **fully built** schema is safe to cache and share across threads: validation only reads the rule set and
+the description. But building a schema is **not** immutable — the fluent rule methods mutate the receiver
+in place (see [Guarantees and Limitations](Guarantees-and-Limitations.md#fluent-rule-methods-mutate-the-receiver)).
+So finish configuring a schema *before* putting it in the cache, and never apply a fluent rule method to a
+schema you retrieved from it: that mutates the instance every other caller is sharing.
+
+Three further properties of `SchemaCache` worth knowing, because it is process-global:
+
+- **The key space is shared.** It is a single static dictionary keyed by an arbitrary string, so two
+ libraries in the same process that both use `"user"` collide. Prefix your keys.
+- **`GetOrCreate` does not verify the cached type.** The same key used with a different `T` throws
+ `InvalidCastException` at the point of retrieval.
+- **There is no eviction or size bound.** A key derived from user input grows the dictionary for the
+ lifetime of the process.
+
+A static field, or your container's singleton lifetime, is usually a better fit than this cache. Prefer it
+when you genuinely need lookup by name.
## The source generator alternative
-For the highest performance, prefer the compile-time source generator: `[ZodSchema]` emits a static validator with no runtime compilation or dispatch overhead. See [Source Generator](Source-Generator.md).
\ No newline at end of file
+For the highest performance, prefer the compile-time source generator: `[ZodSchema]` emits a static
+validator with no runtime compilation or dispatch overhead. See [Source Generator](Source-Generator.md).
diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md
index 09a2b2f..bdab450 100644
--- a/docs/wiki/Core-Concepts.md
+++ b/docs/wiki/Core-Concepts.md
@@ -5,7 +5,7 @@
Every schema derives from `ZodType` (namespace `ZodSharp.Core`). Validation is a two-phase pipeline:
1. **ParseInternal** — each schema overrides this hook to perform its type check and traversal (rejecting `null` where not allowed, coercing types, walking objects/arrays/tuples/unions, producing structured failures).
-2. **Rules** — on success, the accumulated `IValidationRule` structs are evaluated. A failing rule emits a `ValidationError` with code `"validation_failed"`.
+2. **Rules** — on success, the accumulated `IValidationRule` structs are evaluated. A failing rule emits a `ValidationError` carrying the rule's Zod-compatible code (`too_small`, `too_big`, `not_multiple_of`, `not_finite`, `invalid_string`, `invalid_type`, or `invalid_value`; `validation_failed` for custom rules without a code). Each rule exposes its reported code and message as public `const string ErrorCode` / `MessageFormat` constants, so tests can assert against the rule instead of duplicating literals (a rule that omits them is reported as `ZODSGEN042` — see [Custom Rules](Custom-Rules.md)).
```csharp
ValidationResult result = schema.Validate(value);
diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md
index 2a6acc8..464d2f6 100644
--- a/docs/wiki/Custom-Rules.md
+++ b/docs/wiki/Custom-Rules.md
@@ -23,9 +23,79 @@ Implementations should be structs so validation does not allocate. `IsValid` is
For a **string** rule, also implement `ZodSharp.Core.IStringValidationRule` (`bool IsValid(ReadOnlySpan value)` / `string GetErrorMessage(ReadOnlySpan value)`) so the rule participates in `ZodString.ValidateSpan`/`IsValidSpan` without materialising the input. Rules that only implement `IValidationRule` are still fully supported; they simply fall back to the string pipeline for span validation.
+## Error code and message definitions
+
+Every rule **should** expose its error identity as public constants so code (and tests) can assert against the rule rather than re-typing literals:
+
+```csharp
+public readonly record struct EmailRule : IValidationRule
+{
+ public const string ErrorCode = "invalid_string";
+ public const string MessageFormat = "Invalid email format: {0}";
+
+ public string Code => ErrorCode;
+ public string GetErrorMessage(in string value) =>
+ string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat, value);
+}
+```
+
+- **`ErrorCode`** is the rule's canonical code — the value a test compares against when no per-usage override is supplied. The interface member `IValidationRule.Code` defaults to `"validation_failed"`; a rule that accepts a per-usage `code` override returns it (falling back to `ErrorCode`) from both `Code` and `IZodRule.Code`, so every route reports the same effective value.
+- **`MessageFormat`** is a `string.Format` template. `{0}` (and `{1}`, …) are the offending value and any rule-specific arguments; format it with `string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat, …)`.
+- The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a shared base can host them for its concrete derivations.
+
+The convention is enforced by an analyzer: a source-declared rule that does not expose a public `const string ErrorCode` **and** a public `const string MessageFormat` is reported as **ZODSGEN042**. See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list.
+
+A `[Test]` can therefore assert without duplicating strings:
+
+```csharp
+await Assert.That(error.Code).IsEqualTo(EmailRule.ErrorCode);
+await Assert.That(error.Message).IsEqualTo(
+ string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, value));
+```
+
+### Runtime identity: `IZodRule`
+
+The constants are the rule's *static* default. When one attribute must produce a different code per annotated member, the rule implements `ZodSharp.Core.IZodRule` and supplies its own code/origin at runtime:
+
+```csharp
+public interface IZodRule
+{
+ string? Code { get; } // null falls back to the attribute-mapped code
+ string? Origin { get; } // null falls back to the attribute-mapped origin
+}
+```
+
+The generated code casts to `IZodRule` when reading the values, so the identity can depend on the rule's constructor arguments. A rule that accepts a `code`/`origin` constructor parameter **without** implementing `IZodRule` never reaches the reported error identity and is reported as **ZODSGEN039**.
+
+The reported code and origin are resolved from the rule (when it implements `IZodRule`), then the applied attribute, then the `[ZodRule(typeof(...))]` mapping, and finally default to `validation_failed` — see [Error identity: code and origin precedence](#error-identity-code-and-origin-precedence) for the full order.
+
+### Message overrides
+
+The reported message is resolved in this order:
+
+1. A `message` argument on the rule (typically a `Message` constructor parameter), when set.
+2. The attribute's `ErrorMessage` / `ErrorMessageResourceName` / `ErrorMessageResourceType`, mapped to a constructor parameter named `message`.
+3. `GetErrorMessage(value)`, which formats `MessageFormat` with the offending value.
+
+`MessageFormat` is therefore the fallback, not the only message.
+
+## Non-sentinel values (EF-friendly)
+
+`ZodSharp.Rules.NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to represent "no value" — `Guid.Empty`, `DateTime.MinValue`/`MaxValue`, `DateTimeOffset.MinValue`/`MaxValue`, `DateOnly.MinValue`/`MaxValue`, `TimeOnly.MinValue`/`MaxValue`, and `null`/empty/whitespace strings:
+
+```csharp
+var schema = Z.Date().AddRule(new NonSentinelRule());
+
+var result = schema.Validate(DateTime.MinValue);
+// result.Errors[0].Code == NonSentinelRule.ErrorCode ("invalid_value")
+```
+
+Close it with the property type to use it through an attribute (`[ZodRule(typeof(NonSentinelRule<>))]` on a matching `NonSentinelAttribute`). Types without a known sentinel always pass, so the rule never rejects a type it does not understand.
+
## Defining a custom rule
```csharp
+using System.Globalization;
using ZodSharp.Core;
namespace MyRules;
@@ -33,6 +103,9 @@ namespace MyRules;
/// Rejects strings that contain whitespace.
public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule
{
+ public const string ErrorCode = "invalid_string";
+ public const string MessageFormat = "Whitespace is not allowed in '{0}'.";
+
public bool IsValid(in string value)
{
if (value is null)
@@ -47,11 +120,15 @@ public readonly record struct NoWhitespaceRule(string? Message = null) : IValida
return true;
}
+ public string Code => ErrorCode;
+
public string GetErrorMessage(in string value) =>
- Message ?? $"Whitespace is not allowed in '{value}'.";
+ Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat, value);
}
```
+Every rule **should** declare the public `ErrorCode`/`MessageFormat` constants (see [Error code and message definitions](#error-code-and-message-definitions)); omitting them is reported as `ZODSGEN042`, and a rule without them reports the fallback code `validation_failed` when it does not also implement `IZodRule`.
+
The rule can be used standalone:
```csharp
@@ -72,13 +149,61 @@ var schema = Z.String().Rule(new NoWhitespaceRule("No spaces allowed."));
var result = schema.Validate("John Doe");
// result.IsSuccess == false
-// result.Errors[0].Code == "validation_failed"
+// result.Errors[0].Code == NoWhitespaceRule.ErrorCode ("invalid_string")
// result.Errors[0].Message == "No spaces allowed."
// result.Errors[0].Path is empty
```
Both methods mutate the receiver and return it for chaining; see [Guarantees and Limitations](Guarantees-and-Limitations.md#fluent-rule-methods-mutate-the-receiver).
+## Extending the fluent interface
+
+Every built-in rule has a dedicated method on its schema — `Z.String().Email()`, `Z.Number().Min(...)`, `Z.Date().NonSentinel()`, and so on. A custom rule gets the same ergonomics with an **extension method** that adds the rule to the receiver and returns it:
+
+```csharp
+using ZodSharp.Schemas;
+
+namespace MyRules;
+
+public static class ZodStringRuleExtensions
+{
+ /// Rejects strings that contain whitespace.
+ public static ZodString NoWhitespace(this ZodString schema, string? message = null)
+ {
+ schema.AddRule(new NoWhitespaceRule(message));
+ return schema;
+ }
+}
+```
+
+The call site then mirrors the built-in surface and stays chainable:
+
+```csharp
+var schema = Z.String().Min(3).NoWhitespace().ToUpper();
+```
+
+Two details keep the chain intact:
+
+- **Return the concrete schema type** (`ZodString`, `ZodDate`, …), not the base `ZodType`. The generic `Rule` helper and `AddRule` return the base type, so write `schema.Rule(new MyRule()); return schema;` (or call `AddRule` and return `schema`) rather than returning the result of `Rule(...)` directly.
+- **Target the schema whose output type the rule validates.** A rule implementing `IValidationRule` extends `ZodString`; a rule implementing `IValidationRule` extends whichever schema produces a `Guid`. For a rule that applies to *any* schema, extend the single-type-argument base `ZodType`:
+
+```csharp
+using ZodSharp.Core;
+
+public static class ZodTypeRuleExtensions
+{
+ /// Rejects the default value of the schema's output type.
+ public static ZodType NotEmpty(this ZodType schema, string? message = null)
+ where T : struct, IEquatable
+ {
+ schema.AddRule(new NotEmptyRule(Message: message));
+ return schema;
+ }
+}
+```
+
+A fluent extension only covers the runtime API. To make the same rule usable from `[ZodSchema]` models, map it to an attribute with `[ZodRule]` (see below). The built-in catalogue — including each rule's `ErrorCode` and `MessageFormat` — is in [Validation Rules Reference](Validation-Rules-Reference.md).
+
## Exposing a rule as a DataAnnotations attribute
Built-in rules map to `System.ComponentModel.DataAnnotations` attributes (`EmailRule` ↔ `[EmailAddress]`). A custom rule gets the same treatment in two steps:
@@ -128,7 +253,7 @@ Path = ["Name"]
Because the attribute derives from `ValidationAttribute`, the property participates in the same "carries a data annotation" discovery as the built-in attributes. The default error code is `validation_failed` when `Code` is not set.
> [!NOTE]
-> The attribute's constructor arguments are mapped positionally and its named arguments by name (case-insensitive) to the rule's public constructor parameters. A parameter named `message` is supplied from the attribute's `ErrorMessage` when one is set.
+> The attribute's arguments are mapped to the rule's public constructor parameters: named arguments by name (case-insensitive), and positional arguments by the applied attribute's own constructor parameter names first (with the raw position as a fallback for hand-authored attributes whose parameter names differ from the rule's). A parameter named `message` is supplied from the attribute's `ErrorMessage` when one is set.
### Error identity: code and origin precedence
@@ -146,9 +271,13 @@ public readonly record struct NotEmptyRule(string? Code = null, string? Messa
: IValidationRule, IZodRule
where T : struct, IEquatable
{
+ public const string ErrorCode = "invalid_value";
+ public const string MessageFormat = "Value must not be empty.";
+
public bool IsValid(in T value) => !value.Equals(default(T));
- public string GetErrorMessage(in T value) => Message ?? "Value must not be empty.";
+ public string GetErrorMessage(in T value) =>
+ Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat);
// The rule owns its identity, so callers can pass a per-member error code.
string? IZodRule.Code => Code;
@@ -157,6 +286,28 @@ public readonly record struct NotEmptyRule(string? Code = null, string? Messa
}
```
+### Declaring the identity contract
+
+A hand-authored attribute supplies those named arguments by exposing `Code` / `Origin` properties. Implement `ZodSharp.Core.IZodRuleAttribute` to make that contract explicit — the interface requires both members, so the compiler guarantees the properties the generator reads are present, and any member added to the interface is treated as identity rather than as an unconsumed argument:
+
+```csharp
+public sealed class NoWhitespaceAttribute : ValidationAttribute, IZodRuleAttribute
+{
+ public string? Code { get; set; }
+
+ public string? Origin { get; set; }
+}
+```
+
+Attributes that do not implement the interface are still read through the documented `Code` / `Origin` names, so existing declarations keep working.
+
+Two warnings keep the identity honest rather than silently dropped:
+
+| Situation | Diagnostic |
+|---|---|
+| A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the generated validation supplies the value and never reads it back. | `ZODSGEN039` |
+| An attribute supplies an argument the resolved rule never consumes: no matching constructor parameter, and not part of the error identity (`Code`/`Origin`/`IZodRuleAttribute` members) or the inherited `ErrorMessage`/`ErrorMessageResourceName`/`ErrorMessageResourceType` members. | `ZODSGEN040` |
+
## Generic rules
Map an **unbound generic** rule type and the generator closes it with the property type, so one rule serves every underlying primitive:
@@ -175,6 +326,58 @@ public sealed class NotEmptyAttribute : ValidationAttribute
- `[NotEmpty]` on a `Guid` property instantiates `NotEmptyRule`; on an `int` property it instantiates `NotEmptyRule`.
- The rule must expose exactly one type parameter. A type argument that cannot satisfy the rule's constraints (for example `NotEmptyRule where T : struct` applied to a `string`) is reported as `ZODSGEN030` and no rule is emitted, so the generated code always compiles.
+### Rule families: one attribute for a primitive and a scalar value object
+
+A constraint can also be *self-referential* (`where TSelf : IScalarValueObject`), which a primitive can never satisfy — `string` does not implement `IScalarValueObject`. Declare both halves of the rule side by side and the generator resolves the member that fits the annotated type:
+
+```csharp
+namespace MyRules;
+
+// Member-level half: validates the underlying primitive.
+public readonly record struct NonWhiteSpaceStringRule(string? Message = null)
+ : IValidationRule
+{
+ public const string ErrorCode = "invalid_string";
+ public const string MessageFormat = "Value must not be empty.";
+
+ public bool IsValid(in string? value) => value != null && !string.IsNullOrWhiteSpace(value);
+
+ public string GetErrorMessage(in string? value) =>
+ Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat);
+}
+
+// Value-object half: validates the scalar as a unit.
+public readonly record struct NonWhiteSpaceStringRule(string? Code = null, string? Message = null)
+ : IValidationRule, IZodRule
+ where TSelf : IScalarValueObject
+{
+ public const string ErrorCode = "invalid_string";
+ public const string MessageFormat = "Value must not be empty.";
+
+ public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value);
+
+ public string GetErrorMessage(in TSelf value) =>
+ Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat);
+
+ string? IZodRule.Code => Code;
+ string? IZodRule.Origin => "value_object";
+}
+
+[ZodRule(typeof(NonWhiteSpaceStringRule<>))]
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)]
+public sealed class NonWhiteSpaceStringAttribute : ValidationAttribute
+{
+ public string? Code { get; set; }
+
+ public string? Message { get; set; }
+}
+```
+
+- `[NonWhiteSpaceString]` on a `string`/`string?` member resolves to `NonWhiteSpaceStringRule` (the non-generic sibling).
+- `[NonWhiteSpaceString]` on a scalar type resolves to `NonWhiteSpaceStringRule`.
+- Resolution is symmetric: mapping the attribute to the *non-generic* rule still resolves the generic member for a scalar target.
+- When no member of the family can validate the target type, `ZODSGEN030` is reported and nothing is emitted.
+
## Generating the attribute from the rule
If you do not want to hand-write the attribute, mark the rule itself with the parameterless `[ZodRule]` and the generator emits a matching attribute:
@@ -188,9 +391,13 @@ namespace MyRules;
public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? Message = null)
: IValidationRule
{
+ public const string ErrorCode = "invalid_string";
+ public const string MessageFormat = "Whitespace is not allowed.";
+
public bool IsValid(in string value) => AllowEmpty || value.IndexOf(' ') < 0;
- public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed.";
+ public string GetErrorMessage(in string value) =>
+ Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat);
}
```
@@ -199,7 +406,9 @@ This produces a `NoWhitespaceAttribute` in the rule's namespace, shaped like:
```csharp
/// Validation attribute that applies NoWhitespaceRule.
[global::System.AttributeUsage(
- global::System.AttributeTargets.Property
+ global::System.AttributeTargets.Class
+ | global::System.AttributeTargets.Struct
+ | global::System.AttributeTargets.Property
| global::System.AttributeTargets.Field
| global::System.AttributeTargets.Parameter,
Inherited = true,
@@ -208,18 +417,92 @@ This produces a `NoWhitespaceAttribute` in the rule's namespace, shaped like:
public sealed class NoWhitespaceAttribute
: global::System.ComponentModel.DataAnnotations.ValidationAttribute
{
+ /// Initializes the attribute with the values required by NoWhitespaceRule.
+ public NoWhitespaceAttribute(bool allowEmpty = true)
+ {
+ AllowEmpty = allowEmpty;
+ }
+
public bool AllowEmpty { get; set; } = true;
+
+ public string? Message { get; set; } = null;
}
```
Mapping rules:
- The attribute name is the rule name with a trailing `Rule` replaced by `Attribute` (`NoWhitespaceRule` → `NoWhitespaceAttribute`). Override it with `[ZodRule(AttributeName = "…")]`.
-- Each public constructor parameter becomes a settable property, Pascal-cased, with the parameter's default value preserved. A parameter named `message` is omitted — use the inherited `ValidationAttribute.ErrorMessage` instead.
-- The rule must be non-generic, non-nested, and non-abstract, and every parameter type must be a legal attribute-argument type (primitive, `string`, `enum`, `System.Type`).
+- Each public constructor parameter becomes a settable property, Pascal-cased, with the parameter's default value preserved. The rule's **value** parameters are additionally emitted as a constructor: a parameter the rule declares without a default becomes a required constructor argument, so an attribute like `[MinValue]` cannot be applied without its bound, while a parameter with a default keeps that default (so `[NoWhitespace]` still works). A rule that overloads its constructor surfaces one attribute constructor per attribute-addressable overload, and the applied constructor selects the matching rule overload — `UUIDRule` yields both `[UUID]` (the versionless overload) and `[UUID(UuidVersion.V4)]` (the versioned one). A parameter named `message` becomes a `Message` property (the resolver maps it onto the rule's `message` argument) and defaults to the rule's `MessageFormat`; a parameter named `code`/`origin` becomes a `Code`/`Origin` property and `Code` defaults to the rule's `ErrorCode`; the inherited `ValidationAttribute.ErrorMessage` remains the fallback. A type-parameter parameter (for example the bound of `MinValueRule`) is surfaced as a `double`.
+- If the derived name collides with a `System.ComponentModel.DataAnnotations` attribute, the generated attribute is emitted under a `Zod` suffix (`MinLengthAttribute` → `MinLengthZodAttribute`, used as `[MinLengthZod]`).
+- The attribute is always decorated with `AttributeTargets.Class | Struct | Property | Field | Parameter`, so it can annotate a member or a scalar value object. (`Class`/`Struct` are what make the [type-level form](#type-level-rules) possible.)
+- The rule must be non-nested and non-abstract, and every parameter type must be a legal attribute-argument type (primitive, `string`, `enum`, `System.Type`).
+- An **arity-1 generic rule** can be marked as well: the generated attribute maps to the open generic (`[ZodRule(typeof(NonWhiteSpaceStringRule<>))]`), which is the form that serves both a primitive member and a scalar value object. Rules with two or more type parameters are rejected (`ZODSGEN032`).
+- When both halves of a [rule family](#rule-families-one-attribute-for-a-primitive-and-a-scalar-value-object) are marked, only the generic half emits the attribute; the two mappings would otherwise claim the same name.
+- If a hand-authored type already declares the derived name, the generated attribute is suppressed and reported as `ZODSGEN037`. The hand-authored declaration's own `[ZodRule]` mapping then governs every usage of that attribute name, so confirm it still matches what the call sites expect.
+- `[ZodRule(AllowMultiple = true)]` emits `AttributeUsage(..., AllowMultiple = true)`, so the attribute may be applied to a member more than once. Each application becomes its own rule, configured from that application's arguments and evaluated in source order:
+
+```csharp
+[ZodRule(AllowMultiple = true)]
+public readonly record struct MultipleOfRule(int Factor = 1, string? Message = null) : IValidationRule
+{
+ public const string ErrorCode = "not_multiple_of";
+ public const string MessageFormat = "Number must be a multiple of {0}, but got {1}";
+
+ public bool IsValid(in int value) => Factor != 0 && value % Factor == 0;
+
+ public string GetErrorMessage(in int value) =>
+ Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat, Factor, value);
+}
+
+[ZodSchema]
+public partial class Sample
+{
+ [MultipleOf(Factor = 3)]
+ [MultipleOf(Factor = 5)]
+ public int Value { get; set; } // must be a multiple of both 3 and 5
+}
+```
+
+- A **hand-authored** attribute whose name encodes a rule name (`XAttribute` → `XRule`) must map to a rule that addresses every rule declared under that name. A mapping that declares only a non-generic rule while an arity-1 generic sibling exists (or that declares an unrelated rule) is reported as `ZODSGEN038`, because some usages of the attribute would resolve to no rule. An attribute name that does not encode a declared rule family is left alone, so free-form names remain valid.
> [!IMPORTANT]
-> The generated attribute lives in the same assembly as the rule, but Roslyn generators cannot read another generator's output as a symbol. To *consume* the generated attribute with `[ZodSchema]`, reference the rule from a separate assembly (a rules library) — or hand-author the attribute and mark it with `[ZodRule(typeof(...))]`.
+> The generated attribute lives in the same assembly as the rule, but Roslyn generators cannot read another generator's output as a symbol. To *consume* a generated attribute with `[ZodSchema]`, reference the rule from a separate assembly (a rules library) — or hand-author the attribute and mark it with `[ZodRule(typeof(...))]`. The built-in rules below already satisfy this: their attributes ship inside `Purview.ZodSharp`.
+
+## Built-in attributes (shipped with Purview.ZodSharp)
+
+Every built-in rule that can be expressed as an attribute is generated once into the `Purview.ZodSharp` assembly, in the `ZodSharp.Rules` namespace, so a consumer can annotate a member or a scalar without hand-authoring anything:
+
+```csharp
+using ZodSharp;
+using ZodSharp.Rules;
+
+[ZodSchema]
+public partial class Contact
+{
+ [Email]
+ public string Email { get; set; } = string.Empty;
+
+ [E164]
+ public string Phone { get; set; } = string.Empty;
+
+ [Regex("^[a-z]+$")]
+ public string Code { get; set; } = string.Empty;
+
+ [NonSentinel(Message = "Id must not be the default.")]
+ public Guid Id { get; set; }
+}
+```
+
+Each attribute mirrors its rule's constructor parameters and reports the rule's own `ErrorCode`/`Origin` (every built-in rule implements `IZodRule`). A value the rule declares without a default is a **required constructor argument** — `[Regex("^[a-z]+$")]`, `[UUID(UuidVersion.V4)]`, `[MinValue(3)]` — so it can never be silently omitted; a value with a default keeps it (for example `[StartsWith("https://", StringComparison.OrdinalIgnoreCase)]`). A rule with overloaded constructors mirrors each overload, so `[UUID]` uses the versionless overload and `[UUID(UuidVersion.V4)]` (or `[UUID(Version = UuidVersion.V4)]`) the versioned one. `[Regex]` uses the `(string pattern, string? message)` overload, so `Pattern` is a string. A rule's `message` parameter is surfaced as a `Message` property (defaulting to the rule's `MessageFormat`) and its `code` parameter (where present) as a `Code` property (defaulting to the rule's `ErrorCode`). A type-parameter value is surfaced as a `double`; an array of type parameters (for example the allowed values of `AllowedValuesRule`) is surfaced as a `params object[]`, with each element converted back to the member type at the usage site, so `[AllowedValuesZod("a", "b", "c")]` works on a `string` member and `[DeniedValuesZod(1, 2, 3)]` on an `int` member.
+
+Two adjustments keep every rule addressable:
+
+- **Name collisions.** `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule`, `RequiredRule`, `RangeRule`, `LengthRule`, `StringLengthRule`, `CompareRule`, `AllowedValuesRule`, and `DeniedValuesRule` derive an attribute name that `System.ComponentModel.DataAnnotations` already uses. Their attributes are emitted under a `Zod` suffix instead — `[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]` — so the rule's own `Code`/`Message` stay usable alongside the DataAnnotations attribute.
+- **Generic bounds.** The generic bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule`) surface their type-parameter bound as a `double`, so `[MinValue(3)]` works on an `int` or a `double` member (the value is converted to the member type).
+
+The numeric parity and inclusive-comparison rules follow the same pattern: `[GreaterThanOrEqual(…)]`, `[LessThanOrEqual(…)]`, `[Even]`, and `[Odd]`.
+
+`[Enum]` mirrors `EnumRule` and closes the open generic with the annotated enum member type. The `[ZodSchema]` generator applies the rule automatically to enum properties; `[ZodIgnore]` on an enum member excludes that member from the rule everywhere the enum is validated, and a property's `[DeniedValues]` excludes values for that property only. See [Enum rules](Validation-Rules-Reference.md#enum-rules).
## Type-level rules
@@ -253,11 +536,11 @@ if (!assetIdCustomRule0.IsValid(value))
((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Code ?? "invalid_asset_id",
assetIdCustomRule0.GetErrorMessage(value),
EmptyPath,
- origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Origin ?? null));
+ origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Origin));
}
```
-- **Generic closure:** a type-level attribute closes an unbound generic rule with the **target type** (`NotEmptyRule`), so the rule sees the value object and can read its state through its own constraints.
+- **Generic closure:** a type-level attribute closes an unbound generic rule with the **target type** (`NotEmptyRule`), so the rule sees the value object and can read its state through its own constraints. On a `[Scalar]` type, a rule written against the underlying value is instead closed with that value and wrapped — see [Value Objects Integration](Value-Objects-Integration.md).
- **Ordering** in the generated `Validate`: property rules → **type-level rules** → the synchronous `Validate()` refinement.
- Type-level attributes need `AttributeTargets.Class`/`Struct` on the attribute declaration; the property-level attributes above only need `Property`/`Field`.
@@ -269,66 +552,9 @@ if (!assetIdCustomRule0.IsValid(value))
## Validating scalar value objects
-A `Purview.ValueObjects` scalar **is** a single value, so validate it as a unit rather than through its `Value` property. Scalars implement the two-type-parameter contract:
-
-```csharp
-public interface IScalarValueObject : IValueObject, IComparable, IComparable
- where TSelf : IScalarValueObject
-{
- TValue Value { get; }
- static abstract TSelf Create(TValue value);
- static abstract TSelf Hydrate(TValue value);
- int CompareTo(TValue other);
-}
-```
-
-so `AssetId` is `IScalarValueObject`. Today the check is normally repeated on every scalar:
-
-```csharp
-// repeated on every Guid scalar
-partial void OnZodValidate(RefineCtx context)
-{
- if (context.Value.Value == Guid.Empty)
- context.AddIssue("invalid_asset_id", "AssetId must not be empty.", [nameof(Value)]);
-}
-```
-
-> [!NOTE]
-> Refinements are written as the generator-declared `OnZodValidate` hook, not an
-> `IEnumerable Validate()` method — see
-> [Source Generator](Source-Generator.md#refinement-hook-onzodvalidate).
-
-Type **one** rule on the value object and put the attribute on the **scalar type**:
+A `Purview.ValueObjects` scalar **is** a single value, so validate it as a unit rather than through its `Value` property. Attach a rule at the **type level** (on the scalar type, not on its `Value` property) and the reported error has an **empty path**:
```csharp
-// MyRules/NotEmptyRule.cs — a rules library that references Purview.ValueObjects
-public readonly record struct NotEmptyRule(string? Code = null, string? Message = null)
- : IValidationRule, IZodRule
- where TSelf : IScalarValueObject
-{
- public bool IsValid(in TSelf value) => value.Value != Guid.Empty;
-
- public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty.";
-
- string? IZodRule.Code => Code;
-
- string? IZodRule.Origin => "value_object";
-}
-
-[ZodRule(typeof(NotEmptyRule<>))]
-[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)]
-public sealed class NotEmptyAttribute : ValidationAttribute
-{
- public string? Code { get; set; }
- public string? Message { get; set; }
-}
-```
-
-```csharp
-// Purview.ChangeOps
-using Purview.ValueObjects.Serialization;
-using ZodSharp;
-
[Scalar]
[ZodSchema]
[NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")]
@@ -336,18 +562,8 @@ public readonly partial record struct AssetId
{
public Guid Value { get; init; }
}
-
-[Scalar]
-[ZodSchema]
-[NotEmpty(Code = "invalid_external_identity_id", Message = "ExternalIdentityId must not be empty.")]
-public readonly partial record struct ExternalIdentityId
-{
- public Guid Value { get; init; }
-}
```
-The per-scalar `Validate()` refinements disappear, each scalar keeps its own `Code`/`Message`, and the reported error has an **empty path** because the rule applies to the value object itself:
-
```text
Code = "invalid_asset_id"
Message = "AssetId must not be empty."
@@ -355,13 +571,18 @@ Origin = "value_object"
Path = []
```
-Because the rule is closed with `TSelf` (`NotEmptyRule`), it *sees the value object* and reads `Value` through the `IScalarValueObject` constraint. A generic rule must have exactly one type parameter, so the underlying value type is pinned by the constraint — define one rule per primitive (`NotEmptyRule where TSelf : IScalarValueObject`, a `long` variant, and so on).
+A rule can be written against the value object itself (`where TSelf : IScalarValueObject`), or against the underlying value (`IValidationRule`) and adapted automatically by the value-objects generator's `ScalarRuleAdapter`.
+
+> [!IMPORTANT]
+> `Purview.ValueObjects.ScalarRuleAdapter` is emitted into your compilation by the **Purview.ValueObjects** source generator whenever the project references both `Purview.ValueObjects` and `Purview.ZodSharp`. Do not declare it yourself. If the value-objects generator is disabled (`DisableValueObjectsSourceGenerator`), the adapter is not emitted and the generated validator will not compile.
> [!NOTE]
-> If the type has no value-object contract (a plain class with a `Guid` property), the property-level form still works: map the attribute to a `IValidationRule` and put it on `Value`. See [Exposing a rule as a DataAnnotations attribute](#exposing-a-rule-as-a-dataannotations-attribute) and [Generic rules](#generic-rules).
+> If the type has no value-object contract (a plain class with a `Guid` property), the property-level form still works: map the attribute to an `IValidationRule` and put it on `Value`. See [Exposing a rule as a DataAnnotations attribute](#exposing-a-rule-as-a-dataannotations-attribute) and [Generic rules](#generic-rules).
> [!TIP]
-> If the non-empty policy should be implicit rather than an attribute, the value-objects layer is the natural place to emit `[NotEmpty]` on the scalar type (it already knows about ZodSharp through `ZodSchemaMode`).
+> If the non-empty policy should be implicit rather than an attribute, the value-objects layer is the natural place to emit the attribute on the scalar type (it already knows about ZodSharp through `ZodSchemaMode`).
+
+See [Value Objects Integration](Value-Objects-Integration.md) for the full walkthrough: wiring `[Scalar]` + `[ZodSchema]`, both rule shapes, the generated code, the error code / message definitions, and testing.
## Diagnostics
@@ -371,12 +592,20 @@ Because the rule is closed with `TSelf` (`NotEmptyRule`), it *sees the
| ZODSGEN031 | Error | A rule constructor parameter could not be mapped from the attribute. |
| ZODSGEN032 | Error | A validation attribute could not be generated for the rule. |
| ZODSGEN033 | Warning | A rule-mapped attribute is applied to a type that gets no generated schema (no `[ZodSchema]` and not referenced as a complex property), so the rule never runs. |
+| ZODSGEN037 | Warning | A rule marked `[ZodRule]` derives an attribute name that is already declared by hand, so the generated attribute is suppressed and the hand-authored declaration's own `[ZodRule]` mapping governs every usage. |
+| ZODSGEN038 | Warning | A hand-authored rule attribute's `[ZodRule(typeof(...))]` mapping does not address every rule declared under the name the attribute encodes (`XAttribute` → `XRule`), leaving some usages of the attribute unresolved. |
+| ZODSGEN039 | Warning | A rule accepts a `code`/`origin` constructor parameter without implementing `IZodRule`, so the value never reaches the reported error identity. |
+| ZODSGEN040 | Warning | An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity. |
+| ZODSGEN042 | Warning | A validation rule does not expose public `const string ErrorCode` / `MessageFormat` constants, so its error identity cannot be asserted in tests without duplicating literals. |
+| ZODSGEN043 | Info | A built-in rule marked `[ZodRule]` does not generate a validation attribute (its name is taken by a `System.ComponentModel.DataAnnotations` attribute, or a constructor parameter cannot be represented as an attribute property). |
See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list.
## Related
+- [Validation Rules Reference](Validation-Rules-Reference.md) — the catalogue of built-in rules.
- [Fluent Schema API](Fluent-Schema-API.md) — `AddRule`/`Rule` live on `ZodType`.
+- [Value Objects Integration](Value-Objects-Integration.md) — `[Scalar]` value objects and the `ScalarRuleAdapter`.
- [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) — built-in attribute coverage.
- [Guarantees and Limitations](Guarantees-and-Limitations.md) — allocation and mutation semantics.
diff --git a/docs/wiki/Fluent-Schema-API.md b/docs/wiki/Fluent-Schema-API.md
index c0ea7ae..7988ae3 100644
--- a/docs/wiki/Fluent-Schema-API.md
+++ b/docs/wiki/Fluent-Schema-API.md
@@ -10,6 +10,8 @@
| `Number()` | `Z.Number()` | `ZodNumber` |
| `Boolean()` | `Z.Boolean()` | `ZodBoolean` |
| `Null()` | `Z.Null()` | `ZodNull` |
+| `Date()` | `Z.Date()` | `ZodDate` |
+| `BigInt()` | `Z.BigInt()` | `ZodBigInt` |
| `Array` | `Z.Array(IZodSchema elementSchema)` | `ZodArray` |
| `Optional` | `Z.Optional(IZodSchema schema)` — `T : class` | `ZodOptional` |
| `Nullable` | `Z.Nullable(IZodSchema schema)` — `T : struct` | `ZodNullable` |
@@ -46,11 +48,13 @@ Each schema type has its own page:
- `IZodSchema` — `Validate` / `ValidateAsync`; `IZodSchema` is the convenience form where input equals output.
- `IZodSchemaValidator` (marker) and `IZodSchemaValidator` — the DI-facing adapter surface (see [Dependency Injection](Dependency-Injection.md)).
-- `IValidationRule` — the rule contract implemented by every struct rule. `ZodType.AddRule(rule)` and `Rule(rule)` are public, so custom rules can be attached to any schema. `ZodString` also exposes `IsValidSpan`/`ValidateSpan` for span-based string validation, and string rules that implement `IStringValidationRule` participate in the span path.
+- `IValidationRule` — the rule contract implemented by every struct rule. `ZodType.AddRule(rule)` and `Rule(rule)` are public, so custom rules can be attached to any schema, and a fluent extension method can give them a first-class method like the built-in rules. Every rule exposes its reported code and message template as public `const string ErrorCode` / `MessageFormat` constants (enforced by `ZODSGEN042`). `ZodString` also exposes `IsValidSpan`/`ValidateSpan` for span-based string validation, and string rules that implement `IStringValidationRule` participate in the span path.
- `IZodRule` — implemented by rules that own their error identity (`Code`/`Origin`). When a mapped rule implements it, the generator prefers the rule's values over the attribute's, so one attribute can produce a per-member error code.
- `IStringValidationRule` — the span-based counterpart of `IValidationRule`.
-See [Custom Rules](Custom-Rules.md) for defining, attaching, and mapping rules (including generic rules).
+The shipped `ZodSharp.Rules` namespace provides the built-in rules (for example `EmailRule`, `MinLengthRule`, `NonSentinelRule`); each is a `readonly record struct` usable standalone or through the fluent API. Every rule has a fluent method on its schema — see [Validation Rules Reference](Validation-Rules-Reference.md) for the catalogue.
+
+See [Custom Rules](Custom-Rules.md) for defining, attaching, and mapping rules (including generic rules, the `ErrorCode`/`MessageFormat` constants convention, and [fluent extension methods](Custom-Rules.md#extending-the-fluent-interface)).
## Convenience composition on any schema
diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md
index e557e15..68c4f51 100644
--- a/docs/wiki/Guarantees-and-Limitations.md
+++ b/docs/wiki/Guarantees-and-Limitations.md
@@ -6,7 +6,7 @@
- **No reflection on hot paths.** The runtime library uses expression trees only in the opt-in `CompiledValidator` and to compile a one-off discriminator accessor per (type, discriminator) pair for `ZodDiscriminatedUnion`. After that first use, validation runs direct property access; the source generator emits direct typed codegen.
- **Deterministic, reviewable generated code.** The `[ZodSchema]` generator output is stable and de-duplicated; there are no scope leaks in emitted code.
- **Cross-platform parity.** The C# implementation is exercised against TypeScript/Zod fixtures (see [Cross-Platform Interop](Cross-Platform-Interop.md)).
-- **Multi-targeting.** Packages target `net8.0`, `net9.0`, and `net10.0`; the source generator targets `netstandard2.0` so it runs in any compiler host.
+- **Multi-targeting.** Packages target `net8.0`, `net9.0`, `net10.0`, and `net11.0`; the source generator targets `netstandard2.0` so it runs in any compiler host.
- **A fully built schema is safe to cache and share across threads.** Validation only reads the rule set and `Description`, so once construction is finished a schema can be reused concurrently. Building is *not* immutable — see the next section.
## Limitations
@@ -33,7 +33,7 @@ The generator reports `Origin = "string"` for string size failures, `Origin = "a
### Rule errors
-Rules evaluated by the base `Validate` pipeline produce `validation_failed` errors with an empty path. Structured `too_small`/`too_big` issues (with `Origin`, `Minimum`/`Maximum`, and `Inclusive`) are produced by `ZodArray` and by the source generator's size validators.
+Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_small`/`too_big` for bounds, `not_multiple_of`/`not_finite` for numbers, `invalid_string` for string-format validations, `invalid_type` for `IntRule`, and `invalid_value` for `NonSentinelRule`. Custom rules that do not declare a code default to `validation_failed`. Every rule exposes its correlated code and message template as public `const string ErrorCode` / `MessageFormat` constants so tests can assert against the rule rather than duplicating literals; a source-declared rule that omits them is reported as `ZODSGEN042`. Structured `too_small`/`too_big` issues (with `Origin`, `Minimum`/`Maximum`, and `Inclusive`) are produced by `ZodArray` and by the source generator's size validators.
### String transforms allocate
@@ -41,7 +41,7 @@ Rules evaluated by the base `Validate` pipeline produce `validation_failed` erro
### Number semantics
-`ZodNumber` operates on `double`. `Int()`, `Safe()`, and `Finite()` are validation rules, not conversions; `.Int()` rejects fractional values rather than rounding them. `Positive()`/`Negative()` are strict (they reject `0`; use `NonNegative()`/`NonPositive()` for inclusive bounds). `MultipleOf` compares the quotient to its nearest integer with a relative tolerance (`1e-12`), so `0.3` is accepted for `MultipleOf(0.1)` while `0.3000000001` is not; NaN and infinity are rejected, and a zero divisor throws `ArgumentException`.
+`ZodNumber` operates on `double`. `Int()`, `Safe()`, and `Finite()` are validation rules, not conversions; `.Int()` rejects fractional values rather than rounding them. `Positive()`/`Negative()` are strict (they reject `0`; use `NonNegative()`/`NonPositive()` for inclusive bounds). `MultipleOf` compares the distance to the nearest multiple against a relative tolerance (`1e-12`), so `0.3` is accepted for `MultipleOf(0.1)` while `0.3000000001` is not; NaN and infinity are rejected, and a zero divisor throws `ArgumentException`.
### Enum semantics
@@ -57,7 +57,7 @@ Rules evaluated by the base `Validate` pipeline produce `validation_failed` erro
## Custom rules
-Custom rules and their DataAnnotations-style attributes are a first-class extension point. Rules can be attached to a property or to the schema type itself (validating the value object as a unit), and a generic rule can be closed with the target type so one rule serves every scalar of a given shape. See [Custom Rules](Custom-Rules.md) for the rule contract, the public `AddRule`/`Rule` API, and how to map a rule to a `ValidationAttribute` that the source generator honours.
+Custom rules and their DataAnnotations-style attributes are a first-class extension point. Rules can be attached to a property or to the schema type itself (validating the value object as a unit), and a generic rule can be closed with the target type so one rule serves every scalar of a given shape. A rule written against a `Purview.ValueObjects` scalar's underlying value is adapted automatically when it is applied to a `[Scalar]` type, so one rule also serves every scalar backed by the same primitive. See [Custom Rules](Custom-Rules.md) for the rule contract, the public `AddRule`/`Rule` API, and how to map a rule to a `ValidationAttribute` that the source generator honours, and [Value Objects Integration](Value-Objects-Integration.md) for the `[Scalar]` walkthrough and error code / message definitions.
## Contract vs. underlying libraries
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
index 34d6d40..cd5846b 100644
--- a/docs/wiki/Home.md
+++ b/docs/wiki/Home.md
@@ -23,6 +23,8 @@ This wiki is the project documentation hub for the core API, source generator, J
- [Unions and Discriminated Unions](Unions-and-Discriminated-Unions.md)
- [Composition and Transforms](Composition-and-Transforms.md)
- [Custom Rules](Custom-Rules.md)
+- [Validation Rules Reference](Validation-Rules-Reference.md)
+- [Value Objects Integration](Value-Objects-Integration.md)
- [Compiled Validators and Caching](Compiled-Validators-and-Caching.md)
- [Dependency Injection](Dependency-Injection.md)
@@ -51,9 +53,12 @@ This wiki is the project documentation hub for the core API, source generator, J
- **Zero-allocation validation** — validation rules are `readonly record struct`s and hot paths use `Span`; every valid input path validates without allocating.
- **Fluent API** — `Z.String().Min(3).Max(50).Email()`, composable objects, arrays, unions, tuples, records, discriminators, and more.
+- **Native C# 15 unions (.NET 11+)** — `Z.NativeUnion` returns an allocation-free native union for reference-type cases, with exhaustive pattern matching; `Z.Union` stays the zero-allocation choice for value-type cases. The analyzer reports `ZODSGEN041` and offers a code fix.
- **Structured issues** — failures carry machine-readable `Code`, `Path`, `Origin`, `Minimum`/`Maximum`, and `Inclusive` metadata in addition to a human message.
+- **Test-friendly rule identity** — every rule exposes its code and message template as public `ErrorCode`/`MessageFormat` constants (the analyzer reports `ZODSGEN042` when one is missing), so tests assert against the rule instead of duplicating literals. `NonSentinelRule` rejects the ORM sentinel values (`Guid.Empty`, `DateTime.MinValue`/`MaxValue`, and more).
- **JSON Schema interoperability** — export via `Z.ToJsonSchema` (core package) and import via `Z.FromJsonSchema` (in either JSON integration package), enabling cross-language reuse with TypeScript/Zod.
- **Compile-time source generation** — the `[ZodSchema]` attribute turns a class, struct, or record into a zero-allocation static validator, honouring DataAnnotations attributes such as `[Required]`, `[Length]`, `[Range]`, and `[EmailAddress]`.
+- **Value object scalars** — a `Purview.ValueObjects` `[Scalar]` can be validated as a unit with `[ZodSchema]`; a rule written against the scalar's underlying value is adapted automatically. See [Value Objects Integration](Value-Objects-Integration.md).
- **Integration packages** — `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, and `Purview.ZodSharp.AspNetCore` (ProblemDetails).
- **Cross-platform tests** — a shared TypeScript/Zod fixture set is generated into the repo and asserted against from both the C# test suite and a vitest suite.
-- **Multi-target** — packages target `net8.0`, `net9.0`, and `net10.0`; the source generator targets `netstandard2.0` so it runs in any compiler host.
\ No newline at end of file
+- **Multi-target** — packages target `net8.0`, `net9.0`, `net10.0`, and `net11.0`; the source generator targets `netstandard2.0` so it runs in any compiler host.
\ No newline at end of file
diff --git a/docs/wiki/Number-Validation.md b/docs/wiki/Number-Validation.md
index 5f077c4..f03cae1 100644
--- a/docs/wiki/Number-Validation.md
+++ b/docs/wiki/Number-Validation.md
@@ -15,14 +15,24 @@ var result = schema.Validate(30.0);
|---|---|---|
| `Min` | `Min(double minValue)` | `MinValueRule` — `Value must be at least ...` |
| `Max` | `Max(double maxValue)` | `MaxValueRule` |
-| `Int` | `Int()` | `IntRule` — `value == Math.Truncate(value)` |
+| `Gt` | `Gt(double value)` | `GreaterThanRule` — strictly greater than `value` |
+| `Gte` | `Gte(double value)` | `GreaterThanOrEqualRule` — greater than or equal to `value` |
+| `Lt` | `Lt(double value)` | `LessThanRule` — strictly less than `value` |
+| `Lte` | `Lte(double value)` | `LessThanOrEqualRule` — less than or equal to `value` |
+| `Int` | `Int()` | `IntRule` — `value % 1 == 0`; failure code `invalid_type` |
| `Positive` | `Positive()` | `GreaterThanRule(0.0)` — strictly greater than zero |
| `Negative` | `Negative()` | `LessThanRule(0.0)` — strictly less than zero |
| `NonNegative` | `NonNegative()` | `MinValueRule(0.0)` — greater than or equal to zero |
| `NonPositive` | `NonPositive()` | `MaxValueRule(0.0)` — less than or equal to zero |
-| `MultipleOf` | `MultipleOf(double divisor, string? message)` | `MultipleOfRule` — throws `ArgumentException` for a zero divisor; relative-tolerance comparison (`1e-12`) |
-| `Finite` | `Finite(string? message)` | `FiniteRule` — `double.IsFinite` |
-| `Safe` | `Safe(string? message)` | `SafeIntegerRule` — integer within `int.MinValue`..`int.MaxValue` |
+| `MultipleOf` | `MultipleOf(double divisor, string? message)` | `MultipleOfRule` — throws `ArgumentException` for a zero divisor; relative-tolerance comparison (`1e-12`) |
+| `Finite` | `Finite(string? message)` | `FiniteRule` — `T.IsFinite` |
+| `Safe` | `Safe(string? message)` | `SafeIntegerRule` — integer within `int.MinValue`..`int.MaxValue`; failure code `too_big` |
+| `Even` | `Even(string? message)` | `EvenRule` — `value % 2 == 0`; failure code `invalid_value` |
+| `Odd` | `Odd(string? message)` | `OddRule` — `value % 2 != 0`; failure code `invalid_value` |
+
+All six bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule`) are generic over `T : IComparable`, and the arithmetic rules (`IntRule`, `FiniteRule`, `MultipleOfRule`, `EvenRule`, `OddRule`) are generic over `T : INumber`, so they close with any numeric type (`int`, `long`, `decimal`, …) — not just `double`. `ZodNumber` closes them with `double`; `ZodBigInt` and `ZodDate` close the bound rules with `long` and `DateTime`. `SafeIntegerRule` is intentionally `double`-only, because "safe integer" is a JavaScript `Number` concept.
+
+Every numeric method also accepts optional `string? message` and `string? code` parameters: `message` overrides the rule's default message, and `code` overrides the reported error code (otherwise the rule's own `ErrorCode` is reported).
## Examples
@@ -37,10 +47,18 @@ var fractional = Z.Number().MultipleOf(0.1); // 0.3 is accepted (floating-point
var finite = Z.Number().Finite(); // rejects Infinity / NaN
var safe = Z.Number().Safe(); // safe integer range
var whole = Z.Number().Int(); // no fractional part
+var even = Z.Number().Even(); // 0, 2, 4, ...
+var odd = Z.Number().Odd(); // 1, 3, 5, ...
var age = Z.Number().Min(0).Max(120).Int().Validate(25.0);
+var atLeastTen = Z.Number().Gte(10); // >= 10
+var atMostTen = Z.Number().Lte(10); // <= 10
```
## Numeric coercion
-When a `Z.Number()` is used as an object field or union option, boxed values are coerced via `IConvertible` (invariant culture) — for example a `long` from a `Dictionary` validates against a `Z.Number()` field. Non-numeric values fail with `invalid_type`.
\ No newline at end of file
+When a `Z.Number()` is used as an object field or union option, boxed values are coerced via `IConvertible` (invariant culture) — for example a `long` from a `Dictionary` validates against a `Z.Number()` field. Non-numeric values fail with `invalid_type`.
+
+## See also
+
+- [Validation Rules Reference](Validation-Rules-Reference.md) — every built-in rule with its error code and message format.
\ No newline at end of file
diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md
index f01515d..da5e304 100644
--- a/docs/wiki/Performance.md
+++ b/docs/wiki/Performance.md
@@ -19,6 +19,10 @@ Results are written to `BenchmarkDotNet.Artifacts/` (HTML, Markdown, logs) in th
## Measurement environment
- BenchmarkDotNet 0.15.8, .NET 10.0.12, Windows 11 (10.0.28020.2991).
+- **These figures are stale in one respect:** the repository now pins BenchmarkDotNet
+ 0.16.0-preview.2 (`Directory.Packages.props`), so the numbers below were produced by a different version
+ than the suite currently builds against. They are also from one machine. Re-run the suite before relying
+ on absolute values; use them for relative comparison between scenarios.
- 13th Gen Intel Core i9-13900KF 3.00 GHz (24 physical / 32 logical cores), X64 RyuJIT x86-64-v3.
Numbers are indicative; re-run on your own hardware for local planning.
diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md
index 3fd9937..e4c2975 100644
--- a/docs/wiki/Source-Generator-DataAnnotations.md
+++ b/docs/wiki/Source-Generator-DataAnnotations.md
@@ -14,7 +14,7 @@ The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attrib
| `[Range(...)]` | inclusive (or exclusive) numeric/parsed bounds | `invalid_range` |
| `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings | `invalid_string` |
| `[AllowedValues(...)]` | typed equality checks against the allowed set | `invalid_value` |
-| `[DeniedValues(...)]` | typed equality checks against the denied set | `invalid_value` |
+| `[DeniedValues(...)]` | typed equality checks against the denied set; for an enum property the values are absorbed into the automatic enum rule instead | `invalid_value` |
| `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` |
| `[Url]` | reuses `UrlRule` | `invalid_string` |
| `[Phone]` | reuses `PhoneRule` | `invalid_string` |
@@ -25,6 +25,10 @@ The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attrib
`[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also present.
+## Enum properties
+
+Enum properties are validated automatically — the generator rejects a value that is not a defined member of the enum type with `invalid_enum_value`. The check is emitted as `ZodSharp.Rules.EnumRule`. Members can be excluded with `[ZodIgnore]` (on the enum member, for every property of that type) or `[DeniedValues]` (on the property only). `[Flags]` enums and properties with an explicit `[AllowedValues]` allow-list are not auto-validated, and the whole feature is disabled with `[ZodSchema(ValidateEnumValues = false)]`. See [Source Generator](Source-Generator.md#automatic-enum-validation) for the full rules and examples.
+
## Size validators and structured issues
Size attributes generate direct `Length` or `Count` access when possible:
@@ -88,4 +92,6 @@ See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full
## Custom attributes
-The same pipeline honours custom rules exposed as validation attributes. Mark the attribute with `[ZodRule(typeof(MyRule))]` (or mark the rule itself with `[ZodRule]` to have the attribute generated), and properties annotated with it are validated through the rule. See [Custom Rules](Custom-Rules.md).
\ No newline at end of file
+The same pipeline honours custom rules exposed as validation attributes. Mark the attribute with `[ZodRule(typeof(MyRule))]` (or mark the rule itself with `[ZodRule]` to have the attribute generated), and properties annotated with it are validated through the rule. See [Custom Rules](Custom-Rules.md).
+
+Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, `[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, and `[DeniedValuesZod]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp).
\ No newline at end of file
diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md
index 0cbbac4..ad965bb 100644
--- a/docs/wiki/Source-Generator-Diagnostics.md
+++ b/docs/wiki/Source-Generator-Diagnostics.md
@@ -1,6 +1,6 @@
# Source Generator Diagnostics
-The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033` is a warning and the rest are errors.
+The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033`, `ZODSGEN037`–`ZODSGEN040`, and `ZODSGEN042` are warnings, `ZODSGEN041` and `ZODSGEN043` are informational, and the rest are errors.
| ID | Meaning |
|---|---|
@@ -34,6 +34,13 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato
| ZODSGEN034 | The `OnZodValidate` refinement hook is implemented on a type that is not `partial` (or whose containing types are not all `partial`), so the generated declaration cannot be emitted |
| ZODSGEN035 | The `OnZodValidate` refinement hook is not declared as `partial void OnZodValidate(RefineCtx context)` (wrong modifiers, return type, or parameters) |
| ZODSGEN036 | A member still uses the retired synchronous refinement contract (`IEnumerable Validate()`); implement `OnZodValidate` instead |
+| ZODSGEN037 | (warning) A rule marked `[ZodRule]` derives an attribute name (`XRule` → `XAttribute`) that a hand-authored type already declares, so no attribute is generated and that declaration's own `[ZodRule]` mapping governs every usage |
+| ZODSGEN038 | (warning) A hand-authored rule attribute's `[ZodRule(typeof(...))]` mapping does not address every rule declared under the name the attribute encodes (`XAttribute` → `XRule`), so some usages of the attribute resolve to no rule |
+| ZODSGEN039 | (warning) A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity |
+| ZODSGEN040 | (warning) An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity |
+| ZODSGEN041 | (info) A typed union (`Z.Union`) whose option types are all reference types can use the allocation-free native C# 15 union returned by `Z.NativeUnion` on .NET 11+; a code fix is offered |
+| ZODSGEN042 | (warning) A validation rule (a type implementing `IValidationRule`) does not expose a public `const string ErrorCode` and a public `const string MessageFormat`, so its error identity cannot be asserted in tests without duplicating literals |
+| ZODSGEN043 | (info) A built-in rule marked `[ZodRule]` does not generate a validation attribute, because a constructor parameter cannot be represented as an attribute property; the rule is still reachable through the runtime/fluent API. A derived name that collides with `System.ComponentModel.DataAnnotations` is *not* skipped — the attribute is emitted under a `Zod` suffix instead |
IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused; rule identifiers are never renumbered or re-used.
diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md
index f67e84a..042aa11 100644
--- a/docs/wiki/Source-Generator.md
+++ b/docs/wiki/Source-Generator.md
@@ -55,6 +55,7 @@ All options are optional.
| `CustomValidationMethodName` | `null` | Name of an async custom validation method; default lookup name `CustomValidationAsync`. Mutually exclusive with the synchronous `OnZodValidate` refinement hook. |
| `GenerateIValidateOptions` | `false` | Force `IValidateOptions` generation. |
| `SuppressIValidateOptions` | `false` | Opt out even when auto-detection would enable it. |
+| `ValidateEnumValues` | `true` | Set to `false` to skip the automatic enum validation for the type's enum properties. |
> [!NOTE]
> `Parse`, the value-first composition methods (`ApplyAnd`/`ApplyOr`/`ApplyRefine`), the `IZodSchemaValidator` adapter and the `IValidateOptions` validator all depend on `Validate`. Setting `GenerateValidateMethod = false` omits them together.
@@ -147,12 +148,55 @@ MSBuild switches:
| `ZodSharpAutoGenerateOptionsValidators` | `true` | auto-detect `IValidateOptions` (only explicit `false` disables) |
| `ZodSharpAutoGenerateOptionsValidatorSuffixes` | `Options;Settings` | semicolon/comma-separated suffix list |
+## Automatic enum validation
+
+Every non-flags enum property is validated automatically: the generated validator rejects a value that is not a defined member of the enum type. The check is emitted as an `EnumRule` (see [Validation Rules Reference](Validation-Rules-Reference.md#enum-rules)) and reports `invalid_enum_value`.
+
+A member that is defined but never a valid value can be excluded globally by marking it `[ZodIgnore]`, and excluded for a single property with `[DeniedValues]`:
+
+```csharp
+using System.ComponentModel.DataAnnotations;
+using ZodSharp;
+
+public enum ExampleEnum
+{
+ [ZodIgnore]
+ Unspecified,
+
+ AValidValue,
+
+ AnotherValidValue,
+}
+
+[ZodSchema]
+public class Model
+{
+ // Rejects anything that is not AValidValue or AnotherValidValue.
+ public ExampleEnum Status { get; set; }
+
+ // Also rejects AnotherValidValue for this property only.
+ [DeniedValues(ExampleEnum.AnotherValidValue)]
+ public ExampleEnum SecondaryStatus { get; set; }
+}
+```
+
+The automatic validation is skipped when:
+
+- the enum is declared `[Flags]` — a combination is a valid value without being a defined member;
+- the property declares an explicit `[AllowedValues]` allow-list, which governs the property instead;
+- the schema opts out with `[ZodSchema(ValidateEnumValues = false)]`.
+
+A nullable enum property is validated only when it is not `null`.
+
## What is validated
- Properties must be public, non-static, non-indexer.
-- A property is included when it carries any DataAnnotations attribute or its type is a source-defined complex type with a nested schema.
+- Validation is emitted for a property when it carries any DataAnnotations attribute, its type is a source-defined complex type with a nested schema, or its type is an enum (see [Automatic enum validation](#automatic-enum-validation)).
- Classes, structs, and records are supported; structs do not receive `IValidateOptions` (ZODSGEN028 if requested).
- Nested complex types are discovered recursively and get their own generated `{TypeName}Schema`, even when the nested type does not itself carry `[ZodSchema]`.
- Nullable properties are null-guarded before value-set/type validation; a nullable target rejects `null` with `invalid_type`.
+- A `Purview.ValueObjects` scalar marked with `[Scalar]` can carry `[ZodSchema]` on the same type; the generated validator validates the scalar as a unit and reports an empty path. A rule written against the scalar's underlying value is adapted automatically — see [Value Objects Integration](Value-Objects-Integration.md).
+- A rule marked with the parameterless `[ZodRule]` generates a matching validation attribute. Every built-in rule ships its attribute inside `Purview.ZodSharp` (in the `ZodSharp.Rules` namespace — `[Email]`, `[E164]`, `[Regex]`, `[NonSentinel]`, `[MinValue]`, `[Even]`, …; names that collide with `System.ComponentModel.DataAnnotations` use a `Zod` suffix such as `[MinLengthZod]`), so they can annotate a member or a scalar value object directly — see [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp).
+
See [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) for the attribute coverage and structured issue shape, [Custom Rules](Custom-Rules.md) for extending validation with your own rules and attributes, and [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the `ZODSGEN*` diagnostics.
\ No newline at end of file
diff --git a/docs/wiki/String-Validation.md b/docs/wiki/String-Validation.md
index fde1ebc..1571c26 100644
--- a/docs/wiki/String-Validation.md
+++ b/docs/wiki/String-Validation.md
@@ -13,19 +13,33 @@ var result = schema.Validate("user@example.com");
| Method | Signature | Rule added |
|---|---|---|
-| `Min` | `Min(int minLength)` | `MinLengthRule` — `too_small` via `validation_failed` when too short |
+| `Min` | `Min(int minLength)` | `MinLengthRule` — `too_small` when too short |
| `Max` | `Max(int maxLength)` | `MaxLengthRule` |
| `Length` | `Length(int length)` | exact length (both bounds) |
| `Email` | `Email()` | `EmailRule` — static compiled regex |
| `Regex` | `Regex(Regex pattern, string? message)` / `Regex(string pattern, string? message)` | `RegexRule`; the string overload compiles with a 100 ms timeout |
| `Url` | `Url(string? message)` | `UrlRule` — regex or absolute `http`/`https` URI |
+| `Uri` | `Uri(string? message)` / `Uri(UriKind uriKind, string? message)` | `UriRule` — `Uri.TryCreate` against the supplied `UriKind` (defaults to `RelativeOrAbsolute`) |
| `Phone` | `Phone(string? message)` | `PhoneRule` — digits plus `() .+-`, at least one digit |
| `CreditCard` | `CreditCard(string? message)` | `CreditCardRule` — Luhn algorithm |
| `Base64String` | `Base64String(string? message)` | `Base64StringRule` — `Convert.FromBase64String` |
| `UUID` | `UUID(string? message)` | `UUIDRule` — char-scan, RFC 9562 versions 1-8, variant nibble `8-9/a-b`, plus nil and max |
| `UUID` | `UUID(UuidVersion version, string? message)` | `UUIDRule` — requires a specific version (e.g. `V7`), variant nibble `8-9/a-b`, nil/max rejected |
-| `StartsWith` | `StartsWith(string prefix, string? message)` | `StartsWithRule` — ordinal comparison |
-| `EndsWith` | `EndsWith(string suffix, string? message)` | `EndsWithRule` — ordinal comparison |
+| `StartsWith` | `StartsWith(string prefix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `StartsWithRule` — comparison defaults to `Ordinal` |
+| `EndsWith` | `EndsWith(string suffix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `EndsWithRule` — comparison defaults to `Ordinal` |
+| `Includes` | `Includes(string substring, string? message, string? code)` | `IncludesRule` — ordinal substring containment |
+| `IP` | `IP(string? message, string? code)` / `IP(IPAddressRuleType ruleType, string? message, string? code)` | `IPAddressRule` — IPv4/IPv6, or only the requested family (`IPv4`/`IPv6`/`Any`; defaults to `Any`) |
+| `JWT` | `JWT(string? message)` | `JWTRule` — three base64url-encoded segments |
+| `Hex` | `Hex(string? message)` | `HexRule` — hexadecimal characters (empty string is valid, matching Zod) |
+| `Base64Url` | `Base64Url(string? message)` | `Base64UrlRule` — URL-safe base64, no padding (groups of 4 plus a 2-3 character tail) |
+| `ULID` | `ULID(string? message)` | `ULIDRule` — 26 Crockford base32 characters, first character `0`-`7` |
+| `Datetime` | `Datetime(string? message)` | `DatetimeStringRule` — ISO 8601 date-time (`yyyy-MM-ddTHH:mm:ss[.fff]Z`) |
+| `Date` | `Date(string? message)` | `DateStringRule` — ISO 8601 date (`yyyy-MM-dd`) |
+| `Time` | `Time(string? message)` | `TimeStringRule` — ISO 8601 time (`HH:mm`, optionally `:ss` and fractional seconds) |
+| `Nanoid` | `Nanoid(string? message)` | `NanoidRule` — 21 URL-safe characters |
+| `Cuid2` | `Cuid2(string? message)` | `Cuid2Rule` — lowercase alphanumeric characters |
+| `E164` | `E164(string? message)` | `E164Rule` — `+` followed by 7-15 digits |
+| `NonSentinel` | `NonSentinel(string? message)` | `NonSentinelRule` — rejects `null`/empty/whitespace; inherited from `ZodType` and overridden to keep `ZodString` in the chain |
| `ToLower` | `ToLower()` | wraps a transform (`ToLowerInvariant`), returns a `ZodString` |
| `ToUpper` | `ToUpper()` | wraps a transform (`ToUpperInvariant`) |
| `Trim` | `Trim()` | wraps a transform (`Trim`) |
@@ -35,6 +49,8 @@ var result = schema.Validate("user@example.com");
> [!NOTE]
> `ToLower`, `ToUpper`, and `Trim` produce a new string on every validation. `IsValidSpan` does not allocate when the value is valid; `ValidateSpan` allocates once because its result carries a `string`.
+Every method that adds a rule also accepts optional `string? message` and `string? code` parameters: `message` overrides the rule's default message, and `code` overrides the reported error code (otherwise the rule's own `ErrorCode` is reported). The rule's default message format is used when `message` is omitted.
+
## Examples
```csharp
@@ -59,7 +75,15 @@ var spanResult = Z.String().Min(3).Max(50).Email().ValidateSpan(span);
## Error messages
-Rules produce `ValidationError` entries with code `validation_failed` and an empty path. Many methods accept a custom `message` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`.
+Rules produce `ValidationError` entries with an empty path. Size validations emit `too_small` or `too_big`; string-format validations emit `invalid_string`. Many methods accept a custom `message` and `code` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. For the full catalogue — including rules without a fluent method — see [Validation Rules Reference](Validation-Rules-Reference.md).
+
+Every rule exposes its error identity as public constants — `public const string ErrorCode` and a `public const string MessageFormat` (a `{0}`-style template) — so tests and consumers can assert against the rule instead of duplicating literals:
+
+```csharp
+var result = Z.String().Email().Validate("not-an-email");
+// result.Errors[0].Code == EmailRule.ErrorCode ("invalid_string")
+// result.Errors[0].Message == string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, "not-an-email")
+```
## Span validation
diff --git a/docs/wiki/Unions-and-Discriminated-Unions.md b/docs/wiki/Unions-and-Discriminated-Unions.md
index 2455ef5..9cdbe30 100644
--- a/docs/wiki/Unions-and-Discriminated-Unions.md
+++ b/docs/wiki/Unions-and-Discriminated-Unions.md
@@ -41,6 +41,32 @@ The `Union<...>` value type (namespace `ZodSharp.Unions`) is the result of a typ
- `Match(Func, Func)` and `Switch(Action, Action)`.
- `==` / `!=`, `Equals`, `GetHashCode`, `ToString`.
+## Native unions (.NET 11+)
+
+When targeting `net11.0` or later, `Z.NativeUnion` returns a native C# 15 union
+(`ZodSharp.Unions.NativeUnion`) instead of `Union`:
+
+```csharp
+var schema = Z.NativeUnion(Z.String().Min(1), Z.Object().Build());
+var result = schema.Validate("hello");
+
+if (result.IsSuccess)
+{
+ var length = result.Value switch
+ {
+ string s => s.Length,
+ Dictionary o => o.Count,
+ _ => -1,
+ };
+}
+```
+
+Prefer this when **both option types are reference types**: the native union is allocation-free and
+supports exhaustive pattern matching. Value-type cases box, so keep using `Z.Union