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